From 575311b17aa152262c4cb68841c8160fb92467a0 Mon Sep 17 00:00:00 2001 From: gomes <17035424+gomesalexandre@users.noreply.github.com> Date: Sun, 11 Jan 2026 16:22:23 +0100 Subject: [PATCH 01/60] chore: yield polish - Remove unnecessary filteredYields dependency in YieldAssetDetails useMemo hooks - Add validator breakdown utility functions - Enhance yield action modal with additional transaction handling - Refactor yield filters component for better code organization - Update yields list component layout - Improve yield filters hook logic - Add yield transaction flow hooks - Add missing translation key Co-Authored-By: Claude Sonnet 4.5 --- YIELD_POLISH.md | 272 ++++++++++++++++++ src/assets/translations/en/main.json | 1 + src/lib/yieldxyz/utils.ts | 53 +++- src/pages/Yields/YieldAssetDetails.tsx | 10 +- .../Yields/components/ValidatorBreakdown.tsx | 5 +- .../Yields/components/YieldActionModal.tsx | 47 ++- src/pages/Yields/components/YieldFilters.tsx | 14 +- src/pages/Yields/components/YieldsList.tsx | 21 +- src/pages/Yields/hooks/useYieldFilters.ts | 19 +- .../Yields/hooks/useYieldTransactionFlow.ts | 4 + 10 files changed, 406 insertions(+), 40 deletions(-) create mode 100644 YIELD_POLISH.md diff --git a/YIELD_POLISH.md b/YIELD_POLISH.md new file mode 100644 index 00000000000..aab03b0ce20 --- /dev/null +++ b/YIELD_POLISH.md @@ -0,0 +1,272 @@ +# Yield.xyz Integration Polish - Execution Plan + +> **Context**: Bugfixes and polishes identified during testing of PR #11578. Feedback from @NeOMakinG. +> **Related Issue**: [#11611](https://github.com/shapeshift/web/issues/11611) +> **Related PR**: [#11578](https://github.com/shapeshift/web/pull/11578) + +--- + +## Execution Instructions + +Each section below is a **standalone TODO** that can be executed independently. Before proceeding with each item: + +1. **ASK** the user: "Should I proceed with TODO X: [Title]?" +2. **WAIT** for explicit confirmation before implementing +3. **AFTER** completion, ask: "TODO X complete. Should I continue to TODO Y?" + +--- + +## Bugs + +### TODO 1: Fix Network Filtering - Hiding Other Networks + +> [!IMPORTANT] +> **Issue**: Selecting a network hides other networks. Users should be able to click again to see all networks and instantly select another one. + +**Current Behavior**: When a network is selected in the filter dropdown, clicking the dropdown again only shows the selected network (or a subset). + +**Expected Behavior**: The dropdown should always display ALL available networks, with the currently selected one highlighted. Clicking "All Networks" should clear the filter. + +**Investigation Areas**: +- [YieldFilters.tsx](file:///Users/alexandre.gomes/Sites/shapeshiftWebClone/src/pages/Yields/components/YieldFilters.tsx) - `FilterMenu` component logic +- [useYieldFilters.ts](file:///Users/alexandre.gomes/Sites/shapeshiftWebClone/src/pages/Yields/hooks/useYieldFilters.ts) - Filter state management + +**Proposed Fix**: +1. In `YieldFilters.tsx`, ensure the `networks` prop passed to `FilterMenu` is always the full list of networks (not filtered) +2. The filtering should only affect the yields list, not the filter options themselves +3. Verify `YieldsList.tsx` passes the complete `networks` array from `yields.meta.networks` + +--- + +### TODO 2: Debug Tron Deposit Not Working + +> [!CAUTION] +> **Issue**: Deposits on Tron chain don't work. Requires debugging. + +**Investigation Areas**: +- [executeTransaction.ts](file:///Users/alexandre.gomes/Sites/shapeshiftWebClone/src/lib/yieldxyz/executeTransaction.ts#L432-495) - `executeTronTransaction` function +- [constants.ts](file:///Users/alexandre.gomes/Sites/shapeshiftWebClone/src/lib/yieldxyz/constants.ts#L38) - Tron chain ID mapping + +**Debugging Steps**: +1. Verify `tronChainId` mapping to `YieldNetwork.Tron` +2. Check if `executeTronTransaction` handles all required fields +3. Verify Tron chain adapter is correctly initialized +4. Check for errors in transaction parsing/signing + +**Proposed Fix**: TBD after debugging - needs reproduction and console log analysis. + +--- + +### TODO 3: Fix Wrong Translation Chain on Approved Deposit Toast + +> [!NOTE] +> **Issue**: The toast notification for approved deposits shows wrong chain translation. + +**Investigation Areas**: +- [useYieldTransactionFlow.ts](file:///Users/alexandre.gomes/Sites/shapeshiftWebClone/src/pages/Yields/hooks/useYieldTransactionFlow.ts#L235-282) - `dispatchNotification` function +- Translation keys in `main.json` for yield notifications + +**Current Code Analysis**: +```typescript +// Line 275 in useYieldTransactionFlow.ts +message: formatYieldTxTitle(tx.title || 'Transaction', assetSymbol), +``` + +**Proposed Fix**: +1. Review the notification message construction in `dispatchNotification` +2. Ensure the correct chain name is passed to the translation +3. Check if `yieldChainId` is being properly resolved to chain name +4. Add chain name to the toast message if missing + +--- + +### TODO 4: Fix Transparent Icon on Yield Page + +> [!NOTE] +> **Issue**: Some icons appear transparent/invisible on yield pages. + +**Investigation Areas**: +- [YieldDetail.tsx](file:///Users/alexandre.gomes/Sites/shapeshiftWebClone/src/pages/Yields/YieldDetail.tsx#L100-127) - `heroIcon` element +- [YieldItem.tsx](file:///Users/alexandre.gomes/Sites/shapeshiftWebClone/src/pages/Yields/components/YieldItem.tsx#L130-177) - `iconElement` rendering +- CSS/styling for icon backgrounds + +**Proposed Fix**: +1. Add background color to icon containers for better visibility +2. Check if icons with transparency need a white/dark background based on color mode +3. Review `AssetIcon` component for proper fallback handling + +--- + +### TODO 5: Show Pending Deposits After Deposit (Solana) + +> [!IMPORTANT] +> **Issue**: Balance isn't updated after a deposit. For Solana, deposits are pending by nature - should show pending deposit status. + +**Investigation Areas**: +- [useAllYieldBalances.ts](file:///Users/alexandre.gomes/Sites/shapeshiftWebClone/src/react-queries/queries/yieldxyz/useAllYieldBalances.ts) - Balance fetching and normalization +- [YieldPositionCard.tsx](file:///Users/alexandre.gomes/Sites/shapeshiftWebClone/src/pages/Yields/components/YieldPositionCard.tsx) - Position display +- `YieldBalanceType` enum - includes `Entering` type for pending deposits + +**Current Code Analysis**: +The balance types already include: +- `Active` - Active staked balance +- `Entering` - Pending/entering deposits +- `Exiting` - Pending withdrawals +- `Claimable` - Rewards ready to claim + +**Proposed Fix**: +1. Ensure `Entering` balance type is properly displayed in UI +2. Add visual indicator for pending deposits in `YieldPositionCard` +3. Possibly add polling or refetch after deposit completion +4. Show "Pending" badge for `hasEntering` positions (already tracked in `ValidatorSummary`) + +--- + +### TODO 6: Fix Network Filter + Asset Selection Opening Wrong Yield + +> [!IMPORTANT] +> **Issue**: Selecting Ethereum as network, clicking on USDC, selecting the AAVE yield opens the BASE yield instead. + +**Investigation Areas**: +- [YieldItem.tsx](file:///Users/alexandre.gomes/Sites/shapeshiftWebClone/src/pages/Yields/components/YieldItem.tsx#L118-128) - `handleClick` navigation +- [YieldAssetDetails.tsx](file:///Users/alexandre.gomes/Sites/shapeshiftWebClone/src/pages/Yields/YieldAssetDetails.tsx) - Asset yields filtering +- [useYieldFilters.ts](file:///Users/alexandre.gomes/Sites/shapeshiftWebClone/src/pages/Yields/hooks/useYieldFilters.ts) - Filter state via URL params + +**Root Cause Hypothesis**: +When navigating from YieldsList → YieldAssetDetails → YieldDetail, the network filter state may not be properly passed through, or the filtered yields in YieldAssetDetails may not respect the network filter. + +**Proposed Fix**: +1. Pass network filter through URL params when navigating to asset details +2. In `YieldAssetDetails`, respect the `network` search param when filtering yields +3. When clicking a specific yield, ensure the correct yield ID is used based on filtered context +4. Consider storing selected yield context in navigation state + +--- + +## UX Improvements + +### TODO 7: Improve "You Could Earn More" Messaging + +> [!NOTE] +> **Issue**: "You could earn up to X% on your balance" messaging seems weird. Need to emphasize actual annual earnings. + +**Investigation Areas**: +- [YieldOpportunityCard.tsx](file:///Users/alexandre.gomes/Sites/shapeshiftWebClone/src/pages/Yields/components/YieldOpportunityCard.tsx#L25) - `earnUpToText` usage +- Translation key: `yieldXYZ.earnUpTo` = "You could earn up to %{apy}% on your balance" + +**Current Code**: +```typescript +const earnUpToText = useMemo(() => translate('yieldXYZ.earnUpTo', { apy }), [translate, apy]) +``` + +**Proposed Changes**: +1. Change messaging to show estimated annual earnings in $ amount +2. Calculate: `userBalance * APY = estimatedYearlyEarnings` +3. New message format: "Earn ~$X per year" or "Your $X balance could earn ~$Y/year at X% APY" +4. Update translation key and component to accept balance amount + +--- + +### TODO 8: Review Yield Opportunities in Account Page + +> [!NOTE] +> **Issue**: Review design and layout of yield opportunities section in account page. + +**Investigation Areas**: +- [AccountToken.tsx](file:///Users/alexandre.gomes/Sites/shapeshiftWebClone/src/pages/Accounts/AccountToken/AccountToken.tsx#L71) - Integration of `YieldAssetSection` +- [YieldAssetSection.tsx](file:///Users/alexandre.gomes/Sites/shapeshiftWebClone/src/pages/Yields/components/YieldAssetSection.tsx) - Section component +- [YieldOpportunityCard.tsx](file:///Users/alexandre.gomes/Sites/shapeshiftWebClone/src/pages/Yields/components/YieldOpportunityCard.tsx) - Card layout + +**Current Layout**: +- Section appears after `EarnOpportunities` in account page +- Shows `YieldActivePositions` if positions exist +- Shows `YieldOpportunityCard` for best yield if no positions + +**Design Review Points**: +1. Consistency with other account page sections +2. Card styling matches design system +3. Loading states are smooth +4. Empty states are handled gracefully + +--- + +## Design/Polish + +### TODO 9: Align Design with ShapeShift Design System + +> [!WARNING] +> **Issue**: Current design feels "very AI" - needs to follow ShapeShift's established design patterns. + +**Investigation Areas**: +- All yield component files in `src/pages/Yields/components/` +- Compare with established patterns in `src/components/` +- Color usage, spacing, typography + +**Design Audit Checklist**: +1. [ ] Color palette matches design system (avoid arbitrary colors) +2. [ ] Consistent use of Chakra UI theme tokens +3. [ ] Typography follows established patterns +4. [ ] Spacing/padding consistent with other pages +5. [ ] Dark/light mode support complete +6. [ ] Loading states match app patterns +7. [ ] Error states match app patterns +8. [ ] Empty states match app patterns +9. [ ] Animations/transitions smooth and purposeful + +**Files to Review**: +- [YieldsList.tsx](file:///Users/alexandre.gomes/Sites/shapeshiftWebClone/src/pages/Yields/components/YieldsList.tsx) +- [YieldItem.tsx](file:///Users/alexandre.gomes/Sites/shapeshiftWebClone/src/pages/Yields/components/YieldItem.tsx) +- [YieldDetail.tsx](file:///Users/alexandre.gomes/Sites/shapeshiftWebClone/src/pages/Yields/YieldDetail.tsx) +- [YieldEnterExit.tsx](file:///Users/alexandre.gomes/Sites/shapeshiftWebClone/src/pages/Yields/components/YieldEnterExit.tsx) +- [YieldActionModal.tsx](file:///Users/alexandre.gomes/Sites/shapeshiftWebClone/src/pages/Yields/components/YieldActionModal.tsx) + +--- + +### TODO 10: Improve Marketing Content/Messaging + +> [!NOTE] +> **Issue**: Improve messaging and user-facing content throughout yield features. + +**Investigation Areas**: +- Translation keys in `main.json` under `yieldXYZ` namespace +- Component text that may be hardcoded +- Call-to-action buttons +- Descriptions and help text + +**Content Review Points**: +1. Clear value proposition for users +2. Simple, non-technical language where possible +3. Consistent terminology (yield vs staking vs earning) +4. Helpful tooltips for complex concepts +5. Error messages are actionable + +--- + +## Summary Table + +| # | Type | Issue | Priority | Complexity | +|---|------|-------|----------|------------| +| 1 | Bug | Network filtering hides networks | High | Low | +| 2 | Bug | Tron deposit not working | High | Medium | +| 3 | Bug | Wrong toast translation | Medium | Low | +| 4 | Bug | Transparent icon | Low | Low | +| 5 | Bug | Balance not updated (pending) | Medium | Medium | +| 6 | Bug | Wrong yield opens | High | Medium | +| 7 | UX | Earn messaging improvement | Low | Low | +| 8 | UX | Account page layout review | Low | Medium | +| 9 | Design | Design system alignment | Medium | High | +| 10 | Design | Marketing content | Low | Medium | + +--- + +## Execution Order Recommendation + +1. **High priority bugs first**: TODOs 1, 2, 6 +2. **Medium priority bugs**: TODOs 3, 5 +3. **Low priority bugs**: TODO 4 +4. **UX improvements**: TODOs 7, 8 +5. **Design/polish**: TODOs 9, 10 + +--- + +*Generated from issue analysis on 2026-01-11* diff --git a/src/assets/translations/en/main.json b/src/assets/translations/en/main.json index 94c411e4918..044c7b0d22e 100644 --- a/src/assets/translations/en/main.json +++ b/src/assets/translations/en/main.json @@ -52,6 +52,7 @@ "withdrawal": "Withdrawal", "claim": "Claim", "claiming": "Claiming...", + "confirming": "Confirming...", "withdrawAndClaim": "Withdraw & Claim", "overview": "Overview", "connectWallet": "Connect Wallet", diff --git a/src/lib/yieldxyz/utils.ts b/src/lib/yieldxyz/utils.ts index 3fe6b72688f..baee6072ddf 100644 --- a/src/lib/yieldxyz/utils.ts +++ b/src/lib/yieldxyz/utils.ts @@ -19,10 +19,59 @@ const TX_TITLE_PATTERNS: [RegExp, string][] = [ [/supply|deposit|enter/i, 'Deposit'], [/withdraw|exit/i, 'Withdraw'], [/claim/i, 'Claim'], - [/unstake/i, 'Unstake'], - [/stake/i, 'Stake'], + [/unstake|undelegate/i, 'Unstake'], + [/stake|delegate/i, 'Stake'], + [/bridge/i, 'Bridge'], + [/swap/i, 'Swap'], ] +// Map of transaction types to user-friendly button labels +// These should match the action verbs shown in the step row (without the asset symbol) +const TX_TYPE_TO_LABEL: Record = { + APPROVE: 'Approve', + DELEGATE: 'Stake', // Monad uses DELEGATE for staking + UNDELEGATE: 'Unstake', // Monad uses UNDELEGATE for unstaking + STAKE: 'Stake', + UNSTAKE: 'Unstake', + DEPOSIT: 'Deposit', + WITHDRAW: 'Withdraw', + SUPPLY: 'Supply', + EXIT: 'Exit', + ENTER: 'Enter', + BRIDGE: 'Bridge', + SWAP: 'Swap', + CLAIM: 'Claim', + CLAIM_REWARDS: 'Claim', + TRANSFER: 'Transfer', +} + +/** + * Gets a clean button label from a transaction type or title. + * Used for the main CTA button in the yield action modal. + */ +export const getTransactionButtonText = ( + type: string | undefined, + title: string | undefined, +): string => { + // First try to use the transaction type directly + if (type) { + const normalized = type.toUpperCase().replace(/[_-]/g, '_') + if (TX_TYPE_TO_LABEL[normalized]) { + return TX_TYPE_TO_LABEL[normalized] + } + // Fallback: capitalize the type + return type.charAt(0).toUpperCase() + type.slice(1).toLowerCase() + } + + // Fall back to parsing the title + if (title) { + const match = TX_TITLE_PATTERNS.find(([pattern]) => pattern.test(title)) + if (match) return match[1] + } + + return 'Confirm' +} + export const formatYieldTxTitle = (title: string, assetSymbol: string): string => { const normalized = title.replace(/ transaction$/i, '').toLowerCase() const match = TX_TITLE_PATTERNS.find(([pattern]) => pattern.test(normalized)) diff --git a/src/pages/Yields/YieldAssetDetails.tsx b/src/pages/Yields/YieldAssetDetails.tsx index 644a8fae98a..fc44ff95bff 100644 --- a/src/pages/Yields/YieldAssetDetails.tsx +++ b/src/pages/Yields/YieldAssetDetails.tsx @@ -51,9 +51,10 @@ export const YieldAssetDetails = memo(() => { const setViewMode = useCallback( (mode: 'grid' | 'list') => { setSearchParams(prev => { - if (mode === 'grid') prev.delete('view') - else prev.set('view', mode) - return prev + const next = new URLSearchParams(prev) + if (mode === 'grid') next.delete('view') + else next.set('view', mode) + return next }) }, [setSearchParams], @@ -85,6 +86,8 @@ export const YieldAssetDetails = memo(() => { [yields, decodedSymbol], ) + // Networks available for THIS asset - since we're on an asset-specific page, + // we show only networks that have yields for this particular asset (not all global networks) const networks = useMemo( () => Array.from(new Set(assetYields.map(y => y.network))).map(net => ({ @@ -95,6 +98,7 @@ export const YieldAssetDetails = memo(() => { [assetYields], ) + // Providers available for THIS asset - shows only providers that offer yields for this asset const providers = useMemo( () => Array.from(new Set(assetYields.map(y => y.providerId))).map(pId => ({ diff --git a/src/pages/Yields/components/ValidatorBreakdown.tsx b/src/pages/Yields/components/ValidatorBreakdown.tsx index 067df5509a1..dd6b0efd6ba 100644 --- a/src/pages/Yields/components/ValidatorBreakdown.tsx +++ b/src/pages/Yields/components/ValidatorBreakdown.tsx @@ -342,8 +342,9 @@ export const ValidatorBreakdown = memo( (validatorAddress: string) => (e: React.MouseEvent) => { e.stopPropagation() setSearchParams(prev => { - prev.set('validator', validatorAddress) - return prev + const next = new URLSearchParams(prev) + next.set('validator', validatorAddress) + return next }) }, [setSearchParams], diff --git a/src/pages/Yields/components/YieldActionModal.tsx b/src/pages/Yields/components/YieldActionModal.tsx index 45924cb3f17..38131dfa8de 100644 --- a/src/pages/Yields/components/YieldActionModal.tsx +++ b/src/pages/Yields/components/YieldActionModal.tsx @@ -28,7 +28,9 @@ import { Amount } from '@/components/Amount/Amount' import { MiddleEllipsis } from '@/components/MiddleEllipsis/MiddleEllipsis' import { bnOrZero } from '@/lib/bignumber/bignumber' import type { AugmentedYieldDto } from '@/lib/yieldxyz/types' +import { formatYieldTxTitle, getTransactionButtonText } from '@/lib/yieldxyz/utils' import { GradientApy } from '@/pages/Yields/components/GradientApy' +import type { TransactionStep } from '@/pages/Yields/hooks/useYieldTransactionFlow' import { ModalStep, useYieldTransactionFlow } from '@/pages/Yields/hooks/useYieldTransactionFlow' import { useYieldProviders } from '@/react-queries/queries/yieldxyz/useYieldProviders' import { useYieldValidators } from '@/react-queries/queries/yieldxyz/useYieldValidators' @@ -86,10 +88,12 @@ export const YieldActionModal = memo(function YieldActionModal({ step, transactionSteps, isSubmitting, + activeStepIndex, canSubmit, handleConfirm, handleClose, isQuoteLoading, + quoteData, } = useYieldTransactionFlow({ yieldItem, action, @@ -197,16 +201,31 @@ export const YieldActionModal = memo(function YieldActionModal({ const loadingText = useMemo(() => { if (isQuoteLoading) return translate('yieldXYZ.loadingQuote') + // Use the current step's loading message if available + if (activeStepIndex >= 0 && transactionSteps[activeStepIndex]?.loadingMessage) { + return transactionSteps[activeStepIndex].loadingMessage + } if (action === 'enter') return translate('yieldXYZ.depositing') if (action === 'exit') return translate('yieldXYZ.withdrawing') return translate('common.claiming') - }, [isQuoteLoading, action, translate]) + }, [isQuoteLoading, action, translate, activeStepIndex, transactionSteps]) const buttonText = useMemo(() => { + // Use the current step's type/title for a clean button label (e.g., "Delegate", "Undelegate", "Approve") + if (activeStepIndex >= 0 && transactionSteps[activeStepIndex]) { + const step = transactionSteps[activeStepIndex] + return getTransactionButtonText(step.type, step.originalTitle) + } + // Before execution starts, use the first transaction from quoteData + if (quoteData?.transactions?.[0]) { + const firstTx = quoteData.transactions[0] + return getTransactionButtonText(firstTx.type, firstTx.title) + } + // Fallback to action-based text if (action === 'enter') return translate('yieldXYZ.deposit') if (action === 'exit') return translate('yieldXYZ.withdraw') return translate('common.claim') - }, [action, translate]) + }, [action, translate, activeStepIndex, transactionSteps, quoteData]) const modalHeading = useMemo(() => { if (action === 'enter') return translate('yieldXYZ.supplySymbol', { symbol: assetSymbol }) @@ -227,6 +246,24 @@ export const YieldActionModal = memo(function YieldActionModal({ [feeAsset?.networkIcon, feeAsset?.icon], ) + // Show steps from quoteData before execution starts, then switch to actual transactionSteps + const displaySteps = useMemo((): TransactionStep[] => { + // If we have transactionSteps (execution has started or completed), use those + if (transactionSteps.length > 0) { + return transactionSteps + } + // Before execution, create preview steps from quoteData + if (quoteData?.transactions?.length) { + return quoteData.transactions.map((tx, i) => ({ + title: formatYieldTxTitle(tx.title || `Transaction ${i + 1}`, assetSymbol), + originalTitle: tx.title || '', + type: tx.type, + status: 'pending' as const, + })) + } + return [] + }, [transactionSteps, quoteData, assetSymbol]) + const statusCard = useMemo( () => ( - {transactionSteps.map((s, idx) => ( + {displaySteps.map((s, idx) => ( (value === null ? selectedBg : undefined), [value, selectedBg]) const allItemColor = useMemo( () => (value === null ? selectedColor : undefined), [value, selectedColor], @@ -89,7 +87,6 @@ const FilterMenu = memo(({ label, value, options, onSelect, renderIcon }: Filter onSelect(opt.id)} - bg={isSelected ? selectedBg : undefined} color={isSelected ? selectedColor : undefined} fontWeight={isSelected ? 'semibold' : undefined} > @@ -100,7 +97,7 @@ const FilterMenu = memo(({ label, value, options, onSelect, renderIcon }: Filter ) }), - [options, value, selectedBg, selectedColor, renderIcon, onSelect], + [options, value, selectedColor, renderIcon, onSelect], ) return ( @@ -126,12 +123,7 @@ const FilterMenu = memo(({ label, value, options, onSelect, renderIcon }: Filter - + {label} {menuItems} diff --git a/src/pages/Yields/components/YieldsList.tsx b/src/pages/Yields/components/YieldsList.tsx index 9df01b97dba..5479f0bad0a 100644 --- a/src/pages/Yields/components/YieldsList.tsx +++ b/src/pages/Yields/components/YieldsList.tsx @@ -70,9 +70,10 @@ export const YieldsList = memo(() => { const setViewMode = useCallback( (mode: 'grid' | 'list') => { setSearchParams(prev => { - if (mode === 'grid') prev.delete('view') - else prev.set('view', mode) - return prev + const next = new URLSearchParams(prev) + if (mode === 'grid') next.delete('view') + else next.set('view', mode) + return next }) }, [setSearchParams], @@ -110,9 +111,10 @@ export const YieldsList = memo(() => { const handleTabChange = useCallback( (index: number) => { setSearchParams(prev => { - if (index === 0) prev.delete('tab') - else prev.set('tab', 'my-positions') - return prev + const next = new URLSearchParams(prev) + if (index === 0) next.delete('tab') + else next.set('tab', 'my-positions') + return next }) }, [setSearchParams], @@ -120,9 +122,10 @@ export const YieldsList = memo(() => { const handleToggleMyOpportunities = useCallback(() => { setSearchParams(prev => { - if (isMyOpportunities) prev.delete('filter') - else prev.set('filter', 'my-assets') - return prev + const next = new URLSearchParams(prev) + if (isMyOpportunities) next.delete('filter') + else next.set('filter', 'my-assets') + return next }) }, [isMyOpportunities, setSearchParams]) diff --git a/src/pages/Yields/hooks/useYieldFilters.ts b/src/pages/Yields/hooks/useYieldFilters.ts index e9bb091ba37..6b2d61e13e0 100644 --- a/src/pages/Yields/hooks/useYieldFilters.ts +++ b/src/pages/Yields/hooks/useYieldFilters.ts @@ -18,9 +18,10 @@ export const useYieldFilters = () => { const handleNetworkChange = useCallback( (network: string | null) => { setSearchParams(prev => { - if (!network) prev.delete('network') - else prev.set('network', network) - return prev + const next = new URLSearchParams(prev) + if (!network) next.delete('network') + else next.set('network', network) + return next }) }, [setSearchParams], @@ -29,9 +30,10 @@ export const useYieldFilters = () => { const handleProviderChange = useCallback( (provider: string | null) => { setSearchParams(prev => { - if (!provider) prev.delete('provider') - else prev.set('provider', provider) - return prev + const next = new URLSearchParams(prev) + if (!provider) next.delete('provider') + else next.set('provider', provider) + return next }) }, [setSearchParams], @@ -40,8 +42,9 @@ export const useYieldFilters = () => { const handleSortChange = useCallback( (option: SortOption) => { setSearchParams(prev => { - prev.set('sort', option) - return prev + const next = new URLSearchParams(prev) + next.set('sort', option) + return next }) }, [setSearchParams], diff --git a/src/pages/Yields/hooks/useYieldTransactionFlow.ts b/src/pages/Yields/hooks/useYieldTransactionFlow.ts index 8349bc90348..b620bfc69d5 100644 --- a/src/pages/Yields/hooks/useYieldTransactionFlow.ts +++ b/src/pages/Yields/hooks/useYieldTransactionFlow.ts @@ -43,6 +43,7 @@ export type TransactionStep = { title: string status: 'pending' | 'success' | 'loading' originalTitle: string + type?: string txHash?: string txUrl?: string loadingMessage?: string @@ -474,6 +475,7 @@ export const useYieldTransactionFlow = ({ transactions.map((tx, i) => ({ title: formatYieldTxTitle(tx.title || `Transaction ${i + 1}`, assetSymbol), originalTitle: tx.title || '', + type: tx.type, status: 'pending' as const, })), ) @@ -516,6 +518,7 @@ export const useYieldTransactionFlow = ({ handleConfirm, handleClose, isQuoteLoading, + quoteData, }), [ step, @@ -526,6 +529,7 @@ export const useYieldTransactionFlow = ({ handleConfirm, handleClose, isQuoteLoading, + quoteData, ], ) } From 5d6c60cea9147d165325d3cbeb1454e0774119ae Mon Sep 17 00:00:00 2001 From: gomes <17035424+gomesalexandre@users.noreply.github.com> Date: Sun, 11 Jan 2026 16:32:50 +0100 Subject: [PATCH 02/60] fix: yield modal SKIPPED transaction handling - Add APPROVAL type to TX_TYPE_TO_LABEL (button now shows "Approve" not "Approval") - Filter SKIPPED transactions in displaySteps preview - Use first CREATED transaction for button text - Harmonize button/row labels: SUPPLY/ENTER -> "Deposit", EXIT -> "Withdraw" - Fix useCallback missing dependencies in useYieldTransactionFlow - Add icon background color for yield detail hero - Add chainName to action transaction metadata Co-Authored-By: Claude Opus 4.5 --- src/lib/yieldxyz/utils.ts | 7 +++--- src/pages/Yields/YieldDetail.tsx | 6 ++++- .../Yields/components/YieldActionModal.tsx | 24 ++++++++++--------- .../Yields/hooks/useYieldTransactionFlow.ts | 14 ++++++++++- src/state/slices/actionSlice/types.ts | 1 + 5 files changed, 36 insertions(+), 16 deletions(-) diff --git a/src/lib/yieldxyz/utils.ts b/src/lib/yieldxyz/utils.ts index baee6072ddf..f71a9fb4993 100644 --- a/src/lib/yieldxyz/utils.ts +++ b/src/lib/yieldxyz/utils.ts @@ -29,15 +29,16 @@ const TX_TITLE_PATTERNS: [RegExp, string][] = [ // These should match the action verbs shown in the step row (without the asset symbol) const TX_TYPE_TO_LABEL: Record = { APPROVE: 'Approve', + APPROVAL: 'Approve', DELEGATE: 'Stake', // Monad uses DELEGATE for staking UNDELEGATE: 'Unstake', // Monad uses UNDELEGATE for unstaking STAKE: 'Stake', UNSTAKE: 'Unstake', DEPOSIT: 'Deposit', WITHDRAW: 'Withdraw', - SUPPLY: 'Supply', - EXIT: 'Exit', - ENTER: 'Enter', + SUPPLY: 'Deposit', + EXIT: 'Withdraw', + ENTER: 'Deposit', BRIDGE: 'Bridge', SWAP: 'Swap', CLAIM: 'Claim', diff --git a/src/pages/Yields/YieldDetail.tsx b/src/pages/Yields/YieldDetail.tsx index 43c2e759690..3587d39746f 100644 --- a/src/pages/Yields/YieldDetail.tsx +++ b/src/pages/Yields/YieldDetail.tsx @@ -98,6 +98,8 @@ export const YieldDetail = memo(() => { [error, heroBg, navigate, translate], ) + const iconBg = useColorModeValue('white', 'gray.800') + const heroIcon = useMemo(() => { if (!yieldItem) return null const iconSource = resolveYieldInputAssetIcon(yieldItem) @@ -111,6 +113,7 @@ export const YieldDetail = memo(() => { border='4px solid' borderColor={heroIconBorderColor} borderRadius='full' + bg={iconBg} /> ) return ( @@ -121,9 +124,10 @@ export const YieldDetail = memo(() => { border='4px solid' borderColor={heroIconBorderColor} borderRadius='full' + bg={iconBg} /> ) - }, [heroIconBorderColor, yieldItem]) + }, [heroIconBorderColor, yieldItem, iconBg]) const providerOrValidatorsElement = useMemo(() => { if (!yieldItem) return null diff --git a/src/pages/Yields/components/YieldActionModal.tsx b/src/pages/Yields/components/YieldActionModal.tsx index 38131dfa8de..1b0632d3501 100644 --- a/src/pages/Yields/components/YieldActionModal.tsx +++ b/src/pages/Yields/components/YieldActionModal.tsx @@ -216,10 +216,10 @@ export const YieldActionModal = memo(function YieldActionModal({ const step = transactionSteps[activeStepIndex] return getTransactionButtonText(step.type, step.originalTitle) } - // Before execution starts, use the first transaction from quoteData - if (quoteData?.transactions?.[0]) { - const firstTx = quoteData.transactions[0] - return getTransactionButtonText(firstTx.type, firstTx.title) + // Before execution starts, use the first CREATED transaction from quoteData + const firstCreatedTx = quoteData?.transactions?.find(tx => tx.status === 'CREATED') + if (firstCreatedTx) { + return getTransactionButtonText(firstCreatedTx.type, firstCreatedTx.title) } // Fallback to action-based text if (action === 'enter') return translate('yieldXYZ.deposit') @@ -252,14 +252,16 @@ export const YieldActionModal = memo(function YieldActionModal({ if (transactionSteps.length > 0) { return transactionSteps } - // Before execution, create preview steps from quoteData + // Before execution, create preview steps from quoteData (filter out SKIPPED transactions) if (quoteData?.transactions?.length) { - return quoteData.transactions.map((tx, i) => ({ - title: formatYieldTxTitle(tx.title || `Transaction ${i + 1}`, assetSymbol), - originalTitle: tx.title || '', - type: tx.type, - status: 'pending' as const, - })) + return quoteData.transactions + .filter(tx => tx.status === 'CREATED') + .map((tx, i) => ({ + title: formatYieldTxTitle(tx.title || `Transaction ${i + 1}`, assetSymbol), + originalTitle: tx.title || '', + type: tx.type, + status: 'pending' as const, + })) } return [] }, [transactionSteps, quoteData, assetSymbol]) diff --git a/src/pages/Yields/hooks/useYieldTransactionFlow.ts b/src/pages/Yields/hooks/useYieldTransactionFlow.ts index b620bfc69d5..e80dc99fe59 100644 --- a/src/pages/Yields/hooks/useYieldTransactionFlow.ts +++ b/src/pages/Yields/hooks/useYieldTransactionFlow.ts @@ -275,11 +275,23 @@ export const useYieldTransactionFlow = ({ accountId, message: formatYieldTxTitle(tx.title || 'Transaction', assetSymbol), amountCryptoPrecision: amount, + contractName: yieldItem.metadata.name, + chainName: yieldItem.network, }, }), ) }, - [dispatch, yieldChainId, accountId, action, yieldItem.token.assetId, assetSymbol, amount], + [ + dispatch, + yieldChainId, + accountId, + action, + yieldItem.token.assetId, + yieldItem.metadata.name, + yieldItem.network, + assetSymbol, + amount, + ], ) const buildCosmosStakeArgs = useCallback((): CosmosStakeArgs | undefined => { diff --git a/src/state/slices/actionSlice/types.ts b/src/state/slices/actionSlice/types.ts index 08fb078224b..e63a15d63d0 100644 --- a/src/state/slices/actionSlice/types.ts +++ b/src/state/slices/actionSlice/types.ts @@ -114,6 +114,7 @@ type ActionGenericTransactionMetadata = { amountCryptoPrecision: string | undefined newAddress?: string contractName?: string + chainName?: string cooldownPeriod?: string cooldownPeriodSeconds?: number thorMemo?: string | null From d1927d4ccf84e7588fd361d2f084f80be8e066f3 Mon Sep 17 00:00:00 2001 From: gomes <17035424+gomesalexandre@users.noreply.github.com> Date: Sun, 11 Jan 2026 16:40:42 +0100 Subject: [PATCH 03/60] fix: await query refetch after yield transactions - Change invalidateQueries to refetchQueries with await - Ensures position data is fresh before modal closes - Fixes "My Positions" showing empty after deposit/withdraw Co-Authored-By: Claude Opus 4.5 --- src/pages/Yields/hooks/useYieldTransactionFlow.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/pages/Yields/hooks/useYieldTransactionFlow.ts b/src/pages/Yields/hooks/useYieldTransactionFlow.ts index e80dc99fe59..f48f4f13705 100644 --- a/src/pages/Yields/hooks/useYieldTransactionFlow.ts +++ b/src/pages/Yields/hooks/useYieldTransactionFlow.ts @@ -361,8 +361,8 @@ export const useYieldTransactionFlow = ({ if (isLastTransaction) { await waitForActionCompletion(actionId) - queryClient.invalidateQueries({ queryKey: ['yieldxyz', 'allBalances'] }) - queryClient.invalidateQueries({ queryKey: ['yieldxyz', 'yields'] }) + await queryClient.refetchQueries({ queryKey: ['yieldxyz', 'allBalances'] }) + await queryClient.refetchQueries({ queryKey: ['yieldxyz', 'yields'] }) dispatchNotification(tx, txHash) updateStepStatus(index, { status: 'success', loadingMessage: undefined }) setStep(ModalStep.Success) @@ -378,8 +378,8 @@ export const useYieldTransactionFlow = ({ setActiveStepIndex(index + 1) } else { await waitForActionCompletion(actionId) - queryClient.invalidateQueries({ queryKey: ['yieldxyz', 'allBalances'] }) - queryClient.invalidateQueries({ queryKey: ['yieldxyz', 'yields'] }) + await queryClient.refetchQueries({ queryKey: ['yieldxyz', 'allBalances'] }) + await queryClient.refetchQueries({ queryKey: ['yieldxyz', 'yields'] }) dispatchNotification(tx, txHash) updateStepStatus(index, { status: 'success', loadingMessage: undefined }) setStep(ModalStep.Success) From f2c504f8295e860ccaf0a4aeb3c3dc2e95327dfa Mon Sep 17 00:00:00 2001 From: gomes <17035424+gomesalexandre@users.noreply.github.com> Date: Sun, 11 Jan 2026 16:51:51 +0100 Subject: [PATCH 04/60] fix: dispatch yield actions as Complete instead of Pending Since dispatchNotification is called after waitForActionCompletion, the transaction is already confirmed. Dispatch with Complete status directly instead of relying on Unchained subscriber (which doesn't work for chains without Unchained support like Monad). Co-Authored-By: Claude Opus 4.5 --- src/pages/Yields/hooks/useYieldTransactionFlow.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/pages/Yields/hooks/useYieldTransactionFlow.ts b/src/pages/Yields/hooks/useYieldTransactionFlow.ts index f48f4f13705..2d9fb9bd09e 100644 --- a/src/pages/Yields/hooks/useYieldTransactionFlow.ts +++ b/src/pages/Yields/hooks/useYieldTransactionFlow.ts @@ -260,11 +260,17 @@ export const useYieldTransactionFlow = ({ // For now, KISS and simply don't handle claims in action center. if (action === 'manage') return + const typeMessagesMap: Partial> = { + [ActionType.Deposit]: 'actionCenter.deposit.complete', + [ActionType.Withdraw]: 'actionCenter.withdrawal.complete', + [ActionType.Approve]: 'actionCenter.approve.approvalTxComplete', + } + dispatch( actionSlice.actions.upsertAction({ id: uuidv4(), type: actionType, - status: ActionStatus.Pending, + status: ActionStatus.Complete, createdAt: Date.now(), updatedAt: Date.now(), transactionMetadata: { @@ -273,7 +279,9 @@ export const useYieldTransactionFlow = ({ chainId: yieldChainId, assetId: yieldItem.token.assetId as AssetId, accountId, - message: formatYieldTxTitle(tx.title || 'Transaction', assetSymbol), + message: + typeMessagesMap[actionType] ?? + formatYieldTxTitle(tx.title || 'Transaction', assetSymbol), amountCryptoPrecision: amount, contractName: yieldItem.metadata.name, chainName: yieldItem.network, From 14d003e70bb92ba73fb05ed57c07e70e1eeea861 Mon Sep 17 00:00:00 2001 From: gomes <17035424+gomesalexandre@users.noreply.github.com> Date: Sun, 11 Jan 2026 17:01:31 +0100 Subject: [PATCH 05/60] fix: refetch portfolio balance for second class chains after yield tx After yield transaction completion, trigger portfolioApi.getAccount refetch for second class chains (Monad, Tron, etc.) that don't have Unchained support. This ensures wallet balances update immediately. Co-Authored-By: Claude Opus 4.5 --- .../Yields/hooks/useYieldTransactionFlow.ts | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/pages/Yields/hooks/useYieldTransactionFlow.ts b/src/pages/Yields/hooks/useYieldTransactionFlow.ts index 2d9fb9bd09e..146cac79ec2 100644 --- a/src/pages/Yields/hooks/useYieldTransactionFlow.ts +++ b/src/pages/Yields/hooks/useYieldTransactionFlow.ts @@ -1,11 +1,13 @@ import { useToast } from '@chakra-ui/react' import type { AssetId } from '@shapeshiftoss/caip' import { cosmosChainId, fromAccountId } from '@shapeshiftoss/caip' +import type { KnownChainIds } from '@shapeshiftoss/types' import { useQuery, useQueryClient } from '@tanstack/react-query' import { uuidv4 } from '@walletconnect/utils' import { useCallback, useMemo, useState } from 'react' import { useTranslate } from 'react-polyglot' +import { SECOND_CLASS_CHAINS } from '@/constants/chains' import { useWallet } from '@/hooks/useWallet/useWallet' import { bnOrZero } from '@/lib/bignumber/bignumber' import { enterYield, exitYield, fetchAction, manageYield } from '@/lib/yieldxyz/api' @@ -27,6 +29,7 @@ import { ActionType, GenericTransactionDisplayType, } from '@/state/slices/actionSlice/types' +import { portfolioApi } from '@/state/slices/portfolioSlice/portfolioSlice' import { selectPortfolioAccountMetadataByAccountId } from '@/state/slices/portfolioSlice/selectors' import { selectAccountIdByAccountNumberAndChainId, @@ -371,6 +374,18 @@ export const useYieldTransactionFlow = ({ await waitForActionCompletion(actionId) await queryClient.refetchQueries({ queryKey: ['yieldxyz', 'allBalances'] }) await queryClient.refetchQueries({ queryKey: ['yieldxyz', 'yields'] }) + if ( + yieldChainId && + accountId && + SECOND_CLASS_CHAINS.includes(yieldChainId as KnownChainIds) + ) { + dispatch( + portfolioApi.endpoints.getAccount.initiate( + { accountId, upsertOnFetch: true }, + { forceRefetch: true }, + ), + ) + } dispatchNotification(tx, txHash) updateStepStatus(index, { status: 'success', loadingMessage: undefined }) setStep(ModalStep.Success) @@ -388,6 +403,18 @@ export const useYieldTransactionFlow = ({ await waitForActionCompletion(actionId) await queryClient.refetchQueries({ queryKey: ['yieldxyz', 'allBalances'] }) await queryClient.refetchQueries({ queryKey: ['yieldxyz', 'yields'] }) + if ( + yieldChainId && + accountId && + SECOND_CLASS_CHAINS.includes(yieldChainId as KnownChainIds) + ) { + dispatch( + portfolioApi.endpoints.getAccount.initiate( + { accountId, upsertOnFetch: true }, + { forceRefetch: true }, + ), + ) + } dispatchNotification(tx, txHash) updateStepStatus(index, { status: 'success', loadingMessage: undefined }) setStep(ModalStep.Success) @@ -419,6 +446,7 @@ export const useYieldTransactionFlow = ({ queryClient, dispatchNotification, showErrorToast, + dispatch, ], ) From bf3bb789226d22035e46b28157ebd7af31072213 Mon Sep 17 00:00:00 2001 From: gomes <17035424+gomesalexandre@users.noreply.github.com> Date: Sun, 11 Jan 2026 17:12:39 +0100 Subject: [PATCH 06/60] fix: symbol page filters not working - Extract sortedRows from table to use as dependency - TanStack Table's table object reference is stable, so useMemo was not re-running when filtered data changed - Using sortedRows as dependency triggers proper re-memoization Co-Authored-By: Claude Opus 4.5 --- src/pages/Yields/YieldAssetDetails.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/pages/Yields/YieldAssetDetails.tsx b/src/pages/Yields/YieldAssetDetails.tsx index fc44ff95bff..0aeea1a69ac 100644 --- a/src/pages/Yields/YieldAssetDetails.tsx +++ b/src/pages/Yields/YieldAssetDetails.tsx @@ -291,6 +291,8 @@ export const YieldAssetDetails = memo(() => { onSortingChange: setSorting, }) + const sortedRows = table.getSortedRowModel().rows + const handleYieldClick = useCallback( (yieldId: string) => { const balances = allBalances?.[yieldId] @@ -357,7 +359,7 @@ export const YieldAssetDetails = memo(() => { const gridViewElement = useMemo( () => ( - {table.getSortedRowModel().rows.map(row => ( + {sortedRows.map(row => ( { ))} ), - [allBalances, getProviderLogo, handleYieldClick, table], + [allBalances, getProviderLogo, handleYieldClick, sortedRows], ) const listViewElement = useMemo( @@ -388,7 +390,9 @@ export const YieldAssetDetails = memo(() => { ), - [handleRowClick, table], + // sortedRows needed to trigger re-memoization when filtered data changes (table ref is stable) + // eslint-disable-next-line react-hooks/exhaustive-deps + [handleRowClick, sortedRows, table], ) const contentElement = useMemo(() => { From 4f632c55e7bf296374f519150a80f9c59924fb5b Mon Sep 17 00:00:00 2001 From: gomes <17035424+gomesalexandre@users.noreply.github.com> Date: Sun, 11 Jan 2026 17:44:49 +0100 Subject: [PATCH 07/60] feat: preserve filters when navigating to asset page - Add searchString prop to YieldItem - Pass current filter params when navigating to asset detail page - Filters persist across navigation (e.g., network=ethereum) Co-Authored-By: Claude Opus 4.5 --- src/pages/Yields/components/YieldItem.tsx | 580 +++++++++++---------- src/pages/Yields/components/YieldsList.tsx | 7 +- 2 files changed, 297 insertions(+), 290 deletions(-) diff --git a/src/pages/Yields/components/YieldItem.tsx b/src/pages/Yields/components/YieldItem.tsx index cbf6e465903..72da2e25d23 100644 --- a/src/pages/Yields/components/YieldItem.tsx +++ b/src/pages/Yields/components/YieldItem.tsx @@ -49,93 +49,119 @@ type YieldItemProps = { variant: 'card' | 'row' userBalanceUsd?: BigNumber onEnter?: (yieldItem: AugmentedYieldDto) => void + searchString?: string } -export const YieldItem = memo(({ data, variant, userBalanceUsd, onEnter }: YieldItemProps) => { - const navigate = useNavigate() - const translate = useTranslate() - const userCurrencyToUsdRate = useAppSelector(selectUserCurrencyToUsdRate) - const { data: yieldProviders } = useYieldProviders() +export const YieldItem = memo( + ({ data, variant, userBalanceUsd, onEnter, searchString }: YieldItemProps) => { + const navigate = useNavigate() + const translate = useTranslate() + const userCurrencyToUsdRate = useAppSelector(selectUserCurrencyToUsdRate) + const { data: yieldProviders } = useYieldProviders() - const borderColor = useColorModeValue('gray.100', 'gray.750') - const cardBg = useColorModeValue('white', 'gray.800') - const hoverBorderColor = useColorModeValue('blue.500', 'blue.400') - const hoverBg = useColorModeValue('gray.50', 'whiteAlpha.50') - const cardShadow = useColorModeValue('sm', 'none') - const cardHoverShadow = useColorModeValue('lg', 'lg') + const borderColor = useColorModeValue('gray.100', 'gray.750') + const cardBg = useColorModeValue('white', 'gray.800') + const hoverBorderColor = useColorModeValue('blue.500', 'blue.400') + const hoverBg = useColorModeValue('gray.50', 'whiteAlpha.50') + const cardShadow = useColorModeValue('sm', 'none') + const cardHoverShadow = useColorModeValue('lg', 'lg') - const isSingle = data.type === 'single' - const isGroup = data.type === 'group' + const isSingle = data.type === 'single' + const isGroup = data.type === 'group' - const stats = useMemo(() => { - if (isSingle) { - const y = data.yieldItem - return { - apy: y.rewardRate.total, - apyLabel: y.rewardRate.rateType, - tvlUsd: y.statistics?.tvlUsd ?? '0', - providers: [{ id: y.providerId, logo: data.providerIcon }], - chainIds: y.chainId ? [y.chainId] : [], - count: 1, - name: y.metadata.name, - canEnter: y.status.enter, + const stats = useMemo(() => { + if (isSingle) { + const y = data.yieldItem + return { + apy: y.rewardRate.total, + apyLabel: y.rewardRate.rateType, + tvlUsd: y.statistics?.tvlUsd ?? '0', + providers: [{ id: y.providerId, logo: data.providerIcon }], + chainIds: y.chainId ? [y.chainId] : [], + count: 1, + name: y.metadata.name, + canEnter: y.status.enter, + } } - } - const yields = data.yields - const maxApy = Math.max(0, ...yields.map(y => y.rewardRate.total)) - const totalTvlUsd = yields - .reduce((acc, y) => acc.plus(bnOrZero(y.statistics?.tvlUsd)), bnOrZero(0)) - .toFixed() - const providerIds = [...new Set(yields.map(y => y.providerId))] - const chainIds = [...new Set(yields.map(y => y.chainId).filter(Boolean))] as string[] + const yields = data.yields + const maxApy = Math.max(0, ...yields.map(y => y.rewardRate.total)) + const totalTvlUsd = yields + .reduce((acc, y) => acc.plus(bnOrZero(y.statistics?.tvlUsd)), bnOrZero(0)) + .toFixed() + const providerIds = [...new Set(yields.map(y => y.providerId))] + const chainIds = [...new Set(yields.map(y => y.chainId).filter(Boolean))] as string[] - return { - apy: maxApy, - apyLabel: 'APY', - tvlUsd: totalTvlUsd, - providers: providerIds.map(id => ({ id, logo: yieldProviders?.[id]?.logoURI })), - chainIds, - count: yields.length, - name: data.assetName, - canEnter: true, - } - }, [data, isSingle, yieldProviders]) + return { + apy: maxApy, + apyLabel: 'APY', + tvlUsd: totalTvlUsd, + providers: providerIds.map(id => ({ id, logo: yieldProviders?.[id]?.logoURI })), + chainIds, + count: yields.length, + name: data.assetName, + canEnter: true, + } + }, [data, isSingle, yieldProviders]) - const apyFormatted = useMemo(() => `${(stats.apy * 100).toFixed(2)}%`, [stats.apy]) + const apyFormatted = useMemo(() => `${(stats.apy * 100).toFixed(2)}%`, [stats.apy]) - const tvlUserCurrency = useMemo( - () => bnOrZero(stats.tvlUsd).times(userCurrencyToUsdRate).toFixed(), - [stats.tvlUsd, userCurrencyToUsdRate], - ) + const tvlUserCurrency = useMemo( + () => bnOrZero(stats.tvlUsd).times(userCurrencyToUsdRate).toFixed(), + [stats.tvlUsd, userCurrencyToUsdRate], + ) - const userBalanceUserCurrency = useMemo( - () => (userBalanceUsd ? userBalanceUsd.times(userCurrencyToUsdRate).toFixed() : undefined), - [userBalanceUsd, userCurrencyToUsdRate], - ) + const userBalanceUserCurrency = useMemo( + () => (userBalanceUsd ? userBalanceUsd.times(userCurrencyToUsdRate).toFixed() : undefined), + [userBalanceUsd, userCurrencyToUsdRate], + ) - const hasBalance = userBalanceUsd && userBalanceUsd.gt(0) + const hasBalance = userBalanceUsd && userBalanceUsd.gt(0) - const handleClick = useCallback(() => { - if (isSingle) { - if (stats.canEnter && onEnter) { - onEnter(data.yieldItem) + const handleClick = useCallback(() => { + if (isSingle) { + if (stats.canEnter && onEnter) { + onEnter(data.yieldItem) + } else { + navigate(`/yields/${data.yieldItem.id}`) + } } else { - navigate(`/yields/${data.yieldItem.id}`) + const suffix = searchString ? `?${searchString}` : '' + navigate(`/yields/asset/${encodeURIComponent(data.assetSymbol)}${suffix}`) } - } else { - navigate(`/yields/asset/${encodeURIComponent(data.assetSymbol)}`) - } - }, [data, isSingle, navigate, onEnter, stats.canEnter]) + }, [data, isSingle, navigate, onEnter, searchString, stats.canEnter]) - const iconElement = useMemo(() => { - if (isSingle) { - const iconSource = resolveYieldInputAssetIcon(data.yieldItem) + const iconElement = useMemo(() => { + if (isSingle) { + const iconSource = resolveYieldInputAssetIcon(data.yieldItem) + const size = variant === 'card' ? 'md' : 'sm' + if (iconSource.assetId) { + return ( + + ) + } + return ( + + ) + } const size = variant === 'card' ? 'md' : 'sm' - if (iconSource.assetId) { + if (data.assetId) { return ( ) - } - const size = variant === 'card' ? 'md' : 'sm' - if (data.assetId) { + }, [data, isSingle, variant, borderColor]) + + const subtitle = useMemo(() => { + if (isSingle) { + return data.yieldItem.providerId + } + return `${stats.count} ${ + stats.count === 1 ? translate('yieldXYZ.market') : translate('yieldXYZ.markets') + }` + }, [data, isSingle, stats.count, translate]) + + const title = useMemo(() => { + if (isSingle) return data.yieldItem.metadata.name + return data.assetSymbol + }, [data, isSingle]) + + if (variant === 'row') { return ( - + + + + {iconElement} + + + {title} + + + {subtitle} + + + + + + + {isGroup ? translate('yieldXYZ.maxApy') : translate('yieldXYZ.apy')} + + + {apyFormatted} + + + + + {translate('yieldXYZ.tvl')} + + + + + + + {isGroup ? ( + + {stats.providers.map(p => ( + + ))} + + ) : ( + + {stats.providers.slice(0, 1).map(p => ( + + ))} + + )} + + + {hasBalance ? ( + + + + ) : ( + + — + + )} + + + + ) } - return ( - - ) - }, [data, isSingle, variant, borderColor]) - - const subtitle = useMemo(() => { - if (isSingle) { - return data.yieldItem.providerId - } - return `${stats.count} ${ - stats.count === 1 ? translate('yieldXYZ.market') : translate('yieldXYZ.markets') - }` - }, [data, isSingle, stats.count, translate]) - - const title = useMemo(() => { - if (isSingle) return data.yieldItem.metadata.name - return data.assetSymbol - }, [data, isSingle]) - if (variant === 'row') { return ( - - - - {iconElement} - - - {title} - - - {subtitle} - - + + + + {iconElement} + + + {title} + + + {isSingle && data.providerIcon && ( + + )} + + {subtitle} + + + + - - - - {isGroup ? translate('yieldXYZ.maxApy') : translate('yieldXYZ.apy')} - - + + + + + {isGroup + ? translate('yieldXYZ.maxApy') + : `${translate('yieldXYZ.apy')} (${stats.apyLabel})`} + + {apyFormatted} - - - - - {translate('yieldXYZ.tvl')} - - - - - - - {isGroup ? ( - - {stats.providers.map(p => ( - - ))} - - ) : ( - - {stats.providers.slice(0, 1).map(p => ( - - ))} - - )} - - + + + {hasBalance ? ( - + - + ) : ( - - — - + <> + + {translate('yieldXYZ.tvl')} + + + + + )} - - - - - ) - } + + - return ( - - - - - {iconElement} - - - {title} - - - {isSingle && data.providerIcon && ( - - )} - - {subtitle} - + {isGroup && ( + + + + + {stats.providers.length}{' '} + {stats.providers.length === 1 + ? translate('yieldXYZ.protocol') + : translate('yieldXYZ.protocols')} + + + {stats.providers.map(p => ( + + ))} + + + + + {stats.chainIds.length}{' '} + {stats.chainIds.length === 1 + ? translate('yieldXYZ.chain') + : translate('yieldXYZ.chains')} + + + {stats.chainIds.slice(0, 5).map(chainId => ( + + ))} + + - - - - - - - {isGroup - ? translate('yieldXYZ.maxApy') - : `${translate('yieldXYZ.apy')} (${stats.apyLabel})`} - - - {apyFormatted} - - - - {hasBalance ? ( - - - - ) : ( - <> - - {translate('yieldXYZ.tvl')} - - - - - - )} - - - - {isGroup && ( - - - - - {stats.providers.length}{' '} - {stats.providers.length === 1 - ? translate('yieldXYZ.protocol') - : translate('yieldXYZ.protocols')} - - - {stats.providers.map(p => ( - - ))} - - - - - {stats.chainIds.length}{' '} - {stats.chainIds.length === 1 - ? translate('yieldXYZ.chain') - : translate('yieldXYZ.chains')} - - - {stats.chainIds.slice(0, 5).map(chainId => ( - - ))} - - - - - )} - - - ) -}) + )} + + + ) + }, +) export const YieldItemSkeleton = memo(({ variant }: { variant: 'card' | 'row' }) => { const borderColor = useColorModeValue('gray.100', 'gray.750') diff --git a/src/pages/Yields/components/YieldsList.tsx b/src/pages/Yields/components/YieldsList.tsx index 5479f0bad0a..62d90cfb6eb 100644 --- a/src/pages/Yields/components/YieldsList.tsx +++ b/src/pages/Yields/components/YieldsList.tsx @@ -79,6 +79,7 @@ export const YieldsList = memo(() => { [setSearchParams], ) const [searchQuery, setSearchQuery] = useState('') + const filterSearchString = useMemo(() => searchParams.toString(), [searchParams]) const { selectedNetwork, @@ -501,11 +502,12 @@ export const YieldsList = memo(() => { }} variant='card' userBalanceUsd={group.userGroupBalanceUsd} + searchString={filterSearchString} /> ))} ), - [yieldsByAsset], + [filterSearchString, yieldsByAsset], ) const allYieldsListElement = useMemo( @@ -560,11 +562,12 @@ export const YieldsList = memo(() => { }} variant='row' userBalanceUsd={group.userGroupBalanceUsd} + searchString={filterSearchString} /> ))} ), - [headerBg, translate, yieldsByAsset], + [filterSearchString, headerBg, translate, yieldsByAsset], ) const allYieldsContentElement = useMemo(() => { From f063d64f603d0d6d1d707998f035c8d6787e90b4 Mon Sep 17 00:00:00 2001 From: gomes <17035424+gomesalexandre@users.noreply.github.com> Date: Sun, 11 Jan 2026 17:50:54 +0100 Subject: [PATCH 08/60] fix: always fetch fresh quote when modal opens - Set staleTime: Infinity to prevent refetching during modal lifecycle - Set gcTime: 0 to garbage collect cached quote when modal closes - Ensures fresh quote on re-entry, no stale data from previous tx Co-Authored-By: Claude Opus 4.5 --- src/pages/Yields/hooks/useYieldTransactionFlow.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/pages/Yields/hooks/useYieldTransactionFlow.ts b/src/pages/Yields/hooks/useYieldTransactionFlow.ts index 146cac79ec2..1207315817c 100644 --- a/src/pages/Yields/hooks/useYieldTransactionFlow.ts +++ b/src/pages/Yields/hooks/useYieldTransactionFlow.ts @@ -215,7 +215,8 @@ export const useYieldTransactionFlow = ({ return fn({ yieldId: yieldItem.id, address: userAddress, arguments: txArguments }) }, enabled: !!txArguments && !!wallet && !!accountId && canSubmit && isOpen, - staleTime: 60_000, + staleTime: Infinity, + gcTime: 0, retry: false, }) From 1a4a1477837cd5cb67600ec4aed6bbe766d77144 Mon Sep 17 00:00:00 2001 From: gomes <17035424+gomesalexandre@users.noreply.github.com> Date: Sun, 11 Jan 2026 17:54:30 +0100 Subject: [PATCH 09/60] fix: remove cached quote on modal close - Remove quote queries from cache when modal closes - Ensures fresh quote fetch on subsequent modal opens - Remove YIELD_POLISH.md Co-Authored-By: Claude Opus 4.5 --- YIELD_POLISH.md | 272 ------------------ .../Yields/hooks/useYieldTransactionFlow.ts | 3 +- 2 files changed, 2 insertions(+), 273 deletions(-) delete mode 100644 YIELD_POLISH.md diff --git a/YIELD_POLISH.md b/YIELD_POLISH.md deleted file mode 100644 index aab03b0ce20..00000000000 --- a/YIELD_POLISH.md +++ /dev/null @@ -1,272 +0,0 @@ -# Yield.xyz Integration Polish - Execution Plan - -> **Context**: Bugfixes and polishes identified during testing of PR #11578. Feedback from @NeOMakinG. -> **Related Issue**: [#11611](https://github.com/shapeshift/web/issues/11611) -> **Related PR**: [#11578](https://github.com/shapeshift/web/pull/11578) - ---- - -## Execution Instructions - -Each section below is a **standalone TODO** that can be executed independently. Before proceeding with each item: - -1. **ASK** the user: "Should I proceed with TODO X: [Title]?" -2. **WAIT** for explicit confirmation before implementing -3. **AFTER** completion, ask: "TODO X complete. Should I continue to TODO Y?" - ---- - -## Bugs - -### TODO 1: Fix Network Filtering - Hiding Other Networks - -> [!IMPORTANT] -> **Issue**: Selecting a network hides other networks. Users should be able to click again to see all networks and instantly select another one. - -**Current Behavior**: When a network is selected in the filter dropdown, clicking the dropdown again only shows the selected network (or a subset). - -**Expected Behavior**: The dropdown should always display ALL available networks, with the currently selected one highlighted. Clicking "All Networks" should clear the filter. - -**Investigation Areas**: -- [YieldFilters.tsx](file:///Users/alexandre.gomes/Sites/shapeshiftWebClone/src/pages/Yields/components/YieldFilters.tsx) - `FilterMenu` component logic -- [useYieldFilters.ts](file:///Users/alexandre.gomes/Sites/shapeshiftWebClone/src/pages/Yields/hooks/useYieldFilters.ts) - Filter state management - -**Proposed Fix**: -1. In `YieldFilters.tsx`, ensure the `networks` prop passed to `FilterMenu` is always the full list of networks (not filtered) -2. The filtering should only affect the yields list, not the filter options themselves -3. Verify `YieldsList.tsx` passes the complete `networks` array from `yields.meta.networks` - ---- - -### TODO 2: Debug Tron Deposit Not Working - -> [!CAUTION] -> **Issue**: Deposits on Tron chain don't work. Requires debugging. - -**Investigation Areas**: -- [executeTransaction.ts](file:///Users/alexandre.gomes/Sites/shapeshiftWebClone/src/lib/yieldxyz/executeTransaction.ts#L432-495) - `executeTronTransaction` function -- [constants.ts](file:///Users/alexandre.gomes/Sites/shapeshiftWebClone/src/lib/yieldxyz/constants.ts#L38) - Tron chain ID mapping - -**Debugging Steps**: -1. Verify `tronChainId` mapping to `YieldNetwork.Tron` -2. Check if `executeTronTransaction` handles all required fields -3. Verify Tron chain adapter is correctly initialized -4. Check for errors in transaction parsing/signing - -**Proposed Fix**: TBD after debugging - needs reproduction and console log analysis. - ---- - -### TODO 3: Fix Wrong Translation Chain on Approved Deposit Toast - -> [!NOTE] -> **Issue**: The toast notification for approved deposits shows wrong chain translation. - -**Investigation Areas**: -- [useYieldTransactionFlow.ts](file:///Users/alexandre.gomes/Sites/shapeshiftWebClone/src/pages/Yields/hooks/useYieldTransactionFlow.ts#L235-282) - `dispatchNotification` function -- Translation keys in `main.json` for yield notifications - -**Current Code Analysis**: -```typescript -// Line 275 in useYieldTransactionFlow.ts -message: formatYieldTxTitle(tx.title || 'Transaction', assetSymbol), -``` - -**Proposed Fix**: -1. Review the notification message construction in `dispatchNotification` -2. Ensure the correct chain name is passed to the translation -3. Check if `yieldChainId` is being properly resolved to chain name -4. Add chain name to the toast message if missing - ---- - -### TODO 4: Fix Transparent Icon on Yield Page - -> [!NOTE] -> **Issue**: Some icons appear transparent/invisible on yield pages. - -**Investigation Areas**: -- [YieldDetail.tsx](file:///Users/alexandre.gomes/Sites/shapeshiftWebClone/src/pages/Yields/YieldDetail.tsx#L100-127) - `heroIcon` element -- [YieldItem.tsx](file:///Users/alexandre.gomes/Sites/shapeshiftWebClone/src/pages/Yields/components/YieldItem.tsx#L130-177) - `iconElement` rendering -- CSS/styling for icon backgrounds - -**Proposed Fix**: -1. Add background color to icon containers for better visibility -2. Check if icons with transparency need a white/dark background based on color mode -3. Review `AssetIcon` component for proper fallback handling - ---- - -### TODO 5: Show Pending Deposits After Deposit (Solana) - -> [!IMPORTANT] -> **Issue**: Balance isn't updated after a deposit. For Solana, deposits are pending by nature - should show pending deposit status. - -**Investigation Areas**: -- [useAllYieldBalances.ts](file:///Users/alexandre.gomes/Sites/shapeshiftWebClone/src/react-queries/queries/yieldxyz/useAllYieldBalances.ts) - Balance fetching and normalization -- [YieldPositionCard.tsx](file:///Users/alexandre.gomes/Sites/shapeshiftWebClone/src/pages/Yields/components/YieldPositionCard.tsx) - Position display -- `YieldBalanceType` enum - includes `Entering` type for pending deposits - -**Current Code Analysis**: -The balance types already include: -- `Active` - Active staked balance -- `Entering` - Pending/entering deposits -- `Exiting` - Pending withdrawals -- `Claimable` - Rewards ready to claim - -**Proposed Fix**: -1. Ensure `Entering` balance type is properly displayed in UI -2. Add visual indicator for pending deposits in `YieldPositionCard` -3. Possibly add polling or refetch after deposit completion -4. Show "Pending" badge for `hasEntering` positions (already tracked in `ValidatorSummary`) - ---- - -### TODO 6: Fix Network Filter + Asset Selection Opening Wrong Yield - -> [!IMPORTANT] -> **Issue**: Selecting Ethereum as network, clicking on USDC, selecting the AAVE yield opens the BASE yield instead. - -**Investigation Areas**: -- [YieldItem.tsx](file:///Users/alexandre.gomes/Sites/shapeshiftWebClone/src/pages/Yields/components/YieldItem.tsx#L118-128) - `handleClick` navigation -- [YieldAssetDetails.tsx](file:///Users/alexandre.gomes/Sites/shapeshiftWebClone/src/pages/Yields/YieldAssetDetails.tsx) - Asset yields filtering -- [useYieldFilters.ts](file:///Users/alexandre.gomes/Sites/shapeshiftWebClone/src/pages/Yields/hooks/useYieldFilters.ts) - Filter state via URL params - -**Root Cause Hypothesis**: -When navigating from YieldsList → YieldAssetDetails → YieldDetail, the network filter state may not be properly passed through, or the filtered yields in YieldAssetDetails may not respect the network filter. - -**Proposed Fix**: -1. Pass network filter through URL params when navigating to asset details -2. In `YieldAssetDetails`, respect the `network` search param when filtering yields -3. When clicking a specific yield, ensure the correct yield ID is used based on filtered context -4. Consider storing selected yield context in navigation state - ---- - -## UX Improvements - -### TODO 7: Improve "You Could Earn More" Messaging - -> [!NOTE] -> **Issue**: "You could earn up to X% on your balance" messaging seems weird. Need to emphasize actual annual earnings. - -**Investigation Areas**: -- [YieldOpportunityCard.tsx](file:///Users/alexandre.gomes/Sites/shapeshiftWebClone/src/pages/Yields/components/YieldOpportunityCard.tsx#L25) - `earnUpToText` usage -- Translation key: `yieldXYZ.earnUpTo` = "You could earn up to %{apy}% on your balance" - -**Current Code**: -```typescript -const earnUpToText = useMemo(() => translate('yieldXYZ.earnUpTo', { apy }), [translate, apy]) -``` - -**Proposed Changes**: -1. Change messaging to show estimated annual earnings in $ amount -2. Calculate: `userBalance * APY = estimatedYearlyEarnings` -3. New message format: "Earn ~$X per year" or "Your $X balance could earn ~$Y/year at X% APY" -4. Update translation key and component to accept balance amount - ---- - -### TODO 8: Review Yield Opportunities in Account Page - -> [!NOTE] -> **Issue**: Review design and layout of yield opportunities section in account page. - -**Investigation Areas**: -- [AccountToken.tsx](file:///Users/alexandre.gomes/Sites/shapeshiftWebClone/src/pages/Accounts/AccountToken/AccountToken.tsx#L71) - Integration of `YieldAssetSection` -- [YieldAssetSection.tsx](file:///Users/alexandre.gomes/Sites/shapeshiftWebClone/src/pages/Yields/components/YieldAssetSection.tsx) - Section component -- [YieldOpportunityCard.tsx](file:///Users/alexandre.gomes/Sites/shapeshiftWebClone/src/pages/Yields/components/YieldOpportunityCard.tsx) - Card layout - -**Current Layout**: -- Section appears after `EarnOpportunities` in account page -- Shows `YieldActivePositions` if positions exist -- Shows `YieldOpportunityCard` for best yield if no positions - -**Design Review Points**: -1. Consistency with other account page sections -2. Card styling matches design system -3. Loading states are smooth -4. Empty states are handled gracefully - ---- - -## Design/Polish - -### TODO 9: Align Design with ShapeShift Design System - -> [!WARNING] -> **Issue**: Current design feels "very AI" - needs to follow ShapeShift's established design patterns. - -**Investigation Areas**: -- All yield component files in `src/pages/Yields/components/` -- Compare with established patterns in `src/components/` -- Color usage, spacing, typography - -**Design Audit Checklist**: -1. [ ] Color palette matches design system (avoid arbitrary colors) -2. [ ] Consistent use of Chakra UI theme tokens -3. [ ] Typography follows established patterns -4. [ ] Spacing/padding consistent with other pages -5. [ ] Dark/light mode support complete -6. [ ] Loading states match app patterns -7. [ ] Error states match app patterns -8. [ ] Empty states match app patterns -9. [ ] Animations/transitions smooth and purposeful - -**Files to Review**: -- [YieldsList.tsx](file:///Users/alexandre.gomes/Sites/shapeshiftWebClone/src/pages/Yields/components/YieldsList.tsx) -- [YieldItem.tsx](file:///Users/alexandre.gomes/Sites/shapeshiftWebClone/src/pages/Yields/components/YieldItem.tsx) -- [YieldDetail.tsx](file:///Users/alexandre.gomes/Sites/shapeshiftWebClone/src/pages/Yields/YieldDetail.tsx) -- [YieldEnterExit.tsx](file:///Users/alexandre.gomes/Sites/shapeshiftWebClone/src/pages/Yields/components/YieldEnterExit.tsx) -- [YieldActionModal.tsx](file:///Users/alexandre.gomes/Sites/shapeshiftWebClone/src/pages/Yields/components/YieldActionModal.tsx) - ---- - -### TODO 10: Improve Marketing Content/Messaging - -> [!NOTE] -> **Issue**: Improve messaging and user-facing content throughout yield features. - -**Investigation Areas**: -- Translation keys in `main.json` under `yieldXYZ` namespace -- Component text that may be hardcoded -- Call-to-action buttons -- Descriptions and help text - -**Content Review Points**: -1. Clear value proposition for users -2. Simple, non-technical language where possible -3. Consistent terminology (yield vs staking vs earning) -4. Helpful tooltips for complex concepts -5. Error messages are actionable - ---- - -## Summary Table - -| # | Type | Issue | Priority | Complexity | -|---|------|-------|----------|------------| -| 1 | Bug | Network filtering hides networks | High | Low | -| 2 | Bug | Tron deposit not working | High | Medium | -| 3 | Bug | Wrong toast translation | Medium | Low | -| 4 | Bug | Transparent icon | Low | Low | -| 5 | Bug | Balance not updated (pending) | Medium | Medium | -| 6 | Bug | Wrong yield opens | High | Medium | -| 7 | UX | Earn messaging improvement | Low | Low | -| 8 | UX | Account page layout review | Low | Medium | -| 9 | Design | Design system alignment | Medium | High | -| 10 | Design | Marketing content | Low | Medium | - ---- - -## Execution Order Recommendation - -1. **High priority bugs first**: TODOs 1, 2, 6 -2. **Medium priority bugs**: TODOs 3, 5 -3. **Low priority bugs**: TODO 4 -4. **UX improvements**: TODOs 7, 8 -5. **Design/polish**: TODOs 9, 10 - ---- - -*Generated from issue analysis on 2026-01-11* diff --git a/src/pages/Yields/hooks/useYieldTransactionFlow.ts b/src/pages/Yields/hooks/useYieldTransactionFlow.ts index 1207315817c..200350d7a6a 100644 --- a/src/pages/Yields/hooks/useYieldTransactionFlow.ts +++ b/src/pages/Yields/hooks/useYieldTransactionFlow.ts @@ -453,13 +453,14 @@ export const useYieldTransactionFlow = ({ const handleClose = useCallback(() => { if (isSubmitting) return + queryClient.removeQueries({ queryKey: ['yieldxyz', 'quote'] }) setStep(ModalStep.InProgress) setTransactionSteps([]) setRawTransactions([]) setActiveStepIndex(-1) setCurrentActionId(null) onClose() - }, [isSubmitting, onClose]) + }, [isSubmitting, onClose, queryClient]) const handleConfirm = useCallback(async () => { if (activeStepIndex >= 0 && rawTransactions[activeStepIndex] && currentActionId) { From 358ed5616b28161480a3a5ac893d3aea204cf091 Mon Sep 17 00:00:00 2001 From: gomes <17035424+gomesalexandre@users.noreply.github.com> Date: Mon, 12 Jan 2026 13:36:06 +0100 Subject: [PATCH 10/60] feat(yields): add YieldEnterModal for staking from asset pages - Create YieldEnterModal component with Trust Wallet-inspired design - Big centered input with fiat/crypto toggle - Asset icon, percent buttons (25%, 50%, 75%, Max) - Debounced quote fetching (500ms) for pre-loading transactions - Auto-select default validator (ShapeShift DAO for Cosmos) - Stats section with APY, validator/provider info, and min deposit - Modify YieldAssetSection to open modal instead of navigate - Add translation key for "Stake {asset}" Co-Authored-By: Claude Opus 4.5 --- src/assets/translations/en/main.json | 1 + .../Yields/components/YieldAssetSection.tsx | 52 +- .../Yields/components/YieldEnterModal.tsx | 593 ++++++++++++++++++ 3 files changed, 627 insertions(+), 19 deletions(-) create mode 100644 src/pages/Yields/components/YieldEnterModal.tsx diff --git a/src/assets/translations/en/main.json b/src/assets/translations/en/main.json index 044c7b0d22e..5e9a9c26c4b 100644 --- a/src/assets/translations/en/main.json +++ b/src/assets/translations/en/main.json @@ -2671,6 +2671,7 @@ "pageSubtitle": "Discover and manage yield opportunities across multiple chains", "enter": "Enter", "exit": "Exit", + "stakeAsset": "Stake %{asset}", "yield": "Yield", "apy": "APY", "apr": "APR", diff --git a/src/pages/Yields/components/YieldAssetSection.tsx b/src/pages/Yields/components/YieldAssetSection.tsx index 636e039abf5..f3ab5221131 100644 --- a/src/pages/Yields/components/YieldAssetSection.tsx +++ b/src/pages/Yields/components/YieldAssetSection.tsx @@ -1,11 +1,11 @@ import { Box, Heading, Stack, VStack } from '@chakra-ui/react' import type { AccountId, AssetId } from '@shapeshiftoss/caip' import { fromAccountId } from '@shapeshiftoss/caip' -import { memo, useCallback, useMemo } from 'react' +import { memo, useCallback, useMemo, useState } from 'react' import { useTranslate } from 'react-polyglot' -import { useNavigate } from 'react-router-dom' import { YieldActivePositions } from './YieldActivePositions' +import { YieldEnterModal } from './YieldEnterModal' import { YieldItemSkeleton } from './YieldItem' import { YieldOpportunityCard } from './YieldOpportunityCard' @@ -25,7 +25,6 @@ type YieldAssetSectionProps = { export const YieldAssetSection = memo(({ assetId, accountId }: YieldAssetSectionProps) => { const translate = useTranslate() - const navigate = useNavigate() const isYieldXyzEnabled = useFeatureFlag('YieldXyz') const asset = useAppSelector(state => selectAssetById(state, assetId)) const { data: yieldsData, isLoading: isYieldsLoading } = useYields() @@ -34,6 +33,9 @@ export const YieldAssetSection = memo(({ assetId, accountId }: YieldAssetSection useAllYieldBalances(balanceOptions) const isLoading = isYieldsLoading || isBalancesLoading + const [isEnterModalOpen, setIsEnterModalOpen] = useState(false) + const [selectedYield, setSelectedYield] = useState(null) + const yields = useMemo(() => { if (!yieldsData?.all || !asset) return [] return yieldsData.all.filter(yieldItem => { @@ -78,12 +80,15 @@ export const YieldAssetSection = memo(({ assetId, accountId }: YieldAssetSection const hasActivePositions = Object.keys(aggregated).length > 0 - const handleOpportunityClick = useCallback( - (yieldItem: AugmentedYieldDto) => { - navigate(`/yields/${yieldItem.id}`) - }, - [navigate], - ) + const handleOpportunityClick = useCallback((yieldItem: AugmentedYieldDto) => { + setSelectedYield(yieldItem) + setIsEnterModalOpen(true) + }, []) + + const handleEnterModalClose = useCallback(() => { + setIsEnterModalOpen(false) + setSelectedYield(null) + }, []) const yieldHeading = translate('yieldXYZ.yield') ?? 'Yield' @@ -111,15 +116,24 @@ export const YieldAssetSection = memo(({ assetId, accountId }: YieldAssetSection if (!isLoading && yields.length === 0) return null return ( - - - {yieldHeading} - - - {hasActivePositions && activePositionsContent} - {isLoading && loadingContent} - {!isLoading && !hasActivePositions && opportunityCardContent} - - + <> + + + {yieldHeading} + + + {hasActivePositions && activePositionsContent} + {isLoading && loadingContent} + {!isLoading && !hasActivePositions && opportunityCardContent} + + + {selectedYield && ( + + )} + ) }) diff --git a/src/pages/Yields/components/YieldEnterModal.tsx b/src/pages/Yields/components/YieldEnterModal.tsx new file mode 100644 index 00000000000..8fbc8f70270 --- /dev/null +++ b/src/pages/Yields/components/YieldEnterModal.tsx @@ -0,0 +1,593 @@ +import { + Avatar, + Box, + Button, + Flex, + HStack, + Icon, + Input, + Modal, + ModalBody, + ModalCloseButton, + ModalContent, + ModalHeader, + ModalOverlay, + Skeleton, + Text, + useColorModeValue, +} from '@chakra-ui/react' +import { cosmosChainId, fromAccountId } from '@shapeshiftoss/caip' +import { useQuery } from '@tanstack/react-query' +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' +import { useTranslate } from 'react-polyglot' + +import { Amount } from '@/components/Amount/Amount' +import { AssetIcon } from '@/components/AssetIcon' +import { WalletActions } from '@/context/WalletProvider/actions' +import { useDebounce } from '@/hooks/useDebounce/useDebounce' +import { useLocaleFormatter } from '@/hooks/useLocaleFormatter/useLocaleFormatter' +import { useWallet } from '@/hooks/useWallet/useWallet' +import { bnOrZero } from '@/lib/bignumber/bignumber' +import { enterYield } from '@/lib/yieldxyz/api' +import { + DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID, + SHAPESHIFT_COSMOS_VALIDATOR_ADDRESS, + SHAPESHIFT_VALIDATOR_LOGO, +} from '@/lib/yieldxyz/constants' +import type { AugmentedYieldDto } from '@/lib/yieldxyz/types' +import { GradientApy } from '@/pages/Yields/components/GradientApy' +import { YieldActionModal } from '@/pages/Yields/components/YieldActionModal' +import { useYieldProviders } from '@/react-queries/queries/yieldxyz/useYieldProviders' +import { useYieldValidators } from '@/react-queries/queries/yieldxyz/useYieldValidators' +import { allowedDecimalSeparators } from '@/state/slices/preferencesSlice/preferencesSlice' +import { + selectAccountIdByAccountNumberAndChainId, + selectMarketDataByAssetIdUserCurrency, + selectPortfolioCryptoPrecisionBalanceByFilter, +} from '@/state/slices/selectors' +import { useAppSelector } from '@/state/store' + +type YieldEnterModalProps = { + isOpen: boolean + onClose: () => void + yieldItem: AugmentedYieldDto + accountNumber?: number +} + +const QUOTE_DEBOUNCE_MS = 500 + +const FONT_SIZE_THRESHOLDS = { + SMALL: 10, + MEDIUM: 14, + LARGE: 22, +} as const + +const getFontSizeByLength = (length: number): string => { + if (length >= FONT_SIZE_THRESHOLDS.LARGE) return '24px' + if (length >= FONT_SIZE_THRESHOLDS.MEDIUM) return '30px' + if (length >= FONT_SIZE_THRESHOLDS.SMALL) return '38px' + return '48px' +} + +type BigAmountInputProps = { + value?: string + onChange?: (e: React.ChangeEvent) => void + placeholder?: string + [key: string]: unknown +} + +const BigAmountInput = (props: BigAmountInputProps) => { + const valueLength = useMemo(() => (props.value ? String(props.value).length : 0), [props.value]) + const fontSize = useMemo(() => getFontSizeByLength(valueLength), [valueLength]) + + return ( + + ) +} + +const YieldEnterModalSkeleton = memo(() => ( + + + + +)) + +export const YieldEnterModal = memo( + ({ isOpen, onClose, yieldItem, accountNumber = 0 }: YieldEnterModalProps) => { + const translate = useTranslate() + const { state: walletState, dispatch } = useWallet() + const wallet = walletState.wallet + const isConnected = useMemo(() => Boolean(walletState.walletInfo), [walletState.walletInfo]) + const { + number: { localeParts, toCrypto, toFiat }, + } = useLocaleFormatter() + + const modalBg = useColorModeValue('white', 'gray.800') + const borderColor = useColorModeValue('gray.200', 'gray.700') + const statsBg = useColorModeValue('gray.50', 'whiteAlpha.50') + const statsBorderColor = useColorModeValue('gray.100', 'whiteAlpha.100') + const percentButtonBg = useColorModeValue('gray.100', 'whiteAlpha.100') + const percentButtonHoverBg = useColorModeValue('gray.200', 'whiteAlpha.200') + const percentButtonActiveBg = useColorModeValue('blue.100', 'blue.800') + const percentButtonActiveColor = useColorModeValue('blue.600', 'blue.200') + + const [cryptoAmount, setCryptoAmount] = useState('') + const [isFiat, setIsFiat] = useState(false) + const [isActionModalOpen, setIsActionModalOpen] = useState(false) + + const debouncedAmount = useDebounce(cryptoAmount, QUOTE_DEBOUNCE_MS) + + const { chainId } = yieldItem + + const shouldFetchValidators = useMemo( + () => + yieldItem.mechanics.type === 'staking' && yieldItem.mechanics.requiresValidatorSelection, + [yieldItem.mechanics.type, yieldItem.mechanics.requiresValidatorSelection], + ) + const { data: validators, isLoading: isValidatorsLoading } = useYieldValidators( + yieldItem.id, + shouldFetchValidators, + ) + + const selectedValidatorAddress = useMemo(() => { + if (chainId && DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID[chainId]) { + return DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID[chainId] + } + return validators?.[0]?.address + }, [chainId, validators]) + + const { data: providers } = useYieldProviders() + + const isStaking = yieldItem.mechanics.type === 'staking' + + const selectedValidatorMetadata = useMemo(() => { + if (!isStaking || !selectedValidatorAddress) return null + const found = validators?.find(v => v.address === selectedValidatorAddress) + if (found) return found + if (selectedValidatorAddress === SHAPESHIFT_COSMOS_VALIDATOR_ADDRESS) { + return { + name: 'ShapeShift DAO', + logoURI: SHAPESHIFT_VALIDATOR_LOGO, + address: selectedValidatorAddress, + } + } + return null + }, [isStaking, selectedValidatorAddress, validators]) + + const providerMetadata = useMemo(() => { + if (!providers) return null + return providers[yieldItem.providerId] + }, [providers, yieldItem.providerId]) + + const accountId = useAppSelector(state => { + if (!chainId) return undefined + const accountIdsByNumberAndChain = selectAccountIdByAccountNumberAndChainId(state) + return accountIdsByNumberAndChain[accountNumber]?.[chainId] + }) + + const userAddress = useMemo( + () => (accountId ? fromAccountId(accountId).account : ''), + [accountId], + ) + + const inputToken = yieldItem.inputTokens[0] + const inputTokenAssetId = inputToken?.assetId + const inputSymbol = inputToken?.symbol ?? '' + const inputPrecision = inputToken?.decimals ?? 18 + + const inputTokenBalance = useAppSelector(state => + inputTokenAssetId && accountId + ? selectPortfolioCryptoPrecisionBalanceByFilter(state, { + assetId: inputTokenAssetId, + accountId, + }) + : '0', + ) + + const marketData = useAppSelector(state => + selectMarketDataByAssetIdUserCurrency(state, inputTokenAssetId ?? ''), + ) + + const minDeposit = yieldItem.mechanics?.entryLimits?.minimum + + const isBelowMinimum = useMemo(() => { + if (!cryptoAmount || !minDeposit) return false + return bnOrZero(cryptoAmount).lt(minDeposit) + }, [cryptoAmount, minDeposit]) + + const txArguments = useMemo(() => { + if (!yieldItem || !userAddress || !chainId || !debouncedAmount) return null + if (!bnOrZero(debouncedAmount).gt(0)) return null + + const fields = yieldItem.mechanics.arguments.enter.fields + const fieldNames = new Set(fields.map(field => field.name)) + const args: Record = { amount: debouncedAmount } + + if (fieldNames.has('receiverAddress')) { + args.receiverAddress = userAddress + } + + if (fieldNames.has('validatorAddress') && chainId) { + args.validatorAddress = + selectedValidatorAddress || DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID[chainId] + } + + if (fieldNames.has('cosmosPubKey') && chainId === cosmosChainId) { + args.cosmosPubKey = userAddress + } + + return args + }, [yieldItem, userAddress, chainId, debouncedAmount, selectedValidatorAddress]) + + const { isLoading: isQuoteLoading, isFetching: isQuoteFetching } = useQuery({ + queryKey: ['yieldxyz', 'quote', 'enter', yieldItem.id, userAddress, txArguments], + queryFn: () => { + if (!txArguments || !userAddress || !yieldItem.id) throw new Error('Missing arguments') + return enterYield({ yieldId: yieldItem.id, address: userAddress, arguments: txArguments }) + }, + enabled: + !!txArguments && !!wallet && !!accountId && isOpen && bnOrZero(debouncedAmount).gt(0), + staleTime: 30_000, + gcTime: 60_000, + retry: false, + }) + + const isLoading = isValidatorsLoading + const isQuoteActive = isQuoteLoading || isQuoteFetching + + const fiatAmount = useMemo( + () => bnOrZero(cryptoAmount).times(marketData?.price ?? 0), + [cryptoAmount, marketData?.price], + ) + + const balanceFiat = useMemo( + () => bnOrZero(inputTokenBalance).times(marketData?.price ?? 0), + [inputTokenBalance, marketData?.price], + ) + + const apy = useMemo(() => bnOrZero(yieldItem.rewardRate.total), [yieldItem.rewardRate.total]) + const apyDisplay = useMemo(() => `${apy.times(100).toFixed(2)}%`, [apy]) + + const estimatedYearlyEarnings = useMemo( + () => bnOrZero(cryptoAmount).times(apy), + [cryptoAmount, apy], + ) + + const estimatedYearlyEarningsFiat = useMemo( + () => estimatedYearlyEarnings.times(marketData?.price ?? 0), + [estimatedYearlyEarnings, marketData?.price], + ) + + const hasAmount = bnOrZero(cryptoAmount).gt(0) + + const displayPlaceholder = useMemo( + () => (isFiat ? `${localeParts.prefix}0` : '0'), + [isFiat, localeParts.prefix], + ) + + const handleInputChange = useCallback( + (values: NumberFormatValues) => { + setSelectedPercent(null) + if (isFiat) { + const crypto = bnOrZero(values.value) + .div(marketData?.price ?? 1) + .toFixed() + setCryptoAmount(crypto) + } else { + setCryptoAmount(values.value) + } + }, + [isFiat, marketData?.price], + ) + + const displayValue = useMemo(() => { + if (isFiat) { + return fiatAmount.toFixed(2) + } + return cryptoAmount + }, [isFiat, fiatAmount, cryptoAmount]) + + const toggleIsFiat = useCallback(() => setIsFiat(prev => !prev), []) + + const [selectedPercent, setSelectedPercent] = useState(null) + + const handlePercentClick = useCallback( + (percent: number) => { + const percentAmount = bnOrZero(inputTokenBalance).times(percent).toFixed() + setCryptoAmount(percentAmount) + setSelectedPercent(percent) + }, + [inputTokenBalance], + ) + + const handleEnterClick = useCallback(() => { + setIsActionModalOpen(true) + }, []) + + const handleConnectWallet = useCallback( + () => dispatch({ type: WalletActions.SET_WALLET_MODAL, payload: true }), + [dispatch], + ) + + const handleActionModalClose = useCallback(() => { + setIsActionModalOpen(false) + }, []) + + const handleModalClose = useCallback(() => { + setCryptoAmount('') + setSelectedPercent(null) + setIsFiat(false) + onClose() + }, [onClose]) + + const enterButtonDisabled = useMemo( + () => + isConnected && (isLoading || !yieldItem.status.enter || !cryptoAmount || isBelowMinimum), + [isConnected, isLoading, yieldItem.status.enter, cryptoAmount, isBelowMinimum], + ) + + const enterButtonText = useMemo(() => { + if (!isConnected) return translate('common.connectWallet') + if (isQuoteActive) return translate('yieldXYZ.loadingQuote') + return translate('yieldXYZ.stakeAsset', { asset: inputSymbol }) + }, [isConnected, isQuoteActive, translate, inputSymbol]) + + const handleEnterButtonClick = useMemo( + () => (isConnected ? handleEnterClick : handleConnectWallet), + [isConnected, handleEnterClick, handleConnectWallet], + ) + + const modalTitle = useMemo( + () => translate('yieldXYZ.stakeAsset', { asset: inputSymbol }), + [translate, inputSymbol], + ) + + const percentButtons = useMemo( + () => ( + + {[0.25, 0.5, 0.75, 1].map(percent => { + const isSelected = selectedPercent === percent + return ( + + ) + })} + + ), + [ + selectedPercent, + percentButtonActiveBg, + percentButtonBg, + percentButtonActiveColor, + percentButtonHoverBg, + handlePercentClick, + ], + ) + + const statsContent = useMemo( + () => ( + + + + {translate('yieldXYZ.currentApy')} + + + {apyDisplay} + + + {hasAmount && ( + + + {translate('yieldXYZ.estYearlyEarnings')} + + + + {estimatedYearlyEarnings.decimalPlaces(4).toString()} {inputSymbol} + + + + + + + )} + {isStaking && selectedValidatorMetadata && ( + + + {translate('yieldXYZ.validator')} + + + + + {selectedValidatorMetadata.name} + + + + )} + {!isStaking && providerMetadata && ( + + + {translate('yieldXYZ.provider')} + + + + + {providerMetadata.name} + + + + )} + {minDeposit && bnOrZero(minDeposit).gt(0) && ( + + + {translate('yieldXYZ.minDeposit')} + + + {minDeposit} {inputSymbol} + + + )} + + ), + [ + statsBg, + statsBorderColor, + translate, + apyDisplay, + hasAmount, + estimatedYearlyEarnings, + inputSymbol, + estimatedYearlyEarningsFiat, + isStaking, + selectedValidatorMetadata, + providerMetadata, + minDeposit, + isBelowMinimum, + ], + ) + + const balanceDisplay = useMemo(() => { + const cryptoDisplay = toCrypto(bnOrZero(inputTokenBalance), inputSymbol) + const fiatDisplay = toFiat(balanceFiat.toString()) + return `${translate('common.balance')}: ${cryptoDisplay} (${fiatDisplay})` + }, [inputTokenBalance, inputSymbol, balanceFiat, translate, toCrypto, toFiat]) + + const inputContent = useMemo(() => { + if (isLoading) return + + return ( + + {inputTokenAssetId && } + + + + {isFiat ? ( + + ) : ( + + )} + + + + + {balanceDisplay} + + + ) + }, [ + isLoading, + inputTokenAssetId, + isFiat, + inputPrecision, + localeParts, + displayValue, + displayPlaceholder, + inputSymbol, + handleInputChange, + toggleIsFiat, + cryptoAmount, + fiatAmount, + balanceDisplay, + ]) + + return ( + <> + + + + + {modalTitle} + + + + + {inputContent} + {percentButtons} + {statsContent} + + + + + + + + ) + }, +) From 8fa0fd8a21dab537e0812ce673eb220731f8816d Mon Sep 17 00:00:00 2001 From: gomes <17035424+gomesalexandre@users.noreply.github.com> Date: Mon, 12 Jan 2026 14:56:29 +0100 Subject: [PATCH 11/60] wip: yield enter modal polish and code cleanup - Add TransactionStepsList component for shared step display - Extract useConfetti hook to eliminate duplication - Export poll/waitForActionCompletion from useYieldTransactionFlow - Update YieldOpportunityCard with horizontal layout - Use Card variant='dashboard' in YieldAssetSection - Add tx hash links in success view Co-Authored-By: Claude Opus 4.5 --- src/assets/translations/en/main.json | 1 + .../components/TransactionStepsList.tsx | 86 +++ .../Yields/components/YieldActionModal.tsx | 58 +- .../Yields/components/YieldAssetSection.tsx | 24 +- .../Yields/components/YieldEnterModal.tsx | 500 ++++++++++++++---- .../components/YieldOpportunityCard.tsx | 41 +- src/pages/Yields/hooks/useConfetti.ts | 44 ++ .../Yields/hooks/useYieldTransactionFlow.ts | 6 +- vite.config.mts | 1 + 9 files changed, 573 insertions(+), 188 deletions(-) create mode 100644 src/pages/Yields/components/TransactionStepsList.tsx create mode 100644 src/pages/Yields/hooks/useConfetti.ts diff --git a/src/assets/translations/en/main.json b/src/assets/translations/en/main.json index 5e9a9c26c4b..7d56add95f0 100644 --- a/src/assets/translations/en/main.json +++ b/src/assets/translations/en/main.json @@ -2716,6 +2716,7 @@ "opportunities": "Opportunities", "yields": "Yields", "earnUpTo": "You could earn up to %{apy}% on your balance", + "earnUpToOnBalance": "You could earn up to %{apy} on your balance", "startEarning": "Start earning", "maxApy": "Max APY", "validator": "Validator", diff --git a/src/pages/Yields/components/TransactionStepsList.tsx b/src/pages/Yields/components/TransactionStepsList.tsx new file mode 100644 index 00000000000..e2414e63e94 --- /dev/null +++ b/src/pages/Yields/components/TransactionStepsList.tsx @@ -0,0 +1,86 @@ +import { Box, Flex, Icon, Link, Spinner, Text } from '@chakra-ui/react' +import { memo } from 'react' +import { FaCheck, FaExternalLinkAlt } from 'react-icons/fa' +import { useTranslate } from 'react-polyglot' + +import { MiddleEllipsis } from '@/components/MiddleEllipsis/MiddleEllipsis' +import type { TransactionStep } from '@/pages/Yields/hooks/useYieldTransactionFlow' + +export type { TransactionStep } +export type TransactionStepStatus = TransactionStep['status'] + +type TransactionStepsListProps = { + steps: TransactionStep[] +} + +export const TransactionStepsList = memo(({ steps }: TransactionStepsListProps) => { + const translate = useTranslate() + + if (steps.length === 0) return null + + return ( + + {steps.map((step, idx) => ( + + + {step.status === 'success' ? ( + + ) : step.status === 'loading' ? ( + + ) : ( + + )} + + {step.title} + + + {step.txHash && step.txUrl ? ( + + + + + ) : ( + + {step.status === 'success' + ? translate('yieldXYZ.loading.done') + : step.status === 'loading' + ? '' + : translate('yieldXYZ.loading.waiting')} + + )} + + ))} + + ) +}) diff --git a/src/pages/Yields/components/YieldActionModal.tsx b/src/pages/Yields/components/YieldActionModal.tsx index 1b0632d3501..671b12f1d53 100644 --- a/src/pages/Yields/components/YieldActionModal.tsx +++ b/src/pages/Yields/components/YieldActionModal.tsx @@ -17,10 +17,8 @@ import { VStack, } from '@chakra-ui/react' import { keyframes } from '@emotion/react' -import type { Options } from 'canvas-confetti' -import { memo, useCallback, useEffect, useMemo, useRef } from 'react' +import { memo, useEffect, useMemo } from 'react' import ReactCanvasConfetti from 'react-canvas-confetti' -import type { TCanvasConfettiInstance } from 'react-canvas-confetti/dist/types' import { FaCheck, FaExternalLinkAlt, FaWallet } from 'react-icons/fa' import { useTranslate } from 'react-polyglot' @@ -30,6 +28,7 @@ import { bnOrZero } from '@/lib/bignumber/bignumber' import type { AugmentedYieldDto } from '@/lib/yieldxyz/types' import { formatYieldTxTitle, getTransactionButtonText } from '@/lib/yieldxyz/utils' import { GradientApy } from '@/pages/Yields/components/GradientApy' +import { useConfetti } from '@/pages/Yields/hooks/useConfetti' import type { TransactionStep } from '@/pages/Yields/hooks/useYieldTransactionFlow' import { ModalStep, useYieldTransactionFlow } from '@/pages/Yields/hooks/useYieldTransactionFlow' import { useYieldProviders } from '@/react-queries/queries/yieldxyz/useYieldProviders' @@ -582,45 +581,7 @@ export const YieldActionModal = memo(function YieldActionModal({ [statusCard, handleConfirm, isButtonDisabled, isButtonLoading, loadingText, buttonText], ) - const refAnimationInstance = useRef(null) - const getInstance = useCallback(({ confetti }: { confetti: TCanvasConfettiInstance }) => { - refAnimationInstance.current = confetti - }, []) - - const makeShot = useCallback((particleRatio: number, opts: Partial) => { - if (refAnimationInstance.current) { - refAnimationInstance.current({ - ...opts, - origin: { y: 0.7 }, - particleCount: Math.floor(200 * particleRatio), - }) - } - }, []) - - const fireConfetti = useCallback(() => { - makeShot(0.25, { - spread: 26, - startVelocity: 55, - }) - makeShot(0.2, { - spread: 60, - }) - makeShot(0.35, { - spread: 100, - decay: 0.91, - scalar: 0.8, - }) - makeShot(0.1, { - spread: 120, - startVelocity: 25, - decay: 0.92, - scalar: 1.2, - }) - makeShot(0.1, { - spread: 120, - startVelocity: 45, - }) - }, [makeShot]) + const { getInstance, fireConfetti, confettiStyle } = useConfetti() useEffect(() => { if (step === ModalStep.Success) fireConfetti() @@ -707,19 +668,6 @@ export const YieldActionModal = memo(function YieldActionModal({ [translate, successMessage, transactionSteps, handleClose], ) - const confettiStyle = useMemo( - () => ({ - position: 'fixed' as const, - pointerEvents: 'none' as const, - width: '100%', - height: '100%', - top: 0, - left: 0, - zIndex: 9999, - }), - [], - ) - const isNotSuccess = useMemo(() => step !== ModalStep.Success, [step]) const isInProgress = useMemo(() => step === ModalStep.InProgress, [step]) const isSuccess = useMemo(() => step === ModalStep.Success, [step]) diff --git a/src/pages/Yields/components/YieldAssetSection.tsx b/src/pages/Yields/components/YieldAssetSection.tsx index f3ab5221131..042ee9735d1 100644 --- a/src/pages/Yields/components/YieldAssetSection.tsx +++ b/src/pages/Yields/components/YieldAssetSection.tsx @@ -1,4 +1,4 @@ -import { Box, Heading, Stack, VStack } from '@chakra-ui/react' +import { Card, CardBody, CardHeader, Heading, Stack, VStack } from '@chakra-ui/react' import type { AccountId, AssetId } from '@shapeshiftoss/caip' import { fromAccountId } from '@shapeshiftoss/caip' import { memo, useCallback, useMemo, useState } from 'react' @@ -117,16 +117,18 @@ export const YieldAssetSection = memo(({ assetId, accountId }: YieldAssetSection return ( <> - - - {yieldHeading} - - - {hasActivePositions && activePositionsContent} - {isLoading && loadingContent} - {!isLoading && !hasActivePositions && opportunityCardContent} - - + + + {yieldHeading} + + + + {hasActivePositions && activePositionsContent} + {isLoading && loadingContent} + {!isLoading && !hasActivePositions && opportunityCardContent} + + + {selectedYield && ( ( )) +type ModalStep = 'input' | 'success' + export const YieldEnterModal = memo( ({ isOpen, onClose, yieldItem, accountNumber = 0 }: YieldEnterModalProps) => { + const dispatch = useAppDispatch() + const queryClient = useQueryClient() + const toast = useToast() const translate = useTranslate() - const { state: walletState, dispatch } = useWallet() + const { state: walletState, dispatch: walletDispatch } = useWallet() const wallet = walletState.wallet const isConnected = useMemo(() => Boolean(walletState.walletInfo), [walletState.walletInfo]) + const isYieldMultiAccountEnabled = useFeatureFlag('YieldMultiAccount') const { - number: { localeParts, toCrypto, toFiat }, + number: { localeParts }, } = useLocaleFormatter() + const submitHashMutation = useSubmitYieldTransactionHash() - const modalBg = useColorModeValue('white', 'gray.800') - const borderColor = useColorModeValue('gray.200', 'gray.700') const statsBg = useColorModeValue('gray.50', 'whiteAlpha.50') const statsBorderColor = useColorModeValue('gray.100', 'whiteAlpha.100') const percentButtonBg = useColorModeValue('gray.100', 'whiteAlpha.100') @@ -128,11 +161,34 @@ export const YieldEnterModal = memo( const [cryptoAmount, setCryptoAmount] = useState('') const [isFiat, setIsFiat] = useState(false) - const [isActionModalOpen, setIsActionModalOpen] = useState(false) + const [selectedAccountId, setSelectedAccountId] = useState() + const [modalStep, setModalStep] = useState('input') + const [isSubmitting, setIsSubmitting] = useState(false) + const [transactionSteps, setTransactionSteps] = useState([]) const debouncedAmount = useDebounce(cryptoAmount, QUOTE_DEBOUNCE_MS) const { chainId } = yieldItem + const inputToken = yieldItem.inputTokens[0] + const inputTokenAssetId = inputToken?.assetId + + const accountIdFilter = useMemo( + () => ({ assetId: inputTokenAssetId ?? '' }), + [inputTokenAssetId], + ) + const accountIds = useAppSelector(state => + selectPortfolioAccountIdsByAssetIdFilter(state, accountIdFilter), + ) + + const defaultAccountId = useAppSelector(state => { + if (!chainId) return undefined + const accountIdsByNumberAndChain = selectAccountIdByAccountNumberAndChainId(state) + return accountIdsByNumberAndChain[accountNumber]?.[chainId] + }) + + const accountId = selectedAccountId ?? defaultAccountId + const hasMultipleAccounts = accountIds.length > 1 + const isAccountSelectorDisabled = !isYieldMultiAccountEnabled || !hasMultipleAccounts const shouldFetchValidators = useMemo( () => @@ -174,19 +230,11 @@ export const YieldEnterModal = memo( return providers[yieldItem.providerId] }, [providers, yieldItem.providerId]) - const accountId = useAppSelector(state => { - if (!chainId) return undefined - const accountIdsByNumberAndChain = selectAccountIdByAccountNumberAndChainId(state) - return accountIdsByNumberAndChain[accountNumber]?.[chainId] - }) - const userAddress = useMemo( () => (accountId ? fromAccountId(accountId).account : ''), [accountId], ) - const inputToken = yieldItem.inputTokens[0] - const inputTokenAssetId = inputToken?.assetId const inputSymbol = inputToken?.symbol ?? '' const inputPrecision = inputToken?.decimals ?? 18 @@ -203,6 +251,15 @@ export const YieldEnterModal = memo( selectMarketDataByAssetIdUserCurrency(state, inputTokenAssetId ?? ''), ) + const feeAsset = useAppSelector(state => + chainId ? selectFeeAssetByChainId(state, chainId) : undefined, + ) + + const accountMetadataFilter = useMemo(() => ({ accountId: accountId ?? '' }), [accountId]) + const accountMetadata = useAppSelector(state => + selectPortfolioAccountMetadataByAccountId(state, accountMetadataFilter), + ) + const minDeposit = yieldItem.mechanics?.entryLimits?.minimum const isBelowMinimum = useMemo(() => { @@ -234,7 +291,11 @@ export const YieldEnterModal = memo( return args }, [yieldItem, userAddress, chainId, debouncedAmount, selectedValidatorAddress]) - const { isLoading: isQuoteLoading, isFetching: isQuoteFetching } = useQuery({ + const { + data: quoteData, + isLoading: isQuoteLoading, + isFetching: isQuoteFetching, + } = useQuery({ queryKey: ['yieldxyz', 'quote', 'enter', yieldItem.id, userAddress, txArguments], queryFn: () => { if (!txArguments || !userAddress || !yieldItem.id) throw new Error('Missing arguments') @@ -255,11 +316,6 @@ export const YieldEnterModal = memo( [cryptoAmount, marketData?.price], ) - const balanceFiat = useMemo( - () => bnOrZero(inputTokenBalance).times(marketData?.price ?? 0), - [inputTokenBalance, marketData?.price], - ) - const apy = useMemo(() => bnOrZero(yieldItem.rewardRate.total), [yieldItem.rewardRate.total]) const apyDisplay = useMemo(() => `${apy.times(100).toFixed(2)}%`, [apy]) @@ -315,30 +371,205 @@ export const YieldEnterModal = memo( [inputTokenBalance], ) - const handleEnterClick = useCallback(() => { - setIsActionModalOpen(true) - }, []) - const handleConnectWallet = useCallback( - () => dispatch({ type: WalletActions.SET_WALLET_MODAL, payload: true }), - [dispatch], + () => walletDispatch({ type: WalletActions.SET_WALLET_MODAL, payload: true }), + [walletDispatch], ) - const handleActionModalClose = useCallback(() => { - setIsActionModalOpen(false) - }, []) - const handleModalClose = useCallback(() => { + if (isSubmitting) return setCryptoAmount('') setSelectedPercent(null) setIsFiat(false) + setSelectedAccountId(undefined) + setModalStep('input') + setTransactionSteps([]) + queryClient.removeQueries({ queryKey: ['yieldxyz', 'quote', 'enter', yieldItem.id] }) onClose() - }, [onClose]) + }, [onClose, isSubmitting, queryClient, yieldItem.id]) + + const handleAccountChange = useCallback((newAccountId: string) => { + setSelectedAccountId(newAccountId) + setCryptoAmount('') + setSelectedPercent(null) + }, []) + + const buildCosmosStakeArgs = useCallback((): CosmosStakeArgs | undefined => { + if (chainId !== cosmosChainId) return undefined + + const validator = + selectedValidatorAddress || DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID[cosmosChainId] + if (!validator) return undefined + + const inputTokenDecimals = yieldItem.inputTokens[0]?.decimals ?? yieldItem.token.decimals + + return { + validator, + amountCryptoBaseUnit: bnOrZero(cryptoAmount) + .times(bnOrZero(10).pow(inputTokenDecimals)) + .toFixed(0), + action: 'stake', + } + }, [ + chainId, + selectedValidatorAddress, + cryptoAmount, + yieldItem.inputTokens, + yieldItem.token.decimals, + ]) + + const dispatchNotification = useCallback( + (tx: TransactionDto, txHash: string) => { + if (!chainId || !accountId) return + if (!yieldItem.token.assetId) return + + const isApproval = tx.title?.toLowerCase().includes('approv') + const actionType = isApproval ? ActionType.Approve : ActionType.Deposit + + dispatch( + actionSlice.actions.upsertAction({ + id: uuidv4(), + type: actionType, + status: ActionStatus.Complete, + createdAt: Date.now(), + updatedAt: Date.now(), + transactionMetadata: { + displayType: isApproval + ? GenericTransactionDisplayType.Approve + : GenericTransactionDisplayType.Yield, + txHash, + chainId, + assetId: yieldItem.token.assetId, + accountId, + message: isApproval + ? 'actionCenter.approve.approvalTxComplete' + : 'actionCenter.deposit.complete', + amountCryptoPrecision: cryptoAmount, + contractName: yieldItem.metadata.name, + chainName: yieldItem.network, + }, + }), + ) + }, + [dispatch, chainId, accountId, yieldItem, cryptoAmount], + ) + + const updateStepStatus = useCallback( + (index: number, update: Partial & { status: TransactionStep['status'] }) => { + setTransactionSteps(prev => prev.map((s, i) => (i === index ? { ...s, ...update } : s))) + }, + [], + ) + + const handleExecute = useCallback(async () => { + if (!wallet || !accountId || !chainId || !quoteData) return + + const transactions = quoteData.transactions.filter( + tx => tx.status === TransactionStatus.Created, + ) + + if (transactions.length === 0) { + setModalStep('success') + return + } + + const initialSteps: TransactionStep[] = transactions.map((tx, i) => ({ + title: formatYieldTxTitle(tx.title || `Transaction ${i + 1}`, inputSymbol), + status: 'pending' as const, + })) + + setTransactionSteps(initialSteps) + setIsSubmitting(true) + + try { + for (let i = 0; i < transactions.length; i++) { + const tx = transactions[i] + + updateStepStatus(i, { status: 'loading' }) + + const txHash = await executeTransaction({ + tx, + chainId, + wallet, + accountId, + userAddress, + bip44Params: accountMetadata?.bip44Params, + cosmosStakeArgs: buildCosmosStakeArgs(), + }) + + if (!txHash) throw new Error('Broadcast failed') + + await submitHashMutation.mutateAsync({ + transactionId: tx.id, + hash: txHash, + yieldId: yieldItem.id, + address: userAddress, + }) + + const txUrl = feeAsset?.explorerTxLink ? `${feeAsset.explorerTxLink}${txHash}` : '' + updateStepStatus(i, { status: 'success', txHash, txUrl }) + + if (i === transactions.length - 1) { + dispatchNotification(tx, txHash) + } + } + + await waitForActionCompletion(quoteData.id) + await queryClient.refetchQueries({ queryKey: ['yieldxyz', 'allBalances'] }) + await queryClient.refetchQueries({ queryKey: ['yieldxyz', 'yields'] }) + + if (SECOND_CLASS_CHAINS.includes(chainId as KnownChainIds)) { + dispatch( + portfolioApi.endpoints.getAccount.initiate( + { accountId, upsertOnFetch: true }, + { forceRefetch: true }, + ), + ) + } + + setModalStep('success') + } catch (error) { + console.error('Transaction failed:', error) + toast({ + title: translate('yieldXYZ.errors.transactionFailedTitle'), + description: + error instanceof Error + ? error.message + : translate('yieldXYZ.errors.transactionFailedDescription'), + status: 'error', + duration: 5000, + isClosable: true, + }) + setModalStep('input') + setTransactionSteps([]) + } finally { + setIsSubmitting(false) + } + }, [ + wallet, + accountId, + chainId, + quoteData, + inputSymbol, + userAddress, + accountMetadata?.bip44Params, + translate, + updateStepStatus, + buildCosmosStakeArgs, + submitHashMutation, + yieldItem.id, + dispatchNotification, + queryClient, + dispatch, + toast, + feeAsset?.explorerTxLink, + ]) const enterButtonDisabled = useMemo( () => - isConnected && (isLoading || !yieldItem.status.enter || !cryptoAmount || isBelowMinimum), - [isConnected, isLoading, yieldItem.status.enter, cryptoAmount, isBelowMinimum], + isConnected && + (isLoading || !yieldItem.status.enter || !cryptoAmount || isBelowMinimum || !quoteData), + [isConnected, isLoading, yieldItem.status.enter, cryptoAmount, isBelowMinimum, quoteData], ) const enterButtonText = useMemo(() => { @@ -348,14 +579,24 @@ export const YieldEnterModal = memo( }, [isConnected, isQuoteActive, translate, inputSymbol]) const handleEnterButtonClick = useMemo( - () => (isConnected ? handleEnterClick : handleConnectWallet), - [isConnected, handleEnterClick, handleConnectWallet], + () => (isConnected ? handleExecute : handleConnectWallet), + [isConnected, handleExecute, handleConnectWallet], ) - const modalTitle = useMemo( - () => translate('yieldXYZ.stakeAsset', { asset: inputSymbol }), - [translate, inputSymbol], - ) + const modalTitle = useMemo(() => { + if (modalStep === 'success') return translate('common.success') + return translate('yieldXYZ.stakeAsset', { asset: inputSymbol }) + }, [translate, inputSymbol, modalStep]) + + const previewSteps = useMemo((): TransactionStep[] => { + if (!quoteData?.transactions?.length) return [] + return quoteData.transactions + .filter(tx => tx.status === TransactionStatus.Created) + .map((tx, i) => ({ + title: formatYieldTxTitle(tx.title || `Transaction ${i + 1}`, inputSymbol), + status: 'pending' as const, + })) + }, [quoteData, inputSymbol]) const percentButtons = useMemo( () => ( @@ -480,12 +721,6 @@ export const YieldEnterModal = memo( ], ) - const balanceDisplay = useMemo(() => { - const cryptoDisplay = toCrypto(bnOrZero(inputTokenBalance), inputSymbol) - const fiatDisplay = toFiat(balanceFiat.toString()) - return `${translate('common.balance')}: ${cryptoDisplay} (${fiatDisplay})` - }, [inputTokenBalance, inputSymbol, balanceFiat, translate, toCrypto, toFiat]) - const inputContent = useMemo(() => { if (isLoading) return @@ -518,9 +753,6 @@ export const YieldEnterModal = memo( - - {balanceDisplay} - ) }, [ @@ -536,57 +768,123 @@ export const YieldEnterModal = memo( toggleIsFiat, cryptoAmount, fiatAmount, - balanceDisplay, ]) + const { getInstance, fireConfetti, confettiStyle } = useConfetti() + + useEffect(() => { + if (modalStep === 'success') fireConfetti() + }, [modalStep, fireConfetti]) + + const successContent = useMemo( + () => ( + + + + + + + {translate('yieldXYZ.success')} + + + {translate('yieldXYZ.successDeposit', { amount: cryptoAmount, symbol: inputSymbol })} + + + + + + + ), + [translate, cryptoAmount, inputSymbol, transactionSteps], + ) + return ( <> - - - - - {modalTitle} - - - - + + + + {null} + + {modalTitle} + + + + + + + {modalStep === 'input' && ( + {inputContent} {percentButtons} + {inputTokenAssetId && accountId && ( + + + + )} {statsContent} - + {isSubmitting ? ( + + ) : ( + previewSteps.length > 0 && + )} - - - - + )} + {modalStep === 'success' && successContent} + + {modalStep === 'input' && ( + + + + )} + {modalStep === 'success' && ( + + + + )} + ) }, diff --git a/src/pages/Yields/components/YieldOpportunityCard.tsx b/src/pages/Yields/components/YieldOpportunityCard.tsx index 5dc0e10a6ae..4ea6ed66d89 100644 --- a/src/pages/Yields/components/YieldOpportunityCard.tsx +++ b/src/pages/Yields/components/YieldOpportunityCard.tsx @@ -1,9 +1,11 @@ -import { Box, Button, Flex, Heading, Text, useColorModeValue } from '@chakra-ui/react' +import { Box, Button, Flex, useColorModeValue } from '@chakra-ui/react' import { memo, useCallback, useMemo } from 'react' import { useTranslate } from 'react-polyglot' +import { Text } from '@/components/Text/Text' import { bnOrZero } from '@/lib/bignumber/bignumber' import type { AugmentedYieldDto } from '@/lib/yieldxyz/types' +import { GradientApy } from '@/pages/Yields/components/GradientApy' type YieldOpportunityCardProps = { maxApyYield: AugmentedYieldDto @@ -22,43 +24,46 @@ export const YieldOpportunityCard = memo(({ maxApyYield, onClick }: YieldOpportu [maxApyYield.rewardRate.total], ) - const earnUpToText = useMemo(() => translate('yieldXYZ.earnUpTo', { apy }), [translate, apy]) - const startEarningText = useMemo(() => translate('yieldXYZ.startEarning'), [translate]) const handleClick = useCallback(() => { onClick(maxApyYield) }, [onClick, maxApyYield]) + const apyComponent = useMemo( + () => ( + + {apy}% + + ), + [apy], + ) + return ( - - - - {earnUpToText} - - - {apy}% APY - + + + {heroIcon} - + {yieldItem.metadata.name} {providerOrValidatorsElement} {chainElement} - + {yieldItem.metadata.description} diff --git a/src/pages/Yields/components/TransactionStepsList.tsx b/src/pages/Yields/components/TransactionStepsList.tsx index e2414e63e94..eebbd089692 100644 --- a/src/pages/Yields/components/TransactionStepsList.tsx +++ b/src/pages/Yields/components/TransactionStepsList.tsx @@ -22,7 +22,7 @@ export const TransactionStepsList = memo(({ steps }: TransactionStepsListProps) {steps.map((step, idx) => ( void onClaimClick: (e: React.MouseEvent) => void formatUnlockDate: (dateString: string | undefined) => string | null @@ -165,26 +163,8 @@ const BalanceRow: FC = memo( ) const ValidatorCard: FC = memo( - ({ - validatorSummary, - isSelected, - userCurrencyToUsdRate, - hoverBg, - onValidatorSwitch, - onClaimClick, - }) => { + ({ validatorSummary, isSelected, userCurrencyToUsdRate, onValidatorSwitch, onClaimClick }) => { const translate = useTranslate() - const enteringBg = useColorModeValue('blue.50', 'blue.900') - const enteringTextColor = useColorModeValue('blue.700', 'blue.300') - const enteringDateColor = useColorModeValue('blue.600', 'blue.400') - const enteringValueColor = useColorModeValue('blue.800', 'blue.200') - const exitingBg = useColorModeValue('orange.50', 'orange.900') - const exitingTextColor = useColorModeValue('orange.700', 'orange.300') - const exitingDateColor = useColorModeValue('orange.600', 'orange.400') - const exitingValueColor = useColorModeValue('orange.800', 'orange.200') - const claimableBg = useColorModeValue('purple.50', 'purple.900') - const claimableTextColor = useColorModeValue('purple.700', 'purple.300') - const claimableValueColor = useColorModeValue('purple.800', 'purple.200') const { validator, @@ -213,7 +193,14 @@ const ValidatorCard: FC = memo( ) return ( - + {!isSelected && ( )} - + @@ -251,29 +238,29 @@ const ValidatorCard: FC = memo( balance={enteringBalance} hasBalance={hasEntering} label='yieldXYZ.entering' - bg={enteringBg} - textColor={enteringTextColor} - dateColor={enteringDateColor} - valueColor={enteringValueColor} + bg='blue.900' + textColor='blue.300' + dateColor='blue.400' + valueColor='blue.200' showDate /> @@ -289,10 +276,6 @@ export const ValidatorBreakdown = memo( const [claimModalData, setClaimModalData] = useState(null) const handleClaimClose = useCallback(() => setClaimModalData(null), []) - const cardBg = useColorModeValue('white', 'gray.800') - const borderColor = useColorModeValue('gray.100', 'gray.750') - const hoverBg = useColorModeValue('gray.50', 'gray.750') - const { chainId } = yieldItem const { accountNumber } = useYieldAccount() const accountId = useAppSelector(state => { @@ -371,13 +354,7 @@ export const ValidatorBreakdown = memo( const loadingElement = useMemo( () => ( - + @@ -387,7 +364,7 @@ export const ValidatorBreakdown = memo( ), - [borderColor, cardBg], + [], ) const claimModalElement = useMemo(() => { @@ -415,7 +392,7 @@ export const ValidatorBreakdown = memo( if (!hasValidatorPositions) return null return ( - + - {index > 0 && } + {index > 0 && } +const walletIcon = const checkIconBox = ( - + ) @@ -76,12 +75,6 @@ export const YieldActionModal = memo(function YieldActionModal({ ...props }: YieldActionModalProps) { const translate = useTranslate() - const modalBg = useColorModeValue('white', 'gray.900') - const modalBorderColor = useColorModeValue('gray.200', 'gray.700') - const cardBg = useColorModeValue('gray.50', 'gray.800') - const cardBorderColor = useColorModeValue('gray.200', 'whiteAlpha.100') - const subtleTextColor = useColorModeValue('gray.600', 'gray.400') - const avatarBg = useColorModeValue('gray.100', 'gray.900') const { step, @@ -269,13 +262,12 @@ export const YieldActionModal = memo(function YieldActionModal({ () => ( - + {assetSymbol} @@ -328,7 +320,7 @@ export const YieldActionModal = memo(function YieldActionModal({ left={0} right={0} h='2px' - bg='whiteAlpha.100' + bg='border.base' borderRadius='full' /> - + {vaultMetadata.name} @@ -379,9 +371,9 @@ export const YieldActionModal = memo(function YieldActionModal({ align='center' py={2} borderBottomWidth='1px' - borderColor='whiteAlpha.100' + borderColor='border.base' > - + {translate('yieldXYZ.apr')} @@ -394,9 +386,9 @@ export const YieldActionModal = memo(function YieldActionModal({ align='center' py={2} borderBottomWidth='1px' - borderColor='whiteAlpha.100' + borderColor='border.base' > - + {translate('yieldXYZ.estEarnings')} @@ -419,14 +411,14 @@ export const YieldActionModal = memo(function YieldActionModal({ align='center' py={2} borderBottomWidth='1px' - borderColor='whiteAlpha.100' + borderColor='border.base' > - + {translate('yieldXYZ.validator')} - + {vaultMetadata.name} @@ -438,26 +430,26 @@ export const YieldActionModal = memo(function YieldActionModal({ align='center' py={2} borderBottomWidth='1px' - borderColor='whiteAlpha.100' + borderColor='border.base' > - + {translate('yieldXYZ.provider')} - + {vaultMetadata.name} )} - + {translate('yieldXYZ.network')} {feeAsset && } - + {yieldItem.network} @@ -530,14 +522,10 @@ export const YieldActionModal = memo(function YieldActionModal({ ), [ - cardBg, - cardBorderColor, amount, assetSymbol, flexDirection, - avatarBg, assetAvatarSrc, - subtleTextColor, horizontalScroll, vaultMetadata.logoURI, vaultMetadata.name, @@ -596,7 +584,7 @@ export const YieldActionModal = memo(function YieldActionModal({ h={24} borderRadius='full' bgGradient='linear(to-br, green.400, green.600)' - color='white' + color='text.base' display='flex' alignItems='center' justifyContent='center' @@ -615,7 +603,7 @@ export const YieldActionModal = memo(function YieldActionModal({ - + {translate('yieldXYZ.transactions')} {transactionSteps.map((s, idx) => ( @@ -627,7 +615,7 @@ export const YieldActionModal = memo(function YieldActionModal({ bg='whiteAlpha.50' borderRadius='lg' border='1px solid' - borderColor='whiteAlpha.100' + borderColor='border.base' > @@ -692,14 +680,8 @@ export const YieldActionModal = memo(function YieldActionModal({ size='md' closeOnOverlayClick={!isSubmitting} > - - + + {headerContent} diff --git a/src/pages/Yields/components/YieldActivePositions.tsx b/src/pages/Yields/components/YieldActivePositions.tsx index f4859f227c4..3d5c08d4b95 100644 --- a/src/pages/Yields/components/YieldActivePositions.tsx +++ b/src/pages/Yields/components/YieldActivePositions.tsx @@ -10,7 +10,6 @@ import { Th, Thead, Tr, - useColorModeValue, } from '@chakra-ui/react' import type { AssetId } from '@shapeshiftoss/caip' import { memo, useCallback, useMemo } from 'react' @@ -39,8 +38,6 @@ export const YieldActivePositions = memo( const navigate = useNavigate() const asset = useAppSelector(state => selectAssetById(state, assetId)) const userCurrencyToUsdRate = useAppSelector(selectUserCurrencyToUsdRate) - const hoverBg = useColorModeValue('gray.50', 'whiteAlpha.50') - const borderColor = useColorModeValue('gray.100', 'whiteAlpha.100') const { data: providers } = useYieldProviders() @@ -114,7 +111,7 @@ export const YieldActivePositions = memo( return ( handleRowClick(yieldItem.id, validator.address)} > @@ -180,7 +177,7 @@ export const YieldActivePositions = memo( return ( handleRowClick(yieldItem.id)} > @@ -238,7 +235,6 @@ export const YieldActivePositions = memo( asset, getProviderLogo, handleRowClick, - hoverBg, renderAssetIcon, userCurrencyToUsdRate, ]) @@ -248,17 +244,12 @@ export const YieldActivePositions = memo( return ( - + {yourBalanceLabel} - + - + diff --git a/src/pages/Yields/components/YieldEnterExit.tsx b/src/pages/Yields/components/YieldEnterExit.tsx index 14578245e42..08f3e6cba66 100644 --- a/src/pages/Yields/components/YieldEnterExit.tsx +++ b/src/pages/Yields/components/YieldEnterExit.tsx @@ -12,7 +12,6 @@ import { TabPanels, Tabs, Text, - useColorModeValue, } from '@chakra-ui/react' import { memo, useCallback, useEffect, useMemo, useState } from 'react' import { FaMoneyBillWave } from 'react-icons/fa' @@ -69,13 +68,6 @@ export const YieldEnterExit = memo( const { accountNumber } = useYieldAccount() const { state: walletState, dispatch } = useWallet() const isConnected = useMemo(() => Boolean(walletState.walletInfo), [walletState.walletInfo]) - const cardBg = useColorModeValue('white', 'gray.800') - const borderColor = useColorModeValue('gray.100', 'gray.750') - const validatorPickerBg = useColorModeValue('gray.50', 'blackAlpha.50') - const validatorPickerHoverBg = useColorModeValue('gray.100', 'whiteAlpha.100') - const tabListBg = useColorModeValue('gray.50', 'blackAlpha.200') - const estimatedEarningsBg = useColorModeValue('gray.50', 'whiteAlpha.50') - const estimatedEarningsBorderColor = useColorModeValue('gray.100', 'whiteAlpha.100') const initialTab = useMemo(() => { if (location.pathname.endsWith('/exit')) return 1 @@ -281,12 +273,12 @@ export const YieldEnterExit = memo( const enterTabSelectedStyle = useMemo( () => ({ color: 'blue.400', - bg: cardBg, - borderBottomColor: cardBg, + borderBottomColor: 'transparent', borderTopColor: 'blue.400', - borderTopWidth: 2, + borderTopWidth: '3px', + marginTop: '-1px', }), - [cardBg], + [], ) const tabFocusStyle = useMemo(() => ({ boxShadow: 'none' }), []) @@ -376,9 +368,9 @@ export const YieldEnterExit = memo( {validatorPickerContent} @@ -565,15 +553,14 @@ export const YieldEnterExit = memo( variant='enclosed' borderBottomWidth={0} > - + diff --git a/src/pages/Yields/components/YieldEnterModal.tsx b/src/pages/Yields/components/YieldEnterModal.tsx index 9a2478adf20..782df3c3b66 100644 --- a/src/pages/Yields/components/YieldEnterModal.tsx +++ b/src/pages/Yields/components/YieldEnterModal.tsx @@ -9,7 +9,6 @@ import { Input, Skeleton, Text, - useColorModeValue, useToast, VStack, } from '@chakra-ui/react' @@ -71,6 +70,7 @@ import { selectPortfolioAccountMetadataByAccountId } from '@/state/slices/portfo import { allowedDecimalSeparators } from '@/state/slices/preferencesSlice/preferencesSlice' import { selectAccountIdByAccountNumberAndChainId, + selectAssetById, selectFeeAssetByChainId, selectMarketDataByAssetIdUserCurrency, selectPortfolioAccountIdsByAssetIdFilter, @@ -86,17 +86,19 @@ type YieldEnterModalProps = { } const QUOTE_DEBOUNCE_MS = 500 +const PRESET_PERCENTAGES = [0.25, 0.5, 0.75, 1] as const +const SHAPESHIFT_VALIDATOR_NAME = 'ShapeShift DAO' -const FONT_SIZE_THRESHOLDS = { - SMALL: 10, - MEDIUM: 14, - LARGE: 22, +const INPUT_LENGTH_BREAKPOINTS = { + FOR_XS_FONT: 22, + FOR_SM_FONT: 14, + FOR_MD_FONT: 10, } as const const getFontSizeByLength = (length: number): string => { - if (length >= FONT_SIZE_THRESHOLDS.LARGE) return '24px' - if (length >= FONT_SIZE_THRESHOLDS.MEDIUM) return '30px' - if (length >= FONT_SIZE_THRESHOLDS.SMALL) return '38px' + 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' } @@ -152,19 +154,13 @@ export const YieldEnterModal = memo( } = useLocaleFormatter() const submitHashMutation = useSubmitYieldTransactionHash() - const statsBg = useColorModeValue('gray.50', 'whiteAlpha.50') - const statsBorderColor = useColorModeValue('gray.100', 'whiteAlpha.100') - const percentButtonBg = useColorModeValue('gray.100', 'whiteAlpha.100') - const percentButtonHoverBg = useColorModeValue('gray.200', 'whiteAlpha.200') - const percentButtonActiveBg = useColorModeValue('blue.100', 'blue.800') - const percentButtonActiveColor = useColorModeValue('blue.600', 'blue.200') - const [cryptoAmount, setCryptoAmount] = useState('') const [isFiat, setIsFiat] = useState(false) const [selectedAccountId, setSelectedAccountId] = useState() const [modalStep, setModalStep] = useState('input') const [isSubmitting, setIsSubmitting] = useState(false) const [transactionSteps, setTransactionSteps] = useState([]) + const [selectedPercent, setSelectedPercent] = useState(null) const debouncedAmount = useDebounce(cryptoAmount, QUOTE_DEBOUNCE_MS) @@ -217,7 +213,7 @@ export const YieldEnterModal = memo( if (found) return found if (selectedValidatorAddress === SHAPESHIFT_COSMOS_VALIDATOR_ADDRESS) { return { - name: 'ShapeShift DAO', + name: SHAPESHIFT_VALIDATOR_NAME, logoURI: SHAPESHIFT_VALIDATOR_LOGO, address: selectedValidatorAddress, } @@ -235,8 +231,7 @@ export const YieldEnterModal = memo( [accountId], ) - const inputSymbol = inputToken?.symbol ?? '' - const inputPrecision = inputToken?.decimals ?? 18 + const inputTokenAsset = useAppSelector(state => selectAssetById(state, inputTokenAssetId ?? '')) const inputTokenBalance = useAppSelector(state => inputTokenAssetId && accountId @@ -308,7 +303,7 @@ export const YieldEnterModal = memo( retry: false, }) - const isLoading = isValidatorsLoading + const isLoading = isValidatorsLoading || !inputTokenAsset const isQuoteActive = isQuoteLoading || isQuoteFetching const fiatAmount = useMemo( @@ -360,8 +355,6 @@ export const YieldEnterModal = memo( const toggleIsFiat = useCallback(() => setIsFiat(prev => !prev), []) - const [selectedPercent, setSelectedPercent] = useState(null) - const handlePercentClick = useCallback( (percent: number) => { const percentAmount = bnOrZero(inputTokenBalance).times(percent).toFixed() @@ -396,34 +389,28 @@ export const YieldEnterModal = memo( const buildCosmosStakeArgs = useCallback((): CosmosStakeArgs | undefined => { if (chainId !== cosmosChainId) return undefined + if (!inputTokenAsset) return undefined const validator = selectedValidatorAddress || DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID[cosmosChainId] if (!validator) return undefined - const inputTokenDecimals = yieldItem.inputTokens[0]?.decimals ?? yieldItem.token.decimals - return { validator, amountCryptoBaseUnit: bnOrZero(cryptoAmount) - .times(bnOrZero(10).pow(inputTokenDecimals)) + .times(bnOrZero(10).pow(inputTokenAsset.precision)) .toFixed(0), action: 'stake', } - }, [ - chainId, - selectedValidatorAddress, - cryptoAmount, - yieldItem.inputTokens, - yieldItem.token.decimals, - ]) + }, [chainId, selectedValidatorAddress, cryptoAmount, inputTokenAsset]) const dispatchNotification = useCallback( (tx: TransactionDto, txHash: string) => { if (!chainId || !accountId) return if (!yieldItem.token.assetId) return - const isApproval = tx.title?.toLowerCase().includes('approv') + const isApproval = + tx.type?.toLowerCase() === 'approval' || tx.title?.toLowerCase().includes('approv') const actionType = isApproval ? ActionType.Approve : ActionType.Deposit dispatch( @@ -462,7 +449,7 @@ export const YieldEnterModal = memo( ) const handleExecute = useCallback(async () => { - if (!wallet || !accountId || !chainId || !quoteData) return + if (!wallet || !accountId || !chainId || !quoteData || !inputTokenAsset) return const transactions = quoteData.transactions.filter( tx => tx.status === TransactionStatus.Created, @@ -474,7 +461,7 @@ export const YieldEnterModal = memo( } const initialSteps: TransactionStep[] = transactions.map((tx, i) => ({ - title: formatYieldTxTitle(tx.title || `Transaction ${i + 1}`, inputSymbol), + title: formatYieldTxTitle(tx.title || `Transaction ${i + 1}`, inputTokenAsset.symbol), status: 'pending' as const, })) @@ -550,7 +537,7 @@ export const YieldEnterModal = memo( accountId, chainId, quoteData, - inputSymbol, + inputTokenAsset, userAddress, accountMetadata?.bip44Params, translate, @@ -575,8 +562,8 @@ export const YieldEnterModal = memo( const enterButtonText = useMemo(() => { if (!isConnected) return translate('common.connectWallet') if (isQuoteActive) return translate('yieldXYZ.loadingQuote') - return translate('yieldXYZ.stakeAsset', { asset: inputSymbol }) - }, [isConnected, isQuoteActive, translate, inputSymbol]) + return translate('yieldXYZ.stakeAsset', { asset: inputTokenAsset?.symbol }) + }, [isConnected, isQuoteActive, translate, inputTokenAsset?.symbol]) const handleEnterButtonClick = useMemo( () => (isConnected ? handleExecute : handleConnectWallet), @@ -585,56 +572,55 @@ export const YieldEnterModal = memo( const modalTitle = useMemo(() => { if (modalStep === 'success') return translate('common.success') - return translate('yieldXYZ.stakeAsset', { asset: inputSymbol }) - }, [translate, inputSymbol, modalStep]) + return translate('yieldXYZ.stakeAsset', { asset: inputTokenAsset?.symbol }) + }, [translate, inputTokenAsset?.symbol, modalStep]) const previewSteps = useMemo((): TransactionStep[] => { - if (!quoteData?.transactions?.length) return [] + if (!quoteData?.transactions?.length || !inputTokenAsset) return [] return quoteData.transactions .filter(tx => tx.status === TransactionStatus.Created) .map((tx, i) => ({ - title: formatYieldTxTitle(tx.title || `Transaction ${i + 1}`, inputSymbol), + title: formatYieldTxTitle(tx.title || `Transaction ${i + 1}`, inputTokenAsset.symbol), status: 'pending' as const, })) - }, [quoteData, inputSymbol]) + }, [quoteData, inputTokenAsset]) const percentButtons = useMemo( () => ( - {[0.25, 0.5, 0.75, 1].map(percent => { + {PRESET_PERCENTAGES.map(percent => { const isSelected = selectedPercent === percent return ( ) })} ), - [ - selectedPercent, - percentButtonActiveBg, - percentButtonBg, - percentButtonActiveColor, - percentButtonHoverBg, - handlePercentClick, - ], + [selectedPercent, handlePercentClick, translate], ) const statsContent = useMemo( () => ( - + {translate('yieldXYZ.currentApy')} @@ -650,7 +636,7 @@ export const YieldEnterModal = memo( - {estimatedYearlyEarnings.decimalPlaces(4).toString()} {inputSymbol} + {estimatedYearlyEarnings.decimalPlaces(4).toString()} {inputTokenAsset?.symbol} @@ -698,20 +684,18 @@ export const YieldEnterModal = memo( color={isBelowMinimum ? 'red.500' : 'text.base'} fontWeight='medium' > - {minDeposit} {inputSymbol} + {minDeposit} {inputTokenAsset?.symbol} )} ), [ - statsBg, - statsBorderColor, translate, apyDisplay, hasAmount, estimatedYearlyEarnings, - inputSymbol, + inputTokenAsset?.symbol, estimatedYearlyEarningsFiat, isStaking, selectedValidatorMetadata, @@ -730,7 +714,7 @@ export const YieldEnterModal = memo( {isFiat ? ( - + ) : ( )} @@ -759,11 +743,11 @@ export const YieldEnterModal = memo( isLoading, inputTokenAssetId, isFiat, - inputPrecision, + inputTokenAsset?.precision, localeParts, displayValue, displayPlaceholder, - inputSymbol, + inputTokenAsset?.symbol, handleInputChange, toggleIsFiat, cryptoAmount, @@ -798,7 +782,10 @@ export const YieldEnterModal = memo( {translate('yieldXYZ.success')} - {translate('yieldXYZ.successDeposit', { amount: cryptoAmount, symbol: inputSymbol })} + {translate('yieldXYZ.successDeposit', { + amount: cryptoAmount, + symbol: inputTokenAsset?.symbol, + })} @@ -806,7 +793,7 @@ export const YieldEnterModal = memo( ), - [translate, cryptoAmount, inputSymbol, transactionSteps], + [translate, cryptoAmount, inputTokenAsset?.symbol, transactionSteps], ) return ( diff --git a/src/pages/Yields/components/YieldFilters.tsx b/src/pages/Yields/components/YieldFilters.tsx index 073c85b4c26..393570edf5e 100644 --- a/src/pages/Yields/components/YieldFilters.tsx +++ b/src/pages/Yields/components/YieldFilters.tsx @@ -11,7 +11,6 @@ import { Stack, Text, Tooltip, - useColorModeValue, } from '@chakra-ui/react' import type { ChainId } from '@shapeshiftoss/caip' import React, { memo, useCallback, useMemo } from 'react' @@ -57,28 +56,14 @@ const FilterMenu = memo(({ label, value, options, onSelect, renderIcon }: Filter () => (selectedOption ? selectedOption.name : label), [selectedOption, label], ) - const bg = useColorModeValue('white', 'gray.800') - const borderColor = useColorModeValue('gray.200', 'gray.700') - const selectedColor = useColorModeValue('blue.500', 'blue.300') - const hoverBg = useColorModeValue('gray.50', 'gray.750') - const activeBg = useColorModeValue('gray.100', 'gray.700') const handleSelectAll = useCallback(() => onSelect(null), [onSelect]) - const hoverStyle = useMemo(() => ({ bg: hoverBg }), [hoverBg]) - const activeStyle = useMemo(() => ({ bg: activeBg }), [activeBg]) - const selectedIcon = useMemo( () => (selectedOption && renderIcon ? renderIcon(selectedOption) : null), [selectedOption, renderIcon], ) - const allItemColor = useMemo( - () => (value === null ? selectedColor : undefined), - [value, selectedColor], - ) - const allItemFontWeight = useMemo(() => (value === null ? 'semibold' : undefined), [value]) - const menuItems = useMemo( () => options.map(opt => { @@ -87,7 +72,7 @@ const FilterMenu = memo(({ label, value, options, onSelect, renderIcon }: Filter onSelect(opt.id)} - color={isSelected ? selectedColor : undefined} + color={isSelected ? 'blue.500' : undefined} fontWeight={isSelected ? 'semibold' : undefined} > @@ -97,24 +82,12 @@ const FilterMenu = memo(({ label, value, options, onSelect, renderIcon }: Filter ) }), - [options, value, selectedColor, renderIcon, onSelect], + [options, value, renderIcon, onSelect], ) return ( - + {selectedIcon} @@ -122,8 +95,12 @@ const FilterMenu = memo(({ label, value, options, onSelect, renderIcon }: Filter - - + + {label} {menuItems} @@ -156,10 +133,6 @@ export const YieldFilters = memo( ...props }: YieldFiltersProps) => { const translate = useTranslate() - const bg = useColorModeValue('white', 'gray.800') - const borderColor = useColorModeValue('gray.200', 'gray.700') - const hoverBg = useColorModeValue('gray.50', 'gray.750') - const activeBg = useColorModeValue('gray.100', 'gray.700') const sortOptions = useMemo( () => [ @@ -196,17 +169,14 @@ export const YieldFilters = memo( return }, [sortOption]) - const hoverStyle = useMemo(() => ({ bg: hoverBg }), [hoverBg]) - const activeStyle = useMemo(() => ({ bg: activeBg }), [activeBg]) - const sortMenuItems = useMemo( () => sortOptions.map(opt => ( onSortChange(opt.value)} - color={sortOption === opt.value ? 'blue.500' : 'inherit'} - fontWeight={sortOption === opt.value ? 'bold' : 'normal'} + color={sortOption === opt.value ? 'blue.500' : undefined} + fontWeight={sortOption === opt.value ? 'bold' : undefined} > {opt.label} @@ -232,20 +202,9 @@ export const YieldFilters = memo( /> - + - + {sortMenuItems} diff --git a/src/pages/Yields/components/YieldItem.tsx b/src/pages/Yields/components/YieldItem.tsx index 72da2e25d23..1be7df90557 100644 --- a/src/pages/Yields/components/YieldItem.tsx +++ b/src/pages/Yields/components/YieldItem.tsx @@ -12,7 +12,6 @@ import { StatLabel, StatNumber, Text, - useColorModeValue, } from '@chakra-ui/react' import type BigNumber from 'bignumber.js' import { memo, useCallback, useMemo } from 'react' @@ -59,13 +58,6 @@ export const YieldItem = memo( const userCurrencyToUsdRate = useAppSelector(selectUserCurrencyToUsdRate) const { data: yieldProviders } = useYieldProviders() - const borderColor = useColorModeValue('gray.100', 'gray.750') - const cardBg = useColorModeValue('white', 'gray.800') - const hoverBorderColor = useColorModeValue('blue.500', 'blue.400') - const hoverBg = useColorModeValue('gray.50', 'whiteAlpha.50') - const cardShadow = useColorModeValue('sm', 'none') - const cardHoverShadow = useColorModeValue('lg', 'lg') - const isSingle = data.type === 'single' const isGroup = data.type === 'group' @@ -135,49 +127,16 @@ export const YieldItem = memo( const iconSource = resolveYieldInputAssetIcon(data.yieldItem) const size = variant === 'card' ? 'md' : 'sm' if (iconSource.assetId) { - return ( - - ) + return } - return ( - - ) + return } const size = variant === 'card' ? 'md' : 'sm' if (data.assetId) { - return ( - - ) + return } - return ( - - ) - }, [data, isSingle, variant, borderColor]) + return + }, [data, isSingle, variant]) const subtitle = useMemo(() => { if (isSingle) { @@ -198,9 +157,9 @@ export const YieldItem = memo( @@ -266,20 +225,14 @@ export const YieldItem = memo( return ( {isGroup && ( - + @@ -392,14 +345,11 @@ export const YieldItem = memo( ) export const YieldItemSkeleton = memo(({ variant }: { variant: 'card' | 'row' }) => { - const borderColor = useColorModeValue('gray.100', 'gray.750') - const cardBg = useColorModeValue('white', 'gray.800') - if (variant === 'row') { return ( + diff --git a/src/pages/Yields/components/YieldOpportunityCard.tsx b/src/pages/Yields/components/YieldOpportunityCard.tsx index 4ea6ed66d89..142ba8afb2a 100644 --- a/src/pages/Yields/components/YieldOpportunityCard.tsx +++ b/src/pages/Yields/components/YieldOpportunityCard.tsx @@ -1,4 +1,4 @@ -import { Box, Button, Flex, useColorModeValue } from '@chakra-ui/react' +import { Box, Button, Flex } from '@chakra-ui/react' import { memo, useCallback, useMemo } from 'react' import { useTranslate } from 'react-polyglot' @@ -16,8 +16,6 @@ const hoverStyle = { bgGradient: 'linear(to-r, blue.600, purple.700)' } export const YieldOpportunityCard = memo(({ maxApyYield, onClick }: YieldOpportunityCardProps) => { const translate = useTranslate() - const bg = useColorModeValue('gray.50', 'whiteAlpha.100') - const borderColor = useColorModeValue('gray.100', 'whiteAlpha.100') const apy = useMemo( () => bnOrZero(maxApyYield.rewardRate.total).times(100).toFixed(2), @@ -41,13 +39,11 @@ export const YieldOpportunityCard = memo(({ maxApyYield, onClick }: YieldOpportu return ( diff --git a/src/pages/Yields/components/YieldPositionCard.tsx b/src/pages/Yields/components/YieldPositionCard.tsx index 645f3e1eb3e..5edf6775c8b 100644 --- a/src/pages/Yields/components/YieldPositionCard.tsx +++ b/src/pages/Yields/components/YieldPositionCard.tsx @@ -12,7 +12,6 @@ import { HStack, Skeleton, Text, - useColorModeValue, VStack, } from '@chakra-ui/react' import { fromAccountId } from '@shapeshiftoss/caip' @@ -60,26 +59,6 @@ export const YieldPositionCard = memo( ({ yieldItem, balances, isBalancesLoading }: YieldPositionCardProps) => { const [claimModalData, setClaimModalData] = useState(null) const translate = useTranslate() - const cardBg = useColorModeValue('white', 'gray.800') - const borderColor = useColorModeValue('gray.100', 'gray.750') - const badgeBg = useColorModeValue('blue.50', 'blue.900') - const badgeColor = useColorModeValue('blue.700', 'blue.200') - const emptyStateBg = useColorModeValue('blue.50', 'blue.900') - const emptyStateBorderColor = useColorModeValue('blue.200', 'blue.800') - const emptyStateTitleColor = useColorModeValue('blue.700', 'blue.100') - const emptyStateTextColor = useColorModeValue('blue.600', 'blue.200') - const enteringBg = useColorModeValue('yellow.50', 'yellow.900') - const enteringBorderColor = useColorModeValue('yellow.300', 'yellow.700') - const enteringTextColor = useColorModeValue('yellow.700', 'yellow.300') - const exitingBg = useColorModeValue('orange.50', 'orange.900') - const exitingBorderColor = useColorModeValue('orange.300', 'orange.700') - const exitingTextColor = useColorModeValue('orange.700', 'orange.300') - const withdrawableBg = useColorModeValue('green.50', 'green.900') - const withdrawableBorderColor = useColorModeValue('green.300', 'green.700') - const withdrawableTextColor = useColorModeValue('green.700', 'green.300') - const claimableBg = useColorModeValue('purple.50', 'purple.900') - const claimableBorderColor = useColorModeValue('purple.300', 'purple.700') - const claimableTextColor = useColorModeValue('purple.700', 'purple.300') const [searchParams] = useSearchParams() const validatorParam = searchParams.get('validator') @@ -239,199 +218,116 @@ export const YieldPositionCard = memo( flexDirection='column' alignItems='start' p={4} - bg={emptyStateBg} - borderColor={emptyStateBorderColor} - border='1px solid' > - - - {translate('yieldXYZ.startEarning')} - + + {translate('yieldXYZ.startEarning')} - + {translate('yieldXYZ.depositYourToken', { symbol: yieldItem.token.symbol })} ), - [ - emptyStateBg, - emptyStateBorderColor, - emptyStateTextColor, - emptyStateTitleColor, - yieldItem.token.symbol, - translate, - ], + [yieldItem.token.symbol, translate], ) const enteringSection = useMemo(() => { if (!hasEntering) return null return ( - - - - {translate('yieldXYZ.entering')} - - - {formatBalance(enteringBalance)} - - - - {translate('yieldXYZ.pending')} - - + + + + + {translate('yieldXYZ.entering')} + + + {formatBalance(enteringBalance)} + + + + {translate('yieldXYZ.pending')} + + + ) - }, [ - hasEntering, - enteringBg, - enteringBorderColor, - enteringTextColor, - translate, - formatBalance, - enteringBalance, - ]) + }, [hasEntering, translate, formatBalance, enteringBalance]) const exitingSection = useMemo(() => { if (!hasExiting) return null return ( - - - - {translate('yieldXYZ.exiting')} - - - {formatBalance(exitingBalance)} - - - - {translate('yieldXYZ.pending')} - - + + + + + {translate('yieldXYZ.exiting')} + + + {formatBalance(exitingBalance)} + + + + {translate('yieldXYZ.pending')} + + + ) - }, [ - hasExiting, - exitingBg, - exitingBorderColor, - exitingTextColor, - translate, - formatBalance, - exitingBalance, - ]) + }, [hasExiting, translate, formatBalance, exitingBalance]) const withdrawableSection = useMemo(() => { if (!hasWithdrawable) return null return ( - - - - {translate('yieldXYZ.withdrawable')} - - - {formatBalance(withdrawableBalance)} - - - - {translate('yieldXYZ.ready')} - - + + + + + {translate('yieldXYZ.withdrawable')} + + + {formatBalance(withdrawableBalance)} + + + + {translate('yieldXYZ.ready')} + + + ) - }, [ - hasWithdrawable, - withdrawableBg, - withdrawableBorderColor, - withdrawableTextColor, - translate, - formatBalance, - withdrawableBalance, - ]) + }, [hasWithdrawable, translate, formatBalance, withdrawableBalance]) const claimableSection = useMemo(() => { if (!hasClaimable) return null return ( - - - - {translate('yieldXYZ.claimable')} - - - {formatBalance(claimableBalance)} - - - - - {translate('yieldXYZ.reward')} - - {claimAction && ( - - )} - - + + + + + {translate('yieldXYZ.claimable')} + + + {formatBalance(claimableBalance)} + + + + + {translate('yieldXYZ.reward')} + + {claimAction && ( + + )} + + + ) }, [ hasClaimable, - claimableBg, - claimableBorderColor, - claimableTextColor, translate, formatBalance, claimableBalance, @@ -443,25 +339,17 @@ export const YieldPositionCard = memo( const addressBadge = useMemo(() => { if (!address) return null return ( - + {addressBadgeText} ) - }, [address, badgeBg, badgeColor, addressBadgeText]) + }, [address, addressBadgeText]) const pendingActionsSection = useMemo(() => { if (!showPendingActions) return null return ( <> - + {enteringSection} {exitingSection} @@ -470,24 +358,11 @@ export const YieldPositionCard = memo( ) - }, [ - showPendingActions, - borderColor, - enteringSection, - exitingSection, - withdrawableSection, - claimableSection, - ]) + }, [showPendingActions, enteringSection, exitingSection, withdrawableSection, claimableSection]) if (isBalancesLoading) { return ( - + + { const inputTokenMarketData = useAppSelector(state => selectMarketDataByAssetIdUserCurrency(state, inputTokenAssetId), ) - const cardBg = useColorModeValue('white', 'gray.800') - const borderColor = useColorModeValue('gray.100', 'gray.750') - const rewardBreakdownBg = useColorModeValue('gray.50', 'whiteAlpha.50') - const dividerColor = useColorModeValue('gray.200', 'whiteAlpha.100') const [searchParams] = useSearchParams() const validatorParam = useMemo(() => searchParams.get('validator'), [searchParams]) @@ -128,7 +123,14 @@ export const YieldStats = memo(({ yieldItem, balances }: YieldStatsProps) => { const rewardBreakdownContent = useMemo(() => { if (yieldItem.rewardRate.components.length === 0) return null return ( - + {yieldItem.rewardRate.components.map((component, idx) => ( @@ -144,7 +146,7 @@ export const YieldStats = memo(({ yieldItem, balances }: YieldStatsProps) => { ))} ) - }, [yieldItem.rewardRate.components, rewardBreakdownBg]) + }, [yieldItem.rewardRate.components]) const validatorRowContent = useMemo(() => { if (!validatorMetadata) return null @@ -185,7 +187,7 @@ export const YieldStats = memo(({ yieldItem, balances }: YieldStatsProps) => { }, [yieldItem.mechanics.entryLimits.minimum, yieldItem.token.symbol, translate]) return ( - + { {rewardBreakdownContent} - + {translate('yieldXYZ.tvl')} diff --git a/src/pages/Yields/components/YieldTable.tsx b/src/pages/Yields/components/YieldTable.tsx index 239576bbf49..ed00865b474 100644 --- a/src/pages/Yields/components/YieldTable.tsx +++ b/src/pages/Yields/components/YieldTable.tsx @@ -1,15 +1,5 @@ import { ArrowDownIcon, ArrowUpIcon } from '@chakra-ui/icons' -import { - Flex, - Skeleton, - Table, - Tbody, - Td, - Th, - Thead, - Tr, - useColorModeValue, -} from '@chakra-ui/react' +import { Flex, Skeleton, Table, Tbody, Td, Th, Thead, Tr } from '@chakra-ui/react' import type { Row, Table as TanstackTable } from '@tanstack/react-table' import { flexRender } from '@tanstack/react-table' import { memo, useCallback, useMemo } from 'react' @@ -32,9 +22,6 @@ const tableSize = { base: 'sm', md: 'md' } const SKELETON_ROWS = 6 export const YieldTable = memo(({ table, isLoading, onRowClick }: YieldTableProps) => { - const hoverBg = useColorModeValue('gray.50', 'gray.750') - const hoverColor = useColorModeValue('black', 'white') - const columns = useMemo(() => table.getAllColumns(), [table]) const headerGroups = useMemo(() => table.getHeaderGroups(), [table]) const rows = useMemo(() => table.getRowModel().rows, [table]) @@ -70,7 +57,7 @@ export const YieldTable = memo(({ table, isLoading, onRowClick }: YieldTableProp key={row.id} cursor={isClickable ? 'pointer' : undefined} onClick={() => handleRowClick(row)} - _hover={isClickable ? { bg: hoverBg } : undefined} + _hover={isClickable ? { bg: 'background.surface.raised.base' } : undefined} > {row.getVisibleCells().map(cell => { const meta = cell.column.columnDef.meta as YieldColumnMeta | undefined @@ -83,7 +70,7 @@ export const YieldTable = memo(({ table, isLoading, onRowClick }: YieldTableProp ) }), - [rows, handleRowClick, hoverBg], + [rows, handleRowClick], ) const tbodyContent = useMemo( @@ -108,7 +95,7 @@ export const YieldTable = memo(({ table, isLoading, onRowClick }: YieldTableProp textAlign={meta?.textAlign} cursor={canSort ? 'pointer' : undefined} onClick={canSort ? sortingHandler : undefined} - _hover={canSort ? { color: hoverColor } : undefined} + _hover={canSort ? { color: 'text.base' } : undefined} > { if (!balances) return new Map() @@ -144,7 +140,7 @@ export const YieldValidatorSelectModal = memo( p={4} cursor='pointer' borderRadius='lg' - _hover={{ bg: hoverBg }} + _hover={{ bg: 'background.surface.raised.base' }} onClick={() => handleSelect(v.address)} > @@ -185,13 +181,13 @@ export const YieldValidatorSelectModal = memo( ) }, - [balanceMap, userCurrencyToUsdRate, hoverBg, handleSelect, translate], + [balanceMap, userCurrencyToUsdRate, handleSelect, translate], ) return ( - + {translate('yieldXYZ.selectValidator')} diff --git a/src/pages/Yields/components/YieldsList.tsx b/src/pages/Yields/components/YieldsList.tsx index 62d90cfb6eb..135c50a2340 100644 --- a/src/pages/Yields/components/YieldsList.tsx +++ b/src/pages/Yields/components/YieldsList.tsx @@ -18,7 +18,6 @@ import { TabPanels, Tabs, Text, - useColorModeValue, } from '@chakra-ui/react' import type { ColumnDef, Row } from '@tanstack/react-table' import { getCoreRowModel, getSortedRowModel, useReactTable } from '@tanstack/react-table' @@ -55,8 +54,6 @@ export const YieldsList = memo(() => { const navigate = useNavigate() const { state: walletState } = useWallet() const isConnected = useMemo(() => Boolean(walletState.walletInfo), [walletState.walletInfo]) - const headerBg = useColorModeValue('gray.50', 'whiteAlpha.50') - const searchInputBg = useColorModeValue('white', 'gray.800') const [searchParams, setSearchParams] = useSearchParams() const tabParam = useMemo(() => searchParams.get('tab'), [searchParams]) const tabIndex = useMemo(() => (tabParam === 'my-positions' ? 1 : 0), [tabParam]) @@ -519,7 +516,7 @@ export const YieldsList = memo(() => { gap={4} borderBottomWidth='1px' borderColor='inherit' - bg={headerBg} + bg='background.surface.raised.base' > @@ -567,7 +564,7 @@ export const YieldsList = memo(() => { ))} ), - [filterSearchString, headerBg, translate, yieldsByAsset], + [filterSearchString, translate, yieldsByAsset], ) const allYieldsContentElement = useMemo(() => { @@ -715,14 +712,13 @@ export const YieldsList = memo(() => { > - + Date: Mon, 12 Jan 2026 16:26:03 +0100 Subject: [PATCH 13/60] [skip ci] wip: yield cleanup - remove unused translations, naming conventions, style fixes Co-Authored-By: Claude Opus 4.5 --- src/assets/translations/en/main.json | 29 ---- .../components/TransactionStepsList.tsx | 141 ++++++++++-------- .../Yields/components/ValidatorBreakdown.tsx | 37 +++-- .../Yields/components/YieldAssetSection.tsx | 48 +++--- .../Yields/components/YieldEnterExit.tsx | 18 +-- .../Yields/components/YieldEnterModal.tsx | 17 ++- 6 files changed, 139 insertions(+), 151 deletions(-) diff --git a/src/assets/translations/en/main.json b/src/assets/translations/en/main.json index 7d56add95f0..f076685856d 100644 --- a/src/assets/translations/en/main.json +++ b/src/assets/translations/en/main.json @@ -2684,43 +2684,18 @@ "connectWallet": "Connect a wallet to view yields", "stats": "Stats", "minDeposit": "Min Deposit", - "mechanics": "Mechanics", "rewardSchedule": "Reward Schedule", "gasToken": "Gas Token", - "transactionSteps": "Transaction Steps", - "stepApprove": "Approve", - "stepApproveDesc": "Approve the token for deposit", - "stepDeposit": "Deposit", - "stepDepositDesc": "Deposit your assets into the strategy", - "stepComplete": "Complete", - "stepCompleteDesc": "Your deposit is complete and earning yield", - "gasFeeNote": "Gas fees are paid in the native token of the network", - "yourInfo": "Your Position", - "activeBalance": "Active Balance", "entering": "Entering", "withdrawable": "Withdrawable", - "locked": "Locked", - "enterDisabled": "Enter is currently disabled for this yield opportunity", - "exitDisabled": "Exit is currently disabled for this yield opportunity", "type": "Type", - "inputToken": "Input Token", - "netApy": "Net APY", - "grossApy": "Gross APY", "totalValue": "Total Value", "myPosition": "My Position", "myValidatorPosition": "My %{validator} Position", - "vault": "Vault", - "lending": "Lending", - "yourDeposits": "Your Deposits", - "positions": "Positions", - "opportunities": "Opportunities", - "yields": "Yields", - "earnUpTo": "You could earn up to %{apy}% on your balance", "earnUpToOnBalance": "You could earn up to %{apy} on your balance", "startEarning": "Start earning", "maxApy": "Max APY", "validator": "Validator", - "validatorBreakdown": "Validator Breakdown", "staked": "Staked", "exiting": "Exiting", "claimable": "Claimable", @@ -2746,7 +2721,6 @@ "searchValidator": "Search for validator", "depositYourToken": "Deposit your %{symbol} to start earning yield securely.", "noActiveValidators": "You don't have any active validators yet.", - "confirming": "Confirming...", "success": "Success!", "transactions": "Transactions", "currentApy": "Current APY", @@ -2788,15 +2762,12 @@ "successClaim": "You successfully claimed %{amount} %{symbol}", "loading": { "signInWallet": "Sign in Wallet", - "signNow": "Sign now...", "waiting": "Waiting", "done": "Done", - "preparing": "Preparing...", "preparingTransaction": "Preparing transaction..." }, "errors": { "walletNotConnected": "Wallet not connected", - "unsupportedYieldNetwork": "Unsupported yield network", "broadcastFailed": "Failed to broadcast transaction", "transactionFailedTitle": "Transaction failed", "transactionFailedDescription": "Please try again.", diff --git a/src/pages/Yields/components/TransactionStepsList.tsx b/src/pages/Yields/components/TransactionStepsList.tsx index eebbd089692..ed608866dbb 100644 --- a/src/pages/Yields/components/TransactionStepsList.tsx +++ b/src/pages/Yields/components/TransactionStepsList.tsx @@ -1,5 +1,5 @@ import { Box, Flex, Icon, Link, Spinner, Text } from '@chakra-ui/react' -import { memo } from 'react' +import { memo, useMemo } from 'react' import { FaCheck, FaExternalLinkAlt } from 'react-icons/fa' import { useTranslate } from 'react-polyglot' @@ -7,80 +7,93 @@ import { MiddleEllipsis } from '@/components/MiddleEllipsis/MiddleEllipsis' import type { TransactionStep } from '@/pages/Yields/hooks/useYieldTransactionFlow' export type { TransactionStep } -export type TransactionStepStatus = TransactionStep['status'] +type TransactionStepStatus = TransactionStep['status'] type TransactionStepsListProps = { steps: TransactionStep[] } +const StepIcon = memo(({ status }: { status: TransactionStepStatus }) => { + if (status === 'success') { + return + } + if (status === 'loading') { + return + } + return +}) + +const getStatusLabel = ( + status: TransactionStepStatus, + translate: ReturnType, +): string => { + if (status === 'success') return translate('yieldXYZ.loading.done') + if (status === 'loading') return '' + return translate('yieldXYZ.loading.waiting') +} + export const TransactionStepsList = memo(({ steps }: TransactionStepsListProps) => { const translate = useTranslate() - if (steps.length === 0) return null + const stepElements = useMemo(() => { + return steps.map((step, idx) => ( + + + + + {step.title} + + + {step.txHash && step.txUrl ? ( + + + + + ) : ( + + {getStatusLabel(step.status, translate)} + + )} + + )) + }, [steps, translate]) + + if (!steps.length) return null return ( - {steps.map((step, idx) => ( - - - {step.status === 'success' ? ( - - ) : step.status === 'loading' ? ( - - ) : ( - - )} - - {step.title} - - - {step.txHash && step.txUrl ? ( - - - - - ) : ( - - {step.status === 'success' - ? translate('yieldXYZ.loading.done') - : step.status === 'loading' - ? '' - : translate('yieldXYZ.loading.waiting')} - - )} - - ))} + {stepElements} ) }) diff --git a/src/pages/Yields/components/ValidatorBreakdown.tsx b/src/pages/Yields/components/ValidatorBreakdown.tsx index a4b3fbb24c3..ef9119aa25a 100644 --- a/src/pages/Yields/components/ValidatorBreakdown.tsx +++ b/src/pages/Yields/components/ValidatorBreakdown.tsx @@ -11,6 +11,7 @@ import { HStack, Skeleton, Text, + useColorModeValue, useDisclosure, VStack, } from '@chakra-ui/react' @@ -166,6 +167,20 @@ const ValidatorCard: FC = memo( ({ validatorSummary, isSelected, userCurrencyToUsdRate, onValidatorSwitch, onClaimClick }) => { const translate = useTranslate() + const enteringBg = useColorModeValue('blue.50', 'blue.900') + const enteringTextColor = useColorModeValue('blue.700', 'blue.300') + const enteringDateColor = useColorModeValue('blue.600', 'blue.400') + const enteringValueColor = useColorModeValue('blue.800', 'blue.200') + + const exitingBg = useColorModeValue('orange.50', 'orange.900') + const exitingTextColor = useColorModeValue('orange.700', 'orange.300') + const exitingDateColor = useColorModeValue('orange.600', 'orange.400') + const exitingValueColor = useColorModeValue('orange.800', 'orange.200') + + const claimableBg = useColorModeValue('purple.50', 'purple.900') + const claimableTextColor = useColorModeValue('purple.700', 'purple.300') + const claimableValueColor = useColorModeValue('purple.800', 'purple.200') + const { validator, byType, @@ -238,29 +253,29 @@ const ValidatorCard: FC = memo( balance={enteringBalance} hasBalance={hasEntering} label='yieldXYZ.entering' - bg='blue.900' - textColor='blue.300' - dateColor='blue.400' - valueColor='blue.200' + bg={enteringBg} + textColor={enteringTextColor} + dateColor={enteringDateColor} + valueColor={enteringValueColor} showDate /> diff --git a/src/pages/Yields/components/YieldAssetSection.tsx b/src/pages/Yields/components/YieldAssetSection.tsx index 042ee9735d1..d4846621f3e 100644 --- a/src/pages/Yields/components/YieldAssetSection.tsx +++ b/src/pages/Yields/components/YieldAssetSection.tsx @@ -15,8 +15,13 @@ import type { AugmentedYieldDto } from '@/lib/yieldxyz/types' import type { YieldBalanceAggregate } from '@/react-queries/queries/yieldxyz/useAllYieldBalances' import { useAllYieldBalances } from '@/react-queries/queries/yieldxyz/useAllYieldBalances' import { useYields } from '@/react-queries/queries/yieldxyz/useYields' -import { selectAssetById } from '@/state/slices/selectors' -import { useAppSelector } from '@/state/store' + +const LoadingContent = ( + + + + +) type YieldAssetSectionProps = { assetId: AssetId @@ -26,7 +31,6 @@ type YieldAssetSectionProps = { export const YieldAssetSection = memo(({ assetId, accountId }: YieldAssetSectionProps) => { const translate = useTranslate() const isYieldXyzEnabled = useFeatureFlag('YieldXyz') - const asset = useAppSelector(state => selectAssetById(state, assetId)) const { data: yieldsData, isLoading: isYieldsLoading } = useYields() const balanceOptions = useMemo(() => (accountId ? { accountIds: [accountId] } : {}), [accountId]) const { data: allBalancesData, isLoading: isBalancesLoading } = @@ -37,13 +41,13 @@ export const YieldAssetSection = memo(({ assetId, accountId }: YieldAssetSection const [selectedYield, setSelectedYield] = useState(null) const yields = useMemo(() => { - if (!yieldsData?.all || !asset) return [] + if (!yieldsData?.all) return [] return yieldsData.all.filter(yieldItem => { const matchesToken = yieldItem.token.assetId === assetId const matchesInput = yieldItem.inputTokens.some(t => t.assetId === assetId) return matchesToken || matchesInput }) - }, [yieldsData, asset, assetId]) + }, [yieldsData, assetId]) const aggregated = useMemo(() => { const multiAccountEnabled = getConfig().VITE_FEATURE_YIELD_MULTI_ACCOUNT @@ -90,28 +94,6 @@ export const YieldAssetSection = memo(({ assetId, accountId }: YieldAssetSection setSelectedYield(null) }, []) - const yieldHeading = translate('yieldXYZ.yield') ?? 'Yield' - - const loadingContent = useMemo( - () => ( - - - - - ), - [], - ) - - const activePositionsContent = useMemo( - () => , - [aggregated, yields, assetId], - ) - - const opportunityCardContent = useMemo(() => { - if (!bestYield) return null - return - }, [bestYield, handleOpportunityClick]) - if (!isYieldXyzEnabled) return null if (!isLoading && yields.length === 0) return null @@ -119,13 +101,17 @@ export const YieldAssetSection = memo(({ assetId, accountId }: YieldAssetSection <> - {yieldHeading} + {translate('yieldXYZ.yield')} - {hasActivePositions && activePositionsContent} - {isLoading && loadingContent} - {!isLoading && !hasActivePositions && opportunityCardContent} + {hasActivePositions && ( + + )} + {isLoading && LoadingContent} + {!isLoading && !hasActivePositions && bestYield && ( + + )} diff --git a/src/pages/Yields/components/YieldEnterExit.tsx b/src/pages/Yields/components/YieldEnterExit.tsx index 08f3e6cba66..07f62450076 100644 --- a/src/pages/Yields/components/YieldEnterExit.tsx +++ b/src/pages/Yields/components/YieldEnterExit.tsx @@ -270,7 +270,7 @@ export const YieldEnterExit = memo( const uniqueValidatorCount = balances ? balances.validatorAddresses.length : 0 const shouldShowValidatorPicker = uniqueValidatorCount > 1 - const enterTabSelectedStyle = useMemo( + const tabSelectedSx = useMemo( () => ({ color: 'blue.400', borderBottomColor: 'transparent', @@ -281,8 +281,8 @@ export const YieldEnterExit = memo( [], ) - const tabFocusStyle = useMemo(() => ({ boxShadow: 'none' }), []) - const buttonHoverStyle = useMemo(() => ({ transform: 'translateY(-1px)', boxShadow: 'lg' }), []) + const tabFocusSx = useMemo(() => ({ boxShadow: 'none' }), []) + const buttonHoverSx = useMemo(() => ({ transform: 'translateY(-1px)', boxShadow: 'lg' }), []) const enterButtonDisabled = useMemo( () => @@ -555,8 +555,8 @@ export const YieldEnterExit = memo( > {enterButtonText} @@ -628,7 +628,7 @@ export const YieldEnterExit = memo( fontSize='lg' isDisabled={exitButtonDisabled} onClick={handleExitButtonClick} - _hover={buttonHoverStyle} + _hover={buttonHoverSx} > {exitButtonText} diff --git a/src/pages/Yields/components/YieldEnterModal.tsx b/src/pages/Yields/components/YieldEnterModal.tsx index 782df3c3b66..4fd64f0e96e 100644 --- a/src/pages/Yields/components/YieldEnterModal.tsx +++ b/src/pages/Yields/components/YieldEnterModal.tsx @@ -95,23 +95,23 @@ const INPUT_LENGTH_BREAKPOINTS = { FOR_MD_FONT: 10, } as const -const getFontSizeByLength = (length: number): string => { +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 BigAmountInputProps = { +type CryptoAmountInputProps = { value?: string onChange?: (e: React.ChangeEvent) => void placeholder?: string [key: string]: unknown } -const BigAmountInput = (props: BigAmountInputProps) => { +const CryptoAmountInput = (props: CryptoAmountInputProps) => { const valueLength = useMemo(() => (props.value ? String(props.value).length : 0), [props.value]) - const fontSize = useMemo(() => getFontSizeByLength(valueLength), [valueLength]) + const fontSize = useMemo(() => getInputFontSize(valueLength), [valueLength]) return ( ({ bg: 'blue.600' }), []) + const unselectedHoverStyle = useMemo(() => ({ bg: 'background.surface.raised.hover' }), []) + const percentButtons = useMemo( () => ( @@ -597,7 +600,7 @@ export const YieldEnterModal = memo( variant='ghost' bg={isSelected ? 'blue.500' : 'background.surface.raised.base'} color={isSelected ? 'white' : 'text.subtle'} - _hover={{ bg: isSelected ? 'blue.600' : 'background.surface.raised.hover' }} + _hover={isSelected ? selectedHoverStyle : unselectedHoverStyle} onClick={() => handlePercentClick(percent)} borderRadius='full' px={4} @@ -609,7 +612,7 @@ export const YieldEnterModal = memo( })} ), - [selectedPercent, handlePercentClick, translate], + [selectedPercent, handlePercentClick, translate, selectedHoverStyle, unselectedHoverStyle], ) const statsContent = useMemo( @@ -712,7 +715,7 @@ export const YieldEnterModal = memo( {inputTokenAssetId && } Date: Mon, 12 Jan 2026 16:29:24 +0100 Subject: [PATCH 14/60] [skip ci] wip: fix remaining hardcoded colors, use semantic tokens Co-Authored-By: Claude Opus 4.5 --- src/pages/Yields/components/YieldActionModal.tsx | 8 ++++---- src/pages/Yields/components/YieldEnterExit.tsx | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/pages/Yields/components/YieldActionModal.tsx b/src/pages/Yields/components/YieldActionModal.tsx index a53eeb6c763..279a1a9840c 100644 --- a/src/pages/Yields/components/YieldActionModal.tsx +++ b/src/pages/Yields/components/YieldActionModal.tsx @@ -396,7 +396,7 @@ export const YieldActionModal = memo(function YieldActionModal({ {estimatedEarningsAmount} - + @@ -480,10 +480,10 @@ export const YieldActionModal = memo(function YieldActionModal({ ) : s.status === 'loading' ? ( ) : ( - + )} @@ -506,7 +506,7 @@ export const YieldActionModal = memo(function YieldActionModal({ ) : ( {s.status === 'success' diff --git a/src/pages/Yields/components/YieldEnterExit.tsx b/src/pages/Yields/components/YieldEnterExit.tsx index 07f62450076..6c0fed957d5 100644 --- a/src/pages/Yields/components/YieldEnterExit.tsx +++ b/src/pages/Yields/components/YieldEnterExit.tsx @@ -58,8 +58,8 @@ const YieldEnterExitSkeleton = memo(() => ( )) -const moneyBillWaveIcon = -const chevronDownIcon = +const moneyBillWaveIcon = +const chevronDownIcon = export const YieldEnterExit = memo( ({ yieldItem, isQuoteLoading, balances, isBalancesLoading }: YieldEnterExitProps) => { @@ -441,7 +441,7 @@ export const YieldEnterExit = memo( {moneyBillWaveIcon} - + {translate('yieldXYZ.minDeposit')} @@ -464,7 +464,7 @@ export const YieldEnterExit = memo( {estimatedYearlyEarningsDisplay} - + From 062323867351a0d8c530b5cceb60d444b025cec8 Mon Sep 17 00:00:00 2001 From: gomes <17035424+gomesalexandre@users.noreply.github.com> Date: Mon, 12 Jan 2026 17:15:45 +0100 Subject: [PATCH 15/60] [skip ci] chore: yield components cleanup pass - YieldFilters: Use MenuOptionGroup/MenuItemOption for proper theme styling (no more blue.500) - ValidatorBreakdown: Remove useColorModeValue, use direct color tokens - YieldEnterModal: Extract hover style constants outside component - YieldEnterExit: Extract style constants outside component, fix inline button handlers - YieldActionModal: Use STATUS_LABEL_KEYS map to avoid nested ternaries - useYieldTransactionFlow: Rename loadingMessage to statusLabel for consistency Co-Authored-By: Claude Opus 4.5 --- .../components/TransactionStepsList.tsx | 39 ++- .../Yields/components/ValidatorBreakdown.tsx | 38 +-- .../Yields/components/YieldActionModal.tsx | 65 ++--- .../Yields/components/YieldEnterExit.tsx | 238 +++++++++--------- .../Yields/components/YieldEnterModal.tsx | 19 +- src/pages/Yields/components/YieldFilters.tsx | 76 +++--- .../Yields/hooks/useYieldTransactionFlow.ts | 14 +- 7 files changed, 227 insertions(+), 262 deletions(-) diff --git a/src/pages/Yields/components/TransactionStepsList.tsx b/src/pages/Yields/components/TransactionStepsList.tsx index ed608866dbb..3fab8723951 100644 --- a/src/pages/Yields/components/TransactionStepsList.tsx +++ b/src/pages/Yields/components/TransactionStepsList.tsx @@ -1,12 +1,10 @@ import { Box, Flex, Icon, Link, Spinner, Text } from '@chakra-ui/react' import { memo, useMemo } from 'react' import { FaCheck, FaExternalLinkAlt } from 'react-icons/fa' -import { useTranslate } from 'react-polyglot' import { MiddleEllipsis } from '@/components/MiddleEllipsis/MiddleEllipsis' import type { TransactionStep } from '@/pages/Yields/hooks/useYieldTransactionFlow' -export type { TransactionStep } type TransactionStepStatus = TransactionStep['status'] type TransactionStepsListProps = { @@ -20,21 +18,10 @@ const StepIcon = memo(({ status }: { status: TransactionStepStatus }) => { if (status === 'loading') { return } - return + return }) -const getStatusLabel = ( - status: TransactionStepStatus, - translate: ReturnType, -): string => { - if (status === 'success') return translate('yieldXYZ.loading.done') - if (status === 'loading') return '' - return translate('yieldXYZ.loading.waiting') -} - export const TransactionStepsList = memo(({ steps }: TransactionStepsListProps) => { - const translate = useTranslate() - const stepElements = useMemo(() => { return steps.map((step, idx) => ( ) : ( - - {getStatusLabel(step.status, translate)} - + step.statusLabel && ( + + {step.statusLabel} + + ) )} )) - }, [steps, translate]) + }, [steps]) if (!steps.length) return null diff --git a/src/pages/Yields/components/ValidatorBreakdown.tsx b/src/pages/Yields/components/ValidatorBreakdown.tsx index ef9119aa25a..cea9d50ea6c 100644 --- a/src/pages/Yields/components/ValidatorBreakdown.tsx +++ b/src/pages/Yields/components/ValidatorBreakdown.tsx @@ -11,7 +11,6 @@ import { HStack, Skeleton, Text, - useColorModeValue, useDisclosure, VStack, } from '@chakra-ui/react' @@ -166,21 +165,6 @@ const BalanceRow: FC = memo( const ValidatorCard: FC = memo( ({ validatorSummary, isSelected, userCurrencyToUsdRate, onValidatorSwitch, onClaimClick }) => { const translate = useTranslate() - - const enteringBg = useColorModeValue('blue.50', 'blue.900') - const enteringTextColor = useColorModeValue('blue.700', 'blue.300') - const enteringDateColor = useColorModeValue('blue.600', 'blue.400') - const enteringValueColor = useColorModeValue('blue.800', 'blue.200') - - const exitingBg = useColorModeValue('orange.50', 'orange.900') - const exitingTextColor = useColorModeValue('orange.700', 'orange.300') - const exitingDateColor = useColorModeValue('orange.600', 'orange.400') - const exitingValueColor = useColorModeValue('orange.800', 'orange.200') - - const claimableBg = useColorModeValue('purple.50', 'purple.900') - const claimableTextColor = useColorModeValue('purple.700', 'purple.300') - const claimableValueColor = useColorModeValue('purple.800', 'purple.200') - const { validator, byType, @@ -253,29 +237,29 @@ const ValidatorCard: FC = memo( balance={enteringBalance} hasBalance={hasEntering} label='yieldXYZ.entering' - bg={enteringBg} - textColor={enteringTextColor} - dateColor={enteringDateColor} - valueColor={enteringValueColor} + bg='blue.900' + textColor='blue.300' + dateColor='blue.400' + valueColor='blue.200' showDate /> diff --git a/src/pages/Yields/components/YieldActionModal.tsx b/src/pages/Yields/components/YieldActionModal.tsx index 279a1a9840c..39e1bb31d0f 100644 --- a/src/pages/Yields/components/YieldActionModal.tsx +++ b/src/pages/Yields/components/YieldActionModal.tsx @@ -45,6 +45,12 @@ const checkIconBox = ( ) +const STATUS_LABEL_KEYS: Record = { + pending: 'yieldXYZ.loading.waiting', + success: 'yieldXYZ.loading.done', + loading: null, +} + type YieldActionModalProps = { isOpen: boolean onClose: () => void @@ -193,9 +199,8 @@ export const YieldActionModal = memo(function YieldActionModal({ const loadingText = useMemo(() => { if (isQuoteLoading) return translate('yieldXYZ.loadingQuote') - // Use the current step's loading message if available - if (activeStepIndex >= 0 && transactionSteps[activeStepIndex]?.loadingMessage) { - return transactionSteps[activeStepIndex].loadingMessage + if (activeStepIndex >= 0 && transactionSteps[activeStepIndex]?.statusLabel) { + return transactionSteps[activeStepIndex].statusLabel } if (action === 'enter') return translate('yieldXYZ.depositing') if (action === 'exit') return translate('yieldXYZ.withdrawing') @@ -490,32 +495,34 @@ export const YieldActionModal = memo(function YieldActionModal({ {s.title} - {s.txHash ? ( - - - - ) : ( - - {s.status === 'success' - ? translate('yieldXYZ.loading.done') - : s.status === 'loading' - ? '' - : translate('yieldXYZ.loading.waiting')} - - )} + {(() => { + if (s.txHash) { + return ( + + {' '} + + + ) + } + const labelKey = STATUS_LABEL_KEYS[s.status] + if (labelKey) { + return ( + + {translate(labelKey)} + + ) + } + return null + })()} ))} diff --git a/src/pages/Yields/components/YieldEnterExit.tsx b/src/pages/Yields/components/YieldEnterExit.tsx index 6c0fed957d5..4a30eae8cb6 100644 --- a/src/pages/Yields/components/YieldEnterExit.tsx +++ b/src/pages/Yields/components/YieldEnterExit.tsx @@ -3,6 +3,8 @@ import { Avatar, Box, Button, + Card, + CardBody, Flex, Icon, Skeleton, @@ -61,6 +63,16 @@ const YieldEnterExitSkeleton = memo(() => ( const moneyBillWaveIcon = const chevronDownIcon = +const tabSelectedSx = { + color: 'blue.400', + borderBottomColor: 'transparent', + borderTopColor: 'blue.400', + borderTopWidth: '3px', + marginTop: '-1px', +} +const tabFocusSx = { boxShadow: 'none' } +const buttonHoverSx = { transform: 'translateY(-1px)', boxShadow: 'lg' } + export const YieldEnterExit = memo( ({ yieldItem, isQuoteLoading, balances, isBalancesLoading }: YieldEnterExitProps) => { const translate = useTranslate() @@ -270,20 +282,6 @@ export const YieldEnterExit = memo( const uniqueValidatorCount = balances ? balances.validatorAddresses.length : 0 const shouldShowValidatorPicker = uniqueValidatorCount > 1 - const tabSelectedSx = useMemo( - () => ({ - color: 'blue.400', - borderBottomColor: 'transparent', - borderTopColor: 'blue.400', - borderTopWidth: '3px', - marginTop: '-1px', - }), - [], - ) - - const tabFocusSx = useMemo(() => ({ boxShadow: 'none' }), []) - const buttonHoverSx = useMemo(() => ({ transform: 'translateY(-1px)', boxShadow: 'lg' }), []) - const enterButtonDisabled = useMemo( () => isConnected && @@ -318,16 +316,6 @@ export const YieldEnterExit = memo( return translate('common.connectWallet') }, [isConnected, translate]) - const handleEnterButtonClick = useMemo( - () => (isConnected ? handleEnterClick : handleConnectWallet), - [isConnected, handleEnterClick, handleConnectWallet], - ) - - const handleExitButtonClick = useMemo( - () => (isConnected ? handleExitClick : handleConnectWallet), - [isConnected, handleExitClick, handleConnectWallet], - ) - const modalAssetSymbol = useMemo( () => (modalAction === 'enter' ? inputToken?.symbol ?? '' : yieldItem.token.symbol), [modalAction, inputToken?.symbol, yieldItem.token.symbol], @@ -369,7 +357,7 @@ export const YieldEnterExit = memo( p={4} borderBottom='1px solid' borderColor='border.base' - bg='background.surface.raised.base' + bg='background.surface.base' _hover={{ bg: 'background.surface.raised.hover' }} cursor='pointer' onClick={handleOpenValidatorModal} @@ -445,7 +433,7 @@ export const YieldEnterExit = memo( {translate('yieldXYZ.minDeposit')} - + {minDeposit} {inputToken?.symbol} @@ -538,105 +526,105 @@ export const YieldEnterExit = memo( return ( <> - - {validatorPickerContent} - - - - {translate('yieldXYZ.enter')} - - - {translate('yieldXYZ.exit')} - - - - - - {enterTabPanelContent} - {minDepositContent} - - - - {translate('yieldXYZ.currentApy')} - - - {apyDisplay} - - - {estimatedYearlyEarningsContent} - - - - - - - {exitTabPanelContent} - - - - - - + + + {validatorPickerContent} + + + + {translate('yieldXYZ.enter')} + + + {translate('yieldXYZ.exit')} + + + + + + {enterTabPanelContent} + {minDepositContent} + + + + {translate('yieldXYZ.currentApy')} + + + {apyDisplay} + + + {estimatedYearlyEarningsContent} + + + + + + + {exitTabPanelContent} + + + + + + + { return '48px' } +const selectedHoverSx = { bg: 'blue.600' } +const unselectedHoverSx = { bg: 'background.surface.raised.hover' } + type CryptoAmountInputProps = { value?: string onChange?: (e: React.ChangeEvent) => void @@ -565,11 +568,6 @@ export const YieldEnterModal = memo( return translate('yieldXYZ.stakeAsset', { asset: inputTokenAsset?.symbol }) }, [isConnected, isQuoteActive, translate, inputTokenAsset?.symbol]) - const handleEnterButtonClick = useMemo( - () => (isConnected ? handleExecute : handleConnectWallet), - [isConnected, handleExecute, handleConnectWallet], - ) - const modalTitle = useMemo(() => { if (modalStep === 'success') return translate('common.success') return translate('yieldXYZ.stakeAsset', { asset: inputTokenAsset?.symbol }) @@ -585,9 +583,6 @@ export const YieldEnterModal = memo( })) }, [quoteData, inputTokenAsset]) - const selectedHoverStyle = useMemo(() => ({ bg: 'blue.600' }), []) - const unselectedHoverStyle = useMemo(() => ({ bg: 'background.surface.raised.hover' }), []) - const percentButtons = useMemo( () => ( @@ -600,7 +595,7 @@ export const YieldEnterModal = memo( variant='ghost' bg={isSelected ? 'blue.500' : 'background.surface.raised.base'} color={isSelected ? 'white' : 'text.subtle'} - _hover={isSelected ? selectedHoverStyle : unselectedHoverStyle} + _hover={isSelected ? selectedHoverSx : unselectedHoverSx} onClick={() => handlePercentClick(percent)} borderRadius='full' px={4} @@ -612,7 +607,7 @@ export const YieldEnterModal = memo( })} ), - [selectedPercent, handlePercentClick, translate, selectedHoverStyle, unselectedHoverStyle], + [selectedPercent, handlePercentClick, translate], ) const statsContent = useMemo( @@ -852,7 +847,7 @@ export const YieldEnterModal = memo( loadingText={ isSubmitting ? translate('common.confirming') : translate('yieldXYZ.loadingQuote') } - onClick={handleEnterButtonClick} + onClick={isConnected ? handleExecute : handleConnectWallet} > {enterButtonText} diff --git a/src/pages/Yields/components/YieldFilters.tsx b/src/pages/Yields/components/YieldFilters.tsx index 393570edf5e..a4c80daf27b 100644 --- a/src/pages/Yields/components/YieldFilters.tsx +++ b/src/pages/Yields/components/YieldFilters.tsx @@ -6,8 +6,9 @@ import { IconButton, Menu, MenuButton, - MenuItem, + MenuItemOption, MenuList, + MenuOptionGroup, Stack, Text, Tooltip, @@ -50,6 +51,8 @@ type FilterMenuProps = { const chevronDownIcon = +const ALL_OPTION_VALUE = '__all__' + const FilterMenu = memo(({ label, value, options, onSelect, renderIcon }: FilterMenuProps) => { const selectedOption = useMemo(() => options.find(o => o.id === value), [options, value]) const displayLabel = useMemo( @@ -57,32 +60,30 @@ const FilterMenu = memo(({ label, value, options, onSelect, renderIcon }: Filter [selectedOption, label], ) - const handleSelectAll = useCallback(() => onSelect(null), [onSelect]) - const selectedIcon = useMemo( () => (selectedOption && renderIcon ? renderIcon(selectedOption) : null), [selectedOption, renderIcon], ) + const handleChange = useCallback( + (newValue: string | string[]) => { + const selectedValue = Array.isArray(newValue) ? newValue[0] : newValue + onSelect(selectedValue === ALL_OPTION_VALUE ? null : selectedValue) + }, + [onSelect], + ) + const menuItems = useMemo( () => - options.map(opt => { - const isSelected = value === opt.id - return ( - onSelect(opt.id)} - color={isSelected ? 'blue.500' : undefined} - fontWeight={isSelected ? 'semibold' : undefined} - > - - {renderIcon && renderIcon(opt)} - {opt.name} - - - ) - }), - [options, value, renderIcon, onSelect], + options.map(opt => ( + + + {renderIcon && renderIcon(opt)} + {opt.name} + + + )), + [options, renderIcon], ) return ( @@ -96,14 +97,10 @@ const FilterMenu = memo(({ label, value, options, onSelect, renderIcon }: Filter - - {label} - - {menuItems} + + {label} + {menuItems} + ) @@ -169,19 +166,22 @@ export const YieldFilters = memo( return }, [sortOption]) + const handleSortChange = useCallback( + (newValue: string | string[]) => { + const selectedValue = Array.isArray(newValue) ? newValue[0] : newValue + onSortChange(selectedValue as SortOption) + }, + [onSortChange], + ) + const sortMenuItems = useMemo( () => sortOptions.map(opt => ( - onSortChange(opt.value)} - color={sortOption === opt.value ? 'blue.500' : undefined} - fontWeight={sortOption === opt.value ? 'bold' : undefined} - > + {opt.label} - + )), - [sortOptions, sortOption, onSortChange], + [sortOptions], ) return ( @@ -205,7 +205,9 @@ export const YieldFilters = memo( - {sortMenuItems} + + {sortMenuItems} + diff --git a/src/pages/Yields/hooks/useYieldTransactionFlow.ts b/src/pages/Yields/hooks/useYieldTransactionFlow.ts index 706e2b0b690..1d9877b1192 100644 --- a/src/pages/Yields/hooks/useYieldTransactionFlow.ts +++ b/src/pages/Yields/hooks/useYieldTransactionFlow.ts @@ -45,11 +45,11 @@ export enum ModalStep { export type TransactionStep = { title: string status: 'pending' | 'success' | 'loading' + statusLabel?: string originalTitle?: string type?: string txHash?: string txUrl?: string - loadingMessage?: string } export const poll = async ( @@ -341,7 +341,7 @@ export const useYieldTransactionFlow = ({ updateStepStatus(index, { status: 'loading', - loadingMessage: translate('yieldXYZ.loading.signInWallet'), + statusLabel: translate('yieldXYZ.loading.signInWallet'), }) setIsSubmitting(true) @@ -360,7 +360,7 @@ export const useYieldTransactionFlow = ({ const txUrl = feeAsset ? `${feeAsset.explorerTxLink}${txHash}` : '' - updateStepStatus(index, { txHash, txUrl, loadingMessage: translate('common.confirming') }) + updateStepStatus(index, { txHash, txUrl, statusLabel: translate('common.confirming') }) await submitHashMutation.mutateAsync({ transactionId: tx.id, @@ -388,7 +388,7 @@ export const useYieldTransactionFlow = ({ ) } dispatchNotification(tx, txHash) - updateStepStatus(index, { status: 'success', loadingMessage: undefined }) + updateStepStatus(index, { status: 'success', statusLabel: undefined }) setStep(ModalStep.Success) } else { const freshAction = await fetchAction(actionId) @@ -397,7 +397,7 @@ export const useYieldTransactionFlow = ({ ) if (nextTx) { - updateStepStatus(index, { status: 'success', loadingMessage: undefined }) + updateStepStatus(index, { status: 'success', statusLabel: undefined }) setRawTransactions(prev => prev.map((t, i) => (i === index + 1 ? nextTx : t))) setActiveStepIndex(index + 1) } else { @@ -417,7 +417,7 @@ export const useYieldTransactionFlow = ({ ) } dispatchNotification(tx, txHash) - updateStepStatus(index, { status: 'success', loadingMessage: undefined }) + updateStepStatus(index, { status: 'success', statusLabel: undefined }) setStep(ModalStep.Success) } } @@ -427,7 +427,7 @@ export const useYieldTransactionFlow = ({ 'yieldXYZ.errors.transactionFailedTitle', 'yieldXYZ.errors.transactionFailedDescription', ) - updateStepStatus(index, { status: 'pending', loadingMessage: undefined }) + updateStepStatus(index, { status: 'pending', statusLabel: undefined }) } finally { setIsSubmitting(false) } From 14b7524f911a467678e026c968acec7130afe1f8 Mon Sep 17 00:00:00 2001 From: gomes <17035424+gomesalexandre@users.noreply.github.com> Date: Mon, 12 Jan 2026 17:18:37 +0100 Subject: [PATCH 16/60] [skip ci] chore: YieldsList extract tab style constant, fix whiteAlpha.50 Co-Authored-By: Claude Opus 4.5 --- src/pages/Yields/components/YieldsList.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/pages/Yields/components/YieldsList.tsx b/src/pages/Yields/components/YieldsList.tsx index 135c50a2340..16fae2b6699 100644 --- a/src/pages/Yields/components/YieldsList.tsx +++ b/src/pages/Yields/components/YieldsList.tsx @@ -49,6 +49,8 @@ import { } from '@/state/slices/selectors' import { useAppSelector } from '@/state/store' +const tabSelectedSx = { color: 'white', bg: 'blue.500' } + export const YieldsList = memo(() => { const translate = useTranslate() const navigate = useNavigate() @@ -596,7 +598,7 @@ export const YieldsList = memo(() => { const positionsEmptyElement = useMemo( () => ( - + {translate('yieldXYZ.noYields')} @@ -698,8 +700,8 @@ export const YieldsList = memo(() => { onChange={handleTabChange} > - {translate('common.all')} - + {translate('common.all')} + {translate('yieldXYZ.myPosition')} ({myPositions.length}) From 8111b556c9bb84c45758632094f0b534fd31142d Mon Sep 17 00:00:00 2001 From: gomes <17035424+gomesalexandre@users.noreply.github.com> Date: Mon, 12 Jan 2026 17:21:26 +0100 Subject: [PATCH 17/60] [skip ci] chore: replace remaining gray/whiteAlpha with semantic tokens - YieldValidatorSelectModal: gray.300, gray.500 -> text.subtle - YieldActionModal: whiteAlpha.50 -> background.surface.raised.base Co-Authored-By: Claude Opus 4.5 --- src/pages/Yields/components/YieldActionModal.tsx | 2 +- src/pages/Yields/components/YieldValidatorSelectModal.tsx | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/pages/Yields/components/YieldActionModal.tsx b/src/pages/Yields/components/YieldActionModal.tsx index 39e1bb31d0f..8fffae18812 100644 --- a/src/pages/Yields/components/YieldActionModal.tsx +++ b/src/pages/Yields/components/YieldActionModal.tsx @@ -619,7 +619,7 @@ export const YieldActionModal = memo(function YieldActionModal({ justify='space-between' align='center' p={4} - bg='whiteAlpha.50' + bg='background.surface.raised.base' borderRadius='lg' border='1px solid' borderColor='border.base' diff --git a/src/pages/Yields/components/YieldValidatorSelectModal.tsx b/src/pages/Yields/components/YieldValidatorSelectModal.tsx index b52f01d7483..f3c7ad7aedf 100644 --- a/src/pages/Yields/components/YieldValidatorSelectModal.tsx +++ b/src/pages/Yields/components/YieldValidatorSelectModal.tsx @@ -34,7 +34,7 @@ import type { AugmentedYieldBalanceWithAccountId } from '@/react-queries/queries import { selectUserCurrencyToUsdRate } from '@/state/slices/selectors' import { useAppSelector } from '@/state/store' -const searchIcon = +const searchIcon = type YieldValidatorSelectModalProps = { isOpen: boolean @@ -210,7 +210,7 @@ export const YieldValidatorSelectModal = memo( {filteredAll.length === 0 ? ( - + {translate('yieldXYZ.noValidatorsFound')} ) : ( @@ -221,7 +221,7 @@ export const YieldValidatorSelectModal = memo( {filteredMy.length === 0 ? ( - + {translate('yieldXYZ.noActiveValidators')} ) : ( From 5305b6f5a14451051157e6ed60c8460cfaf36f81 Mon Sep 17 00:00:00 2001 From: gomes <17035424+gomesalexandre@users.noreply.github.com> Date: Mon, 12 Jan 2026 17:37:17 +0100 Subject: [PATCH 18/60] wip: yield tab styling cleanup, remove unused variables Co-Authored-By: Claude Opus 4.5 --- .../Yields/components/YieldEnterExit.tsx | 59 ++++++++----------- 1 file changed, 24 insertions(+), 35 deletions(-) diff --git a/src/pages/Yields/components/YieldEnterExit.tsx b/src/pages/Yields/components/YieldEnterExit.tsx index 4a30eae8cb6..62511692a93 100644 --- a/src/pages/Yields/components/YieldEnterExit.tsx +++ b/src/pages/Yields/components/YieldEnterExit.tsx @@ -62,15 +62,6 @@ const YieldEnterExitSkeleton = memo(() => ( const moneyBillWaveIcon = const chevronDownIcon = - -const tabSelectedSx = { - color: 'blue.400', - borderBottomColor: 'transparent', - borderTopColor: 'blue.400', - borderTopWidth: '3px', - marginTop: '-1px', -} -const tabFocusSx = { boxShadow: 'none' } const buttonHoverSx = { transform: 'translateY(-1px)', boxShadow: 'lg' } export const YieldEnterExit = memo( @@ -323,8 +314,6 @@ export const YieldEnterExit = memo( const enterTabDisabled = !yieldItem.status.enter const exitTabDisabled = !yieldItem.status.exit - const enterTabOpacity = enterTabDisabled ? 0.5 : 1 - const exitTabOpacity = exitTabDisabled ? 0.5 : 1 const isPreferredValidator = useMemo( () => (validatorMetadata as ValidatorDto | undefined)?.preferred === true, @@ -529,43 +518,43 @@ export const YieldEnterExit = memo( {validatorPickerContent} - - + + {translate('yieldXYZ.enter')} {translate('yieldXYZ.exit')} - + {enterTabPanelContent} {minDepositContent} @@ -604,7 +593,7 @@ export const YieldEnterExit = memo( - + {exitTabPanelContent} @@ -94,7 +97,10 @@ const RowWrap = ({ onRowLongPress?.(row as Row) }, defaultLongPressConfig) - const rowProps = useMemo(() => row.getRowProps(), [row]) + const rowProps = useMemo(() => { + const { key: _key, ...rest } = row.getRowProps() + return rest + }, [row]) const dataTest = useMemo(() => { if (!rowDataTestKey) return undefined @@ -229,33 +235,48 @@ export const ReactTable = ({
{assetColumnHeader} {providerColumnHeader}
({ cell }: { cell: Cell }) => { data-label={dataLabel} display={cell.column.display} textAlign={cell.column.textAlign} - key={cell.column.id} > {cell.render('Cell')}
{displayHeaders && ( - {headerGroups.map(headerGroup => ( - - {headerGroup.headers.map(column => ( - - ))} - - ))} + {headerGroups.map(headerGroup => { + const { key: headerGroupKey, ...headerGroupProps } = headerGroup.getHeaderGroupProps() + return ( + + {headerGroup.headers.map(column => { + const { key: columnKey, ...columnProps } = column.getHeaderProps( + column.getSortByToggleProps(), + ) + return ( + + ) + })} + + ) + })} )} {renderedRows} diff --git a/src/lib/yieldxyz/utils.ts b/src/lib/yieldxyz/utils.ts index f71a9fb4993..52fc4d0971f 100644 --- a/src/lib/yieldxyz/utils.ts +++ b/src/lib/yieldxyz/utils.ts @@ -1,6 +1,7 @@ import type { ChainId } from '@shapeshiftoss/caip' import { + COSMOS_NETWORK_FALLBACK_APR, isSupportedYieldNetwork, SHAPESHIFT_COSMOS_VALIDATOR_ADDRESS, YIELD_NETWORK_TO_CHAIN_ID, @@ -147,3 +148,15 @@ export const sortValidators = ( export const toUserCurrency = (usdAmount: string | number, rate: string | number): string => bnOrZero(usdAmount).times(rate).toFixed() + +export const ensureValidatorApr = (validator: ValidatorDto): ValidatorDto => + validator.rewardRate?.total + ? validator + : { + ...validator, + rewardRate: { + total: COSMOS_NETWORK_FALLBACK_APR, + rateType: 'APR' as const, + components: validator.rewardRate?.components ?? [], + }, + } diff --git a/src/pages/Yields/components/YieldActivePositions.tsx b/src/pages/Yields/components/YieldActivePositions.tsx index 3d5c08d4b95..eeb29dac83f 100644 --- a/src/pages/Yields/components/YieldActivePositions.tsx +++ b/src/pages/Yields/components/YieldActivePositions.tsx @@ -17,10 +17,9 @@ import { useTranslate } from 'react-polyglot' import { useNavigate } from 'react-router-dom' import { Amount } from '@/components/Amount/Amount' -import { AssetIcon } from '@/components/AssetIcon' import { bnOrZero } from '@/lib/bignumber/bignumber' import type { AugmentedYieldDto } from '@/lib/yieldxyz/types' -import { resolveYieldInputAssetIcon, toUserCurrency } from '@/lib/yieldxyz/utils' +import { toUserCurrency } from '@/lib/yieldxyz/utils' import type { YieldBalanceAggregate } from '@/react-queries/queries/yieldxyz/useAllYieldBalances' import { useYieldProviders } from '@/react-queries/queries/yieldxyz/useYieldProviders' import { selectAssetById, selectUserCurrencyToUsdRate } from '@/state/slices/selectors' @@ -66,8 +65,6 @@ export const YieldActivePositions = memo( [activeYields, aggregated], ) - const assetColumnHeader = useMemo(() => translate('yieldXYZ.asset') ?? 'Asset', [translate]) - const providerColumnHeader = useMemo( () => hasValidators @@ -85,14 +82,6 @@ export const YieldActivePositions = memo( [translate], ) - const yourBalanceLabel = useMemo(() => translate('defi.yourBalance'), [translate]) - - const renderAssetIcon = useCallback((yieldItem: AugmentedYieldDto) => { - const iconSource = resolveYieldInputAssetIcon(yieldItem) - if (iconSource.assetId) return - return - }, []) - const tableRows = useMemo(() => { if (!asset) return null @@ -114,14 +103,6 @@ export const YieldActivePositions = memo( _hover={{ bg: 'background.surface.raised.base', cursor: 'pointer' }} onClick={() => handleRowClick(yieldItem.id, validator.address)} > -
- - {column.render('Header')} - - {column.isSorted ? ( - column.isSortedDesc ? ( - - ) : ( - - ) - ) : null} - - -
+ + {column.render('Header')} + + {column.isSorted ? ( + column.isSortedDesc ? ( + + ) : ( + + ) + ) : null} + + +
- - {renderAssetIcon(yieldItem)} - - {yieldItem.metadata.name} - - - {validator.logoURI ? ( @@ -180,14 +161,6 @@ export const YieldActivePositions = memo( _hover={{ bg: 'background.surface.raised.base', cursor: 'pointer' }} onClick={() => handleRowClick(yieldItem.id)} > - - - {renderAssetIcon(yieldItem)} - - {yieldItem.metadata.name} - - - ) }) - }, [ - activeYields, - aggregated, - asset, - getProviderLogo, - handleRowClick, - renderAssetIcon, - userCurrencyToUsdRate, - ]) + }, [activeYields, aggregated, asset, getProviderLogo, handleRowClick, userCurrencyToUsdRate]) if (!asset) return null if (activeYields.length === 0) return null return ( - - - {yourBalanceLabel} - - - - - - - - - - - - - {tableRows} -
{assetColumnHeader}{providerColumnHeader}{apyColumnHeader}{tvlColumnHeader}{balanceColumnHeader}
-
-
+ + + + + + + + + + + {tableRows} +
{providerColumnHeader}{apyColumnHeader}{tvlColumnHeader}{balanceColumnHeader}
+
) }, ) diff --git a/src/pages/Yields/components/YieldValidatorSelectModal.tsx b/src/pages/Yields/components/YieldValidatorSelectModal.tsx index 6e168e9b293..72c315fea87 100644 --- a/src/pages/Yields/components/YieldValidatorSelectModal.tsx +++ b/src/pages/Yields/components/YieldValidatorSelectModal.tsx @@ -26,12 +26,9 @@ import { useTranslate } from 'react-polyglot' import { Amount } from '@/components/Amount/Amount' import { bnOrZero } from '@/lib/bignumber/bignumber' -import { - COSMOS_NETWORK_FALLBACK_APR, - SHAPESHIFT_COSMOS_VALIDATOR_ADDRESS, -} from '@/lib/yieldxyz/constants' +import { SHAPESHIFT_COSMOS_VALIDATOR_ADDRESS } from '@/lib/yieldxyz/constants' import type { ValidatorDto } from '@/lib/yieldxyz/types' -import { searchValidators, toUserCurrency } from '@/lib/yieldxyz/utils' +import { ensureValidatorApr, searchValidators, toUserCurrency } from '@/lib/yieldxyz/utils' import { GradientApy } from '@/pages/Yields/components/GradientApy' import type { AugmentedYieldBalanceWithAccountId } from '@/react-queries/queries/yieldxyz/useAllYieldBalances' import { selectUserCurrencyToUsdRate } from '@/state/slices/selectors' @@ -85,22 +82,24 @@ export const YieldValidatorSelectModal = memo( if (full) { result.push(full) } else { - result.push({ - address: balance.validator.address, - name: balance.validator.name, - logoURI: balance.validator.logoURI, - preferred: false, - votingPower: 0, - commission: balance.validator.commission ?? 0, - status: balance.validator.status ?? 'active', - tvl: '0', - tvlRaw: '0', - rewardRate: { - total: balance.validator.apr ?? COSMOS_NETWORK_FALLBACK_APR, - rateType: 'APR' as const, - components: [], - }, - }) + result.push( + ensureValidatorApr({ + address: balance.validator.address, + name: balance.validator.name, + logoURI: balance.validator.logoURI, + preferred: false, + votingPower: 0, + commission: balance.validator.commission ?? 0, + status: balance.validator.status ?? 'active', + tvl: '0', + tvlRaw: '0', + rewardRate: { + total: balance.validator.apr ?? 0, + rateType: 'APR' as const, + components: [], + }, + }), + ) } } return result diff --git a/src/react-queries/queries/yieldxyz/useYieldValidators.ts b/src/react-queries/queries/yieldxyz/useYieldValidators.ts index febf8f43567..7c8ca744f3f 100644 --- a/src/react-queries/queries/yieldxyz/useYieldValidators.ts +++ b/src/react-queries/queries/yieldxyz/useYieldValidators.ts @@ -7,6 +7,7 @@ import { SHAPESHIFT_VALIDATOR, } from '@/lib/yieldxyz/constants' import type { ValidatorDto } from '@/lib/yieldxyz/types' +import { ensureValidatorApr } from '@/lib/yieldxyz/utils' const normalizeCosmosValidators = (validators: ValidatorDto[]): ValidatorDto[] => { const existingShapeshift = validators.find(v => v.address === SHAPESHIFT_COSMOS_VALIDATOR_ADDRESS) @@ -17,7 +18,7 @@ const normalizeCosmosValidators = (validators: ValidatorDto[]): ValidatorDto[] = const otherValidators = validators .filter(v => v.address !== SHAPESHIFT_COSMOS_VALIDATOR_ADDRESS) - .map(v => ({ ...v, preferred: false })) + .map(v => ensureValidatorApr({ ...v, preferred: false })) return [shapeshiftValidator, ...otherValidators] } From a4e4d9d40d2b794be0bfde93356becedf2a7b6b3 Mon Sep 17 00:00:00 2001 From: gomes <17035424+gomesalexandre@users.noreply.github.com> Date: Mon, 12 Jan 2026 18:18:10 +0100 Subject: [PATCH 21/60] refactor(yields): cleanup pass - extract component, reuse poll util, hide section without wallet - Extract TransactionStepStatus from anonymous IIFE in YieldActionModal - Reuse existing poll utility from lib/poll instead of custom implementation - Hide YieldAssetSection when wallet is not connected - Reorder TransactionStep type fields for cleaner grouping - Remove unnecessary curly braces in useYieldValidators Co-Authored-By: Claude Opus 4.5 --- .../Yields/components/YieldActionModal.tsx | 63 ++++++++++--------- .../Yields/components/YieldAssetSection.tsx | 5 ++ .../Yields/hooks/useYieldTransactionFlow.ts | 38 ++++------- .../queries/yieldxyz/useYieldValidators.ts | 5 +- 4 files changed, 54 insertions(+), 57 deletions(-) diff --git a/src/pages/Yields/components/YieldActionModal.tsx b/src/pages/Yields/components/YieldActionModal.tsx index 8fffae18812..9d31003ec3a 100644 --- a/src/pages/Yields/components/YieldActionModal.tsx +++ b/src/pages/Yields/components/YieldActionModal.tsx @@ -51,6 +51,40 @@ const STATUS_LABEL_KEYS: Record = { loading: null, } +const TransactionStepStatus = memo(function TransactionStepStatus({ + step, + translate, +}: { + step: TransactionStep + translate: ReturnType +}) { + if (step.txHash) { + return ( + + + + ) + } + const labelKey = STATUS_LABEL_KEYS[step.status] + if (labelKey) { + return ( + + {translate(labelKey)} + + ) + } + return null +}) + type YieldActionModalProps = { isOpen: boolean onClose: () => void @@ -495,34 +529,7 @@ export const YieldActionModal = memo(function YieldActionModal({ {s.title} - {(() => { - if (s.txHash) { - return ( - - {' '} - - - ) - } - const labelKey = STATUS_LABEL_KEYS[s.status] - if (labelKey) { - return ( - - {translate(labelKey)} - - ) - } - return null - })()} + ))} diff --git a/src/pages/Yields/components/YieldAssetSection.tsx b/src/pages/Yields/components/YieldAssetSection.tsx index d4846621f3e..1f8195d46a2 100644 --- a/src/pages/Yields/components/YieldAssetSection.tsx +++ b/src/pages/Yields/components/YieldAssetSection.tsx @@ -11,6 +11,7 @@ import { YieldOpportunityCard } from './YieldOpportunityCard' import { getConfig } from '@/config' import { useFeatureFlag } from '@/hooks/useFeatureFlag/useFeatureFlag' +import { useWallet } from '@/hooks/useWallet/useWallet' import type { AugmentedYieldDto } from '@/lib/yieldxyz/types' import type { YieldBalanceAggregate } from '@/react-queries/queries/yieldxyz/useAllYieldBalances' import { useAllYieldBalances } from '@/react-queries/queries/yieldxyz/useAllYieldBalances' @@ -31,6 +32,9 @@ type YieldAssetSectionProps = { export const YieldAssetSection = memo(({ assetId, accountId }: YieldAssetSectionProps) => { const translate = useTranslate() const isYieldXyzEnabled = useFeatureFlag('YieldXyz') + const { + state: { isConnected }, + } = useWallet() const { data: yieldsData, isLoading: isYieldsLoading } = useYields() const balanceOptions = useMemo(() => (accountId ? { accountIds: [accountId] } : {}), [accountId]) const { data: allBalancesData, isLoading: isBalancesLoading } = @@ -95,6 +99,7 @@ export const YieldAssetSection = memo(({ assetId, accountId }: YieldAssetSection }, []) if (!isYieldXyzEnabled) return null + if (!isConnected) return null if (!isLoading && yields.length === 0) return null return ( diff --git a/src/pages/Yields/hooks/useYieldTransactionFlow.ts b/src/pages/Yields/hooks/useYieldTransactionFlow.ts index 1d9877b1192..37a7d3b1809 100644 --- a/src/pages/Yields/hooks/useYieldTransactionFlow.ts +++ b/src/pages/Yields/hooks/useYieldTransactionFlow.ts @@ -10,6 +10,7 @@ import { useTranslate } from 'react-polyglot' import { SECOND_CLASS_CHAINS } from '@/constants/chains' import { useWallet } from '@/hooks/useWallet/useWallet' import { bnOrZero } from '@/lib/bignumber/bignumber' +import { poll } from '@/lib/poll/poll' import { enterYield, exitYield, fetchAction, manageYield } from '@/lib/yieldxyz/api' import { DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID, @@ -44,39 +45,26 @@ export enum ModalStep { export type TransactionStep = { title: string - status: 'pending' | 'success' | 'loading' - statusLabel?: string originalTitle?: string type?: string + status: 'pending' | 'success' | 'loading' + statusLabel?: string txHash?: string txUrl?: string } -export const poll = async ( - fn: () => Promise, - isComplete: (result: T) => boolean, - shouldThrow?: (result: T) => Error | undefined, -): Promise => { - for (let i = 0; i < YIELD_MAX_POLL_ATTEMPTS; i++) { - const result = await fn() - const error = shouldThrow?.(result) - if (error) throw error - if (isComplete(result)) return result - await new Promise(resolve => setTimeout(resolve, YIELD_POLL_INTERVAL_MS)) - } - throw new Error('Polling timed out') -} - export const waitForActionCompletion = (actionId: string): Promise => { - return poll( - () => fetchAction(actionId), - action => action.status === YieldActionStatus.Success, - action => { - if (action.status === YieldActionStatus.Failed) return new Error('Action failed') - if (action.status === YieldActionStatus.Canceled) return new Error('Action was canceled') - return undefined + const { promise } = poll({ + fn: () => fetchAction(actionId), + validate: action => { + if (action.status === YieldActionStatus.Failed) throw new Error('Action failed') + if (action.status === YieldActionStatus.Canceled) throw new Error('Action was canceled') + return action.status === YieldActionStatus.Success }, - ) + interval: YIELD_POLL_INTERVAL_MS, + maxAttempts: YIELD_MAX_POLL_ATTEMPTS, + }) + return promise } const filterExecutableTransactions = (transactions: TransactionDto[]): TransactionDto[] => { diff --git a/src/react-queries/queries/yieldxyz/useYieldValidators.ts b/src/react-queries/queries/yieldxyz/useYieldValidators.ts index 7c8ca744f3f..0c0b5842ec4 100644 --- a/src/react-queries/queries/yieldxyz/useYieldValidators.ts +++ b/src/react-queries/queries/yieldxyz/useYieldValidators.ts @@ -31,11 +31,8 @@ export const useYieldValidators = (yieldId: string, enabled: boolean = true) => ? async () => { const data = await fetchYieldValidators(yieldId) const validators = data.items - - if (yieldId === COSMOS_ATOM_NATIVE_STAKING_YIELD_ID) { + if (yieldId === COSMOS_ATOM_NATIVE_STAKING_YIELD_ID) return normalizeCosmosValidators(validators) - } - return validators } : skipToken, From dafa3b8e6ab2e2dc8e463f6025f809949ebcf0ba Mon Sep 17 00:00:00 2001 From: gomes <17035424+gomesalexandre@users.noreply.github.com> Date: Mon, 12 Jan 2026 18:31:18 +0100 Subject: [PATCH 22/60] fix(yields): restore original poll impl, fix button text to show transaction step - Reverted poll changes to use original inline implementation - Export waitForActionCompletion for YieldEnterModal usage - Fix enterButtonText to use getTransactionButtonText for proper labels (shows "Approve" instead of "Stake USDT" when approval is first step) - Add originalTitle and type to TransactionStep in YieldEnterModal - Ensure consistent use of loadingMessage property Co-Authored-By: Claude Opus 4.5 --- .../components/TransactionStepsList.tsx | 4 +- .../Yields/components/YieldActionModal.tsx | 4 +- .../Yields/components/YieldEnterModal.tsx | 30 +++++++++- .../Yields/hooks/useYieldTransactionFlow.ts | 55 +++++++++++-------- 4 files changed, 65 insertions(+), 28 deletions(-) diff --git a/src/pages/Yields/components/TransactionStepsList.tsx b/src/pages/Yields/components/TransactionStepsList.tsx index 3fab8723951..111fbaf01ce 100644 --- a/src/pages/Yields/components/TransactionStepsList.tsx +++ b/src/pages/Yields/components/TransactionStepsList.tsx @@ -62,7 +62,7 @@ export const TransactionStepsList = memo(({ steps }: TransactionStepsListProps) ) : ( - step.statusLabel && ( + step.loadingMessage && ( - {step.statusLabel} + {step.loadingMessage} ) )} diff --git a/src/pages/Yields/components/YieldActionModal.tsx b/src/pages/Yields/components/YieldActionModal.tsx index 9d31003ec3a..2f7d2a25d41 100644 --- a/src/pages/Yields/components/YieldActionModal.tsx +++ b/src/pages/Yields/components/YieldActionModal.tsx @@ -233,8 +233,8 @@ export const YieldActionModal = memo(function YieldActionModal({ const loadingText = useMemo(() => { if (isQuoteLoading) return translate('yieldXYZ.loadingQuote') - if (activeStepIndex >= 0 && transactionSteps[activeStepIndex]?.statusLabel) { - return transactionSteps[activeStepIndex].statusLabel + if (activeStepIndex >= 0 && transactionSteps[activeStepIndex]?.loadingMessage) { + return transactionSteps[activeStepIndex].loadingMessage } if (action === 'enter') return translate('yieldXYZ.depositing') if (action === 'exit') return translate('yieldXYZ.withdrawing') diff --git a/src/pages/Yields/components/YieldEnterModal.tsx b/src/pages/Yields/components/YieldEnterModal.tsx index 7d9760be6e0..409bd96c9fe 100644 --- a/src/pages/Yields/components/YieldEnterModal.tsx +++ b/src/pages/Yields/components/YieldEnterModal.tsx @@ -50,7 +50,7 @@ import type { CosmosStakeArgs } from '@/lib/yieldxyz/executeTransaction' import { executeTransaction } from '@/lib/yieldxyz/executeTransaction' import type { AugmentedYieldDto, TransactionDto } from '@/lib/yieldxyz/types' import { TransactionStatus } from '@/lib/yieldxyz/types' -import { formatYieldTxTitle } from '@/lib/yieldxyz/utils' +import { formatYieldTxTitle, getTransactionButtonText } from '@/lib/yieldxyz/utils' import { GradientApy } from '@/pages/Yields/components/GradientApy' import { TransactionStepsList } from '@/pages/Yields/components/TransactionStepsList' import { useConfetti } from '@/pages/Yields/hooks/useConfetti' @@ -465,6 +465,8 @@ export const YieldEnterModal = memo( const initialSteps: TransactionStep[] = transactions.map((tx, i) => ({ title: formatYieldTxTitle(tx.title || `Transaction ${i + 1}`, inputTokenAsset.symbol), + originalTitle: tx.title || '', + type: tx.type, status: 'pending' as const, })) @@ -565,8 +567,30 @@ export const YieldEnterModal = memo( const enterButtonText = useMemo(() => { if (!isConnected) return translate('common.connectWallet') if (isQuoteActive) return translate('yieldXYZ.loadingQuote') + + // During execution, find the current active step (first non-success) + if (isSubmitting && transactionSteps.length > 0) { + const activeStep = transactionSteps.find(s => s.status !== 'success') + if (activeStep) return getTransactionButtonText(activeStep.type, activeStep.originalTitle) + } + + // Before execution, use the first CREATED transaction from quoteData + const firstCreatedTx = quoteData?.transactions?.find( + tx => tx.status === TransactionStatus.Created, + ) + if (firstCreatedTx) return getTransactionButtonText(firstCreatedTx.type, firstCreatedTx.title) + + // Fallback to generic stake text return translate('yieldXYZ.stakeAsset', { asset: inputTokenAsset?.symbol }) - }, [isConnected, isQuoteActive, translate, inputTokenAsset?.symbol]) + }, [ + isConnected, + isQuoteActive, + isSubmitting, + transactionSteps, + quoteData, + translate, + inputTokenAsset?.symbol, + ]) const modalTitle = useMemo(() => { if (modalStep === 'success') return translate('common.success') @@ -579,6 +603,8 @@ export const YieldEnterModal = memo( .filter(tx => tx.status === TransactionStatus.Created) .map((tx, i) => ({ title: formatYieldTxTitle(tx.title || `Transaction ${i + 1}`, inputTokenAsset.symbol), + originalTitle: tx.title || '', + type: tx.type, status: 'pending' as const, })) }, [quoteData, inputTokenAsset]) diff --git a/src/pages/Yields/hooks/useYieldTransactionFlow.ts b/src/pages/Yields/hooks/useYieldTransactionFlow.ts index 37a7d3b1809..fd83a9fd978 100644 --- a/src/pages/Yields/hooks/useYieldTransactionFlow.ts +++ b/src/pages/Yields/hooks/useYieldTransactionFlow.ts @@ -10,7 +10,6 @@ import { useTranslate } from 'react-polyglot' import { SECOND_CLASS_CHAINS } from '@/constants/chains' import { useWallet } from '@/hooks/useWallet/useWallet' import { bnOrZero } from '@/lib/bignumber/bignumber' -import { poll } from '@/lib/poll/poll' import { enterYield, exitYield, fetchAction, manageYield } from '@/lib/yieldxyz/api' import { DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID, @@ -45,28 +44,40 @@ export enum ModalStep { export type TransactionStep = { title: string - originalTitle?: string - type?: string status: 'pending' | 'success' | 'loading' - statusLabel?: string + originalTitle: string + type?: string txHash?: string txUrl?: string + loadingMessage?: string } -export const waitForActionCompletion = (actionId: string): Promise => { - const { promise } = poll({ - fn: () => fetchAction(actionId), - validate: action => { - if (action.status === YieldActionStatus.Failed) throw new Error('Action failed') - if (action.status === YieldActionStatus.Canceled) throw new Error('Action was canceled') - return action.status === YieldActionStatus.Success - }, - interval: YIELD_POLL_INTERVAL_MS, - maxAttempts: YIELD_MAX_POLL_ATTEMPTS, - }) - return promise +const poll = async ( + fn: () => Promise, + isComplete: (result: T) => boolean, + shouldThrow?: (result: T) => Error | undefined, +): Promise => { + for (let i = 0; i < YIELD_MAX_POLL_ATTEMPTS; i++) { + const result = await fn() + const error = shouldThrow?.(result) + if (error) throw error + if (isComplete(result)) return result + await new Promise(resolve => setTimeout(resolve, YIELD_POLL_INTERVAL_MS)) + } + throw new Error('Polling timed out') } +export const waitForActionCompletion = (actionId: string): Promise => + poll( + () => fetchAction(actionId), + action => action.status === YieldActionStatus.Success, + action => { + if (action.status === YieldActionStatus.Failed) return new Error('Action failed') + if (action.status === YieldActionStatus.Canceled) return new Error('Action was canceled') + return undefined + }, + ) + const filterExecutableTransactions = (transactions: TransactionDto[]): TransactionDto[] => { const seen = new Set() return transactions.filter(tx => { @@ -329,7 +340,7 @@ export const useYieldTransactionFlow = ({ updateStepStatus(index, { status: 'loading', - statusLabel: translate('yieldXYZ.loading.signInWallet'), + loadingMessage: translate('yieldXYZ.loading.signInWallet'), }) setIsSubmitting(true) @@ -348,7 +359,7 @@ export const useYieldTransactionFlow = ({ const txUrl = feeAsset ? `${feeAsset.explorerTxLink}${txHash}` : '' - updateStepStatus(index, { txHash, txUrl, statusLabel: translate('common.confirming') }) + updateStepStatus(index, { txHash, txUrl, loadingMessage: translate('common.confirming') }) await submitHashMutation.mutateAsync({ transactionId: tx.id, @@ -376,7 +387,7 @@ export const useYieldTransactionFlow = ({ ) } dispatchNotification(tx, txHash) - updateStepStatus(index, { status: 'success', statusLabel: undefined }) + updateStepStatus(index, { status: 'success', loadingMessage: undefined }) setStep(ModalStep.Success) } else { const freshAction = await fetchAction(actionId) @@ -385,7 +396,7 @@ export const useYieldTransactionFlow = ({ ) if (nextTx) { - updateStepStatus(index, { status: 'success', statusLabel: undefined }) + updateStepStatus(index, { status: 'success', loadingMessage: undefined }) setRawTransactions(prev => prev.map((t, i) => (i === index + 1 ? nextTx : t))) setActiveStepIndex(index + 1) } else { @@ -405,7 +416,7 @@ export const useYieldTransactionFlow = ({ ) } dispatchNotification(tx, txHash) - updateStepStatus(index, { status: 'success', statusLabel: undefined }) + updateStepStatus(index, { status: 'success', loadingMessage: undefined }) setStep(ModalStep.Success) } } @@ -415,7 +426,7 @@ export const useYieldTransactionFlow = ({ 'yieldXYZ.errors.transactionFailedTitle', 'yieldXYZ.errors.transactionFailedDescription', ) - updateStepStatus(index, { status: 'pending', statusLabel: undefined }) + updateStepStatus(index, { status: 'pending', loadingMessage: undefined }) } finally { setIsSubmitting(false) } From c954a352cb1f0442ce58647e9a824de18359c003 Mon Sep 17 00:00:00 2001 From: gomes <17035424+gomesalexandre@users.noreply.github.com> Date: Mon, 12 Jan 2026 18:44:24 +0100 Subject: [PATCH 23/60] fix(yields): refactor YieldEnterModal to use click-per-step execution model - Replace for-loop auto-execution with click-per-step model matching useYieldTransactionFlow - Add activeStepIndex, rawTransactions, currentActionId state for multi-step tracking - Extract executeSingleTransaction callback for single transaction execution - Export filterExecutableTransactions from hook for code reuse - Fix button text and steps display between transaction steps [skip ci] Co-Authored-By: Claude Opus 4.5 --- .../Yields/components/YieldEnterModal.tsx | 243 ++++++++++++------ .../Yields/hooks/useYieldTransactionFlow.ts | 2 +- 2 files changed, 165 insertions(+), 80 deletions(-) diff --git a/src/pages/Yields/components/YieldEnterModal.tsx b/src/pages/Yields/components/YieldEnterModal.tsx index 409bd96c9fe..9fd777aaef2 100644 --- a/src/pages/Yields/components/YieldEnterModal.tsx +++ b/src/pages/Yields/components/YieldEnterModal.tsx @@ -40,7 +40,7 @@ import { useFeatureFlag } from '@/hooks/useFeatureFlag/useFeatureFlag' import { useLocaleFormatter } from '@/hooks/useLocaleFormatter/useLocaleFormatter' import { useWallet } from '@/hooks/useWallet/useWallet' import { bnOrZero } from '@/lib/bignumber/bignumber' -import { enterYield } from '@/lib/yieldxyz/api' +import { enterYield, fetchAction } from '@/lib/yieldxyz/api' import { DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID, SHAPESHIFT_COSMOS_VALIDATOR_ADDRESS, @@ -55,7 +55,10 @@ import { GradientApy } from '@/pages/Yields/components/GradientApy' import { TransactionStepsList } from '@/pages/Yields/components/TransactionStepsList' import { useConfetti } from '@/pages/Yields/hooks/useConfetti' import type { TransactionStep } from '@/pages/Yields/hooks/useYieldTransactionFlow' -import { waitForActionCompletion } from '@/pages/Yields/hooks/useYieldTransactionFlow' +import { + filterExecutableTransactions, + waitForActionCompletion, +} from '@/pages/Yields/hooks/useYieldTransactionFlow' import { useSubmitYieldTransactionHash } from '@/react-queries/queries/yieldxyz/useSubmitYieldTransactionHash' import { useYieldProviders } from '@/react-queries/queries/yieldxyz/useYieldProviders' import { useYieldValidators } from '@/react-queries/queries/yieldxyz/useYieldValidators' @@ -164,6 +167,9 @@ export const YieldEnterModal = memo( const [isSubmitting, setIsSubmitting] = useState(false) const [transactionSteps, setTransactionSteps] = useState([]) const [selectedPercent, setSelectedPercent] = useState(null) + const [activeStepIndex, setActiveStepIndex] = useState(-1) + const [rawTransactions, setRawTransactions] = useState([]) + const [currentActionId, setCurrentActionId] = useState(null) const debouncedAmount = useDebounce(cryptoAmount, QUOTE_DEBOUNCE_MS) @@ -380,6 +386,9 @@ export const YieldEnterModal = memo( setSelectedAccountId(undefined) setModalStep('input') setTransactionSteps([]) + setActiveStepIndex(-1) + setRawTransactions([]) + setCurrentActionId(null) queryClient.removeQueries({ queryKey: ['yieldxyz', 'quote', 'enter', yieldItem.id] }) onClose() }, [onClose, isSubmitting, queryClient, yieldItem.id]) @@ -444,41 +453,28 @@ export const YieldEnterModal = memo( [dispatch, chainId, accountId, yieldItem, cryptoAmount], ) - const updateStepStatus = useCallback( - (index: number, update: Partial & { status: TransactionStep['status'] }) => { - setTransactionSteps(prev => prev.map((s, i) => (i === index ? { ...s, ...update } : s))) - }, - [], - ) - - const handleExecute = useCallback(async () => { - if (!wallet || !accountId || !chainId || !quoteData || !inputTokenAsset) return - - const transactions = quoteData.transactions.filter( - tx => tx.status === TransactionStatus.Created, - ) - - if (transactions.length === 0) { - setModalStep('success') - return - } - - const initialSteps: TransactionStep[] = transactions.map((tx, i) => ({ - title: formatYieldTxTitle(tx.title || `Transaction ${i + 1}`, inputTokenAsset.symbol), - originalTitle: tx.title || '', - type: tx.type, - status: 'pending' as const, - })) - - setTransactionSteps(initialSteps) - setIsSubmitting(true) + const updateStepStatus = useCallback((index: number, updates: Partial) => { + setTransactionSteps(prev => prev.map((s, i) => (i === index ? { ...s, ...updates } : s))) + }, []) - try { - for (let i = 0; i < transactions.length; i++) { - const tx = transactions[i] + const executeSingleTransaction = useCallback( + async ( + tx: TransactionDto, + index: number, + allTransactions: TransactionDto[], + actionId: string, + ) => { + if (!wallet || !accountId || !chainId) { + throw new Error(translate('yieldXYZ.errors.walletNotConnected')) + } - updateStepStatus(i, { status: 'loading' }) + updateStepStatus(index, { + status: 'loading', + loadingMessage: translate('yieldXYZ.loading.signInWallet'), + }) + setIsSubmitting(true) + try { const txHash = await executeTransaction({ tx, chainId, @@ -489,7 +485,10 @@ export const YieldEnterModal = memo( cosmosStakeArgs: buildCosmosStakeArgs(), }) - if (!txHash) throw new Error('Broadcast failed') + if (!txHash) throw new Error(translate('yieldXYZ.errors.broadcastFailed')) + + const txUrl = feeAsset?.explorerTxLink ? `${feeAsset.explorerTxLink}${txHash}` : '' + updateStepStatus(index, { txHash, txUrl, loadingMessage: translate('common.confirming') }) await submitHashMutation.mutateAsync({ transactionId: tx.id, @@ -498,63 +497,141 @@ export const YieldEnterModal = memo( address: userAddress, }) - const txUrl = feeAsset?.explorerTxLink ? `${feeAsset.explorerTxLink}${txHash}` : '' - updateStepStatus(i, { status: 'success', txHash, txUrl }) + const isLastTransaction = index + 1 >= allTransactions.length + + if (isLastTransaction) { + await waitForActionCompletion(actionId) + await queryClient.refetchQueries({ queryKey: ['yieldxyz', 'allBalances'] }) + await queryClient.refetchQueries({ queryKey: ['yieldxyz', 'yields'] }) + + if (chainId && SECOND_CLASS_CHAINS.includes(chainId as KnownChainIds)) { + dispatch( + portfolioApi.endpoints.getAccount.initiate( + { accountId, upsertOnFetch: true }, + { forceRefetch: true }, + ), + ) + } - if (i === transactions.length - 1) { dispatchNotification(tx, txHash) + updateStepStatus(index, { status: 'success', loadingMessage: undefined }) + setModalStep('success') + } else { + // Not last transaction - fetch fresh action to get the next tx + const freshAction = await fetchAction(actionId) + const nextTx = freshAction.transactions.find( + t => t.status === TransactionStatus.Created && t.stepIndex === index + 1, + ) + + if (nextTx) { + updateStepStatus(index, { status: 'success', loadingMessage: undefined }) + setRawTransactions(prev => prev.map((t, i) => (i === index + 1 ? nextTx : t))) + setActiveStepIndex(index + 1) + } else { + // No next tx found - action might be complete + await waitForActionCompletion(actionId) + await queryClient.refetchQueries({ queryKey: ['yieldxyz', 'allBalances'] }) + await queryClient.refetchQueries({ queryKey: ['yieldxyz', 'yields'] }) + + if (chainId && SECOND_CLASS_CHAINS.includes(chainId as KnownChainIds)) { + dispatch( + portfolioApi.endpoints.getAccount.initiate( + { accountId, upsertOnFetch: true }, + { forceRefetch: true }, + ), + ) + } + + dispatchNotification(tx, txHash) + updateStepStatus(index, { status: 'success', loadingMessage: undefined }) + setModalStep('success') + } } + } catch (error) { + console.error('Transaction execution failed:', error) + toast({ + title: translate('yieldXYZ.errors.transactionFailedTitle'), + description: + error instanceof Error + ? error.message + : translate('yieldXYZ.errors.transactionFailedDescription'), + status: 'error', + duration: 5000, + isClosable: true, + }) + updateStepStatus(index, { status: 'pending', loadingMessage: undefined }) + } finally { + setIsSubmitting(false) } + }, + [ + wallet, + accountId, + chainId, + userAddress, + accountMetadata?.bip44Params, + feeAsset?.explorerTxLink, + translate, + updateStepStatus, + buildCosmosStakeArgs, + submitHashMutation, + yieldItem.id, + queryClient, + dispatchNotification, + dispatch, + toast, + ], + ) - await waitForActionCompletion(quoteData.id) - await queryClient.refetchQueries({ queryKey: ['yieldxyz', 'allBalances'] }) - await queryClient.refetchQueries({ queryKey: ['yieldxyz', 'yields'] }) - - if (SECOND_CLASS_CHAINS.includes(chainId as KnownChainIds)) { - dispatch( - portfolioApi.endpoints.getAccount.initiate( - { accountId, upsertOnFetch: true }, - { forceRefetch: true }, - ), - ) - } + const handleExecute = useCallback(async () => { + // If we're in the middle of a multi-step flow, execute the next step + if (activeStepIndex >= 0 && rawTransactions[activeStepIndex] && currentActionId) { + await executeSingleTransaction( + rawTransactions[activeStepIndex], + activeStepIndex, + rawTransactions, + currentActionId, + ) + return + } + + // Initial execution - set up and execute first transaction + if (!wallet || !accountId || !chainId || !quoteData || !inputTokenAsset) return + const transactions = filterExecutableTransactions(quoteData.transactions) + + if (transactions.length === 0) { setModalStep('success') - } catch (error) { - console.error('Transaction failed:', error) - toast({ - title: translate('yieldXYZ.errors.transactionFailedTitle'), - description: - error instanceof Error - ? error.message - : translate('yieldXYZ.errors.transactionFailedDescription'), - status: 'error', - duration: 5000, - isClosable: true, - }) - setModalStep('input') - setTransactionSteps([]) - } finally { - setIsSubmitting(false) + return } + + setCurrentActionId(quoteData.id) + setRawTransactions(transactions) + setTransactionSteps( + transactions.map((tx, i) => ({ + title: formatYieldTxTitle( + tx.title || translate('yieldXYZ.transactionNumber', { number: i + 1 }), + inputTokenAsset.symbol, + ), + originalTitle: tx.title || '', + type: tx.type, + status: 'pending' as const, + })), + ) + setActiveStepIndex(0) + + await executeSingleTransaction(transactions[0], 0, transactions, quoteData.id) }, [ + activeStepIndex, + rawTransactions, + currentActionId, wallet, accountId, chainId, quoteData, inputTokenAsset, - userAddress, - accountMetadata?.bip44Params, translate, - updateStepStatus, - buildCosmosStakeArgs, - submitHashMutation, - yieldItem.id, - dispatchNotification, - queryClient, - dispatch, - toast, - feeAsset?.explorerTxLink, + executeSingleTransaction, ]) const enterButtonDisabled = useMemo( @@ -568,12 +645,18 @@ export const YieldEnterModal = memo( if (!isConnected) return translate('common.connectWallet') if (isQuoteActive) return translate('yieldXYZ.loadingQuote') - // During execution, find the current active step (first non-success) + // During execution, show the current step's action if (isSubmitting && transactionSteps.length > 0) { const activeStep = transactionSteps.find(s => s.status !== 'success') if (activeStep) return getTransactionButtonText(activeStep.type, activeStep.originalTitle) } + // In multi-step flow (waiting for next click), use rawTransactions for current step + if (activeStepIndex >= 0 && rawTransactions[activeStepIndex]) { + const nextTx = rawTransactions[activeStepIndex] + return getTransactionButtonText(nextTx.type, nextTx.title) + } + // Before execution, use the first CREATED transaction from quoteData const firstCreatedTx = quoteData?.transactions?.find( tx => tx.status === TransactionStatus.Created, @@ -587,6 +670,8 @@ export const YieldEnterModal = memo( isQuoteActive, isSubmitting, transactionSteps, + activeStepIndex, + rawTransactions, quoteData, translate, inputTokenAsset?.symbol, @@ -849,7 +934,7 @@ export const YieldEnterModal = memo( )} {statsContent} - {isSubmitting ? ( + {activeStepIndex >= 0 ? ( ) : ( previewSteps.length > 0 && diff --git a/src/pages/Yields/hooks/useYieldTransactionFlow.ts b/src/pages/Yields/hooks/useYieldTransactionFlow.ts index fd83a9fd978..a3b1c44aa1e 100644 --- a/src/pages/Yields/hooks/useYieldTransactionFlow.ts +++ b/src/pages/Yields/hooks/useYieldTransactionFlow.ts @@ -78,7 +78,7 @@ export const waitForActionCompletion = (actionId: string): Promise => }, ) -const filterExecutableTransactions = (transactions: TransactionDto[]): TransactionDto[] => { +export const filterExecutableTransactions = (transactions: TransactionDto[]): TransactionDto[] => { const seen = new Set() return transactions.filter(tx => { if (tx.status !== TransactionStatus.Created) return false From 89936b573f54efd6d3a7f7a40cc18438db77391c Mon Sep 17 00:00:00 2001 From: gomes <17035424+gomesalexandre@users.noreply.github.com> Date: Mon, 12 Jan 2026 18:46:39 +0100 Subject: [PATCH 24/60] feat(yields): add provider/validator icon to success state in both modals [skip ci] Co-Authored-By: Claude Opus 4.5 --- .../Yields/components/YieldActionModal.tsx | 17 +++++++++- .../Yields/components/YieldEnterModal.tsx | 33 ++++++++++++++++++- 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/src/pages/Yields/components/YieldActionModal.tsx b/src/pages/Yields/components/YieldActionModal.tsx index 2f7d2a25d41..d28b8e5f0be 100644 --- a/src/pages/Yields/components/YieldActionModal.tsx +++ b/src/pages/Yields/components/YieldActionModal.tsx @@ -615,6 +615,21 @@ export const YieldActionModal = memo(function YieldActionModal({ {successMessage} + {vaultMetadata && ( + + + + {vaultMetadata.name} + + + )} @@ -667,7 +682,7 @@ export const YieldActionModal = memo(function YieldActionModal({ ), - [translate, successMessage, transactionSteps, handleClose], + [translate, successMessage, vaultMetadata, transactionSteps, handleClose], ) const isNotSuccess = useMemo(() => step !== ModalStep.Success, [step]) diff --git a/src/pages/Yields/components/YieldEnterModal.tsx b/src/pages/Yields/components/YieldEnterModal.tsx index 9fd777aaef2..5923f13d1c3 100644 --- a/src/pages/Yields/components/YieldEnterModal.tsx +++ b/src/pages/Yields/components/YieldEnterModal.tsx @@ -869,6 +869,22 @@ export const YieldEnterModal = memo( if (modalStep === 'success') fireConfetti() }, [modalStep, fireConfetti]) + const successProviderInfo = useMemo(() => { + if (isStaking && selectedValidatorMetadata) { + return { + name: selectedValidatorMetadata.name, + logoURI: selectedValidatorMetadata.logoURI, + } + } + if (providerMetadata) { + return { + name: providerMetadata.name, + logoURI: providerMetadata.logoURI, + } + } + return null + }, [isStaking, selectedValidatorMetadata, providerMetadata]) + const successContent = useMemo( () => ( @@ -897,12 +913,27 @@ export const YieldEnterModal = memo( })} + {successProviderInfo && ( + + + + {successProviderInfo.name} + + + )} ), - [translate, cryptoAmount, inputTokenAsset?.symbol, transactionSteps], + [translate, cryptoAmount, inputTokenAsset?.symbol, successProviderInfo, transactionSteps], ) return ( From ac4fa04c89da62d42ce6d5385572fdf98c650cff Mon Sep 17 00:00:00 2001 From: gomes <17035424+gomesalexandre@users.noreply.github.com> Date: Mon, 12 Jan 2026 18:52:32 +0100 Subject: [PATCH 25/60] fix(yields): wait for tx confirmation before advancing to next step Adds waitForTransactionConfirmation helper to poll backend until the submitted transaction is confirmed, preventing nonce conflicts when users quickly transition between Approve and Deposit transactions. This resolves "replacement transaction underpriced" errors by ensuring the on-chain nonce counter has incremented before constructing the next transaction. [skip ci] Co-Authored-By: Claude Opus 4.5 --- .../Yields/components/YieldAssetSection.tsx | 2 +- .../Yields/components/YieldEnterModal.tsx | 9 ++++---- .../Yields/hooks/useYieldTransactionFlow.ts | 22 +++++++++++++++++-- 3 files changed, 26 insertions(+), 7 deletions(-) diff --git a/src/pages/Yields/components/YieldAssetSection.tsx b/src/pages/Yields/components/YieldAssetSection.tsx index 1f8195d46a2..fdeb1c864c0 100644 --- a/src/pages/Yields/components/YieldAssetSection.tsx +++ b/src/pages/Yields/components/YieldAssetSection.tsx @@ -105,7 +105,7 @@ export const YieldAssetSection = memo(({ assetId, accountId }: YieldAssetSection return ( <> - + {translate('yieldXYZ.yield')} diff --git a/src/pages/Yields/components/YieldEnterModal.tsx b/src/pages/Yields/components/YieldEnterModal.tsx index 5923f13d1c3..0b206920ab0 100644 --- a/src/pages/Yields/components/YieldEnterModal.tsx +++ b/src/pages/Yields/components/YieldEnterModal.tsx @@ -40,7 +40,7 @@ import { useFeatureFlag } from '@/hooks/useFeatureFlag/useFeatureFlag' import { useLocaleFormatter } from '@/hooks/useLocaleFormatter/useLocaleFormatter' import { useWallet } from '@/hooks/useWallet/useWallet' import { bnOrZero } from '@/lib/bignumber/bignumber' -import { enterYield, fetchAction } from '@/lib/yieldxyz/api' +import { enterYield } from '@/lib/yieldxyz/api' import { DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID, SHAPESHIFT_COSMOS_VALIDATOR_ADDRESS, @@ -58,6 +58,7 @@ import type { TransactionStep } from '@/pages/Yields/hooks/useYieldTransactionFl import { filterExecutableTransactions, waitForActionCompletion, + waitForTransactionConfirmation, } from '@/pages/Yields/hooks/useYieldTransactionFlow' import { useSubmitYieldTransactionHash } from '@/react-queries/queries/yieldxyz/useSubmitYieldTransactionHash' import { useYieldProviders } from '@/react-queries/queries/yieldxyz/useYieldProviders' @@ -517,9 +518,9 @@ export const YieldEnterModal = memo( updateStepStatus(index, { status: 'success', loadingMessage: undefined }) setModalStep('success') } else { - // Not last transaction - fetch fresh action to get the next tx - const freshAction = await fetchAction(actionId) - const nextTx = freshAction.transactions.find( + // Wait for current tx to be confirmed before advancing to next step + const confirmedAction = await waitForTransactionConfirmation(actionId, tx.id) + const nextTx = confirmedAction.transactions.find( t => t.status === TransactionStatus.Created && t.stepIndex === index + 1, ) diff --git a/src/pages/Yields/hooks/useYieldTransactionFlow.ts b/src/pages/Yields/hooks/useYieldTransactionFlow.ts index a3b1c44aa1e..b66e5cf083c 100644 --- a/src/pages/Yields/hooks/useYieldTransactionFlow.ts +++ b/src/pages/Yields/hooks/useYieldTransactionFlow.ts @@ -78,6 +78,23 @@ export const waitForActionCompletion = (actionId: string): Promise => }, ) +export const waitForTransactionConfirmation = ( + actionId: string, + transactionId: string, +): Promise => + poll( + () => fetchAction(actionId), + action => { + const tx = action.transactions.find(t => t.id === transactionId) + return tx?.status !== TransactionStatus.Created + }, + action => { + if (action.status === YieldActionStatus.Failed) return new Error('Action failed') + if (action.status === YieldActionStatus.Canceled) return new Error('Action was canceled') + return undefined + }, + ) + export const filterExecutableTransactions = (transactions: TransactionDto[]): TransactionDto[] => { const seen = new Set() return transactions.filter(tx => { @@ -390,8 +407,9 @@ export const useYieldTransactionFlow = ({ updateStepStatus(index, { status: 'success', loadingMessage: undefined }) setStep(ModalStep.Success) } else { - const freshAction = await fetchAction(actionId) - const nextTx = freshAction.transactions.find( + // Wait for current tx to be confirmed before advancing to next step + const confirmedAction = await waitForTransactionConfirmation(actionId, tx.id) + const nextTx = confirmedAction.transactions.find( t => t.status === TransactionStatus.Created && t.stepIndex === index + 1, ) From 4d132d8b6c10ecd0e7b3020d42950605fc2edaa7 Mon Sep 17 00:00:00 2001 From: gomes <17035424+gomesalexandre@users.noreply.github.com> Date: Mon, 12 Jan 2026 19:02:29 +0100 Subject: [PATCH 26/60] wip --- src/assets/translations/en/main.json | 1 + src/pages/Yields/components/TransactionStepsList.tsx | 7 +++++-- src/pages/Yields/components/YieldActionModal.tsx | 1 + src/pages/Yields/hooks/useYieldTransactionFlow.ts | 4 +++- 4 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/assets/translations/en/main.json b/src/assets/translations/en/main.json index f076685856d..b6748df9aec 100644 --- a/src/assets/translations/en/main.json +++ b/src/assets/translations/en/main.json @@ -2764,6 +2764,7 @@ "signInWallet": "Sign in Wallet", "waiting": "Waiting", "done": "Done", + "failed": "Failed", "preparingTransaction": "Preparing transaction..." }, "errors": { diff --git a/src/pages/Yields/components/TransactionStepsList.tsx b/src/pages/Yields/components/TransactionStepsList.tsx index 111fbaf01ce..8c98f219fb9 100644 --- a/src/pages/Yields/components/TransactionStepsList.tsx +++ b/src/pages/Yields/components/TransactionStepsList.tsx @@ -1,6 +1,6 @@ import { Box, Flex, Icon, Link, Spinner, Text } from '@chakra-ui/react' import { memo, useMemo } from 'react' -import { FaCheck, FaExternalLinkAlt } from 'react-icons/fa' +import { FaCheck, FaExternalLinkAlt, FaTimes } from 'react-icons/fa' import { MiddleEllipsis } from '@/components/MiddleEllipsis/MiddleEllipsis' import type { TransactionStep } from '@/pages/Yields/hooks/useYieldTransactionFlow' @@ -15,6 +15,9 @@ const StepIcon = memo(({ status }: { status: TransactionStepStatus }) => { if (status === 'success') { return } + if (status === 'failed') { + return + } if (status === 'loading') { return } @@ -37,7 +40,7 @@ export const TransactionStepsList = memo(({ steps }: TransactionStepsListProps) = { pending: 'yieldXYZ.loading.waiting', success: 'yieldXYZ.loading.done', loading: null, + failed: 'yieldXYZ.loading.failed', } const TransactionStepStatus = memo(function TransactionStepStatus({ diff --git a/src/pages/Yields/hooks/useYieldTransactionFlow.ts b/src/pages/Yields/hooks/useYieldTransactionFlow.ts index b66e5cf083c..93fd699ebc1 100644 --- a/src/pages/Yields/hooks/useYieldTransactionFlow.ts +++ b/src/pages/Yields/hooks/useYieldTransactionFlow.ts @@ -44,7 +44,7 @@ export enum ModalStep { export type TransactionStep = { title: string - status: 'pending' | 'success' | 'loading' + status: 'pending' | 'success' | 'loading' | 'failed' originalTitle: string type?: string txHash?: string @@ -91,6 +91,8 @@ export const waitForTransactionConfirmation = ( action => { if (action.status === YieldActionStatus.Failed) return new Error('Action failed') if (action.status === YieldActionStatus.Canceled) return new Error('Action was canceled') + const tx = action.transactions.find(t => t.id === transactionId) + if (tx?.status === TransactionStatus.Failed) return new Error('Transaction failed') return undefined }, ) From 1452d11917fc54eb822b2afd90395097c96fae83 Mon Sep 17 00:00:00 2001 From: gomes <17035424+gomesalexandre@users.noreply.github.com> Date: Mon, 12 Jan 2026 23:18:50 +0100 Subject: [PATCH 27/60] wip Co-Authored-By: Claude Opus 4.5 --- src/assets/translations/en/main.json | 28 +- src/lib/yieldxyz/utils.ts | 26 +- .../components/TransactionStepsList.tsx | 116 ++-- .../Yields/components/ValidatorBreakdown.tsx | 2 +- .../Yields/components/YieldActionModal.tsx | 584 +++++++----------- .../Yields/components/YieldEnterExit.tsx | 2 +- .../Yields/components/YieldEnterModal.tsx | 264 ++++++-- .../components/YieldOpportunityStats.tsx | 2 +- .../Yields/components/YieldPositionCard.tsx | 2 +- src/pages/Yields/components/YieldStats.tsx | 2 +- .../Yields/hooks/useYieldTransactionFlow.ts | 243 +++++++- 11 files changed, 764 insertions(+), 507 deletions(-) diff --git a/src/assets/translations/en/main.json b/src/assets/translations/en/main.json index b6748df9aec..93b178df6ef 100644 --- a/src/assets/translations/en/main.json +++ b/src/assets/translations/en/main.json @@ -2671,7 +2671,7 @@ "pageSubtitle": "Discover and manage yield opportunities across multiple chains", "enter": "Enter", "exit": "Exit", - "stakeAsset": "Stake %{asset}", + "enterAsset": "Enter %{asset}", "yield": "Yield", "apy": "APY", "apr": "APR", @@ -2683,10 +2683,11 @@ "noYields": "No yield opportunities available", "connectWallet": "Connect a wallet to view yields", "stats": "Stats", - "minDeposit": "Min Deposit", + "minEnter": "Min Enter", "rewardSchedule": "Reward Schedule", "gasToken": "Gas Token", - "entering": "Entering", + "entering": "Entering...", + "exiting": "Exiting...", "withdrawable": "Withdrawable", "type": "Type", "totalValue": "Total Value", @@ -2696,12 +2697,9 @@ "startEarning": "Start earning", "maxApy": "Max APY", "validator": "Validator", - "staked": "Staked", - "exiting": "Exiting", + "entered": "Entered", "claimable": "Claimable", "loadingQuote": "Loading Quote...", - "depositing": "Depositing...", - "withdrawing": "Withdrawing...", "selectValidator": "Select Validator", "allValidators": "All Validators", "myValidators": "My Validators", @@ -2719,7 +2717,7 @@ "allProviders": "All Providers", "showAll": "Show All", "searchValidator": "Search for validator", - "depositYourToken": "Deposit your %{symbol} to start earning yield securely.", + "enterYourToken": "Enter your %{symbol} to start earning yield securely.", "noActiveValidators": "You don't have any active validators yet.", "success": "Success!", "transactions": "Transactions", @@ -2727,8 +2725,8 @@ "estYearlyEarnings": "Est. Yearly Earnings", "allPositions": "All Positions", "switch": "Switch", - "supplySymbol": "Supply %{symbol}", - "withdrawSymbol": "Withdraw %{symbol}", + "enterSymbol": "Enter %{symbol}", + "exitSymbol": "Exit %{symbol}", "claimSymbol": "Claim %{symbol}", "noActivePositions": "You do not have any active yield positions.", "connectWalletPositions": "Connect a wallet to view your active yield positions.", @@ -2746,7 +2744,7 @@ "assetYields": "%{asset} Yields", "opportunitiesAvailable": "%{count} opportunities available", "noYieldsMatchingFilters": "No yields found matching filters.", - "activeDeposits": "Active Deposits", + "activePositions": "Active Positions", "acrossPositions": "Across %{count} positions", "availableToEarn": "Available to Earn", "idleAssetsEarning": "Idle assets that could be earning up to %{apy}% APY", @@ -2755,11 +2753,11 @@ "earn": "Earn", "myBalance": "My Balance", "providers": "Providers", - "deposit": "Deposit", - "withdraw": "Withdraw", - "successDeposit": "You successfully deposited %{amount} %{symbol}", - "successWithdraw": "You successfully withdrew %{amount} %{symbol}", + "successEnter": "You successfully entered %{amount} %{symbol}", + "successExit": "You successfully exited %{amount} %{symbol}", "successClaim": "You successfully claimed %{amount} %{symbol}", + "resetAllowance": "Reset Allowance", + "transactionNumber": "Transaction %{number}", "loading": { "signInWallet": "Sign in Wallet", "waiting": "Waiting", diff --git a/src/lib/yieldxyz/utils.ts b/src/lib/yieldxyz/utils.ts index 52fc4d0971f..e0feda81908 100644 --- a/src/lib/yieldxyz/utils.ts +++ b/src/lib/yieldxyz/utils.ts @@ -17,29 +17,29 @@ export const yieldNetworkToChainId = (network: string): ChainId | undefined => { const TX_TITLE_PATTERNS: [RegExp, string][] = [ [/approv/i, 'Approve'], - [/supply|deposit|enter/i, 'Deposit'], - [/withdraw|exit/i, 'Withdraw'], + [/supply|deposit|enter/i, 'Enter'], + [/withdraw|exit|unstake|undelegate/i, 'Exit'], [/claim/i, 'Claim'], - [/unstake|undelegate/i, 'Unstake'], - [/stake|delegate/i, 'Stake'], + [/stake|delegate/i, 'Enter'], [/bridge/i, 'Bridge'], [/swap/i, 'Swap'], ] // Map of transaction types to user-friendly button labels // These should match the action verbs shown in the step row (without the asset symbol) +// Yield.xyz uses Enter/Exit terminology consistently const TX_TYPE_TO_LABEL: Record = { APPROVE: 'Approve', APPROVAL: 'Approve', - DELEGATE: 'Stake', // Monad uses DELEGATE for staking - UNDELEGATE: 'Unstake', // Monad uses UNDELEGATE for unstaking - STAKE: 'Stake', - UNSTAKE: 'Unstake', - DEPOSIT: 'Deposit', - WITHDRAW: 'Withdraw', - SUPPLY: 'Deposit', - EXIT: 'Withdraw', - ENTER: 'Deposit', + DELEGATE: 'Enter', // Monad uses DELEGATE for staking + UNDELEGATE: 'Exit', // Monad uses UNDELEGATE for unstaking + STAKE: 'Enter', + UNSTAKE: 'Exit', + DEPOSIT: 'Enter', + WITHDRAW: 'Exit', + SUPPLY: 'Enter', + EXIT: 'Exit', + ENTER: 'Enter', BRIDGE: 'Bridge', SWAP: 'Swap', CLAIM: 'Claim', diff --git a/src/pages/Yields/components/TransactionStepsList.tsx b/src/pages/Yields/components/TransactionStepsList.tsx index 8c98f219fb9..3fc74468f2c 100644 --- a/src/pages/Yields/components/TransactionStepsList.tsx +++ b/src/pages/Yields/components/TransactionStepsList.tsx @@ -1,6 +1,7 @@ import { Box, Flex, Icon, Link, Spinner, Text } from '@chakra-ui/react' import { memo, useMemo } from 'react' import { FaCheck, FaExternalLinkAlt, FaTimes } from 'react-icons/fa' +import { useTranslate } from 'react-polyglot' import { MiddleEllipsis } from '@/components/MiddleEllipsis/MiddleEllipsis' import type { TransactionStep } from '@/pages/Yields/hooks/useYieldTransactionFlow' @@ -24,48 +25,69 @@ const StepIcon = memo(({ status }: { status: TransactionStepStatus }) => { return }) +const getStatusText = ( + step: TransactionStep, + translate: ReturnType, +): string | null => { + if (step.txHash) return null + if (step.loadingMessage) return step.loadingMessage + if (step.status === 'pending') return translate('yieldXYZ.loading.waiting') + if (step.status === 'success') return translate('yieldXYZ.loading.done') + if (step.status === 'failed') return translate('yieldXYZ.loading.failed') + return null +} + export const TransactionStepsList = memo(({ steps }: TransactionStepsListProps) => { + const translate = useTranslate() + const stepElements = useMemo(() => { - return steps.map((step, idx) => ( - - - - - {step.title} - - - {step.txHash && step.txUrl ? ( - - - - - ) : ( - step.loadingMessage && ( + return steps.map((step, idx) => { + const statusText = getStatusText(step, translate) + return ( + + + + + {step.title} + + + {step.txHash && step.txUrl ? ( + + + + + ) : statusText ? ( - {step.loadingMessage} + {statusText} - ) - )} - - )) - }, [steps]) + ) : null} + + ) + }) + }, [steps, translate]) if (!steps.length) return null return ( - + {stepElements} ) diff --git a/src/pages/Yields/components/ValidatorBreakdown.tsx b/src/pages/Yields/components/ValidatorBreakdown.tsx index cea9d50ea6c..c6f107d82a2 100644 --- a/src/pages/Yields/components/ValidatorBreakdown.tsx +++ b/src/pages/Yields/components/ValidatorBreakdown.tsx @@ -232,7 +232,7 @@ const ValidatorCard: FC = memo(
- + ) -const STATUS_LABEL_KEYS: Record = { - pending: 'yieldXYZ.loading.waiting', - success: 'yieldXYZ.loading.done', - loading: null, - failed: 'yieldXYZ.loading.failed', -} - -const TransactionStepStatus = memo(function TransactionStepStatus({ - step, - translate, -}: { - step: TransactionStep - translate: ReturnType -}) { - if (step.txHash) { - return ( - - - - ) - } - const labelKey = STATUS_LABEL_KEYS[step.status] - if (labelKey) { - return ( - - {translate(labelKey)} - - ) - } - return null -}) - type YieldActionModalProps = { isOpen: boolean onClose: () => void @@ -127,6 +76,8 @@ export const YieldActionModal = memo(function YieldActionModal({ handleClose, isQuoteLoading, quoteData, + isAllowanceCheckPending, + isUsdtResetRequired, } = useYieldTransactionFlow({ yieldItem, action, @@ -228,8 +179,8 @@ export const YieldActionModal = memo(function YieldActionModal({ ) const isButtonLoading = useMemo( - () => isSubmitting || isQuoteLoading, - [isSubmitting, isQuoteLoading], + () => isSubmitting || isQuoteLoading || isAllowanceCheckPending, + [isSubmitting, isQuoteLoading, isAllowanceCheckPending], ) const loadingText = useMemo(() => { @@ -237,8 +188,8 @@ export const YieldActionModal = memo(function YieldActionModal({ if (activeStepIndex >= 0 && transactionSteps[activeStepIndex]?.loadingMessage) { return transactionSteps[activeStepIndex].loadingMessage } - if (action === 'enter') return translate('yieldXYZ.depositing') - if (action === 'exit') return translate('yieldXYZ.withdrawing') + if (action === 'enter') return translate('yieldXYZ.entering') + if (action === 'exit') return translate('yieldXYZ.exiting') return translate('common.claiming') }, [isQuoteLoading, action, translate, activeStepIndex, transactionSteps]) @@ -254,22 +205,21 @@ export const YieldActionModal = memo(function YieldActionModal({ return getTransactionButtonText(firstCreatedTx.type, firstCreatedTx.title) } // Fallback to action-based text - if (action === 'enter') return translate('yieldXYZ.deposit') - if (action === 'exit') return translate('yieldXYZ.withdraw') + if (action === 'enter') return translate('yieldXYZ.enter') + if (action === 'exit') return translate('yieldXYZ.exit') return translate('common.claim') }, [action, translate, activeStepIndex, transactionSteps, quoteData]) const modalHeading = useMemo(() => { - if (action === 'enter') return translate('yieldXYZ.supplySymbol', { symbol: assetSymbol }) - if (action === 'exit') return translate('yieldXYZ.withdrawSymbol', { symbol: assetSymbol }) + if (action === 'enter') return translate('yieldXYZ.enterSymbol', { symbol: assetSymbol }) + if (action === 'exit') return translate('yieldXYZ.exitSymbol', { symbol: assetSymbol }) return translate('yieldXYZ.claimSymbol', { symbol: assetSymbol }) }, [action, assetSymbol, translate]) const successMessage = useMemo(() => { if (action === 'enter') - return translate('yieldXYZ.successDeposit', { symbol: assetSymbol, amount }) - if (action === 'exit') - return translate('yieldXYZ.successWithdraw', { symbol: assetSymbol, amount }) + return translate('yieldXYZ.successEnter', { symbol: assetSymbol, amount }) + if (action === 'exit') return translate('yieldXYZ.successExit', { symbol: assetSymbol, amount }) return translate('yieldXYZ.successClaim', { symbol: assetSymbol, amount }) }, [action, assetSymbol, amount, translate]) @@ -284,266 +234,165 @@ export const YieldActionModal = memo(function YieldActionModal({ if (transactionSteps.length > 0) { return transactionSteps } + // Don't show preview steps while still checking if USDT reset is needed + if (isAllowanceCheckPending) return [] // Before execution, create preview steps from quoteData (filter out SKIPPED transactions) if (quoteData?.transactions?.length) { - return quoteData.transactions - .filter(tx => tx.status === 'CREATED') - .map((tx, i) => ({ - title: formatYieldTxTitle(tx.title || `Transaction ${i + 1}`, assetSymbol), - originalTitle: tx.title || '', - type: tx.type, + const steps: TransactionStep[] = [] + // Add reset step if USDT reset is required + if (isUsdtResetRequired) { + steps.push({ + title: translate('yieldXYZ.resetAllowance'), + originalTitle: 'Reset Allowance', + type: 'RESET', status: 'pending' as const, - })) + }) + } + // Add yield.xyz transactions + steps.push( + ...quoteData.transactions + .filter(tx => tx.status === 'CREATED') + .map((tx, i) => ({ + title: formatYieldTxTitle(tx.title || `Transaction ${i + 1}`, assetSymbol), + originalTitle: tx.title || '', + type: tx.type, + status: 'pending' as const, + })), + ) + return steps } return [] - }, [transactionSteps, quoteData, assetSymbol]) + }, [ + transactionSteps, + quoteData, + assetSymbol, + isAllowanceCheckPending, + isUsdtResetRequired, + translate, + ]) - const statusCard = useMemo( + const animatedAvatarRow = useMemo( () => ( - - - - - - - - - - - - {assetSymbol} - - + + + + + + + {assetSymbol} + + + + - - + position='absolute' + top='50%' + left={0} + right={0} + h='6px' + transform='translateY(-50%)' + opacity={0.6} + backgroundImage='radial-gradient(circle, var(--chakra-colors-text-subtle) 2px, transparent 2.5px)' + backgroundSize='14px 100%' + animation={`${horizontalScroll} 3s infinite linear`} + style={{ + maskImage: 'linear-gradient(to right, transparent, black 20%, black 80%, transparent)', + WebkitMaskImage: + 'linear-gradient(to right, transparent, black 20%, black 80%, transparent)', + }} + /> + + + + - - - - - - {vaultMetadata.name} - - + + {vaultMetadata.name} + + + + ), + [flexDirection, assetAvatarSrc, assetSymbol, horizontalScroll, vaultMetadata.logoURI, vaultMetadata.name], + ) + + const statsContent = useMemo( + () => ( + + + + {translate('common.amount')} + + - - {action === 'enter' && ( - <> - - - {translate('yieldXYZ.apr')} + {action === 'enter' && ( + <> + + + {translate('yieldXYZ.apr')} + + + {aprFormatted} + + + {showEstimatedEarnings && ( + + + {translate('yieldXYZ.estEarnings')} - - {aprFormatted} - - - {showEstimatedEarnings && ( - - - {translate('yieldXYZ.estEarnings')} + + + {estimatedEarningsAmount} + + + - - - - {estimatedEarningsAmount} - - - - - - - )} - - )} - {showValidatorRow && ( - - - {translate('yieldXYZ.validator')} - - - - - {vaultMetadata.name} - - - )} - {!isStaking && ( - - - {translate('yieldXYZ.provider')} + )} + + )} + {showValidatorRow && ( + + + {translate('yieldXYZ.validator')} + + + + + {vaultMetadata.name} - - - - {vaultMetadata.name} - - - )} - - - {translate('yieldXYZ.network')} + + )} + {!isStaking && ( + + + {translate('yieldXYZ.provider')} - {feeAsset && } - - {yieldItem.network} + + + {vaultMetadata.name} - - - {displaySteps.map((s, idx) => ( - - - {s.status === 'success' ? ( - - ) : s.status === 'loading' ? ( - - ) : ( - - )} - - {s.title} - - - - - ))} - + )} + + + {translate('yieldXYZ.network')} + + + {feeAsset && } + + {yieldItem.network} + + + ), [ amount, assetSymbol, - flexDirection, - assetAvatarSrc, - horizontalScroll, - vaultMetadata.logoURI, - vaultMetadata.name, action, translate, aprFormatted, @@ -552,36 +401,23 @@ export const YieldActionModal = memo(function YieldActionModal({ estimatedEarningsFiat, showValidatorRow, isStaking, + vaultMetadata.logoURI, + vaultMetadata.name, feeAsset, networkAvatarSrc, yieldItem.network, - displaySteps, ], ) const actionContent = useMemo( () => ( - - {statusCard} - - + + {animatedAvatarRow} + {statsContent} + + ), - [statusCard, handleConfirm, isButtonDisabled, isButtonLoading, loadingText, buttonText], + [animatedAvatarRow, statsContent, displaySteps], ) const { getInstance, fireConfetti, confettiStyle } = useConfetti() @@ -671,56 +507,74 @@ export const YieldActionModal = memo(function YieldActionModal({ ))} - ), - [translate, successMessage, vaultMetadata, transactionSteps, handleClose], + [translate, successMessage, vaultMetadata, transactionSteps], ) - const isNotSuccess = useMemo(() => step !== ModalStep.Success, [step]) - const isInProgress = useMemo(() => step === ModalStep.InProgress, [step]) - const isSuccess = useMemo(() => step === ModalStep.Success, [step]) - - const headerContent = useMemo(() => { - if (!isNotSuccess) return null - return ( - - - {modalHeading} - - - ) - }, [isNotSuccess, modalHeading]) + const isInProgress = step === ModalStep.InProgress + const isSuccess = step === ModalStep.Success return ( <> - + - - - - - {headerContent} - {isInProgress && actionContent} - {isSuccess && successContent} - - - - + {!isSuccess && ( + + {null} + + {modalHeading} + + + + + + )} + + {isInProgress && actionContent} + {isSuccess && successContent} + + {isInProgress && ( + + + + )} + {isSuccess && ( + + + + )} + ) }) diff --git a/src/pages/Yields/components/YieldEnterExit.tsx b/src/pages/Yields/components/YieldEnterExit.tsx index 62511692a93..71b93711b7e 100644 --- a/src/pages/Yields/components/YieldEnterExit.tsx +++ b/src/pages/Yields/components/YieldEnterExit.tsx @@ -419,7 +419,7 @@ export const YieldEnterExit = memo( {moneyBillWaveIcon} - {translate('yieldXYZ.minDeposit')} + {translate('yieldXYZ.minEnter')} diff --git a/src/pages/Yields/components/YieldEnterModal.tsx b/src/pages/Yields/components/YieldEnterModal.tsx index 0b206920ab0..18ebc0c4dad 100644 --- a/src/pages/Yields/components/YieldEnterModal.tsx +++ b/src/pages/Yields/components/YieldEnterModal.tsx @@ -12,7 +12,8 @@ import { useToast, VStack, } from '@chakra-ui/react' -import { cosmosChainId, fromAccountId } from '@shapeshiftoss/caip' +import { cosmosChainId, ethChainId, fromAccountId } from '@shapeshiftoss/caip' +import { assertGetViemClient } from '@shapeshiftoss/contracts' import type { KnownChainIds } from '@shapeshiftoss/types' import { useQuery, useQueryClient } from '@tanstack/react-query' import { uuidv4 } from '@walletconnect/utils' @@ -23,6 +24,7 @@ import { TbSwitchVertical } from 'react-icons/tb' import type { NumberFormatValues } from 'react-number-format' import { NumericFormat } from 'react-number-format' import { useTranslate } from 'react-polyglot' +import type { Hash } from 'viem' import { AccountSelector } from '@/components/AccountSelector/AccountSelector' import { Amount } from '@/components/Amount/Amount' @@ -57,9 +59,14 @@ import { useConfetti } from '@/pages/Yields/hooks/useConfetti' import type { TransactionStep } from '@/pages/Yields/hooks/useYieldTransactionFlow' import { filterExecutableTransactions, + getSpenderFromApprovalTx, + isApprovalTransaction, + isUsdtOnEthereumMainnet, waitForActionCompletion, waitForTransactionConfirmation, } from '@/pages/Yields/hooks/useYieldTransactionFlow' +import { reactQueries } from '@/react-queries' +import { useAllowance } from '@/react-queries/hooks/useAllowance' import { useSubmitYieldTransactionHash } from '@/react-queries/queries/yieldxyz/useSubmitYieldTransactionHash' import { useYieldProviders } from '@/react-queries/queries/yieldxyz/useYieldProviders' import { useYieldValidators } from '@/react-queries/queries/yieldxyz/useYieldValidators' @@ -156,6 +163,7 @@ export const YieldEnterModal = memo( const wallet = walletState.wallet const isConnected = useMemo(() => Boolean(walletState.walletInfo), [walletState.walletInfo]) const isYieldMultiAccountEnabled = useFeatureFlag('YieldMultiAccount') + const isUsdtApprovalResetEnabled = useFeatureFlag('UsdtApprovalReset') const { number: { localeParts }, } = useLocaleFormatter() @@ -171,6 +179,7 @@ export const YieldEnterModal = memo( const [activeStepIndex, setActiveStepIndex] = useState(-1) const [rawTransactions, setRawTransactions] = useState([]) const [currentActionId, setCurrentActionId] = useState(null) + const [resetTxHash, setResetTxHash] = useState(null) const debouncedAmount = useDebounce(cryptoAmount, QUOTE_DEBOUNCE_MS) @@ -313,8 +322,50 @@ export const YieldEnterModal = memo( retry: false, }) + const approvalSpender = useMemo(() => { + if (!quoteData?.transactions) return null + const createdTransactions = quoteData.transactions.filter( + tx => tx.status === TransactionStatus.Created, + ) + const approvalTx = createdTransactions.find(isApprovalTransaction) + if (!approvalTx) return null + return getSpenderFromApprovalTx(approvalTx) + }, [quoteData?.transactions]) + + const allowanceQuery = useAllowance({ + assetId: inputTokenAssetId, + spender: approvalSpender ?? undefined, + from: userAddress || undefined, + isDisabled: !approvalSpender || !isUsdtApprovalResetEnabled, + isRefetchEnabled: true, + }) + + const isUsdtResetRequired = useMemo(() => { + if (!isUsdtApprovalResetEnabled) return false + if (!isUsdtOnEthereumMainnet(inputTokenAssetId, chainId)) return false + if (!approvalSpender) return false + if (!allowanceQuery.data) return false + return bnOrZero(allowanceQuery.data).gt(0) + }, [isUsdtApprovalResetEnabled, inputTokenAssetId, chainId, approvalSpender, allowanceQuery.data]) + + // Check if we're waiting for USDT allowance check before we can determine reset requirement + const isAllowanceCheckPending = useMemo(() => { + if (!isUsdtApprovalResetEnabled) return false + if (!isUsdtOnEthereumMainnet(inputTokenAssetId, chainId)) return false + if (!approvalSpender) return false + // If we have an approval spender for USDT but allowance data hasn't loaded yet + return allowanceQuery.data === undefined && !allowanceQuery.isError + }, [ + isUsdtApprovalResetEnabled, + inputTokenAssetId, + chainId, + approvalSpender, + allowanceQuery.data, + allowanceQuery.isError, + ]) + const isLoading = isValidatorsLoading || !inputTokenAsset - const isQuoteActive = isQuoteLoading || isQuoteFetching + const isQuoteActive = isQuoteLoading || isQuoteFetching || isAllowanceCheckPending const fiatAmount = useMemo( () => bnOrZero(cryptoAmount).times(marketData?.price ?? 0), @@ -390,6 +441,7 @@ export const YieldEnterModal = memo( setActiveStepIndex(-1) setRawTransactions([]) setCurrentActionId(null) + setResetTxHash(null) queryClient.removeQueries({ queryKey: ['yieldxyz', 'quote', 'enter', yieldItem.id] }) onClose() }, [onClose, isSubmitting, queryClient, yieldItem.id]) @@ -458,10 +510,77 @@ export const YieldEnterModal = memo( setTransactionSteps(prev => prev.map((s, i) => (i === index ? { ...s, ...updates } : s))) }, []) + const executeResetAllowance = useCallback(async () => { + if (!wallet || !accountId || !inputTokenAssetId || !approvalSpender) { + throw new Error(translate('yieldXYZ.errors.walletNotConnected')) + } + + setIsSubmitting(true) + updateStepStatus(0, { + status: 'loading', + loadingMessage: translate('yieldXYZ.loading.signInWallet'), + }) + + try { + const txHash = await reactQueries.mutations + .approve({ + assetId: inputTokenAssetId, + spender: approvalSpender, + amountCryptoBaseUnit: '0', + accountNumber: accountMetadata?.bip44Params?.accountNumber ?? 0, + wallet, + from: userAddress, + }) + .mutationFn() + + if (!txHash) throw new Error(translate('yieldXYZ.errors.broadcastFailed')) + + setResetTxHash(txHash) + const txUrl = feeAsset?.explorerTxLink ? `${feeAsset.explorerTxLink}${txHash}` : '' + updateStepStatus(0, { txHash, txUrl, loadingMessage: translate('common.confirming') }) + + const publicClient = assertGetViemClient(ethChainId) + await publicClient.waitForTransactionReceipt({ hash: txHash as Hash }) + + await allowanceQuery.refetch() + updateStepStatus(0, { status: 'success', loadingMessage: undefined }) + setActiveStepIndex(1) + } catch (error) { + toast({ + title: translate('yieldXYZ.errors.transactionFailedTitle'), + description: + error instanceof Error + ? error.message + : translate('yieldXYZ.errors.transactionFailedDescription'), + status: 'error', + duration: 5000, + isClosable: true, + }) + updateStepStatus(0, { status: 'failed', loadingMessage: undefined }) + } finally { + setIsSubmitting(false) + } + }, [ + wallet, + accountId, + inputTokenAssetId, + approvalSpender, + userAddress, + accountMetadata?.bip44Params?.accountNumber, + feeAsset?.explorerTxLink, + translate, + updateStepStatus, + toast, + allowanceQuery, + activeStepIndex, + resetTxHash, + ]) + const executeSingleTransaction = useCallback( async ( tx: TransactionDto, - index: number, + yieldTxIndex: number, + uiStepIndex: number, allTransactions: TransactionDto[], actionId: string, ) => { @@ -469,7 +588,7 @@ export const YieldEnterModal = memo( throw new Error(translate('yieldXYZ.errors.walletNotConnected')) } - updateStepStatus(index, { + updateStepStatus(uiStepIndex, { status: 'loading', loadingMessage: translate('yieldXYZ.loading.signInWallet'), }) @@ -489,7 +608,11 @@ export const YieldEnterModal = memo( if (!txHash) throw new Error(translate('yieldXYZ.errors.broadcastFailed')) const txUrl = feeAsset?.explorerTxLink ? `${feeAsset.explorerTxLink}${txHash}` : '' - updateStepStatus(index, { txHash, txUrl, loadingMessage: translate('common.confirming') }) + updateStepStatus(uiStepIndex, { + txHash, + txUrl, + loadingMessage: translate('common.confirming'), + }) await submitHashMutation.mutateAsync({ transactionId: tx.id, @@ -498,7 +621,7 @@ export const YieldEnterModal = memo( address: userAddress, }) - const isLastTransaction = index + 1 >= allTransactions.length + const isLastTransaction = yieldTxIndex + 1 >= allTransactions.length if (isLastTransaction) { await waitForActionCompletion(actionId) @@ -515,21 +638,19 @@ export const YieldEnterModal = memo( } dispatchNotification(tx, txHash) - updateStepStatus(index, { status: 'success', loadingMessage: undefined }) + updateStepStatus(uiStepIndex, { status: 'success', loadingMessage: undefined }) setModalStep('success') } else { - // Wait for current tx to be confirmed before advancing to next step const confirmedAction = await waitForTransactionConfirmation(actionId, tx.id) const nextTx = confirmedAction.transactions.find( - t => t.status === TransactionStatus.Created && t.stepIndex === index + 1, + t => t.status === TransactionStatus.Created && t.stepIndex === yieldTxIndex + 1, ) if (nextTx) { - updateStepStatus(index, { status: 'success', loadingMessage: undefined }) - setRawTransactions(prev => prev.map((t, i) => (i === index + 1 ? nextTx : t))) - setActiveStepIndex(index + 1) + updateStepStatus(uiStepIndex, { status: 'success', loadingMessage: undefined }) + setRawTransactions(prev => prev.map((t, i) => (i === yieldTxIndex + 1 ? nextTx : t))) + setActiveStepIndex(uiStepIndex + 1) } else { - // No next tx found - action might be complete await waitForActionCompletion(actionId) await queryClient.refetchQueries({ queryKey: ['yieldxyz', 'allBalances'] }) await queryClient.refetchQueries({ queryKey: ['yieldxyz', 'yields'] }) @@ -544,12 +665,11 @@ export const YieldEnterModal = memo( } dispatchNotification(tx, txHash) - updateStepStatus(index, { status: 'success', loadingMessage: undefined }) + updateStepStatus(uiStepIndex, { status: 'success', loadingMessage: undefined }) setModalStep('success') } } } catch (error) { - console.error('Transaction execution failed:', error) toast({ title: translate('yieldXYZ.errors.transactionFailedTitle'), description: @@ -560,7 +680,7 @@ export const YieldEnterModal = memo( duration: 5000, isClosable: true, }) - updateStepStatus(index, { status: 'pending', loadingMessage: undefined }) + updateStepStatus(uiStepIndex, { status: 'failed', loadingMessage: undefined }) } finally { setIsSubmitting(false) } @@ -581,14 +701,31 @@ export const YieldEnterModal = memo( dispatchNotification, dispatch, toast, + isUsdtResetRequired, ], ) const handleExecute = useCallback(async () => { + // Handle USDT reset step if required and not yet done + const shouldExecuteReset = isUsdtResetRequired && activeStepIndex === 0 && !resetTxHash + + if (shouldExecuteReset) { + await executeResetAllowance() + return + } + + // Calculate the yield transaction index (offset by 1 if we had a reset step) + // Use resetTxHash as indicator, not isUsdtResetRequired (which changes to false after reset) + const hadResetStep = Boolean(resetTxHash) + const yieldStepIndex = hadResetStep ? activeStepIndex - 1 : activeStepIndex + // If we're in the middle of a multi-step flow, execute the next step - if (activeStepIndex >= 0 && rawTransactions[activeStepIndex] && currentActionId) { + const hasYieldTx = yieldStepIndex >= 0 && rawTransactions[yieldStepIndex] && currentActionId + + if (hasYieldTx) { await executeSingleTransaction( - rawTransactions[activeStepIndex], + rawTransactions[yieldStepIndex], + yieldStepIndex, activeStepIndex, rawTransactions, currentActionId, @@ -608,8 +745,21 @@ export const YieldEnterModal = memo( setCurrentActionId(quoteData.id) setRawTransactions(transactions) - setTransactionSteps( - transactions.map((tx, i) => ({ + + // Build transaction steps with reset step if needed + const steps: TransactionStep[] = [] + + if (isUsdtResetRequired) { + steps.push({ + title: translate('yieldXYZ.resetAllowance'), + originalTitle: 'Reset Allowance', + type: 'RESET', + status: 'pending', + }) + } + + steps.push( + ...transactions.map((tx, i) => ({ title: formatYieldTxTitle( tx.title || translate('yieldXYZ.transactionNumber', { number: i + 1 }), inputTokenAsset.symbol, @@ -619,11 +769,21 @@ export const YieldEnterModal = memo( status: 'pending' as const, })), ) + + setTransactionSteps(steps) setActiveStepIndex(0) - await executeSingleTransaction(transactions[0], 0, transactions, quoteData.id) + // Execute first step (reset if required, otherwise first yield tx) + if (isUsdtResetRequired) { + await executeResetAllowance() + } else { + await executeSingleTransaction(transactions[0], 0, 0, transactions, quoteData.id) + } }, [ + isUsdtResetRequired, activeStepIndex, + resetTxHash, + executeResetAllowance, rawTransactions, currentActionId, wallet, @@ -652,27 +812,31 @@ export const YieldEnterModal = memo( if (activeStep) return getTransactionButtonText(activeStep.type, activeStep.originalTitle) } - // In multi-step flow (waiting for next click), use rawTransactions for current step - if (activeStepIndex >= 0 && rawTransactions[activeStepIndex]) { - const nextTx = rawTransactions[activeStepIndex] - return getTransactionButtonText(nextTx.type, nextTx.title) + // In multi-step flow (waiting for next click) + if (activeStepIndex >= 0 && transactionSteps[activeStepIndex]) { + const currentStep = transactionSteps[activeStepIndex] + return getTransactionButtonText(currentStep.type, currentStep.originalTitle) + } + + // Before execution - show reset if required, otherwise first yield tx + if (isUsdtResetRequired) { + return translate('yieldXYZ.resetAllowance') } - // Before execution, use the first CREATED transaction from quoteData const firstCreatedTx = quoteData?.transactions?.find( tx => tx.status === TransactionStatus.Created, ) if (firstCreatedTx) return getTransactionButtonText(firstCreatedTx.type, firstCreatedTx.title) - // Fallback to generic stake text - return translate('yieldXYZ.stakeAsset', { asset: inputTokenAsset?.symbol }) + // Fallback to generic enter text + return translate('yieldXYZ.enterAsset', { asset: inputTokenAsset?.symbol }) }, [ isConnected, isQuoteActive, isSubmitting, transactionSteps, activeStepIndex, - rawTransactions, + isUsdtResetRequired, quoteData, translate, inputTokenAsset?.symbol, @@ -680,20 +844,38 @@ export const YieldEnterModal = memo( const modalTitle = useMemo(() => { if (modalStep === 'success') return translate('common.success') - return translate('yieldXYZ.stakeAsset', { asset: inputTokenAsset?.symbol }) + return translate('yieldXYZ.enterAsset', { asset: inputTokenAsset?.symbol }) }, [translate, inputTokenAsset?.symbol, modalStep]) const previewSteps = useMemo((): TransactionStep[] => { if (!quoteData?.transactions?.length || !inputTokenAsset) return [] - return quoteData.transactions - .filter(tx => tx.status === TransactionStatus.Created) - .map((tx, i) => ({ - title: formatYieldTxTitle(tx.title || `Transaction ${i + 1}`, inputTokenAsset.symbol), - originalTitle: tx.title || '', - type: tx.type, - status: 'pending' as const, - })) - }, [quoteData, inputTokenAsset]) + // Don't show preview steps while still checking if USDT reset is needed + if (isAllowanceCheckPending) return [] + + const steps: TransactionStep[] = [] + + if (isUsdtResetRequired) { + steps.push({ + title: translate('yieldXYZ.resetAllowance'), + originalTitle: 'Reset Allowance', + type: 'RESET', + status: 'pending', + }) + } + + steps.push( + ...quoteData.transactions + .filter(tx => tx.status === TransactionStatus.Created) + .map((tx, i) => ({ + title: formatYieldTxTitle(tx.title || `Transaction ${i + 1}`, inputTokenAsset.symbol), + originalTitle: tx.title || '', + type: tx.type, + status: 'pending' as const, + })), + ) + + return steps + }, [quoteData, inputTokenAsset, isUsdtResetRequired, isAllowanceCheckPending, translate]) const percentButtons = useMemo( () => ( @@ -787,7 +969,7 @@ export const YieldEnterModal = memo( {minDeposit && bnOrZero(minDeposit).gt(0) && ( - {translate('yieldXYZ.minDeposit')} + {translate('yieldXYZ.minEnter')} - {translate('yieldXYZ.successDeposit', { + {translate('yieldXYZ.successEnter', { amount: cryptoAmount, symbol: inputTokenAsset?.symbol, })} diff --git a/src/pages/Yields/components/YieldOpportunityStats.tsx b/src/pages/Yields/components/YieldOpportunityStats.tsx index b661e16e6f6..ef4aad71238 100644 --- a/src/pages/Yields/components/YieldOpportunityStats.tsx +++ b/src/pages/Yields/components/YieldOpportunityStats.tsx @@ -158,7 +158,7 @@ export const YieldOpportunityStats = memo(function YieldOpportunityStats({ - {translate('yieldXYZ.activeDeposits')} + {translate('yieldXYZ.activePositions')} diff --git a/src/pages/Yields/components/YieldPositionCard.tsx b/src/pages/Yields/components/YieldPositionCard.tsx index 5edf6775c8b..d47fb561843 100644 --- a/src/pages/Yields/components/YieldPositionCard.tsx +++ b/src/pages/Yields/components/YieldPositionCard.tsx @@ -224,7 +224,7 @@ export const YieldPositionCard = memo( {translate('yieldXYZ.startEarning')} - {translate('yieldXYZ.depositYourToken', { symbol: yieldItem.token.symbol })} + {translate('yieldXYZ.enterYourToken', { symbol: yieldItem.token.symbol })} ), diff --git a/src/pages/Yields/components/YieldStats.tsx b/src/pages/Yields/components/YieldStats.tsx index 3a445d231ee..f072efab76f 100644 --- a/src/pages/Yields/components/YieldStats.tsx +++ b/src/pages/Yields/components/YieldStats.tsx @@ -174,7 +174,7 @@ export const YieldStats = memo(({ yieldItem, balances }: YieldStatsProps) => { {moneyBillWaveIcon} - {translate('yieldXYZ.minDeposit')} + {translate('yieldXYZ.minEnter')} { + try { + const parsed = JSON.parse(tx.unsignedTransaction) + const data = parsed.data as string | undefined + if (!data || !data.toLowerCase().startsWith('0x095ea7b3')) return null + return ('0x' + data.slice(10, 74).slice(-40)).toLowerCase() + } catch { + return null + } +} + +export const isApprovalTransaction = (tx: TransactionDto): boolean => { + const type = tx.type?.toUpperCase() + return type === 'APPROVE' || type === 'APPROVAL' +} + +export const isUsdtOnEthereumMainnet = ( + assetId: string | undefined, + chainId: ChainId | undefined, +): boolean => { + return assetId === usdtAssetId && chainId === ethChainId +} + type UseYieldTransactionFlowProps = { yieldItem: AugmentedYieldDto action: 'enter' | 'exit' | 'manage' @@ -144,9 +172,16 @@ export const useYieldTransactionFlow = ({ const [isSubmitting, setIsSubmitting] = useState(false) const [activeStepIndex, setActiveStepIndex] = useState(-1) const [currentActionId, setCurrentActionId] = useState(null) + const [resetTxHash, setResetTxHash] = useState(null) + const isUsdtApprovalResetEnabled = useFeatureFlag('UsdtApprovalReset') const submitHashMutation = useSubmitYieldTransactionHash() + const inputTokenAssetId = useMemo( + () => yieldItem.inputTokens[0]?.assetId, + [yieldItem.inputTokens], + ) + const { chainId: yieldChainId } = yieldItem const { accountNumber } = useYieldAccount() @@ -238,6 +273,60 @@ export const useYieldTransactionFlow = ({ retry: false, }) + // USDT reset logic - only for enter action on USDT/ETH + const approvalSpender = useMemo(() => { + if (action !== 'enter') return null + if (!quoteData?.transactions) return null + const createdTransactions = quoteData.transactions.filter( + tx => tx.status === TransactionStatus.Created, + ) + const approvalTx = createdTransactions.find(isApprovalTransaction) + if (!approvalTx) return null + return getSpenderFromApprovalTx(approvalTx) + }, [action, quoteData?.transactions]) + + const allowanceQuery = useAllowance({ + assetId: inputTokenAssetId, + spender: approvalSpender ?? undefined, + from: userAddress || undefined, + isDisabled: !approvalSpender || !isUsdtApprovalResetEnabled || action !== 'enter', + isRefetchEnabled: true, + }) + + const isUsdtResetRequired = useMemo(() => { + if (action !== 'enter') return false + if (!isUsdtApprovalResetEnabled) return false + if (!isUsdtOnEthereumMainnet(inputTokenAssetId, yieldChainId)) return false + if (!approvalSpender) return false + if (!allowanceQuery.data) return false + return bnOrZero(allowanceQuery.data).gt(0) + }, [ + action, + isUsdtApprovalResetEnabled, + inputTokenAssetId, + yieldChainId, + approvalSpender, + allowanceQuery.data, + ]) + + // Check if we're waiting for USDT allowance check before we can determine reset requirement + const isAllowanceCheckPending = useMemo(() => { + if (action !== 'enter') return false + if (!isUsdtApprovalResetEnabled) return false + if (!isUsdtOnEthereumMainnet(inputTokenAssetId, yieldChainId)) return false + if (!approvalSpender) return false + // If we have an approval spender for USDT but allowance data hasn't loaded yet + return allowanceQuery.data === undefined && !allowanceQuery.isError + }, [ + action, + isUsdtApprovalResetEnabled, + inputTokenAssetId, + yieldChainId, + approvalSpender, + allowanceQuery.data, + allowanceQuery.isError, + ]) + const updateStepStatus = useCallback((index: number, updates: Partial) => { setTransactionSteps(prev => prev.map((s, i) => (i === index ? { ...s, ...updates } : s))) }, []) @@ -346,10 +435,78 @@ export const useYieldTransactionFlow = ({ action, ]) + const executeResetAllowance = useCallback(async () => { + if (!wallet || !accountId || !inputTokenAssetId || !approvalSpender) { + throw new Error(translate('yieldXYZ.errors.walletNotConnected')) + } + + setIsSubmitting(true) + updateStepStatus(0, { + status: 'loading', + loadingMessage: translate('yieldXYZ.loading.signInWallet'), + }) + + try { + const txHash = await reactQueries.mutations + .approve({ + assetId: inputTokenAssetId, + spender: approvalSpender, + amountCryptoBaseUnit: '0', + accountNumber, + wallet, + from: userAddress, + }) + .mutationFn() + + if (!txHash) throw new Error(translate('yieldXYZ.errors.broadcastFailed')) + + setResetTxHash(txHash) + const txUrl = feeAsset?.explorerTxLink ? `${feeAsset.explorerTxLink}${txHash}` : '' + updateStepStatus(0, { txHash, txUrl, loadingMessage: translate('common.confirming') }) + + const publicClient = assertGetViemClient(ethChainId) + await publicClient.waitForTransactionReceipt({ hash: txHash as Hash }) + + await allowanceQuery.refetch() + updateStepStatus(0, { status: 'success', loadingMessage: undefined }) + setActiveStepIndex(1) + } catch (error) { + console.error('Reset allowance failed:', error) + toast({ + title: translate('yieldXYZ.errors.transactionFailedTitle'), + description: + error instanceof Error + ? error.message + : translate('yieldXYZ.errors.transactionFailedDescription'), + status: 'error', + duration: 5000, + isClosable: true, + }) + updateStepStatus(0, { status: 'failed', loadingMessage: undefined }) + } finally { + setIsSubmitting(false) + } + }, [ + wallet, + accountId, + inputTokenAssetId, + approvalSpender, + accountNumber, + userAddress, + feeAsset?.explorerTxLink, + translate, + updateStepStatus, + toast, + allowanceQuery, + activeStepIndex, + resetTxHash, + ]) + const executeSingleTransaction = useCallback( async ( tx: TransactionDto, - index: number, + yieldTxIndex: number, + uiStepIndex: number, allTransactions: TransactionDto[], actionId: string, ) => { @@ -357,7 +514,7 @@ export const useYieldTransactionFlow = ({ throw new Error(translate('yieldXYZ.errors.walletNotConnected')) } - updateStepStatus(index, { + updateStepStatus(uiStepIndex, { status: 'loading', loadingMessage: translate('yieldXYZ.loading.signInWallet'), }) @@ -378,7 +535,11 @@ export const useYieldTransactionFlow = ({ const txUrl = feeAsset ? `${feeAsset.explorerTxLink}${txHash}` : '' - updateStepStatus(index, { txHash, txUrl, loadingMessage: translate('common.confirming') }) + updateStepStatus(uiStepIndex, { + txHash, + txUrl, + loadingMessage: translate('common.confirming'), + }) await submitHashMutation.mutateAsync({ transactionId: tx.id, @@ -387,7 +548,7 @@ export const useYieldTransactionFlow = ({ address: userAddress, }) - const isLastTransaction = index + 1 >= allTransactions.length + const isLastTransaction = yieldTxIndex + 1 >= allTransactions.length if (isLastTransaction) { await waitForActionCompletion(actionId) @@ -406,19 +567,18 @@ export const useYieldTransactionFlow = ({ ) } dispatchNotification(tx, txHash) - updateStepStatus(index, { status: 'success', loadingMessage: undefined }) + updateStepStatus(uiStepIndex, { status: 'success', loadingMessage: undefined }) setStep(ModalStep.Success) } else { - // Wait for current tx to be confirmed before advancing to next step const confirmedAction = await waitForTransactionConfirmation(actionId, tx.id) const nextTx = confirmedAction.transactions.find( - t => t.status === TransactionStatus.Created && t.stepIndex === index + 1, + t => t.status === TransactionStatus.Created && t.stepIndex === yieldTxIndex + 1, ) if (nextTx) { - updateStepStatus(index, { status: 'success', loadingMessage: undefined }) - setRawTransactions(prev => prev.map((t, i) => (i === index + 1 ? nextTx : t))) - setActiveStepIndex(index + 1) + updateStepStatus(uiStepIndex, { status: 'success', loadingMessage: undefined }) + setRawTransactions(prev => prev.map((t, i) => (i === yieldTxIndex + 1 ? nextTx : t))) + setActiveStepIndex(uiStepIndex + 1) } else { await waitForActionCompletion(actionId) await queryClient.refetchQueries({ queryKey: ['yieldxyz', 'allBalances'] }) @@ -436,7 +596,7 @@ export const useYieldTransactionFlow = ({ ) } dispatchNotification(tx, txHash) - updateStepStatus(index, { status: 'success', loadingMessage: undefined }) + updateStepStatus(uiStepIndex, { status: 'success', loadingMessage: undefined }) setStep(ModalStep.Success) } } @@ -446,7 +606,7 @@ export const useYieldTransactionFlow = ({ 'yieldXYZ.errors.transactionFailedTitle', 'yieldXYZ.errors.transactionFailedDescription', ) - updateStepStatus(index, { status: 'pending', loadingMessage: undefined }) + updateStepStatus(uiStepIndex, { status: 'failed', loadingMessage: undefined }) } finally { setIsSubmitting(false) } @@ -478,13 +638,29 @@ export const useYieldTransactionFlow = ({ setRawTransactions([]) setActiveStepIndex(-1) setCurrentActionId(null) + setResetTxHash(null) onClose() }, [isSubmitting, onClose, queryClient]) const handleConfirm = useCallback(async () => { - if (activeStepIndex >= 0 && rawTransactions[activeStepIndex] && currentActionId) { + // Handle USDT reset step if required and not yet done + const shouldExecuteReset = isUsdtResetRequired && activeStepIndex === 0 && !resetTxHash + + if (shouldExecuteReset) { + await executeResetAllowance() + return + } + + // Calculate the yield transaction index (offset by 1 if we had a reset step) + // Use resetTxHash as indicator, not isUsdtResetRequired (which changes to false after reset) + const hadResetStep = Boolean(resetTxHash) + const yieldStepIndex = hadResetStep ? activeStepIndex - 1 : activeStepIndex + + // If we're in the middle of a multi-step flow, execute the next step + if (yieldStepIndex >= 0 && rawTransactions[yieldStepIndex] && currentActionId) { await executeSingleTransaction( - rawTransactions[activeStepIndex], + rawTransactions[yieldStepIndex], + yieldStepIndex, activeStepIndex, rawTransactions, currentActionId, @@ -540,17 +716,35 @@ export const useYieldTransactionFlow = ({ setCurrentActionId(quoteData.id) setRawTransactions(transactions) - setTransactionSteps( - transactions.map((tx, i) => ({ + + // Build transaction steps with reset step if needed + const steps: TransactionStep[] = [] + if (isUsdtResetRequired) { + steps.push({ + title: translate('yieldXYZ.resetAllowance'), + originalTitle: 'Reset Allowance', + type: 'RESET', + status: 'pending', + }) + } + steps.push( + ...transactions.map((tx, i) => ({ title: formatYieldTxTitle(tx.title || `Transaction ${i + 1}`, assetSymbol), originalTitle: tx.title || '', type: tx.type, status: 'pending' as const, })), ) + + setTransactionSteps(steps) setActiveStepIndex(0) - await executeSingleTransaction(transactions[0], 0, transactions, quoteData.id) + // Execute first step (reset if required, otherwise first yield tx) + if (isUsdtResetRequired) { + await executeResetAllowance() + } else { + await executeSingleTransaction(transactions[0], 0, 0, transactions, quoteData.id) + } } catch (error) { console.error('Failed to initiate action:', error) showErrorToast( @@ -561,9 +755,13 @@ export const useYieldTransactionFlow = ({ setTransactionSteps([]) } }, [ + isUsdtResetRequired, activeStepIndex, - rawTransactions, + resetTxHash, currentActionId, + rawTransactions, + executeResetAllowance, + executeSingleTransaction, yieldChainId, wallet, accountId, @@ -574,7 +772,6 @@ export const useYieldTransactionFlow = ({ assetSymbol, translate, showErrorToast, - executeSingleTransaction, ]) return useMemo( @@ -588,6 +785,8 @@ export const useYieldTransactionFlow = ({ handleClose, isQuoteLoading, quoteData, + isAllowanceCheckPending, + isUsdtResetRequired, }), [ step, @@ -599,6 +798,8 @@ export const useYieldTransactionFlow = ({ handleClose, isQuoteLoading, quoteData, + isAllowanceCheckPending, + isUsdtResetRequired, ], ) } From 4326075021fb6ee4a148297c1e51046119ccfc84 Mon Sep 17 00:00:00 2001 From: gomes <17035424+gomesalexandre@users.noreply.github.com> Date: Mon, 12 Jan 2026 23:27:17 +0100 Subject: [PATCH 28/60] wip Co-Authored-By: Claude Opus 4.5 --- src/pages/Yields/YieldDetail.tsx | 67 +++++----- .../Yields/components/YieldActionModal.tsx | 116 +++++++----------- .../Yields/components/YieldEnterModal.tsx | 11 +- src/pages/Yields/components/YieldsList.tsx | 9 +- .../Yields/hooks/useYieldTransactionFlow.ts | 2 - 5 files changed, 93 insertions(+), 112 deletions(-) diff --git a/src/pages/Yields/YieldDetail.tsx b/src/pages/Yields/YieldDetail.tsx index 36b66e5c14d..53dabc65a21 100644 --- a/src/pages/Yields/YieldDetail.tsx +++ b/src/pages/Yields/YieldDetail.tsx @@ -93,30 +93,17 @@ export const YieldDetail = memo(() => { const heroIcon = useMemo(() => { if (!yieldItem) return null const iconSource = resolveYieldInputAssetIcon(yieldItem) - if (iconSource.assetId) - return ( - - ) - return ( - - ) + const iconProps = { + showNetworkIcon: false, + boxSize: { base: 16, md: 24 }, + boxShadow: '2xl', + border: '4px solid', + borderColor: 'border.base', + borderRadius: 'full', + bg: 'background.surface.base', + } + if (iconSource.assetId) return + return }, [yieldItem]) const providerOrValidatorsElement = useMemo(() => { @@ -177,17 +164,37 @@ export const YieldDetail = memo(() => { > {translate('common.back')} - + {heroIcon} - - + + {yieldItem.metadata.name} - + {providerOrValidatorsElement} - {chainElement} - + {chainElement} + {yieldItem.metadata.description} diff --git a/src/pages/Yields/components/YieldActionModal.tsx b/src/pages/Yields/components/YieldActionModal.tsx index d4637c66b09..9125963bbe1 100644 --- a/src/pages/Yields/components/YieldActionModal.tsx +++ b/src/pages/Yields/components/YieldActionModal.tsx @@ -1,8 +1,8 @@ -import { Avatar, Box, Button, Flex, Heading, Icon, Link, Text, VStack } from '@chakra-ui/react' +import { Avatar, Box, Button, Flex, Icon, Text, VStack } from '@chakra-ui/react' import { keyframes } from '@emotion/react' import { memo, useEffect, useMemo } from 'react' import ReactCanvasConfetti from 'react-canvas-confetti' -import { FaCheck, FaExternalLinkAlt, FaWallet } from 'react-icons/fa' +import { FaCheck, FaWallet } from 'react-icons/fa' import { useTranslate } from 'react-polyglot' import { Amount } from '@/components/Amount/Amount' @@ -296,7 +296,8 @@ export const YieldActionModal = memo(function YieldActionModal({ backgroundSize='14px 100%' animation={`${horizontalScroll} 3s infinite linear`} style={{ - maskImage: 'linear-gradient(to right, transparent, black 20%, black 80%, transparent)', + maskImage: + 'linear-gradient(to right, transparent, black 20%, black 80%, transparent)', WebkitMaskImage: 'linear-gradient(to right, transparent, black 20%, black 80%, transparent)', }} @@ -304,7 +305,12 @@ export const YieldActionModal = memo(function YieldActionModal({ - + {vaultMetadata.name} @@ -312,12 +318,25 @@ export const YieldActionModal = memo(function YieldActionModal({ ), - [flexDirection, assetAvatarSrc, assetSymbol, horizontalScroll, vaultMetadata.logoURI, vaultMetadata.name], + [ + flexDirection, + assetAvatarSrc, + assetSymbol, + horizontalScroll, + vaultMetadata.logoURI, + vaultMetadata.name, + ], ) const statsContent = useMemo( () => ( - + {translate('common.amount')} @@ -428,30 +447,22 @@ export const YieldActionModal = memo(function YieldActionModal({ const successContent = useMemo( () => ( - + - - - - - {translate('yieldXYZ.success')} - - - {successMessage} - + + + {successMessage} + {vaultMetadata && ( )} - - - {translate('yieldXYZ.transactions')} - - {transactionSteps.map((s, idx) => ( - - - - - {s.title} - - - {s.txHash && ( - - {translate('yieldXYZ.view')} - - )} - - ))} - + ), - [translate, successMessage, vaultMetadata, transactionSteps], + [successMessage, vaultMetadata, transactionSteps], ) const isInProgress = step === ModalStep.InProgress @@ -524,17 +498,15 @@ export const YieldActionModal = memo(function YieldActionModal({ isFullScreen modalProps={{ closeOnOverlayClick: !isSubmitting }} > - {!isSuccess && ( - - {null} - - {modalHeading} - - - - - - )} + + {null} + + {isSuccess ? translate('common.success') : modalHeading} + + + + + {isInProgress && actionContent} {isSuccess && successContent} diff --git a/src/pages/Yields/components/YieldEnterModal.tsx b/src/pages/Yields/components/YieldEnterModal.tsx index 18ebc0c4dad..4d91a389885 100644 --- a/src/pages/Yields/components/YieldEnterModal.tsx +++ b/src/pages/Yields/components/YieldEnterModal.tsx @@ -346,7 +346,13 @@ export const YieldEnterModal = memo( if (!approvalSpender) return false if (!allowanceQuery.data) return false return bnOrZero(allowanceQuery.data).gt(0) - }, [isUsdtApprovalResetEnabled, inputTokenAssetId, chainId, approvalSpender, allowanceQuery.data]) + }, [ + isUsdtApprovalResetEnabled, + inputTokenAssetId, + chainId, + approvalSpender, + allowanceQuery.data, + ]) // Check if we're waiting for USDT allowance check before we can determine reset requirement const isAllowanceCheckPending = useMemo(() => { @@ -572,8 +578,6 @@ export const YieldEnterModal = memo( updateStepStatus, toast, allowanceQuery, - activeStepIndex, - resetTxHash, ]) const executeSingleTransaction = useCallback( @@ -701,7 +705,6 @@ export const YieldEnterModal = memo( dispatchNotification, dispatch, toast, - isUsdtResetRequired, ], ) diff --git a/src/pages/Yields/components/YieldsList.tsx b/src/pages/Yields/components/YieldsList.tsx index 16fae2b6699..9f74310af59 100644 --- a/src/pages/Yields/components/YieldsList.tsx +++ b/src/pages/Yields/components/YieldsList.tsx @@ -723,10 +723,11 @@ export const YieldsList = memo(() => { borderRadius='full' /> - { mb={0} /> - + {allYieldsContentElement} diff --git a/src/pages/Yields/hooks/useYieldTransactionFlow.ts b/src/pages/Yields/hooks/useYieldTransactionFlow.ts index 8606c5b582c..c4c3973948a 100644 --- a/src/pages/Yields/hooks/useYieldTransactionFlow.ts +++ b/src/pages/Yields/hooks/useYieldTransactionFlow.ts @@ -498,8 +498,6 @@ export const useYieldTransactionFlow = ({ updateStepStatus, toast, allowanceQuery, - activeStepIndex, - resetTxHash, ]) const executeSingleTransaction = useCallback( From 3d0121efccd0506de1ac40632814908177bcf268 Mon Sep 17 00:00:00 2001 From: gomes <17035424+gomesalexandre@users.noreply.github.com> Date: Tue, 13 Jan 2026 00:02:30 +0100 Subject: [PATCH 29/60] fix(yields): mobile layout polish and bug fixes - Add responsive padding to Container components for breathing room on mobile - Fix YieldFilters to stack vertically on mobile with full-width controls - Fix ViewToggle to be full-width on mobile with equal flex buttons - Fix YieldAssetDetails filters layout for mobile responsiveness - Hide YieldOpportunityStats when wallet not connected (stale data bug) - Add isConnected guard to prevent showing stale portfolio balances - Add "myPositions" translation key for plural form in list tab Co-Authored-By: Claude Opus 4.5 --- src/assets/translations/en/main.json | 1 + src/pages/Yields/YieldAssetDetails.tsx | 33 +++++++++---------- src/pages/Yields/YieldDetail.tsx | 4 +-- src/pages/Yields/components/YieldFilters.tsx | 14 ++++++-- .../components/YieldOpportunityStats.tsx | 10 +++--- .../Yields/components/YieldViewHelpers.tsx | 32 +++++++++--------- src/pages/Yields/components/YieldsList.tsx | 21 +++++++----- 7 files changed, 64 insertions(+), 51 deletions(-) diff --git a/src/assets/translations/en/main.json b/src/assets/translations/en/main.json index 93b178df6ef..115ede0c8de 100644 --- a/src/assets/translations/en/main.json +++ b/src/assets/translations/en/main.json @@ -2692,6 +2692,7 @@ "type": "Type", "totalValue": "Total Value", "myPosition": "My Position", + "myPositions": "My Positions", "myValidatorPosition": "My %{validator} Position", "earnUpToOnBalance": "You could earn up to %{apy} on your balance", "startEarning": "Start earning", diff --git a/src/pages/Yields/YieldAssetDetails.tsx b/src/pages/Yields/YieldAssetDetails.tsx index 0aeea1a69ac..28776aaf989 100644 --- a/src/pages/Yields/YieldAssetDetails.tsx +++ b/src/pages/Yields/YieldAssetDetails.tsx @@ -412,7 +412,7 @@ export const YieldAssetDetails = memo(() => { ]) return ( - + {assetHeaderElement} - - - - - + + {contentElement} diff --git a/src/pages/Yields/YieldDetail.tsx b/src/pages/Yields/YieldDetail.tsx index 53dabc65a21..0c9fd5d3386 100644 --- a/src/pages/Yields/YieldDetail.tsx +++ b/src/pages/Yields/YieldDetail.tsx @@ -153,7 +153,7 @@ export const YieldDetail = memo(() => { return ( - + + + } + footerContent={null} + isLoading={false} + onBack={handleBack} + headerTranslation='earn.confirmEarn' + /> + ) + } + + if (step === ModalStep.Success) { + return ( + + + + } + footerContent={null} + isLoading={false} + onBack={handleBack} + headerTranslation='yieldXYZ.success' + /> + ) + } + + const bodyContent = ( + + + + + + + + {translate('common.amount')} + + {isLoading ? ( + + ) : ( + + )} + + + + + {translate('common.apy')} + + {isLoading ? ( + + ) : ( + + {apy.toFixed(2)}% + + )} + + + {estimatedYearlyEarnings && ( + + + {translate('yieldXYZ.estEarnings')} + + {isLoading ? ( + + ) : ( + + + {estimatedYearlyEarnings} {sellAsset?.symbol ?? ''}/yr + + {sellAmountUserCurrency && ( + + + + )} + + )} + + )} + + {providerInfo && ( + + + {selectedValidator + ? translate('yieldXYZ.validator') + : translate('yieldXYZ.provider')} + + + + + {providerInfo.name} + + + + )} + + + {stepsToShow.length > 0 && ( + + + + )} + + + ) + + const footerContent = ( + + + + ) + + return ( + + ) +}) diff --git a/src/components/MultiHopTrade/components/Earn/EarnInput.tsx b/src/components/MultiHopTrade/components/Earn/EarnInput.tsx new file mode 100644 index 00000000000..b994d43ca15 --- /dev/null +++ b/src/components/MultiHopTrade/components/Earn/EarnInput.tsx @@ -0,0 +1,527 @@ +import { Box, Flex, Stack, useMediaQuery } from '@chakra-ui/react' +import type { AccountId, AssetId } from '@shapeshiftoss/caip' +import { cosmosChainId, ethAssetId, fromAccountId } from '@shapeshiftoss/caip' +import type { Asset } from '@shapeshiftoss/types' +import { isToken } from '@shapeshiftoss/utils' +import { useQuery } from '@tanstack/react-query' +import type { FormEvent } from 'react' +import { memo, useCallback, useEffect, useMemo } from 'react' +import { useTranslate } from 'react-polyglot' +import { useNavigate } from 'react-router-dom' + +import type { SideComponentProps } from '../SharedTradeInput/SharedTradeInput' +import { SharedTradeInput } from '../SharedTradeInput/SharedTradeInput' +import { SellAssetInput } from '../TradeInput/components/SellAssetInput' +import { EarnFooter } from './components/EarnFooter' +import { YieldSelector } from './components/YieldSelector' +import { EarnRoutePaths } from './types' + +import { TradeAssetSelect } from '@/components/AssetSelection/AssetSelection' +import { FormDivider } from '@/components/FormDivider' +import { TradeInputTab } from '@/components/MultiHopTrade/types' +import { useDebounce } from '@/hooks/useDebounce/useDebounce' +import { useModal } from '@/hooks/useModal/useModal' +import { useWallet } from '@/hooks/useWallet/useWallet' +import { bnOrZero, positiveOrZero } from '@/lib/bignumber/bignumber' +import { enterYield } from '@/lib/yieldxyz/api' +import { DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID } from '@/lib/yieldxyz/constants' +import { useYields } from '@/react-queries/queries/yieldxyz/useYields' +import { useYieldValidators } from '@/react-queries/queries/yieldxyz/useYieldValidators' +import { + selectAccountIdByAccountNumberAndChainId, + selectAssetById, + selectFeeAssetByChainId, + selectMarketDataByAssetIdUserCurrency, + selectMarketDataByFilter, + selectPortfolioCryptoPrecisionBalanceByFilter, +} from '@/state/slices/selectors' +import { + selectEarnHasUserEnteredAmount, + selectInputSellAmountCryptoBaseUnit, + selectInputSellAmountCryptoPrecision, + selectInputSellAmountUserCurrency, + selectInputSellAsset, + selectIsInputtingFiatSellAmount, + selectSelectedYieldId, + selectSellAccountId, +} from '@/state/slices/tradeEarnInputSlice/selectors' +import { tradeEarnInput } from '@/state/slices/tradeEarnInputSlice/tradeEarnInputSlice' +import { useAppDispatch, useAppSelector } from '@/state/store' +import { breakpoints } from '@/theme/theme' + +export type EarnInputProps = { + onChangeTab: (newTab: TradeInputTab) => void + tradeInputRef: React.RefObject + defaultSellAssetId?: string + defaultYieldId?: string + defaultSellAmountCryptoBaseUnit?: string +} + +const EmptySideComponent: React.FC = () => null + +export const EarnInput = memo( + ({ + onChangeTab, + tradeInputRef, + defaultSellAssetId, + defaultYieldId, + defaultSellAmountCryptoBaseUnit, + }: EarnInputProps) => { + const translate = useTranslate() + const navigate = useNavigate() + const dispatch = useAppDispatch() + const [isSmallerThanMd] = useMediaQuery(`(max-width: ${breakpoints.md})`, { ssr: false }) + + const { + state: { isConnected, wallet }, + } = useWallet() + + const sellAsset = useAppSelector(selectInputSellAsset) + const sellAccountId = useAppSelector(selectSellAccountId) + const sellAmountCryptoPrecision = useAppSelector(selectInputSellAmountCryptoPrecision) + const sellAmountUserCurrency = useAppSelector(selectInputSellAmountUserCurrency) + const isInputtingFiatSellAmount = useAppSelector(selectIsInputtingFiatSellAmount) + const hasUserEnteredAmount = useAppSelector(selectEarnHasUserEnteredAmount) + const selectedYieldId = useAppSelector(selectSelectedYieldId) + + const { data: yieldsData, isLoading: isLoadingYields } = useYields() + + const ethAsset = useAppSelector(state => selectAssetById(state, ethAssetId)) + const defaultSellAsset = useAppSelector(state => + defaultSellAssetId ? selectAssetById(state, defaultSellAssetId as AssetId) : undefined, + ) + + useEffect(() => { + if (defaultSellAsset && !sellAsset.assetId) { + dispatch(tradeEarnInput.actions.setSellAssetWithYieldReset(defaultSellAsset)) + } else if (!sellAsset.assetId && ethAsset) { + dispatch(tradeEarnInput.actions.setSellAssetWithYieldReset(ethAsset)) + } + }, [sellAsset.assetId, ethAsset, defaultSellAsset, dispatch]) + + // Set default accountId (account 0) on mount if not already set + const sellAssetChainId = sellAsset?.chainId + const defaultAccountId = useAppSelector(state => { + if (!sellAssetChainId) return undefined + return selectAccountIdByAccountNumberAndChainId(state)[0]?.[sellAssetChainId] + }) + + useEffect(() => { + if (!sellAccountId && defaultAccountId) { + dispatch(tradeEarnInput.actions.setSellAccountId(defaultAccountId)) + } + }, [sellAccountId, defaultAccountId, dispatch]) + + useEffect(() => { + if (defaultYieldId && !selectedYieldId && yieldsData?.byId?.[defaultYieldId]) { + dispatch(tradeEarnInput.actions.setSelectedYieldId(defaultYieldId)) + } + }, [defaultYieldId, selectedYieldId, yieldsData?.byId, dispatch]) + + useEffect(() => { + if (defaultSellAmountCryptoBaseUnit && defaultSellAsset && !sellAmountCryptoPrecision) { + const precision = defaultSellAsset.precision ?? 18 + const amountCryptoPrecision = bnOrZero(defaultSellAmountCryptoBaseUnit) + .div(bnOrZero(10).pow(precision)) + .toString() + dispatch(tradeEarnInput.actions.setSellAmountCryptoPrecision(amountCryptoPrecision)) + } + }, [defaultSellAmountCryptoBaseUnit, defaultSellAsset, sellAmountCryptoPrecision, dispatch]) + + const sellAmountCryptoBaseUnit = useAppSelector(selectInputSellAmountCryptoBaseUnit) + + useEffect(() => { + if (!sellAsset.assetId || !selectedYieldId) return + + const encodedYieldId = encodeURIComponent(selectedYieldId) + const baseUnit = sellAmountCryptoBaseUnit ?? '0' + + navigate(`/earn/${sellAsset.assetId}/${encodedYieldId}/${baseUnit}`, { replace: true }) + }, [sellAsset.assetId, selectedYieldId, sellAmountCryptoBaseUnit, navigate]) + + const selectedYield = useMemo(() => { + if (!selectedYieldId || !yieldsData?.byId) return undefined + return yieldsData.byId[selectedYieldId] + }, [selectedYieldId, yieldsData?.byId]) + + const requiresValidatorSelection = useMemo(() => { + return selectedYield?.mechanics.requiresValidatorSelection ?? false + }, [selectedYield?.mechanics.requiresValidatorSelection]) + + const { data: validators } = useYieldValidators( + selectedYieldId ?? '', + requiresValidatorSelection, + ) + + const selectedValidator = useMemo(() => { + if (!requiresValidatorSelection || !validators?.length) return undefined + const chainId = selectedYield?.chainId + const defaultAddress = chainId ? DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID[chainId] : undefined + if (defaultAddress) { + return ( + validators.find(v => v.address === defaultAddress) ?? + validators.find(v => v.preferred) ?? + validators[0] + ) + } + return validators.find(v => v.preferred) ?? validators[0] + }, [requiresValidatorSelection, validators, selectedYield?.chainId]) + + const selectedValidatorAddress = selectedValidator?.address + + const yieldChainId = selectedYield?.chainId + + const userAddress = useMemo( + () => (sellAccountId ? fromAccountId(sellAccountId).account : ''), + [sellAccountId], + ) + + const feeAsset = useAppSelector(state => + yieldChainId ? selectFeeAssetByChainId(state, yieldChainId) : undefined, + ) + + const feeAssetMarketData = useAppSelector(state => + feeAsset?.assetId + ? selectMarketDataByAssetIdUserCurrency(state, feeAsset.assetId) + : undefined, + ) + + const debouncedAmount = useDebounce(sellAmountCryptoPrecision, 500) + + const txArguments = useMemo(() => { + if (!selectedYield || !userAddress || !yieldChainId || !debouncedAmount) return null + if (!bnOrZero(debouncedAmount).gt(0)) return null + + const fields = selectedYield.mechanics.arguments.enter.fields + const fieldNames = new Set(fields.map(field => field.name)) + const args: Record = { amount: debouncedAmount } + + if (fieldNames.has('receiverAddress')) { + args.receiverAddress = userAddress + } + + if (fieldNames.has('validatorAddress') && yieldChainId) { + args.validatorAddress = + selectedValidatorAddress || DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID[yieldChainId] + } + + if (fieldNames.has('cosmosPubKey') && yieldChainId === cosmosChainId) { + args.cosmosPubKey = userAddress + } + + return args + }, [selectedYield, userAddress, yieldChainId, debouncedAmount, selectedValidatorAddress]) + + const { data: quoteData, isLoading: isQuoteLoading } = useQuery({ + queryKey: ['yieldxyz', 'quote', 'enter', selectedYield?.id, userAddress, txArguments], + queryFn: () => { + if (!txArguments || !userAddress || !selectedYield?.id) throw new Error('Missing arguments') + return enterYield({ + yieldId: selectedYield.id, + address: userAddress, + arguments: txArguments, + }) + }, + enabled: + !!txArguments && + !!wallet && + !!sellAccountId && + !!selectedYield && + isConnected && + bnOrZero(debouncedAmount).gt(0), + staleTime: 30_000, + gcTime: 60_000, + retry: false, + }) + + const networkFeeFiatUserCurrency = useMemo(() => { + if (!quoteData?.transactions?.length || !feeAssetMarketData?.price) { + return undefined + } + + const totalGasCryptoPrecision = quoteData.transactions.reduce((acc, tx) => { + if (!tx.gasEstimate) return acc + try { + const gasData = JSON.parse(tx.gasEstimate) + return acc.plus(bnOrZero(gasData.amount)) + } catch { + return acc + } + }, bnOrZero(0)) + + if (totalGasCryptoPrecision.isZero()) return undefined + return totalGasCryptoPrecision.times(feeAssetMarketData.price).toFixed(2) + }, [quoteData?.transactions, feeAssetMarketData?.price]) + + const { price: sellAssetUserCurrencyRate } = + useAppSelector(state => selectMarketDataByFilter(state, { assetId: sellAsset?.assetId })) || + {} + + const balanceFilter = useMemo( + () => ({ accountId: sellAccountId ?? '', assetId: sellAsset?.assetId ?? '' }), + [sellAccountId, sellAsset?.assetId], + ) + const sellAssetBalanceCryptoPrecision = useAppSelector(state => + isConnected ? selectPortfolioCryptoPrecisionBalanceByFilter(state, balanceFilter) : '0', + ) + + const minDeposit = useMemo( + () => selectedYield?.mechanics.entryLimits.minimum, + [selectedYield?.mechanics.entryLimits.minimum], + ) + + const isBelowMinimum = useMemo(() => { + if (!sellAmountCryptoPrecision || !minDeposit) return false + return ( + bnOrZero(sellAmountCryptoPrecision).gt(0) && + bnOrZero(sellAmountCryptoPrecision).lt(minDeposit) + ) + }, [sellAmountCryptoPrecision, minDeposit]) + + const isInsufficientBalance = useMemo(() => { + if (!sellAmountCryptoPrecision || !sellAssetBalanceCryptoPrecision) return false + return bnOrZero(sellAmountCryptoPrecision).gt(sellAssetBalanceCryptoPrecision) + }, [sellAmountCryptoPrecision, sellAssetBalanceCryptoPrecision]) + + const sellAssetSearch = useModal('sellTradeAssetSearch') + + const availableAssetIds = useMemo(() => { + if (!yieldsData?.byInputAssetId) return new Set() + return new Set(Object.keys(yieldsData.byInputAssetId)) + }, [yieldsData?.byInputAssetId]) + + const assetFilterPredicate = useCallback( + (assetId: AssetId) => availableAssetIds.has(assetId), + [availableAssetIds], + ) + + const chainIdFilterPredicate = useCallback(() => true, []) + + const handleSellAssetClick = useCallback(() => { + sellAssetSearch.open({ + onAssetClick: (asset: Asset) => { + dispatch(tradeEarnInput.actions.setSellAssetWithYieldReset(asset)) + }, + title: 'trade.tradeFrom', + assetFilterPredicate, + chainIdFilterPredicate, + }) + }, [assetFilterPredicate, chainIdFilterPredicate, dispatch, sellAssetSearch]) + + const setSellAsset = useCallback( + (asset: Asset) => { + dispatch(tradeEarnInput.actions.setSellAssetWithYieldReset(asset)) + }, + [dispatch], + ) + + const setSellAccountId = useCallback( + (accountId: AccountId) => { + dispatch(tradeEarnInput.actions.setSellAccountId(accountId)) + }, + [dispatch], + ) + + const handleSellAmountChange = useCallback( + (value: string) => { + dispatch( + tradeEarnInput.actions.setSellAmountCryptoPrecision(positiveOrZero(value).toString()), + ) + }, + [dispatch], + ) + + const handleIsInputtingFiatSellAmountChange = useCallback( + (isInputtingFiat: boolean) => { + dispatch(tradeEarnInput.actions.setIsInputtingFiatSellAmount(isInputtingFiat)) + }, + [dispatch], + ) + + const handleYieldSelect = useCallback( + (yieldId: string) => { + dispatch(tradeEarnInput.actions.setSelectedYieldId(yieldId)) + }, + [dispatch], + ) + + const percentOptions = useMemo(() => { + if (!sellAsset?.assetId) return [] + if (!isToken(sellAsset.assetId)) return [] + return [1] + }, [sellAsset?.assetId]) + + const assetSelectButtonProps = useMemo( + () => ({ + maxWidth: isSmallerThanMd ? '100%' : undefined, + }), + [isSmallerThanMd], + ) + + const sellTradeAssetSelect = useMemo( + () => ( + + ), + [ + sellAsset?.assetId, + handleSellAssetClick, + setSellAsset, + assetFilterPredicate, + chainIdFilterPredicate, + isSmallerThanMd, + assetSelectButtonProps, + ], + ) + + const yieldsForAsset = useMemo(() => { + if (!sellAsset?.assetId || !yieldsData?.byInputAssetId) return [] + return yieldsData.byInputAssetId[sellAsset.assetId] ?? [] + }, [sellAsset?.assetId, yieldsData?.byInputAssetId]) + + useEffect(() => { + if (yieldsForAsset.length > 0 && !selectedYieldId) { + const sortedByApy = [...yieldsForAsset].sort( + (a, b) => (b.rewardRate?.total ?? 0) - (a.rewardRate?.total ?? 0), + ) + if (sortedByApy[0]) { + dispatch(tradeEarnInput.actions.setSelectedYieldId(sortedByApy[0].id)) + } + } + }, [yieldsForAsset, selectedYieldId, dispatch]) + + const handleSubmit = useCallback( + (e: FormEvent) => { + e.preventDefault() + if (!selectedYield || !hasUserEnteredAmount || !isConnected) return + navigate(EarnRoutePaths.Confirm) + }, + [selectedYield, hasUserEnteredAmount, isConnected, navigate], + ) + + const estimatedYearlyEarnings = useMemo(() => { + if (!selectedYield || !sellAmountCryptoPrecision) return undefined + const apy = selectedYield.rewardRate?.total ?? 0 + const amount = bnOrZero(sellAmountCryptoPrecision) + if (amount.isZero()) return undefined + return amount.times(apy).decimalPlaces(6).toString() + }, [selectedYield, sellAmountCryptoPrecision]) + + const estimatedYearlyEarningsUserCurrency = useMemo(() => { + if (!estimatedYearlyEarnings || !sellAssetUserCurrencyRate) return undefined + return bnOrZero(estimatedYearlyEarnings) + .times(sellAssetUserCurrencyRate) + .decimalPlaces(2) + .toString() + }, [estimatedYearlyEarnings, sellAssetUserCurrencyRate]) + + const bodyContent = useMemo( + () => ( + + + + + + + + + + ), + [ + sellAccountId, + sellAsset, + isInputtingFiatSellAmount, + isLoadingYields, + translate, + sellTradeAssetSelect, + percentOptions, + sellAmountCryptoPrecision, + sellAmountUserCurrency, + setSellAccountId, + handleIsInputtingFiatSellAmountChange, + handleSellAmountChange, + selectedYieldId, + yieldsForAsset, + handleYieldSelect, + ], + ) + + const footerContent = useMemo( + () => ( + + ), + [ + selectedYield, + hasUserEnteredAmount, + isLoadingYields, + sellAsset, + estimatedYearlyEarnings, + estimatedYearlyEarningsUserCurrency, + isConnected, + selectedValidator, + isBelowMinimum, + isInsufficientBalance, + networkFeeFiatUserCurrency, + isQuoteLoading, + ], + ) + + return ( + } + isCompact={false} + isLoading={isLoadingYields} + SideComponent={EmptySideComponent} + shouldOpenSideComponent={false} + tradeInputTab={TradeInputTab.Earn} + tradeInputRef={tradeInputRef} + onSubmit={handleSubmit} + /> + ) + }, +) diff --git a/src/components/MultiHopTrade/components/Earn/components/EarnFooter.tsx b/src/components/MultiHopTrade/components/Earn/components/EarnFooter.tsx new file mode 100644 index 00000000000..8e6b5d54a0d --- /dev/null +++ b/src/components/MultiHopTrade/components/Earn/components/EarnFooter.tsx @@ -0,0 +1,347 @@ +import { InfoIcon } from '@chakra-ui/icons' +import type { CardFooterProps, FlexProps } from '@chakra-ui/react' +import { + Box, + CardFooter, + Flex, + HStack, + Icon, + Image, + Skeleton, + Text, + VStack, +} from '@chakra-ui/react' +import type { Asset } from '@shapeshiftoss/types' +import { memo, useMemo } from 'react' +import { FaGift } from 'react-icons/fa' +import { MdSwapHoriz } from 'react-icons/md' +import { useTranslate } from 'react-polyglot' + +import { Amount } from '@/components/Amount/Amount' +import { ButtonWalletPredicate } from '@/components/ButtonWalletPredicate/ButtonWalletPredicate' +import { bnOrZero } from '@/lib/bignumber/bignumber' +import type { AugmentedYieldDto, ValidatorDto } from '@/lib/yieldxyz/types' +import { GradientApy } from '@/pages/Yields/components/GradientApy' + +type EarnFooterProps = { + selectedYield: AugmentedYieldDto | undefined + hasUserEnteredAmount: boolean + isLoading: boolean + sellAsset: Asset + estimatedYearlyEarnings: string | undefined + estimatedYearlyEarningsUserCurrency: string | undefined + isConnected: boolean + selectedValidator: ValidatorDto | undefined + isBelowMinimum: boolean + isInsufficientBalance: boolean + networkFeeFiatUserCurrency: string | undefined + isQuoteLoading: boolean +} + +const isStakingType = (type: string | undefined): boolean => + ['native-staking', 'pooled-staking', 'staking'].includes(type ?? '') + +const footerBgProp = { base: 'background.surface.base', md: 'transparent' } +const footerPosition: CardFooterProps['position'] = { base: 'sticky', md: 'static' } + +const statsBoxSx: FlexProps = { + bg: 'background.surface.raised.base', + borderRadius: 'lg', + p: 3, + borderWidth: '1px', + borderColor: 'border.base', +} + +const getActionTextKey = (yieldType: string | undefined): string => { + switch (yieldType) { + case 'native-staking': + case 'pooled-staking': + case 'liquid-staking': + case 'staking': + return 'defi.stake' + case 'vault': + return 'common.deposit' + case 'lending': + return 'common.supply' + default: + return 'common.deposit' + } +} + +type ExplainerItem = { + icon: React.ReactNode + textKey: string +} + +const getYieldExplainers = (selectedYield: AugmentedYieldDto): ExplainerItem[] => { + const yieldType = selectedYield.mechanics.type + const outputTokenSymbol = selectedYield.outputToken?.symbol + + switch (yieldType) { + case 'liquid-staking': + return [ + { + icon: , + textKey: outputTokenSymbol + ? 'earn.explainers.liquidStakingReceive' + : 'earn.explainers.liquidStakingTrade', + }, + { + icon: , + textKey: 'earn.explainers.rewardsSchedule', + }, + { + icon: , + textKey: 'earn.explainers.liquidStakingWithdraw', + }, + ] + case 'native-staking': + case 'pooled-staking': + case 'staking': + return [ + { + icon: , + textKey: 'earn.explainers.rewardsSchedule', + }, + { icon: , textKey: 'earn.explainers.stakingUnbonding' }, + ] + case 'restaking': + return [ + { + icon: , + textKey: 'earn.explainers.restakingYield', + }, + { icon: , textKey: 'earn.explainers.restakingWithdraw' }, + ] + case 'vault': + return [ + { icon: , textKey: 'earn.explainers.vaultYield' }, + { icon: , textKey: 'earn.explainers.vaultWithdraw' }, + ] + case 'lending': + return [ + { icon: , textKey: 'earn.explainers.lendingYield' }, + { icon: , textKey: 'earn.explainers.lendingWithdraw' }, + ] + default: + return [] + } +} + +export const EarnFooter = memo( + ({ + selectedYield, + hasUserEnteredAmount, + isLoading, + sellAsset, + estimatedYearlyEarnings, + estimatedYearlyEarningsUserCurrency, + isConnected, + selectedValidator, + isBelowMinimum, + isInsufficientBalance, + networkFeeFiatUserCurrency, + isQuoteLoading, + }: EarnFooterProps) => { + const translate = useTranslate() + + const apy = useMemo( + () => (selectedYield ? (selectedYield.rewardRate?.total ?? 0) * 100 : 0), + [selectedYield], + ) + + const apyDisplay = useMemo(() => `${apy.toFixed(2)}%`, [apy]) + + const minDeposit = selectedYield?.mechanics.entryLimits.minimum + const hasMinDeposit = minDeposit && bnOrZero(minDeposit).gt(0) + + const hasValidationError = isBelowMinimum || isInsufficientBalance + const isDisabled = + !hasUserEnteredAmount || !selectedYield || isLoading || !isConnected || hasValidationError + + const buttonText = useMemo(() => { + if (!isConnected) return translate('common.connectWallet') + if (!selectedYield) return translate('earn.selectYieldOpportunity') + if (!hasUserEnteredAmount) return translate('common.enterAmount') + if (isInsufficientBalance) return translate('common.insufficientFunds') + if (isBelowMinimum) return translate('earn.belowMinimum') + return translate(getActionTextKey(selectedYield.mechanics.type)) + }, [ + isConnected, + selectedYield, + hasUserEnteredAmount, + isInsufficientBalance, + isBelowMinimum, + translate, + ]) + + const explainers = useMemo( + () => (selectedYield ? getYieldExplainers(selectedYield) : []), + [selectedYield], + ) + + const rewardSchedule = selectedYield?.mechanics.rewardSchedule + const cooldownDays = useMemo(() => { + const seconds = selectedYield?.mechanics.cooldownPeriod?.seconds + if (!seconds) return undefined + return Math.ceil(seconds / 86400) + }, [selectedYield?.mechanics.cooldownPeriod?.seconds]) + + return ( + + + {selectedYield && isStakingType(selectedYield.mechanics.type) && ( + + + {translate('earn.earnWith')} + + + {selectedValidator?.name + + {selectedValidator?.name ?? selectedYield.metadata.name} + + + + )} + + {selectedYield && hasUserEnteredAmount && ( + + + + {translate('common.apy')} + + {isLoading ? ( + + ) : ( + {apyDisplay} + )} + + + {estimatedYearlyEarnings && ( + + + {translate('earn.estimatedYearlyEarnings')} + + {isLoading ? ( + + ) : ( + + + + + {estimatedYearlyEarningsUserCurrency && ( + + + + )} + + )} + + )} + + {hasMinDeposit && ( + + + {translate('earn.minimumDeposit')} + + + + )} + + {selectedYield.mechanics.type && ( + + + {translate('earn.yieldType')} + + + {selectedYield.mechanics.type.replace(/-/g, ' ')} + + + )} + + + + {translate('trade.networkFee')} + + {isQuoteLoading ? ( + + ) : networkFeeFiatUserCurrency ? ( + + ) : ( + + — + + )} + + + )} + + {selectedYield && explainers.length > 0 && ( + + {explainers.map((explainer, index) => ( + + {explainer.icon} + + {translate(explainer.textKey, { + symbol: selectedYield.outputToken?.symbol ?? sellAsset?.symbol ?? '', + schedule: rewardSchedule ?? '', + days: cooldownDays ?? '', + })} + + + ))} + + )} + + + {buttonText} + + + + ) + }, +) diff --git a/src/components/MultiHopTrade/components/Earn/components/YieldSelector.tsx b/src/components/MultiHopTrade/components/Earn/components/YieldSelector.tsx new file mode 100644 index 00000000000..556f316a57f --- /dev/null +++ b/src/components/MultiHopTrade/components/Earn/components/YieldSelector.tsx @@ -0,0 +1,296 @@ +import { ChevronDownIcon, SearchIcon } from '@chakra-ui/icons' +import { + Box, + Button, + HStack, + Image, + Input, + InputGroup, + InputLeftElement, + Modal, + ModalBody, + ModalCloseButton, + ModalContent, + ModalHeader, + ModalOverlay, + Skeleton, + Text, + useColorModeValue, + useDisclosure, + VStack, +} from '@chakra-ui/react' +import type { Asset } from '@shapeshiftoss/types' +import { memo, useCallback, useMemo, useState } from 'react' +import { useTranslate } from 'react-polyglot' + +import type { AugmentedYieldDto, ProviderDto } from '@/lib/yieldxyz/types' +import { GradientApy } from '@/pages/Yields/components/GradientApy' +import { useYieldProviders } from '@/react-queries/queries/yieldxyz/useYieldProviders' + +const chevronDownIcon = +const searchIcon = + +type YieldSelectorProps = { + selectedYieldId: string | undefined + yields: AugmentedYieldDto[] + onYieldSelect: (yieldId: string) => void + isLoading: boolean + sellAsset: Asset +} + +const getProviderInfo = ( + yieldItem: AugmentedYieldDto, + providers: Record | undefined, +): { name: string; logoURI: string | undefined } => { + const provider = providers?.[yieldItem.providerId] + if (provider) { + return { name: provider.name, logoURI: provider.logoURI } + } + return { name: yieldItem.metadata.name, logoURI: yieldItem.metadata.logoURI } +} + +const hoverBg = { bg: 'background.surface.raised.hover' } + +const getYieldTypeName = (type: string): string => { + const typeMap: Record = { + 'native-staking': 'Native Staking', + 'pooled-staking': 'Pooled Staking', + 'liquid-staking': 'Liquid Staking', + staking: 'Staking', + lending: 'Lending', + vault: 'Vault', + restaking: 'Restaking', + } + if (typeMap[type]) return typeMap[type] + return type + .split('-') + .filter((word): word is string => Boolean(word)) + .map(word => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' ') +} + +const YieldItem = memo( + ({ + yieldItem, + isSelected, + onClick, + providers, + }: { + yieldItem: AugmentedYieldDto + isSelected: boolean + onClick: () => void + providers: Record | undefined + }) => { + const selectedBg = useColorModeValue('blue.50', 'whiteAlpha.100') + + const apyDisplay = useMemo(() => { + const apy = (yieldItem.rewardRate?.total ?? 0) * 100 + return `${apy.toFixed(2)}%` + }, [yieldItem.rewardRate?.total]) + + const providerInfo = useMemo( + () => getProviderInfo(yieldItem, providers), + [yieldItem, providers], + ) + + return ( + + + {providerInfo.name} + + + {providerInfo.name} + + + + {apyDisplay} + + + + ) + }, +) + +export const YieldSelector = memo( + ({ selectedYieldId, yields, onYieldSelect, isLoading, sellAsset }: YieldSelectorProps) => { + const translate = useTranslate() + const { isOpen, onOpen, onClose } = useDisclosure() + const borderColor = useColorModeValue('gray.200', 'gray.700') + const [searchQuery, setSearchQuery] = useState('') + const { data: providers } = useYieldProviders() + + const selectedYield = useMemo(() => { + if (!selectedYieldId) return undefined + return yields.find(y => y.id === selectedYieldId) + }, [selectedYieldId, yields]) + + const filteredYields = useMemo(() => { + if (!searchQuery.trim()) return yields + const query = searchQuery.toLowerCase() + return yields.filter( + y => + y.metadata.name.toLowerCase().includes(query) || + y.mechanics.type.toLowerCase().includes(query), + ) + }, [yields, searchQuery]) + + const groupedYields = useMemo(() => { + const groups: Record = {} + for (const yieldItem of filteredYields) { + const type = yieldItem.mechanics.type + if (!groups[type]) groups[type] = [] + groups[type].push(yieldItem) + } + for (const type of Object.keys(groups)) { + groups[type].sort((a, b) => (b.rewardRate?.total ?? 0) - (a.rewardRate?.total ?? 0)) + } + return groups + }, [filteredYields]) + + const handleYieldClick = useCallback( + (yieldId: string) => { + onYieldSelect(yieldId) + setSearchQuery('') + onClose() + }, + [onYieldSelect, onClose], + ) + + const handleClose = useCallback(() => { + setSearchQuery('') + onClose() + }, [onClose]) + + const selectedApyDisplay = useMemo(() => { + if (!selectedYield) return '0.00%' + const apy = (selectedYield.rewardRate?.total ?? 0) * 100 + return `${apy.toFixed(2)}%` + }, [selectedYield]) + + const selectedProviderInfo = useMemo( + () => (selectedYield ? getProviderInfo(selectedYield, providers) : null), + [selectedYield, providers], + ) + + if (isLoading) { + return + } + + if (yields.length === 0) { + return ( + + + {translate('earn.noYieldsAvailable', { asset: sellAsset?.symbol ?? 'asset' })} + + + ) + } + + return ( + <> + + + + + + + {translate('earn.selectYieldFor', { asset: sellAsset?.symbol ?? '' })} + + + + + + {searchIcon} + setSearchQuery(e.target.value)} + variant='filled' + /> + + + {Object.entries(groupedYields).length === 0 ? ( + + {translate('common.noResultsFound')} + + ) : ( + Object.entries(groupedYields).map(([type, typeYields]) => ( + + + {getYieldTypeName(type).toUpperCase()} + + + {typeYields.map(yieldItem => ( + handleYieldClick(yieldItem.id)} + providers={providers} + /> + ))} + + + )) + )} + + + + + + ) + }, +) diff --git a/src/components/MultiHopTrade/components/Earn/types.ts b/src/components/MultiHopTrade/components/Earn/types.ts new file mode 100644 index 00000000000..145e4eaf27e --- /dev/null +++ b/src/components/MultiHopTrade/components/Earn/types.ts @@ -0,0 +1,4 @@ +export enum EarnRoutePaths { + Input = '/earn', + Confirm = '/earn/confirm', +} diff --git a/src/components/MultiHopTrade/components/SharedTradeInput/SharedTradeInputHeader.tsx b/src/components/MultiHopTrade/components/SharedTradeInput/SharedTradeInputHeader.tsx index 3aa5e0b2065..694f1b299da 100644 --- a/src/components/MultiHopTrade/components/SharedTradeInput/SharedTradeInputHeader.tsx +++ b/src/components/MultiHopTrade/components/SharedTradeInput/SharedTradeInputHeader.tsx @@ -45,6 +45,7 @@ export const SharedTradeInputHeader = ({ const enableLimitOrders = useFeatureFlag('LimitOrders') const enableSwapperFiatRamps = useFeatureFlag('SwapperFiatRamps') + const enableEarnTab = useFeatureFlag('EarnTab') const handleChangeTab = useCallback( (newTab: TradeInputTab) => { @@ -70,6 +71,10 @@ export const SharedTradeInputHeader = ({ handleChangeTab(TradeInputTab.SellFiat) }, [handleChangeTab]) + const handleClickEarn = useCallback(() => { + handleChangeTab(TradeInputTab.Earn) + }, [handleChangeTab]) + return ( )} + {enableEarnTab && !isStandalone && ( + + {translate('navBar.earn')} + + )} {rightContent} @@ -210,6 +226,23 @@ export const SharedTradeInputHeader = ({ {translate('fiatRamps.sell')} )} + {enableEarnTab && !isStandalone && ( + + {translate('navBar.earn')} + + )} diff --git a/src/components/MultiHopTrade/types.ts b/src/components/MultiHopTrade/types.ts index e273e264f5d..cb35d40571d 100644 --- a/src/components/MultiHopTrade/types.ts +++ b/src/components/MultiHopTrade/types.ts @@ -20,4 +20,5 @@ export enum TradeInputTab { LimitOrder = 'limitOrder', BuyFiat = 'buy', SellFiat = 'sell', + Earn = 'earn', } diff --git a/src/config.ts b/src/config.ts index d24f2055e2d..e7557edefff 100644 --- a/src/config.ts +++ b/src/config.ts @@ -236,6 +236,7 @@ const validators = { VITE_FEATURE_APP_RATING: bool({ default: false }), VITE_FEATURE_YIELD_XYZ: bool({ default: false }), VITE_FEATURE_YIELDS_PAGE: bool({ default: false }), + VITE_FEATURE_EARN_TAB: bool({ default: false }), VITE_YIELD_XYZ_API_KEY: str({ default: '' }), VITE_YIELD_XYZ_BASE_URL: url({ default: 'https://api.yield.xyz/v1' }), VITE_FEATURE_YIELD_MULTI_ACCOUNT: bool({ default: false }), diff --git a/src/lib/yieldxyz/types.ts b/src/lib/yieldxyz/types.ts index df7c880d734..be584174067 100644 --- a/src/lib/yieldxyz/types.ts +++ b/src/lib/yieldxyz/types.ts @@ -238,6 +238,7 @@ export type YieldMechanics = { entryLimits: YieldEntryLimits arguments: YieldArguments supportsLedgerWalletApi?: boolean + cooldownPeriod?: { seconds: number } possibleFeeTakingMechanisms?: { depositFee: boolean managementFee: boolean diff --git a/src/pages/Trade/tabs/EarnTab.tsx b/src/pages/Trade/tabs/EarnTab.tsx new file mode 100644 index 00000000000..482931cea10 --- /dev/null +++ b/src/pages/Trade/tabs/EarnTab.tsx @@ -0,0 +1,133 @@ +import { Box, Flex } from '@chakra-ui/react' +import { memo, useCallback, useMemo, useRef } from 'react' +import { FormProvider, useForm } from 'react-hook-form' +import { useTranslate } from 'react-polyglot' +import { matchPath, Route, Routes, useLocation, useNavigate } from 'react-router-dom' + +import { Main } from '@/components/Layout/Main' +import { SEO } from '@/components/Layout/Seo' +import { EarnConfirm } from '@/components/MultiHopTrade/components/Earn/EarnConfirm' +import { EarnInput } from '@/components/MultiHopTrade/components/Earn/EarnInput' +import { EarnRoutePaths } from '@/components/MultiHopTrade/components/Earn/types' +import { FiatRampRoutePaths } from '@/components/MultiHopTrade/components/FiatRamps/types' +import { LimitOrderRoutePaths } from '@/components/MultiHopTrade/components/LimitOrder/types' +import { TradeInputTab, TradeRoutePaths } from '@/components/MultiHopTrade/types' +import { blurBackgroundSx, gridOverlaySx } from '@/pages/Trade/constants' +import { EARN_ROUTE_ASSET_SPECIFIC } from '@/Routes/RoutesCommon' + +const padding = { base: 0, md: 8 } +const mainPaddingTop = { base: 0, md: '4.5rem' } +const mainMarginTop = { base: 0, md: '-4.5rem' } + +const containerPaddingTop = { base: 0, md: 12 } +const containerPaddingBottom = { base: 0, md: 12 } + +export const EarnTab = memo(function EarnTab() { + const translate = useTranslate() + const methods = useForm({ mode: 'onChange' }) + const navigate = useNavigate() + const location = useLocation() + const tradeInputRef = useRef(null) + + const earnMatch = useMemo( + () => matchPath({ path: EARN_ROUTE_ASSET_SPECIFIC, end: true }, location.pathname), + [location.pathname], + ) + + const params = earnMatch?.params + + const defaultSellAssetId = useMemo( + () => + params?.sellChainId && params.sellAssetSubId + ? `${params.sellChainId}/${params.sellAssetSubId}` + : undefined, + [params?.sellChainId, params?.sellAssetSubId], + ) + + const defaultYieldId = useMemo( + () => (params?.yieldId ? decodeURIComponent(params.yieldId) : undefined), + [params?.yieldId], + ) + + const defaultSellAmountCryptoBaseUnit = useMemo( + () => params?.sellAmountCryptoBaseUnit, + [params?.sellAmountCryptoBaseUnit], + ) + + const handleChangeTab = useCallback( + (newTab: TradeInputTab) => { + switch (newTab) { + case TradeInputTab.Trade: + navigate(TradeRoutePaths.Input) + break + case TradeInputTab.LimitOrder: + navigate(LimitOrderRoutePaths.Input) + break + case TradeInputTab.BuyFiat: + navigate(FiatRampRoutePaths.Buy) + break + case TradeInputTab.SellFiat: + navigate(FiatRampRoutePaths.Sell) + break + case TradeInputTab.Earn: + navigate(EarnRoutePaths.Input) + break + default: + break + } + }, + [navigate], + ) + + const title = useMemo(() => { + return translate('navBar.earn') + }, [translate]) + + const earnInputElement = useMemo( + () => ( + + ), + [handleChangeTab, defaultSellAssetId, defaultYieldId, defaultSellAmountCryptoBaseUnit], + ) + + const earnConfirmElement = useMemo(() => , []) + + return ( +
+ + + + + + + + + + + +
+ ) +}) diff --git a/src/pages/Trade/tabs/LimitTab.tsx b/src/pages/Trade/tabs/LimitTab.tsx index ba413bff5f9..1a9c2710b10 100644 --- a/src/pages/Trade/tabs/LimitTab.tsx +++ b/src/pages/Trade/tabs/LimitTab.tsx @@ -6,6 +6,7 @@ import { matchPath, Route, Routes, useLocation, useNavigate } from 'react-router import { Main } from '@/components/Layout/Main' import { SEO } from '@/components/Layout/Seo' +import { EarnRoutePaths } from '@/components/MultiHopTrade/components/Earn/types' import { FiatRampRoutePaths } from '@/components/MultiHopTrade/components/FiatRamps/types' import { LimitOrder } from '@/components/MultiHopTrade/components/LimitOrder/LimitOrder' import { LimitOrderRoutePaths } from '@/components/MultiHopTrade/components/LimitOrder/types' @@ -68,6 +69,9 @@ export const LimitTab = memo(() => { case TradeInputTab.SellFiat: navigate(FiatRampRoutePaths.Sell) break + case TradeInputTab.Earn: + navigate(EarnRoutePaths.Input) + break default: break } diff --git a/src/pages/Trade/tabs/RampTab.tsx b/src/pages/Trade/tabs/RampTab.tsx index e6eef70057d..484fae0cc50 100644 --- a/src/pages/Trade/tabs/RampTab.tsx +++ b/src/pages/Trade/tabs/RampTab.tsx @@ -8,6 +8,7 @@ import { RampErrorBoundary } from '@/components/ErrorBoundary/RampErrorBoundary' import { Main } from '@/components/Layout/Main' import { SEO } from '@/components/Layout/Seo' import { FiatRampAction } from '@/components/Modals/FiatRamps/FiatRampsCommon' +import { EarnRoutePaths } from '@/components/MultiHopTrade/components/Earn/types' import { FiatRampTrade } from '@/components/MultiHopTrade/components/FiatRamps/FiatRampTrade' import { FiatRampRoutePaths } from '@/components/MultiHopTrade/components/FiatRamps/types' import { LimitOrderRoutePaths } from '@/components/MultiHopTrade/components/LimitOrder/types' @@ -48,6 +49,9 @@ export const RampTab = () => { case TradeInputTab.SellFiat: navigate(FiatRampRoutePaths.Sell) break + case TradeInputTab.Earn: + navigate(EarnRoutePaths.Input) + break default: break } diff --git a/src/pages/Trade/tabs/TradeTab.tsx b/src/pages/Trade/tabs/TradeTab.tsx index a1e4ea6d111..9e6d0f537cd 100644 --- a/src/pages/Trade/tabs/TradeTab.tsx +++ b/src/pages/Trade/tabs/TradeTab.tsx @@ -7,6 +7,7 @@ import { matchPath, Route, Routes, useLocation, useNavigate } from 'react-router import { TradingErrorBoundary } from '@/components/ErrorBoundary' import { Main } from '@/components/Layout/Main' import { SEO } from '@/components/Layout/Seo' +import { EarnRoutePaths } from '@/components/MultiHopTrade/components/Earn/types' import { FiatRampRoutePaths } from '@/components/MultiHopTrade/components/FiatRamps/types' import { LimitOrderRoutePaths } from '@/components/MultiHopTrade/components/LimitOrder/types' import { TopAssetsCarousel } from '@/components/MultiHopTrade/components/TradeInput/components/TopAssetsCarousel' @@ -78,6 +79,9 @@ export const TradeTab = memo(() => { case TradeInputTab.SellFiat: navigate(FiatRampRoutePaths.Sell) break + case TradeInputTab.Earn: + navigate(EarnRoutePaths.Input) + break default: break } diff --git a/src/pages/Yields/components/YieldActionModal.tsx b/src/pages/Yields/components/YieldActionModal.tsx index 9125963bbe1..20cc67088bf 100644 --- a/src/pages/Yields/components/YieldActionModal.tsx +++ b/src/pages/Yields/components/YieldActionModal.tsx @@ -1,8 +1,5 @@ -import { Avatar, Box, Button, Flex, Icon, Text, VStack } from '@chakra-ui/react' -import { keyframes } from '@emotion/react' -import { memo, useEffect, useMemo } from 'react' -import ReactCanvasConfetti from 'react-canvas-confetti' -import { FaCheck, FaWallet } from 'react-icons/fa' +import { Avatar, Box, Button, Flex, Text } from '@chakra-ui/react' +import { memo, useMemo } from 'react' import { useTranslate } from 'react-polyglot' import { Amount } from '@/components/Amount/Amount' @@ -14,11 +11,11 @@ import { DialogHeader } from '@/components/Modal/components/DialogHeader' import { DialogTitle } from '@/components/Modal/components/DialogTitle' import { bnOrZero } from '@/lib/bignumber/bignumber' import type { AugmentedYieldDto } from '@/lib/yieldxyz/types' -import { formatYieldTxTitle, getTransactionButtonText } from '@/lib/yieldxyz/utils' +import { getTransactionButtonText } from '@/lib/yieldxyz/utils' import { GradientApy } from '@/pages/Yields/components/GradientApy' import { TransactionStepsList } from '@/pages/Yields/components/TransactionStepsList' -import { useConfetti } from '@/pages/Yields/hooks/useConfetti' -import type { TransactionStep } from '@/pages/Yields/hooks/useYieldTransactionFlow' +import { YieldAssetFlow } from '@/pages/Yields/components/YieldAssetFlow' +import { YieldSuccess } from '@/pages/Yields/components/YieldSuccess' import { ModalStep, useYieldTransactionFlow } from '@/pages/Yields/hooks/useYieldTransactionFlow' import { useYieldProviders } from '@/react-queries/queries/yieldxyz/useYieldProviders' import { useYieldValidators } from '@/react-queries/queries/yieldxyz/useYieldValidators' @@ -28,13 +25,6 @@ import { } from '@/state/slices/selectors' import { useAppSelector } from '@/state/store' -const walletIcon = -const checkIconBox = ( - - - -) - type YieldActionModalProps = { isOpen: boolean onClose: () => void @@ -48,6 +38,7 @@ type YieldActionModalProps = { validatorLogoURI?: string passthrough?: string manageActionType?: string + accountId?: string } export const YieldActionModal = memo(function YieldActionModal({ @@ -69,6 +60,7 @@ export const YieldActionModal = memo(function YieldActionModal({ const { step, transactionSteps, + displaySteps, isSubmitting, activeStepIndex, canSubmit, @@ -88,6 +80,7 @@ export const YieldActionModal = memo(function YieldActionModal({ validatorAddress, passthrough, manageActionType: props.manageActionType, + accountId: props.accountId, }) const shouldFetchValidators = useMemo( @@ -120,19 +113,6 @@ export const YieldActionModal = memo(function YieldActionModal({ const chainId = useMemo(() => yieldItem.chainId ?? '', [yieldItem.chainId]) const feeAsset = useAppSelector(state => selectFeeAssetByChainId(state, chainId)) - const horizontalScroll = useMemo( - () => keyframes` - 0% { background-position: 0 0; } - 100% { background-position: 28px 0; } - `, - [], - ) - - const flexDirection = useMemo( - () => (action === 'enter' ? 'row' : 'row-reverse') as 'row' | 'row-reverse', - [action], - ) - const assetAvatarSrc = useMemo( () => assetLogoURI ?? yieldItem.token.logoURI, [assetLogoURI, yieldItem.token.logoURI], @@ -194,11 +174,15 @@ export const YieldActionModal = memo(function YieldActionModal({ }, [isQuoteLoading, action, translate, activeStepIndex, transactionSteps]) const buttonText = useMemo(() => { - // Use the current step's type/title for a clean button label (e.g., "Delegate", "Undelegate", "Approve") + // Use the current step's type/title for a clean button label (e.g., "Enter", "Exit", "Approve") if (activeStepIndex >= 0 && transactionSteps[activeStepIndex]) { const step = transactionSteps[activeStepIndex] return getTransactionButtonText(step.type, step.originalTitle) } + // USDT reset required before other transactions + if (isUsdtResetRequired) { + return translate('yieldXYZ.resetAllowance') + } // Before execution starts, use the first CREATED transaction from quoteData const firstCreatedTx = quoteData?.transactions?.find(tx => tx.status === 'CREATED') if (firstCreatedTx) { @@ -208,7 +192,7 @@ export const YieldActionModal = memo(function YieldActionModal({ if (action === 'enter') return translate('yieldXYZ.enter') if (action === 'exit') return translate('yieldXYZ.exit') return translate('common.claim') - }, [action, translate, activeStepIndex, transactionSteps, quoteData]) + }, [action, translate, activeStepIndex, transactionSteps, quoteData, isUsdtResetRequired]) const modalHeading = useMemo(() => { if (action === 'enter') return translate('yieldXYZ.enterSymbol', { symbol: assetSymbol }) @@ -216,116 +200,24 @@ export const YieldActionModal = memo(function YieldActionModal({ return translate('yieldXYZ.claimSymbol', { symbol: assetSymbol }) }, [action, assetSymbol, translate]) - const successMessage = useMemo(() => { - if (action === 'enter') - return translate('yieldXYZ.successEnter', { symbol: assetSymbol, amount }) - if (action === 'exit') return translate('yieldXYZ.successExit', { symbol: assetSymbol, amount }) - return translate('yieldXYZ.successClaim', { symbol: assetSymbol, amount }) - }, [action, assetSymbol, amount, translate]) - const networkAvatarSrc = useMemo( () => feeAsset?.networkIcon ?? feeAsset?.icon, [feeAsset?.networkIcon, feeAsset?.icon], ) - // Show steps from quoteData before execution starts, then switch to actual transactionSteps - const displaySteps = useMemo((): TransactionStep[] => { - // If we have transactionSteps (execution has started or completed), use those - if (transactionSteps.length > 0) { - return transactionSteps - } - // Don't show preview steps while still checking if USDT reset is needed - if (isAllowanceCheckPending) return [] - // Before execution, create preview steps from quoteData (filter out SKIPPED transactions) - if (quoteData?.transactions?.length) { - const steps: TransactionStep[] = [] - // Add reset step if USDT reset is required - if (isUsdtResetRequired) { - steps.push({ - title: translate('yieldXYZ.resetAllowance'), - originalTitle: 'Reset Allowance', - type: 'RESET', - status: 'pending' as const, - }) - } - // Add yield.xyz transactions - steps.push( - ...quoteData.transactions - .filter(tx => tx.status === 'CREATED') - .map((tx, i) => ({ - title: formatYieldTxTitle(tx.title || `Transaction ${i + 1}`, assetSymbol), - originalTitle: tx.title || '', - type: tx.type, - status: 'pending' as const, - })), - ) - return steps - } - return [] - }, [ - transactionSteps, - quoteData, - assetSymbol, - isAllowanceCheckPending, - isUsdtResetRequired, - translate, - ]) + const assetFlowDirection = action === 'exit' ? 'exit' : 'enter' const animatedAvatarRow = useMemo( () => ( - - - - - - - {assetSymbol} - - - - - - - - - - - - {vaultMetadata.name} - - - + ), - [ - flexDirection, - assetAvatarSrc, - assetSymbol, - horizontalScroll, - vaultMetadata.logoURI, - vaultMetadata.name, - ], + [assetSymbol, assetAvatarSrc, vaultMetadata.name, vaultMetadata.logoURI, assetFlowDirection], ) const statsContent = useMemo( @@ -439,114 +331,98 @@ export const YieldActionModal = memo(function YieldActionModal({ [animatedAvatarRow, statsContent, displaySteps], ) - const { getInstance, fireConfetti, confettiStyle } = useConfetti() + const successMessageKey = useMemo(() => { + if (action === 'enter') return 'successEnter' as const + if (action === 'exit') return 'successExit' as const + return 'successClaim' as const + }, [action]) - useEffect(() => { - if (step === ModalStep.Success) fireConfetti() - }, [step, fireConfetti]) + const successProviderInfo = useMemo( + () => (vaultMetadata ? { name: vaultMetadata.name, logoURI: vaultMetadata.logoURI } : null), + [vaultMetadata], + ) const successContent = useMemo( () => ( - - - - - - {successMessage} - - {vaultMetadata && ( - - - - {vaultMetadata.name} - - - )} - - - - + ), - [successMessage, vaultMetadata, transactionSteps], + [ + amount, + assetSymbol, + successProviderInfo, + transactionSteps, + yieldItem.id, + handleClose, + successMessageKey, + ], ) const isInProgress = step === ModalStep.InProgress const isSuccess = step === ModalStep.Success return ( - <> - - - - {null} - - {isSuccess ? translate('common.success') : modalHeading} - - - - - - - {isInProgress && actionContent} - {isSuccess && successContent} - - {isInProgress && ( - - - - )} - {isSuccess && ( - - - - )} - - + + + {null} + + {isSuccess ? translate('common.success') : modalHeading} + + + + + + + {isInProgress && actionContent} + {isSuccess && successContent} + + {isInProgress && ( + + + + )} + {isSuccess && ( + + + + )} + ) }) diff --git a/src/pages/Yields/components/YieldAssetFlow.tsx b/src/pages/Yields/components/YieldAssetFlow.tsx new file mode 100644 index 00000000000..9e384e0284a --- /dev/null +++ b/src/pages/Yields/components/YieldAssetFlow.tsx @@ -0,0 +1,76 @@ +import { Avatar, Box, Flex, Text, VStack } from '@chakra-ui/react' +import { keyframes } from '@emotion/react' +import { memo, useMemo } from 'react' + +type YieldAssetFlowProps = { + assetSymbol: string + assetLogoURI: string + providerName: string + providerLogoURI: string | undefined + direction?: 'enter' | 'exit' +} + +export const YieldAssetFlow = memo( + ({ + assetSymbol, + assetLogoURI, + providerName, + providerLogoURI, + direction = 'enter', + }: YieldAssetFlowProps) => { + const horizontalScroll = useMemo( + () => keyframes` + 0% { background-position: 0 0; } + 100% { background-position: 28px 0; } + `, + [], + ) + + const flexDirection = useMemo( + () => (direction === 'enter' ? 'row' : 'row-reverse') as 'row' | 'row-reverse', + [direction], + ) + + return ( + + + + + + + {assetSymbol} + + + + + + + + + + + + {providerName} + + + + ) + }, +) diff --git a/src/pages/Yields/components/YieldEnterModal.tsx b/src/pages/Yields/components/YieldEnterModal.tsx index 4d91a389885..e16dab4cfd6 100644 --- a/src/pages/Yields/components/YieldEnterModal.tsx +++ b/src/pages/Yields/components/YieldEnterModal.tsx @@ -1,30 +1,10 @@ -import { - Avatar, - Box, - Button, - Flex, - Heading, - HStack, - Icon, - Input, - Skeleton, - Text, - useToast, - VStack, -} from '@chakra-ui/react' -import { cosmosChainId, ethChainId, fromAccountId } from '@shapeshiftoss/caip' -import { assertGetViemClient } from '@shapeshiftoss/contracts' -import type { KnownChainIds } from '@shapeshiftoss/types' -import { useQuery, useQueryClient } from '@tanstack/react-query' -import { uuidv4 } from '@walletconnect/utils' +import { Avatar, Box, Button, Flex, HStack, Icon, Input, Skeleton, Text } from '@chakra-ui/react' +import { useQueryClient } from '@tanstack/react-query' import { memo, useCallback, useEffect, useMemo, useState } from 'react' -import ReactCanvasConfetti from 'react-canvas-confetti' -import { FaCheck } from 'react-icons/fa' import { TbSwitchVertical } from 'react-icons/tb' import type { NumberFormatValues } from 'react-number-format' import { NumericFormat } from 'react-number-format' import { useTranslate } from 'react-polyglot' -import type { Hash } from 'viem' import { AccountSelector } from '@/components/AccountSelector/AccountSelector' import { Amount } from '@/components/Amount/Amount' @@ -35,59 +15,33 @@ import { DialogCloseButton } from '@/components/Modal/components/DialogCloseButt import { DialogFooter } from '@/components/Modal/components/DialogFooter' import { DialogHeader } from '@/components/Modal/components/DialogHeader' import { DialogTitle } from '@/components/Modal/components/DialogTitle' -import { SECOND_CLASS_CHAINS } from '@/constants/chains' import { WalletActions } from '@/context/WalletProvider/actions' -import { useDebounce } from '@/hooks/useDebounce/useDebounce' import { useFeatureFlag } from '@/hooks/useFeatureFlag/useFeatureFlag' import { useLocaleFormatter } from '@/hooks/useLocaleFormatter/useLocaleFormatter' import { useWallet } from '@/hooks/useWallet/useWallet' import { bnOrZero } from '@/lib/bignumber/bignumber' -import { enterYield } from '@/lib/yieldxyz/api' import { DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID, SHAPESHIFT_COSMOS_VALIDATOR_ADDRESS, SHAPESHIFT_VALIDATOR_LOGO, } from '@/lib/yieldxyz/constants' -import type { CosmosStakeArgs } from '@/lib/yieldxyz/executeTransaction' -import { executeTransaction } from '@/lib/yieldxyz/executeTransaction' -import type { AugmentedYieldDto, TransactionDto } from '@/lib/yieldxyz/types' -import { TransactionStatus } from '@/lib/yieldxyz/types' -import { formatYieldTxTitle, getTransactionButtonText } from '@/lib/yieldxyz/utils' +import type { AugmentedYieldDto } from '@/lib/yieldxyz/types' +import { getTransactionButtonText } from '@/lib/yieldxyz/utils' import { GradientApy } from '@/pages/Yields/components/GradientApy' import { TransactionStepsList } from '@/pages/Yields/components/TransactionStepsList' -import { useConfetti } from '@/pages/Yields/hooks/useConfetti' -import type { TransactionStep } from '@/pages/Yields/hooks/useYieldTransactionFlow' -import { - filterExecutableTransactions, - getSpenderFromApprovalTx, - isApprovalTransaction, - isUsdtOnEthereumMainnet, - waitForActionCompletion, - waitForTransactionConfirmation, -} from '@/pages/Yields/hooks/useYieldTransactionFlow' -import { reactQueries } from '@/react-queries' -import { useAllowance } from '@/react-queries/hooks/useAllowance' -import { useSubmitYieldTransactionHash } from '@/react-queries/queries/yieldxyz/useSubmitYieldTransactionHash' +import { YieldSuccess } from '@/pages/Yields/components/YieldSuccess' +import { ModalStep, useYieldTransactionFlow } from '@/pages/Yields/hooks/useYieldTransactionFlow' import { useYieldProviders } from '@/react-queries/queries/yieldxyz/useYieldProviders' import { useYieldValidators } from '@/react-queries/queries/yieldxyz/useYieldValidators' -import { actionSlice } from '@/state/slices/actionSlice/actionSlice' -import { - ActionStatus, - ActionType, - GenericTransactionDisplayType, -} from '@/state/slices/actionSlice/types' -import { portfolioApi } from '@/state/slices/portfolioSlice/portfolioSlice' -import { selectPortfolioAccountMetadataByAccountId } from '@/state/slices/portfolioSlice/selectors' import { allowedDecimalSeparators } from '@/state/slices/preferencesSlice/preferencesSlice' import { selectAccountIdByAccountNumberAndChainId, selectAssetById, - selectFeeAssetByChainId, selectMarketDataByAssetIdUserCurrency, selectPortfolioAccountIdsByAssetIdFilter, selectPortfolioCryptoPrecisionBalanceByFilter, } from '@/state/slices/selectors' -import { useAppDispatch, useAppSelector } from '@/state/store' +import { useAppSelector } from '@/state/store' type YieldEnterModalProps = { isOpen: boolean @@ -96,7 +50,6 @@ type YieldEnterModalProps = { accountNumber?: number } -const QUOTE_DEBOUNCE_MS = 500 const PRESET_PERCENTAGES = [0.25, 0.5, 0.75, 1] as const const SHAPESHIFT_VALIDATOR_NAME = 'ShapeShift DAO' @@ -151,37 +104,21 @@ const YieldEnterModalSkeleton = memo(() => ( )) -type ModalStep = 'input' | 'success' - export const YieldEnterModal = memo( ({ isOpen, onClose, yieldItem, accountNumber = 0 }: YieldEnterModalProps) => { - const dispatch = useAppDispatch() const queryClient = useQueryClient() - const toast = useToast() const translate = useTranslate() const { state: walletState, dispatch: walletDispatch } = useWallet() - const wallet = walletState.wallet const isConnected = useMemo(() => Boolean(walletState.walletInfo), [walletState.walletInfo]) const isYieldMultiAccountEnabled = useFeatureFlag('YieldMultiAccount') - const isUsdtApprovalResetEnabled = useFeatureFlag('UsdtApprovalReset') const { number: { localeParts }, } = useLocaleFormatter() - const submitHashMutation = useSubmitYieldTransactionHash() const [cryptoAmount, setCryptoAmount] = useState('') const [isFiat, setIsFiat] = useState(false) const [selectedAccountId, setSelectedAccountId] = useState() - const [modalStep, setModalStep] = useState('input') - const [isSubmitting, setIsSubmitting] = useState(false) - const [transactionSteps, setTransactionSteps] = useState([]) const [selectedPercent, setSelectedPercent] = useState(null) - const [activeStepIndex, setActiveStepIndex] = useState(-1) - const [rawTransactions, setRawTransactions] = useState([]) - const [currentActionId, setCurrentActionId] = useState(null) - const [resetTxHash, setResetTxHash] = useState(null) - - const debouncedAmount = useDebounce(cryptoAmount, QUOTE_DEBOUNCE_MS) const { chainId } = yieldItem const inputToken = yieldItem.inputTokens[0] @@ -245,11 +182,6 @@ export const YieldEnterModal = memo( return providers[yieldItem.providerId] }, [providers, yieldItem.providerId]) - const userAddress = useMemo( - () => (accountId ? fromAccountId(accountId).account : ''), - [accountId], - ) - const inputTokenAsset = useAppSelector(state => selectAssetById(state, inputTokenAssetId ?? '')) const inputTokenBalance = useAppSelector(state => @@ -265,15 +197,6 @@ export const YieldEnterModal = memo( selectMarketDataByAssetIdUserCurrency(state, inputTokenAssetId ?? ''), ) - const feeAsset = useAppSelector(state => - chainId ? selectFeeAssetByChainId(state, chainId) : undefined, - ) - - const accountMetadataFilter = useMemo(() => ({ accountId: accountId ?? '' }), [accountId]) - const accountMetadata = useAppSelector(state => - selectPortfolioAccountMetadataByAccountId(state, accountMetadataFilter), - ) - const minDeposit = yieldItem.mechanics?.entryLimits?.minimum const isBelowMinimum = useMemo(() => { @@ -281,97 +204,7 @@ export const YieldEnterModal = memo( return bnOrZero(cryptoAmount).lt(minDeposit) }, [cryptoAmount, minDeposit]) - const txArguments = useMemo(() => { - if (!yieldItem || !userAddress || !chainId || !debouncedAmount) return null - if (!bnOrZero(debouncedAmount).gt(0)) return null - - const fields = yieldItem.mechanics.arguments.enter.fields - const fieldNames = new Set(fields.map(field => field.name)) - const args: Record = { amount: debouncedAmount } - - if (fieldNames.has('receiverAddress')) { - args.receiverAddress = userAddress - } - - if (fieldNames.has('validatorAddress') && chainId) { - args.validatorAddress = - selectedValidatorAddress || DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID[chainId] - } - - if (fieldNames.has('cosmosPubKey') && chainId === cosmosChainId) { - args.cosmosPubKey = userAddress - } - - return args - }, [yieldItem, userAddress, chainId, debouncedAmount, selectedValidatorAddress]) - - const { - data: quoteData, - isLoading: isQuoteLoading, - isFetching: isQuoteFetching, - } = useQuery({ - queryKey: ['yieldxyz', 'quote', 'enter', yieldItem.id, userAddress, txArguments], - queryFn: () => { - if (!txArguments || !userAddress || !yieldItem.id) throw new Error('Missing arguments') - return enterYield({ yieldId: yieldItem.id, address: userAddress, arguments: txArguments }) - }, - enabled: - !!txArguments && !!wallet && !!accountId && isOpen && bnOrZero(debouncedAmount).gt(0), - staleTime: 30_000, - gcTime: 60_000, - retry: false, - }) - - const approvalSpender = useMemo(() => { - if (!quoteData?.transactions) return null - const createdTransactions = quoteData.transactions.filter( - tx => tx.status === TransactionStatus.Created, - ) - const approvalTx = createdTransactions.find(isApprovalTransaction) - if (!approvalTx) return null - return getSpenderFromApprovalTx(approvalTx) - }, [quoteData?.transactions]) - - const allowanceQuery = useAllowance({ - assetId: inputTokenAssetId, - spender: approvalSpender ?? undefined, - from: userAddress || undefined, - isDisabled: !approvalSpender || !isUsdtApprovalResetEnabled, - isRefetchEnabled: true, - }) - - const isUsdtResetRequired = useMemo(() => { - if (!isUsdtApprovalResetEnabled) return false - if (!isUsdtOnEthereumMainnet(inputTokenAssetId, chainId)) return false - if (!approvalSpender) return false - if (!allowanceQuery.data) return false - return bnOrZero(allowanceQuery.data).gt(0) - }, [ - isUsdtApprovalResetEnabled, - inputTokenAssetId, - chainId, - approvalSpender, - allowanceQuery.data, - ]) - - // Check if we're waiting for USDT allowance check before we can determine reset requirement - const isAllowanceCheckPending = useMemo(() => { - if (!isUsdtApprovalResetEnabled) return false - if (!isUsdtOnEthereumMainnet(inputTokenAssetId, chainId)) return false - if (!approvalSpender) return false - // If we have an approval spender for USDT but allowance data hasn't loaded yet - return allowanceQuery.data === undefined && !allowanceQuery.isError - }, [ - isUsdtApprovalResetEnabled, - inputTokenAssetId, - chainId, - approvalSpender, - allowanceQuery.data, - allowanceQuery.isError, - ]) - const isLoading = isValidatorsLoading || !inputTokenAsset - const isQuoteActive = isQuoteLoading || isQuoteFetching || isAllowanceCheckPending const fiatAmount = useMemo( () => bnOrZero(cryptoAmount).times(marketData?.price ?? 0), @@ -437,20 +270,13 @@ export const YieldEnterModal = memo( ) const handleModalClose = useCallback(() => { - if (isSubmitting) return setCryptoAmount('') setSelectedPercent(null) setIsFiat(false) setSelectedAccountId(undefined) - setModalStep('input') - setTransactionSteps([]) - setActiveStepIndex(-1) - setRawTransactions([]) - setCurrentActionId(null) - setResetTxHash(null) queryClient.removeQueries({ queryKey: ['yieldxyz', 'quote', 'enter', yieldItem.id] }) onClose() - }, [onClose, isSubmitting, queryClient, yieldItem.id]) + }, [onClose, queryClient, yieldItem.id]) const handleAccountChange = useCallback((newAccountId: string) => { setSelectedAccountId(newAccountId) @@ -458,345 +284,52 @@ export const YieldEnterModal = memo( setSelectedPercent(null) }, []) - const buildCosmosStakeArgs = useCallback((): CosmosStakeArgs | undefined => { - if (chainId !== cosmosChainId) return undefined - if (!inputTokenAsset) return undefined - - const validator = - selectedValidatorAddress || DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID[cosmosChainId] - if (!validator) return undefined - - return { - validator, - amountCryptoBaseUnit: bnOrZero(cryptoAmount) - .times(bnOrZero(10).pow(inputTokenAsset.precision)) - .toFixed(0), - action: 'stake', - } - }, [chainId, selectedValidatorAddress, cryptoAmount, inputTokenAsset]) - - const dispatchNotification = useCallback( - (tx: TransactionDto, txHash: string) => { - if (!chainId || !accountId) return - if (!yieldItem.token.assetId) return - - const isApproval = - tx.type?.toLowerCase() === 'approval' || tx.title?.toLowerCase().includes('approv') - const actionType = isApproval ? ActionType.Approve : ActionType.Deposit - - dispatch( - actionSlice.actions.upsertAction({ - id: uuidv4(), - type: actionType, - status: ActionStatus.Complete, - createdAt: Date.now(), - updatedAt: Date.now(), - transactionMetadata: { - displayType: isApproval - ? GenericTransactionDisplayType.Approve - : GenericTransactionDisplayType.Yield, - txHash, - chainId, - assetId: yieldItem.token.assetId, - accountId, - message: isApproval - ? 'actionCenter.approve.approvalTxComplete' - : 'actionCenter.deposit.complete', - amountCryptoPrecision: cryptoAmount, - contractName: yieldItem.metadata.name, - chainName: yieldItem.network, - }, - }), - ) - }, - [dispatch, chainId, accountId, yieldItem, cryptoAmount], - ) - - const updateStepStatus = useCallback((index: number, updates: Partial) => { - setTransactionSteps(prev => prev.map((s, i) => (i === index ? { ...s, ...updates } : s))) - }, []) - - const executeResetAllowance = useCallback(async () => { - if (!wallet || !accountId || !inputTokenAssetId || !approvalSpender) { - throw new Error(translate('yieldXYZ.errors.walletNotConnected')) - } - - setIsSubmitting(true) - updateStepStatus(0, { - status: 'loading', - loadingMessage: translate('yieldXYZ.loading.signInWallet'), - }) - - try { - const txHash = await reactQueries.mutations - .approve({ - assetId: inputTokenAssetId, - spender: approvalSpender, - amountCryptoBaseUnit: '0', - accountNumber: accountMetadata?.bip44Params?.accountNumber ?? 0, - wallet, - from: userAddress, - }) - .mutationFn() - - if (!txHash) throw new Error(translate('yieldXYZ.errors.broadcastFailed')) - - setResetTxHash(txHash) - const txUrl = feeAsset?.explorerTxLink ? `${feeAsset.explorerTxLink}${txHash}` : '' - updateStepStatus(0, { txHash, txUrl, loadingMessage: translate('common.confirming') }) - - const publicClient = assertGetViemClient(ethChainId) - await publicClient.waitForTransactionReceipt({ hash: txHash as Hash }) - - await allowanceQuery.refetch() - updateStepStatus(0, { status: 'success', loadingMessage: undefined }) - setActiveStepIndex(1) - } catch (error) { - toast({ - title: translate('yieldXYZ.errors.transactionFailedTitle'), - description: - error instanceof Error - ? error.message - : translate('yieldXYZ.errors.transactionFailedDescription'), - status: 'error', - duration: 5000, - isClosable: true, - }) - updateStepStatus(0, { status: 'failed', loadingMessage: undefined }) - } finally { - setIsSubmitting(false) - } - }, [ - wallet, + const { + step, + transactionSteps, + displaySteps, + isSubmitting, + activeStepIndex, + handleConfirm, + handleClose: hookHandleClose, + isQuoteLoading, + quoteData, + isAllowanceCheckPending, + isUsdtResetRequired, + } = useYieldTransactionFlow({ + yieldItem, + action: 'enter', + amount: cryptoAmount, + assetSymbol: inputTokenAsset?.symbol ?? '', + onClose: handleModalClose, + isOpen, + validatorAddress: selectedValidatorAddress, accountId, - inputTokenAssetId, - approvalSpender, - userAddress, - accountMetadata?.bip44Params?.accountNumber, - feeAsset?.explorerTxLink, - translate, - updateStepStatus, - toast, - allowanceQuery, - ]) - - const executeSingleTransaction = useCallback( - async ( - tx: TransactionDto, - yieldTxIndex: number, - uiStepIndex: number, - allTransactions: TransactionDto[], - actionId: string, - ) => { - if (!wallet || !accountId || !chainId) { - throw new Error(translate('yieldXYZ.errors.walletNotConnected')) - } - - updateStepStatus(uiStepIndex, { - status: 'loading', - loadingMessage: translate('yieldXYZ.loading.signInWallet'), - }) - setIsSubmitting(true) - - try { - const txHash = await executeTransaction({ - tx, - chainId, - wallet, - accountId, - userAddress, - bip44Params: accountMetadata?.bip44Params, - cosmosStakeArgs: buildCosmosStakeArgs(), - }) - - if (!txHash) throw new Error(translate('yieldXYZ.errors.broadcastFailed')) - - const txUrl = feeAsset?.explorerTxLink ? `${feeAsset.explorerTxLink}${txHash}` : '' - updateStepStatus(uiStepIndex, { - txHash, - txUrl, - loadingMessage: translate('common.confirming'), - }) - - await submitHashMutation.mutateAsync({ - transactionId: tx.id, - hash: txHash, - yieldId: yieldItem.id, - address: userAddress, - }) - - const isLastTransaction = yieldTxIndex + 1 >= allTransactions.length - - if (isLastTransaction) { - await waitForActionCompletion(actionId) - await queryClient.refetchQueries({ queryKey: ['yieldxyz', 'allBalances'] }) - await queryClient.refetchQueries({ queryKey: ['yieldxyz', 'yields'] }) - - if (chainId && SECOND_CLASS_CHAINS.includes(chainId as KnownChainIds)) { - dispatch( - portfolioApi.endpoints.getAccount.initiate( - { accountId, upsertOnFetch: true }, - { forceRefetch: true }, - ), - ) - } - - dispatchNotification(tx, txHash) - updateStepStatus(uiStepIndex, { status: 'success', loadingMessage: undefined }) - setModalStep('success') - } else { - const confirmedAction = await waitForTransactionConfirmation(actionId, tx.id) - const nextTx = confirmedAction.transactions.find( - t => t.status === TransactionStatus.Created && t.stepIndex === yieldTxIndex + 1, - ) - - if (nextTx) { - updateStepStatus(uiStepIndex, { status: 'success', loadingMessage: undefined }) - setRawTransactions(prev => prev.map((t, i) => (i === yieldTxIndex + 1 ? nextTx : t))) - setActiveStepIndex(uiStepIndex + 1) - } else { - await waitForActionCompletion(actionId) - await queryClient.refetchQueries({ queryKey: ['yieldxyz', 'allBalances'] }) - await queryClient.refetchQueries({ queryKey: ['yieldxyz', 'yields'] }) - - if (chainId && SECOND_CLASS_CHAINS.includes(chainId as KnownChainIds)) { - dispatch( - portfolioApi.endpoints.getAccount.initiate( - { accountId, upsertOnFetch: true }, - { forceRefetch: true }, - ), - ) - } - - dispatchNotification(tx, txHash) - updateStepStatus(uiStepIndex, { status: 'success', loadingMessage: undefined }) - setModalStep('success') - } - } - } catch (error) { - toast({ - title: translate('yieldXYZ.errors.transactionFailedTitle'), - description: - error instanceof Error - ? error.message - : translate('yieldXYZ.errors.transactionFailedDescription'), - status: 'error', - duration: 5000, - isClosable: true, - }) - updateStepStatus(uiStepIndex, { status: 'failed', loadingMessage: undefined }) - } finally { - setIsSubmitting(false) - } - }, - [ - wallet, - accountId, - chainId, - userAddress, - accountMetadata?.bip44Params, - feeAsset?.explorerTxLink, - translate, - updateStepStatus, - buildCosmosStakeArgs, - submitHashMutation, - yieldItem.id, - queryClient, - dispatchNotification, - dispatch, - toast, - ], - ) - - const handleExecute = useCallback(async () => { - // Handle USDT reset step if required and not yet done - const shouldExecuteReset = isUsdtResetRequired && activeStepIndex === 0 && !resetTxHash - - if (shouldExecuteReset) { - await executeResetAllowance() - return - } - - // Calculate the yield transaction index (offset by 1 if we had a reset step) - // Use resetTxHash as indicator, not isUsdtResetRequired (which changes to false after reset) - const hadResetStep = Boolean(resetTxHash) - const yieldStepIndex = hadResetStep ? activeStepIndex - 1 : activeStepIndex - - // If we're in the middle of a multi-step flow, execute the next step - const hasYieldTx = yieldStepIndex >= 0 && rawTransactions[yieldStepIndex] && currentActionId - - if (hasYieldTx) { - await executeSingleTransaction( - rawTransactions[yieldStepIndex], - yieldStepIndex, - activeStepIndex, - rawTransactions, - currentActionId, - ) - return - } - - // Initial execution - set up and execute first transaction - if (!wallet || !accountId || !chainId || !quoteData || !inputTokenAsset) return + }) - const transactions = filterExecutableTransactions(quoteData.transactions) + const isQuoteActive = isQuoteLoading || isAllowanceCheckPending - if (transactions.length === 0) { - setModalStep('success') - return + useEffect(() => { + if (step === ModalStep.Success) { + handleModalClose() } + }, [step, handleModalClose]) - setCurrentActionId(quoteData.id) - setRawTransactions(transactions) - - // Build transaction steps with reset step if needed - const steps: TransactionStep[] = [] - - if (isUsdtResetRequired) { - steps.push({ - title: translate('yieldXYZ.resetAllowance'), - originalTitle: 'Reset Allowance', - type: 'RESET', - status: 'pending', - }) + const successProviderInfo = useMemo(() => { + if (isStaking && selectedValidatorMetadata) { + return { + name: selectedValidatorMetadata.name, + logoURI: selectedValidatorMetadata.logoURI, + } } - - steps.push( - ...transactions.map((tx, i) => ({ - title: formatYieldTxTitle( - tx.title || translate('yieldXYZ.transactionNumber', { number: i + 1 }), - inputTokenAsset.symbol, - ), - originalTitle: tx.title || '', - type: tx.type, - status: 'pending' as const, - })), - ) - - setTransactionSteps(steps) - setActiveStepIndex(0) - - // Execute first step (reset if required, otherwise first yield tx) - if (isUsdtResetRequired) { - await executeResetAllowance() - } else { - await executeSingleTransaction(transactions[0], 0, 0, transactions, quoteData.id) + if (providerMetadata) { + return { + name: providerMetadata.name, + logoURI: providerMetadata.logoURI, + } } - }, [ - isUsdtResetRequired, - activeStepIndex, - resetTxHash, - executeResetAllowance, - rawTransactions, - currentActionId, - wallet, - accountId, - chainId, - quoteData, - inputTokenAsset, - translate, - executeSingleTransaction, - ]) + return null + }, [isStaking, selectedValidatorMetadata, providerMetadata]) const enterButtonDisabled = useMemo( () => @@ -809,29 +342,23 @@ export const YieldEnterModal = memo( if (!isConnected) return translate('common.connectWallet') if (isQuoteActive) return translate('yieldXYZ.loadingQuote') - // During execution, show the current step's action if (isSubmitting && transactionSteps.length > 0) { const activeStep = transactionSteps.find(s => s.status !== 'success') if (activeStep) return getTransactionButtonText(activeStep.type, activeStep.originalTitle) } - // In multi-step flow (waiting for next click) if (activeStepIndex >= 0 && transactionSteps[activeStepIndex]) { const currentStep = transactionSteps[activeStepIndex] return getTransactionButtonText(currentStep.type, currentStep.originalTitle) } - // Before execution - show reset if required, otherwise first yield tx if (isUsdtResetRequired) { return translate('yieldXYZ.resetAllowance') } - const firstCreatedTx = quoteData?.transactions?.find( - tx => tx.status === TransactionStatus.Created, - ) + const firstCreatedTx = quoteData?.transactions?.find(tx => tx.status === 'CREATED') if (firstCreatedTx) return getTransactionButtonText(firstCreatedTx.type, firstCreatedTx.title) - // Fallback to generic enter text return translate('yieldXYZ.enterAsset', { asset: inputTokenAsset?.symbol }) }, [ isConnected, @@ -846,39 +373,9 @@ export const YieldEnterModal = memo( ]) const modalTitle = useMemo(() => { - if (modalStep === 'success') return translate('common.success') + if (step === ModalStep.Success) return translate('common.success') return translate('yieldXYZ.enterAsset', { asset: inputTokenAsset?.symbol }) - }, [translate, inputTokenAsset?.symbol, modalStep]) - - const previewSteps = useMemo((): TransactionStep[] => { - if (!quoteData?.transactions?.length || !inputTokenAsset) return [] - // Don't show preview steps while still checking if USDT reset is needed - if (isAllowanceCheckPending) return [] - - const steps: TransactionStep[] = [] - - if (isUsdtResetRequired) { - steps.push({ - title: translate('yieldXYZ.resetAllowance'), - originalTitle: 'Reset Allowance', - type: 'RESET', - status: 'pending', - }) - } - - steps.push( - ...quoteData.transactions - .filter(tx => tx.status === TransactionStatus.Created) - .map((tx, i) => ({ - title: formatYieldTxTitle(tx.title || `Transaction ${i + 1}`, inputTokenAsset.symbol), - originalTitle: tx.title || '', - type: tx.type, - status: 'pending' as const, - })), - ) - - return steps - }, [quoteData, inputTokenAsset, isUsdtResetRequired, isAllowanceCheckPending, translate]) + }, [translate, inputTokenAsset?.symbol, step]) const percentButtons = useMemo( () => ( @@ -1049,156 +546,108 @@ export const YieldEnterModal = memo( fiatAmount, ]) - const { getInstance, fireConfetti, confettiStyle } = useConfetti() - - useEffect(() => { - if (modalStep === 'success') fireConfetti() - }, [modalStep, fireConfetti]) - - const successProviderInfo = useMemo(() => { - if (isStaking && selectedValidatorMetadata) { - return { - name: selectedValidatorMetadata.name, - logoURI: selectedValidatorMetadata.logoURI, - } - } - if (providerMetadata) { - return { - name: providerMetadata.name, - logoURI: providerMetadata.logoURI, - } - } - return null - }, [isStaking, selectedValidatorMetadata, providerMetadata]) + const isInProgress = step === ModalStep.InProgress + const isSuccess = step === ModalStep.Success const successContent = useMemo( () => ( - - - - - - - {translate('yieldXYZ.success')} - - - {translate('yieldXYZ.successEnter', { - amount: cryptoAmount, - symbol: inputTokenAsset?.symbol, - })} - - - {successProviderInfo && ( - - - - {successProviderInfo.name} - - - )} - - - - + ), - [translate, cryptoAmount, inputTokenAsset?.symbol, successProviderInfo, transactionSteps], + [ + cryptoAmount, + inputTokenAsset?.symbol, + successProviderInfo, + transactionSteps, + yieldItem.id, + hookHandleClose, + ], + ) + + const stepsToShow = activeStepIndex >= 0 ? transactionSteps : displaySteps + + const dialogOnClose = useMemo( + () => (isSubmitting ? () => {} : hookHandleClose), + [isSubmitting, hookHandleClose], ) return ( - <> - - - - {null} - - {modalTitle} - - - - - - - {modalStep === 'input' && ( - - {inputContent} - {percentButtons} - {inputTokenAssetId && accountId && ( - - - - )} - {statsContent} - {activeStepIndex >= 0 ? ( - - ) : ( - previewSteps.length > 0 && - )} - - )} - {modalStep === 'success' && successContent} - - {modalStep === 'input' && ( - - - - )} - {modalStep === 'success' && ( - - - + + + {null} + + {modalTitle} + + + + + + + {isInProgress && ( + + {inputContent} + {percentButtons} + {inputTokenAssetId && accountId && ( + + + + )} + {statsContent} + {stepsToShow.length > 0 && } + )} - - + {isSuccess && successContent} + + {isInProgress && ( + + + + )} + {isSuccess && ( + + + + )} + ) }, ) diff --git a/src/pages/Yields/components/YieldSuccess.tsx b/src/pages/Yields/components/YieldSuccess.tsx new file mode 100644 index 00000000000..588d1469551 --- /dev/null +++ b/src/pages/Yields/components/YieldSuccess.tsx @@ -0,0 +1,139 @@ +import { Avatar, Box, Button, Flex, Heading, Icon, Text, VStack } from '@chakra-ui/react' +import { memo, useCallback, useEffect, useMemo } from 'react' +import ReactCanvasConfetti from 'react-canvas-confetti' +import { FaCheck } from 'react-icons/fa' +import { useTranslate } from 'react-polyglot' +import { useNavigate } from 'react-router-dom' + +import { useConfetti } from '../hooks/useConfetti' +import type { TransactionStep } from '../hooks/useYieldTransactionFlow' +import { TransactionStepsList } from './TransactionStepsList' + +type ProviderInfo = { + name: string + logoURI: string | undefined +} + +type YieldSuccessProps = { + amount: string + symbol: string + providerInfo: ProviderInfo | null + transactionSteps: TransactionStep[] + yieldId?: string + onDone: () => void + showConfetti?: boolean + successMessageKey?: 'successEnter' | 'successExit' | 'successClaim' +} + +export const YieldSuccess = memo( + ({ + amount, + symbol, + providerInfo, + transactionSteps, + yieldId, + onDone, + showConfetti = true, + successMessageKey = 'successEnter', + }: YieldSuccessProps) => { + const translate = useTranslate() + const navigate = useNavigate() + const { getInstance, fireConfetti, confettiStyle } = useConfetti() + + useEffect(() => { + if (showConfetti) fireConfetti() + }, [showConfetti, fireConfetti]) + + const handleViewPosition = useCallback(() => { + if (!yieldId) return + navigate(`/yields/${yieldId}`) + }, [yieldId, navigate]) + + const providerPillProps = useMemo( + () => + yieldId + ? { + cursor: 'pointer' as const, + onClick: handleViewPosition, + _hover: { bg: 'background.surface.raised.hover' }, + transition: 'background 0.2s', + } + : {}, + [yieldId, handleViewPosition], + ) + + return ( + <> + + + + + + + + + {translate('yieldXYZ.success')} + + + {translate(`yieldXYZ.${successMessageKey}`, { amount, symbol })} + + + + {providerInfo && ( + + + {translate('yieldXYZ.via')} + + + + {providerInfo.name} + + + )} + + {transactionSteps.length > 0 && ( + + + + )} + + + {yieldId && ( + + )} + + + + + ) + }, +) diff --git a/src/pages/Yields/hooks/useYieldTransactionFlow.ts b/src/pages/Yields/hooks/useYieldTransactionFlow.ts index c4c3973948a..801ee375854 100644 --- a/src/pages/Yields/hooks/useYieldTransactionFlow.ts +++ b/src/pages/Yields/hooks/useYieldTransactionFlow.ts @@ -145,6 +145,7 @@ type UseYieldTransactionFlowProps = { validatorAddress?: string passthrough?: string manageActionType?: string + accountId?: string } export const useYieldTransactionFlow = ({ @@ -157,6 +158,7 @@ export const useYieldTransactionFlow = ({ validatorAddress, passthrough, manageActionType, + accountId: accountIdProp, }: UseYieldTransactionFlowProps) => { const dispatch = useAppDispatch() const queryClient = useQueryClient() @@ -177,19 +179,19 @@ export const useYieldTransactionFlow = ({ const isUsdtApprovalResetEnabled = useFeatureFlag('UsdtApprovalReset') const submitHashMutation = useSubmitYieldTransactionHash() - const inputTokenAssetId = useMemo( - () => yieldItem.inputTokens[0]?.assetId, - [yieldItem.inputTokens], - ) + const inputTokenAssetId = useMemo(() => yieldItem.inputTokens?.[0]?.assetId, [yieldItem]) const { chainId: yieldChainId } = yieldItem - const { accountNumber } = useYieldAccount() + const { accountNumber: contextAccountNumber } = useYieldAccount() - const accountId = useAppSelector(state => { + const derivedAccountId = useAppSelector(state => { + if (accountIdProp) return undefined if (!yieldChainId) return undefined - return selectAccountIdByAccountNumberAndChainId(state)[accountNumber]?.[yieldChainId] + return selectAccountIdByAccountNumberAndChainId(state)[contextAccountNumber]?.[yieldChainId] }) + const accountId = accountIdProp ?? derivedAccountId + const feeAsset = useAppSelector(state => yieldChainId ? selectFeeAssetByChainId(state, yieldChainId) : undefined, ) @@ -268,7 +270,7 @@ export const useYieldTransactionFlow = ({ return fn({ yieldId: yieldItem.id, address: userAddress, arguments: txArguments }) }, enabled: !!txArguments && !!wallet && !!accountId && canSubmit && isOpen, - staleTime: Infinity, + staleTime: 0, gcTime: 0, retry: false, }) @@ -327,6 +329,43 @@ export const useYieldTransactionFlow = ({ allowanceQuery.isError, ]) + const displaySteps = useMemo((): TransactionStep[] => { + if (transactionSteps.length > 0) { + return transactionSteps + } + if (isAllowanceCheckPending) return [] + if (quoteData?.transactions?.length) { + const steps: TransactionStep[] = [] + if (isUsdtResetRequired) { + steps.push({ + title: translate('yieldXYZ.resetAllowance'), + originalTitle: 'Reset Allowance', + type: 'RESET', + status: 'pending' as const, + }) + } + steps.push( + ...quoteData.transactions + .filter(tx => tx.status === TransactionStatus.Created) + .map((tx, i) => ({ + title: formatYieldTxTitle(tx.title || `Transaction ${i + 1}`, assetSymbol), + originalTitle: tx.title || '', + type: tx.type, + status: 'pending' as const, + })), + ) + return steps + } + return [] + }, [ + transactionSteps, + quoteData, + assetSymbol, + isAllowanceCheckPending, + isUsdtResetRequired, + translate, + ]) + const updateStepStatus = useCallback((index: number, updates: Partial) => { setTransactionSteps(prev => prev.map((s, i) => (i === index ? { ...s, ...updates } : s))) }, []) @@ -419,21 +458,15 @@ export const useYieldTransactionFlow = ({ const validator = validatorAddress || DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID[cosmosChainId] if (!validator) return undefined - const inputTokenDecimals = yieldItem.inputTokens[0]?.decimals ?? yieldItem.token.decimals + const inputTokenDecimals = + yieldItem.inputTokens?.[0]?.decimals ?? yieldItem.token?.decimals ?? 18 return { validator, amountCryptoBaseUnit: bnOrZero(amount).times(bnOrZero(10).pow(inputTokenDecimals)).toFixed(0), action: action === 'enter' ? 'stake' : action === 'exit' ? 'unstake' : 'claim', } - }, [ - yieldChainId, - validatorAddress, - amount, - yieldItem.inputTokens, - yieldItem.token.decimals, - action, - ]) + }, [yieldChainId, validatorAddress, amount, yieldItem, action]) const executeResetAllowance = useCallback(async () => { if (!wallet || !accountId || !inputTokenAssetId || !approvalSpender) { @@ -443,7 +476,6 @@ export const useYieldTransactionFlow = ({ setIsSubmitting(true) updateStepStatus(0, { status: 'loading', - loadingMessage: translate('yieldXYZ.loading.signInWallet'), }) try { @@ -452,7 +484,7 @@ export const useYieldTransactionFlow = ({ assetId: inputTokenAssetId, spender: approvalSpender, amountCryptoBaseUnit: '0', - accountNumber, + accountNumber: accountMetadata?.bip44Params?.accountNumber ?? 0, wallet, from: userAddress, }) @@ -491,7 +523,7 @@ export const useYieldTransactionFlow = ({ accountId, inputTokenAssetId, approvalSpender, - accountNumber, + accountMetadata?.bip44Params?.accountNumber, userAddress, feeAsset?.explorerTxLink, translate, @@ -514,7 +546,6 @@ export const useYieldTransactionFlow = ({ updateStepStatus(uiStepIndex, { status: 'loading', - loadingMessage: translate('yieldXYZ.loading.signInWallet'), }) setIsSubmitting(true) @@ -566,6 +597,7 @@ export const useYieldTransactionFlow = ({ } dispatchNotification(tx, txHash) updateStepStatus(uiStepIndex, { status: 'success', loadingMessage: undefined }) + queryClient.removeQueries({ queryKey: ['yieldxyz', 'quote'] }) setStep(ModalStep.Success) } else { const confirmedAction = await waitForTransactionConfirmation(actionId, tx.id) @@ -595,6 +627,7 @@ export const useYieldTransactionFlow = ({ } dispatchNotification(tx, txHash) updateStepStatus(uiStepIndex, { status: 'success', loadingMessage: undefined }) + queryClient.removeQueries({ queryKey: ['yieldxyz', 'quote'] }) setStep(ModalStep.Success) } } @@ -776,6 +809,7 @@ export const useYieldTransactionFlow = ({ () => ({ step, transactionSteps, + displaySteps, isSubmitting, activeStepIndex, canSubmit, @@ -789,6 +823,7 @@ export const useYieldTransactionFlow = ({ [ step, transactionSteps, + displaySteps, isSubmitting, activeStepIndex, canSubmit, diff --git a/src/state/reducer.ts b/src/state/reducer.ts index ffeaf6cc684..363a1080d99 100644 --- a/src/state/reducer.ts +++ b/src/state/reducer.ts @@ -49,6 +49,7 @@ import { txHistory, txHistoryApi } from './slices/txHistorySlice/txHistorySlice' import { gridplusSlice } from '@/state/slices/gridplusSlice/gridplusSlice' import type { GridPlusState } from '@/state/slices/gridplusSlice/types' +import { tradeEarnInput } from '@/state/slices/tradeEarnInputSlice/tradeEarnInputSlice' import { tradeQuoteSlice } from '@/state/slices/tradeQuoteSlice/tradeQuoteSlice' import { tradeRampInput } from '@/state/slices/tradeRampInputSlice/tradeRampInputSlice' @@ -62,6 +63,7 @@ export const slices = { tradeInput, limitOrderInput, tradeRampInput, + tradeEarnInput, tradeQuote: tradeQuoteSlice, limitOrder: limitOrderSlice, snapshot, @@ -166,6 +168,7 @@ export const sliceReducers = { tradeInput: tradeInput.reducer, limitOrderInput: limitOrderInput.reducer, tradeRampInput: tradeRampInput.reducer, + tradeEarnInput: tradeEarnInput.reducer, opportunities: persistReducer( opportunitiesPersistConfig, opportunities.reducer, diff --git a/src/state/slices/preferencesSlice/preferencesSlice.ts b/src/state/slices/preferencesSlice/preferencesSlice.ts index 3577b5a0669..41d2c8f3c8b 100644 --- a/src/state/slices/preferencesSlice/preferencesSlice.ts +++ b/src/state/slices/preferencesSlice/preferencesSlice.ts @@ -113,6 +113,7 @@ export type FeatureFlags = { YieldXyz: boolean YieldsPage: boolean YieldMultiAccount: boolean + EarnTab: boolean } export type Flag = keyof FeatureFlags @@ -260,6 +261,7 @@ const initialState: Preferences = { YieldXyz: getConfig().VITE_FEATURE_YIELD_XYZ, YieldsPage: getConfig().VITE_FEATURE_YIELDS_PAGE, YieldMultiAccount: getConfig().VITE_FEATURE_YIELD_MULTI_ACCOUNT, + EarnTab: getConfig().VITE_FEATURE_EARN_TAB, }, selectedLocale: simpleLocale(), hasWalletSeenTcyClaimAlert: {}, diff --git a/src/state/slices/tradeEarnInputSlice/selectors.ts b/src/state/slices/tradeEarnInputSlice/selectors.ts new file mode 100644 index 00000000000..31c23613557 --- /dev/null +++ b/src/state/slices/tradeEarnInputSlice/selectors.ts @@ -0,0 +1,41 @@ +import { createSelector } from '@reduxjs/toolkit' + +import { createTradeInputBaseSelectors } from '../common/tradeInputBase/createTradeInputBaseSelectors' + +import { bnOrZero } from '@/lib/bignumber/bignumber' +import type { TradeEarnInputState } from '@/state/slices/tradeEarnInputSlice/tradeEarnInputSlice' + +export const { + selectInputBuyAsset, + selectInputSellAsset, + selectInputBuyAssetUserCurrencyRate, + selectInputSellAssetUserCurrencyRate, + selectSellAccountId, + selectBuyAccountId, + selectInputSellAmountCryptoBaseUnit, + selectManualReceiveAddress, + selectIsManualReceiveAddressValidating, + selectIsManualReceiveAddressEditing, + selectIsManualReceiveAddressValid, + selectInputSellAmountUsd, + selectInputSellAmountUserCurrency, + selectSellAssetBalanceCryptoBaseUnit, + selectIsInputtingFiatSellAmount, + selectInputSellAmountCryptoPrecision, + selectSelectedSellAssetChainId, + selectSelectedBuyAssetChainId, + selectHasUserEnteredAmount, + ...privateSelectors +} = createTradeInputBaseSelectors('tradeEarnInput') + +const { selectBaseSlice } = privateSelectors + +export const selectSelectedYieldId = createSelector( + selectBaseSlice, + tradeEarnInput => tradeEarnInput.selectedYieldId, +) + +export const selectEarnHasUserEnteredAmount = createSelector( + selectInputSellAmountCryptoPrecision, + sellAmountCryptoPrecision => bnOrZero(sellAmountCryptoPrecision).gt(0), +) diff --git a/src/state/slices/tradeEarnInputSlice/tradeEarnInputSlice.ts b/src/state/slices/tradeEarnInputSlice/tradeEarnInputSlice.ts new file mode 100644 index 00000000000..8d471081070 --- /dev/null +++ b/src/state/slices/tradeEarnInputSlice/tradeEarnInputSlice.ts @@ -0,0 +1,57 @@ +import type { PayloadAction } from '@reduxjs/toolkit' +import type { AccountId, ChainId } from '@shapeshiftoss/caip' +import type { Asset } from '@shapeshiftoss/types' + +import { defaultAsset } from '../assetsSlice/assetsSlice' +import type { BaseReducers } from '../common/tradeInputBase/createTradeInputBaseSlice' +import { createTradeInputBaseSlice } from '../common/tradeInputBase/createTradeInputBaseSlice' + +export type TradeEarnInputState = { + buyAsset: Asset + sellAsset: Asset + sellAccountId: AccountId | undefined + buyAccountId: AccountId | undefined + sellAmountCryptoPrecision: string + isInputtingFiatSellAmount: boolean + manualReceiveAddress: string | undefined + isManualReceiveAddressValidating: boolean + isManualReceiveAddressEditing: boolean + isManualReceiveAddressValid: boolean | undefined + selectedSellAssetChainId: ChainId | 'All' + selectedBuyAssetChainId: ChainId | 'All' + selectedYieldId: string | undefined +} + +const initialState: TradeEarnInputState = { + buyAsset: defaultAsset, + sellAsset: defaultAsset, + sellAccountId: undefined, + buyAccountId: undefined, + sellAmountCryptoPrecision: '', + isInputtingFiatSellAmount: false, + manualReceiveAddress: undefined, + isManualReceiveAddressValidating: false, + isManualReceiveAddressValid: undefined, + isManualReceiveAddressEditing: false, + selectedSellAssetChainId: 'All', + selectedBuyAssetChainId: 'All', + selectedYieldId: undefined, +} + +export const tradeEarnInput = createTradeInputBaseSlice({ + name: 'tradeEarnInput', + initialState, + extraReducers: (baseReducers: BaseReducers) => ({ + setSelectedYieldId: (state: TradeEarnInputState, action: PayloadAction) => { + state.selectedYieldId = action.payload + }, + setSellAssetWithYieldReset: (state: TradeEarnInputState, action: PayloadAction) => { + baseReducers.setSellAsset(state, action) + state.selectedYieldId = undefined + state.isInputtingFiatSellAmount = false + }, + }), + selectors: { + selectSelectedYieldId: state => state.selectedYieldId, + }, +}) diff --git a/src/state/store.ts b/src/state/store.ts index 7b17f864839..b3e7cf0615d 100644 --- a/src/state/store.ts +++ b/src/state/store.ts @@ -53,6 +53,7 @@ export const clearState = () => { store.dispatch(slices.tradeInput.actions.clear()) store.dispatch(slices.localWallet.actions.clear()) store.dispatch(slices.limitOrderInput.actions.clear()) + store.dispatch(slices.tradeEarnInput.actions.clear()) store.dispatch(slices.limitOrder.actions.clear()) store.dispatch(slices.gridplus.actions.clear()) store.dispatch(slices.addressBook.actions.clear()) diff --git a/src/test/mocks/store.ts b/src/test/mocks/store.ts index bec51689460..3d06288f098 100644 --- a/src/test/mocks/store.ts +++ b/src/test/mocks/store.ts @@ -186,6 +186,7 @@ export const mockStore: ReduxState = { YieldXyz: false, YieldsPage: false, YieldMultiAccount: false, + EarnTab: false, }, showTopAssetsCarousel: true, quickBuyAmounts: [10, 50, 100], @@ -348,6 +349,21 @@ export const mockStore: ReduxState = { selectedBuyFiatRampQuote: null, selectedSellFiatRampQuote: null, }, + tradeEarnInput: { + buyAsset: defaultAsset, + sellAsset: defaultAsset, + sellAccountId: undefined, + buyAccountId: undefined, + sellAmountCryptoPrecision: '0', + isInputtingFiatSellAmount: false, + manualReceiveAddress: undefined, + isManualReceiveAddressValidating: false, + isManualReceiveAddressEditing: false, + isManualReceiveAddressValid: undefined, + selectedBuyAssetChainId: 'All', + selectedSellAssetChainId: 'All', + selectedYieldId: undefined, + }, tradeQuote: { activeQuoteMeta: undefined, confirmedQuote: undefined, From eac5579a86d4966611c811982064af1ba354bc28 Mon Sep 17 00:00:00 2001 From: gomes <17035424+gomesalexandre@users.noreply.github.com> Date: Tue, 13 Jan 2026 16:45:06 +0100 Subject: [PATCH 31/60] feat(yields): add metric clarity tooltips for Available to Earn, APY, and Potential Earnings --- src/assets/translations/en/main.json | 3 ++ .../components/YieldOpportunityStats.tsx | 49 ++++++++++++++----- 2 files changed, 40 insertions(+), 12 deletions(-) diff --git a/src/assets/translations/en/main.json b/src/assets/translations/en/main.json index ae1c50d2548..27e25110e87 100644 --- a/src/assets/translations/en/main.json +++ b/src/assets/translations/en/main.json @@ -2752,6 +2752,9 @@ "availableToEarn": "Available to Earn", "idleAssetsEarning": "Idle assets that could be earning up to %{apy}% APY", "potentialEarnings": "Potential Earnings", + "potentialEarningsTooltip": "Estimated yearly earnings based on your idle balance × best available APY for each asset.", + "availableToEarnTooltip": "Total idle balance across assets that have yield opportunities available.", + "apyTooltip": "Annual percentage yield shown by the provider. Rates may fluctuate over time.", "perYear": "/yr", "earn": "Earn", "myBalance": "My Balance", diff --git a/src/pages/Yields/components/YieldOpportunityStats.tsx b/src/pages/Yields/components/YieldOpportunityStats.tsx index d3f60b86213..38720d69a28 100644 --- a/src/pages/Yields/components/YieldOpportunityStats.tsx +++ b/src/pages/Yields/components/YieldOpportunityStats.tsx @@ -2,6 +2,7 @@ import { Box, Button, Flex, + HStack, Icon, SimpleGrid, Stat, @@ -9,9 +10,10 @@ import { StatLabel, StatNumber, Text, + Tooltip, } from '@chakra-ui/react' import { memo, useMemo } from 'react' -import { FaChartPie, FaMoon } from 'react-icons/fa' +import { FaChartPie, FaInfoCircle, FaMoon } from 'react-icons/fa' import { useTranslate } from 'react-polyglot' import { Amount } from '@/components/Amount/Amount' @@ -209,13 +211,29 @@ export const YieldOpportunityStats = memo(function YieldOpportunityStats({ - {translate('yieldXYZ.availableToEarn')} + + {translate('yieldXYZ.availableToEarn')} + + + + + + - {translate('yieldXYZ.idleAssetsEarning', { apy: weightedApyFormatted })} + + + {translate('yieldXYZ.idleAssetsEarning', { apy: weightedApyFormatted })} + + + + + + + - - {translate('yieldXYZ.potentialEarnings')} - + + + {translate('yieldXYZ.potentialEarnings')} + + + + + + + {translate('yieldXYZ.perYear')} From 446d6660865cc0922ac81f1bef94a2665bf118c0 Mon Sep 17 00:00:00 2001 From: gomes <17035424+gomesalexandre@users.noreply.github.com> Date: Tue, 13 Jan 2026 16:49:04 +0100 Subject: [PATCH 32/60] feat(yields): add recommendation strip showing top 3 yield opportunities based on user holdings --- src/assets/translations/en/main.json | 2 + src/pages/Yields/components/YieldsList.tsx | 85 +++++++++++++++++++++- 2 files changed, 86 insertions(+), 1 deletion(-) diff --git a/src/assets/translations/en/main.json b/src/assets/translations/en/main.json index 27e25110e87..87081bb6873 100644 --- a/src/assets/translations/en/main.json +++ b/src/assets/translations/en/main.json @@ -2756,6 +2756,8 @@ "availableToEarnTooltip": "Total idle balance across assets that have yield opportunities available.", "apyTooltip": "Annual percentage yield shown by the provider. Rates may fluctuate over time.", "perYear": "/yr", + "recommendedForYou": "Recommended for you", + "viewAllOpportunities": "View all opportunities", "earn": "Earn", "myBalance": "My Balance", "providers": "Providers", diff --git a/src/pages/Yields/components/YieldsList.tsx b/src/pages/Yields/components/YieldsList.tsx index 6e8ba7c5174..95614d4f78b 100644 --- a/src/pages/Yields/components/YieldsList.tsx +++ b/src/pages/Yields/components/YieldsList.tsx @@ -2,6 +2,7 @@ import { SearchIcon } from '@chakra-ui/icons' import { Avatar, Box, + Button, Container, Flex, Heading, @@ -244,6 +245,38 @@ export const YieldsList = memo(() => { userCurrencyBalances, ]) + const recommendedYields = useMemo(() => { + if (!isConnected || !yields?.byInputAssetId || !userCurrencyBalances) return [] + + const recommendations: { + yield: AugmentedYieldDto + balanceFiat: ReturnType + potentialEarnings: ReturnType + }[] = [] + + for (const [assetId, balanceFiat] of Object.entries(userCurrencyBalances)) { + const yieldsForAsset = yields.byInputAssetId[assetId] + if (!yieldsForAsset?.length) continue + + const balance = bnOrZero(balanceFiat) + if (balance.lte(0)) continue + + const bestYield = yieldsForAsset.reduce((best, current) => + current.rewardRate.total > best.rewardRate.total ? current : best, + ) + + recommendations.push({ + yield: bestYield, + balanceFiat: balance, + potentialEarnings: balance.times(bestYield.rewardRate.total), + }) + } + + return recommendations + .sort((a, b) => b.potentialEarnings.minus(a.potentialEarnings).toNumber()) + .slice(0, 3) + }, [isConnected, yields?.byInputAssetId, userCurrencyBalances]) + const myPositions = useMemo(() => { if (!yields?.all || !allBalances) return [] const positions = yields.all.filter(yieldItem => { @@ -569,6 +602,53 @@ export const YieldsList = memo(() => { [filterSearchString, translate, yieldsByAsset], ) + const recommendedStripElement = useMemo(() => { + if (!isConnected || recommendedYields.length === 0 || isMyOpportunities) return null + + return ( + + + + {translate('yieldXYZ.recommendedForYou')} + + + + + {recommendedYields.map(rec => ( + handleYieldClick(rec.yield.id)} + /> + ))} + + + ) + }, [ + isConnected, + recommendedYields, + isMyOpportunities, + translate, + getProviderLogo, + handleYieldClick, + ]) + const allYieldsContentElement = useMemo(() => { if (isLoading) return viewMode === 'grid' ? allYieldsLoadingGridElement : allYieldsLoadingListElement @@ -747,7 +827,10 @@ export const YieldsList = memo(() => { - {allYieldsContentElement} + + {recommendedStripElement} + {allYieldsContentElement} + {positionsContentElement} From f3aa05aa9db2fbefdea14009b5a1e7f24559bb62 Mon Sep 17 00:00:00 2001 From: gomes <17035424+gomesalexandre@users.noreply.github.com> Date: Tue, 13 Jan 2026 17:01:24 +0100 Subject: [PATCH 33/60] feat(yields): add type filter and reposition recommendations --- src/assets/translations/en/main.json | 2 +- src/pages/Yields/YieldAssetDetails.tsx | 18 +++++++- src/pages/Yields/components/YieldFilters.tsx | 18 ++++++++ src/pages/Yields/components/YieldsList.tsx | 43 ++++++++++---------- src/pages/Yields/hooks/useYieldFilters.ts | 15 +++++++ 5 files changed, 73 insertions(+), 23 deletions(-) diff --git a/src/assets/translations/en/main.json b/src/assets/translations/en/main.json index 87081bb6873..f4298d2832e 100644 --- a/src/assets/translations/en/main.json +++ b/src/assets/translations/en/main.json @@ -2718,6 +2718,7 @@ "nameZA": "Name (Z-A)", "allNetworks": "All Networks", "allProviders": "All Providers", + "allTypes": "All Types", "showAll": "Show All", "searchValidator": "Search for validator", "enterYourToken": "Enter your %{symbol} to start earning yield securely.", @@ -2757,7 +2758,6 @@ "apyTooltip": "Annual percentage yield shown by the provider. Rates may fluctuate over time.", "perYear": "/yr", "recommendedForYou": "Recommended for you", - "viewAllOpportunities": "View all opportunities", "earn": "Earn", "myBalance": "My Balance", "providers": "Providers", diff --git a/src/pages/Yields/YieldAssetDetails.tsx b/src/pages/Yields/YieldAssetDetails.tsx index 28776aaf989..dfdda579e87 100644 --- a/src/pages/Yields/YieldAssetDetails.tsx +++ b/src/pages/Yields/YieldAssetDetails.tsx @@ -63,10 +63,12 @@ export const YieldAssetDetails = memo(() => { selectedNetwork, selectedProvider, sortOption, + selectedType, sorting, setSorting, handleNetworkChange, handleProviderChange, + handleTypeChange, handleSortChange, } = useYieldFilters() @@ -109,14 +111,25 @@ export const YieldAssetDetails = memo(() => { [assetYields, getProviderLogo], ) + // Types available for THIS asset + const types = useMemo( + () => + Array.from(new Set(assetYields.map(y => y.mechanics.type))).map(type => ({ + id: type, + name: type.charAt(0).toUpperCase() + type.slice(1).replace(/-/g, ' '), + })), + [assetYields], + ) + const filteredYields = useMemo( () => assetYields.filter(y => { if (selectedNetwork && y.network !== selectedNetwork) return false if (selectedProvider && y.providerId !== selectedProvider) return false + if (selectedType && y.mechanics.type !== selectedType) return false return true }), - [assetYields, selectedNetwork, selectedProvider], + [assetYields, selectedNetwork, selectedProvider, selectedType], ) const assetInfo = useMemo(() => { @@ -437,6 +450,9 @@ export const YieldAssetDetails = memo(() => { providers={providers} selectedProvider={selectedProvider} onSelectProvider={handleProviderChange} + types={types} + selectedType={selectedType} + onSelectType={handleTypeChange} sortOption={sortOption} onSortChange={handleSortChange} /> diff --git a/src/pages/Yields/components/YieldFilters.tsx b/src/pages/Yields/components/YieldFilters.tsx index 9838eb56fb0..1acf96a12a1 100644 --- a/src/pages/Yields/components/YieldFilters.tsx +++ b/src/pages/Yields/components/YieldFilters.tsx @@ -36,6 +36,11 @@ export type ProviderOption = { icon?: string } +export type TypeOption = { + id: string + name: string +} + type FilterMenuProps = { label: string value: string | null @@ -113,6 +118,9 @@ type YieldFiltersProps = { providers: ProviderOption[] selectedProvider: string | null onSelectProvider: (id: string | null) => void + types: TypeOption[] + selectedType: string | null + onSelectType: (id: string | null) => void sortOption: SortOption onSortChange: (option: SortOption) => void } & StackProps @@ -125,6 +133,9 @@ export const YieldFilters = memo( providers, selectedProvider, onSelectProvider, + types, + selectedType, + onSelectType, sortOption, onSortChange, ...props @@ -145,6 +156,7 @@ export const YieldFilters = memo( const allNetworksLabel = useMemo(() => translate('yieldXYZ.allNetworks'), [translate]) const allProvidersLabel = useMemo(() => translate('yieldXYZ.allProviders'), [translate]) + const allTypesLabel = useMemo(() => translate('yieldXYZ.allTypes'), [translate]) const renderNetworkIcon = useCallback( (opt: { id: string; name: string; icon?: string; chainId?: ChainId }) => { @@ -205,6 +217,12 @@ export const YieldFilters = memo( onSelect={onSelectProvider} renderIcon={renderProviderIcon} /> + { const { selectedNetwork, selectedProvider, + selectedType, sortOption, sorting: positionsSorting, setSorting: setPositionsSorting, handleNetworkChange, handleProviderChange, + handleTypeChange, handleSortChange, } = useYieldFilters() @@ -164,6 +165,15 @@ export const YieldsList = memo(() => { [yields, getProviderLogo], ) + const types = useMemo(() => { + if (!yields?.all) return [] + const uniqueTypes = [...new Set(yields.all.map(y => y.mechanics.type))] + return uniqueTypes.map(type => ({ + id: type, + name: type.charAt(0).toUpperCase() + type.slice(1).replace(/-/g, ' '), + })) + }, [yields]) + const yieldsByAsset = useMemo(() => { if (!yields?.assetGroups) return [] @@ -181,6 +191,8 @@ export const YieldsList = memo(() => { filteredYields = filteredYields.filter(y => y.network === selectedNetwork) if (selectedProvider) filteredYields = filteredYields.filter(y => y.providerId === selectedProvider) + if (selectedType) + filteredYields = filteredYields.filter(y => y.mechanics.type === selectedType) if (searchQuery) filteredYields = searchYields(filteredYields, searchQuery) if (filteredYields.length === 0) return null @@ -239,6 +251,7 @@ export const YieldsList = memo(() => { isMyOpportunities, selectedNetwork, selectedProvider, + selectedType, searchQuery, allBalances, sortOption, @@ -607,22 +620,9 @@ export const YieldsList = memo(() => { return ( - - - {translate('yieldXYZ.recommendedForYou')} - - - + + {translate('yieldXYZ.recommendedForYou')} + {recommendedYields.map(rec => ( { isConnected={isConnected} /> )} + {recommendedStripElement} { providers={providers} selectedProvider={selectedProvider} onSelectProvider={handleProviderChange} + types={types} + selectedType={selectedType} + onSelectType={handleTypeChange} sortOption={sortOption} onSortChange={handleSortChange} mb={0} @@ -827,10 +831,7 @@ export const YieldsList = memo(() => { - - {recommendedStripElement} - {allYieldsContentElement} - + {allYieldsContentElement} {positionsContentElement} diff --git a/src/pages/Yields/hooks/useYieldFilters.ts b/src/pages/Yields/hooks/useYieldFilters.ts index 6b2d61e13e0..af615661d79 100644 --- a/src/pages/Yields/hooks/useYieldFilters.ts +++ b/src/pages/Yields/hooks/useYieldFilters.ts @@ -10,6 +10,7 @@ export const useYieldFilters = () => { const selectedNetwork = useMemo(() => searchParams.get('network'), [searchParams]) const selectedProvider = useMemo(() => searchParams.get('provider'), [searchParams]) + const selectedType = useMemo(() => searchParams.get('type'), [searchParams]) const sortOption = useMemo( () => (searchParams.get('sort') as SortOption) || 'apy-desc', [searchParams], @@ -39,6 +40,18 @@ export const useYieldFilters = () => { [setSearchParams], ) + const handleTypeChange = useCallback( + (type: string | null) => { + setSearchParams(prev => { + const next = new URLSearchParams(prev) + if (!type) next.delete('type') + else next.set('type', type) + return next + }) + }, + [setSearchParams], + ) + const handleSortChange = useCallback( (option: SortOption) => { setSearchParams(prev => { @@ -78,11 +91,13 @@ export const useYieldFilters = () => { return { selectedNetwork, selectedProvider, + selectedType, sortOption, sorting, setSorting, handleNetworkChange, handleProviderChange, + handleTypeChange, handleSortChange, } } From 9b7f3f8caec70daad9f130022fb8332c846645c2 Mon Sep 17 00:00:00 2001 From: gomes <17035424+gomesalexandre@users.noreply.github.com> Date: Wed, 14 Jan 2026 00:19:03 +0100 Subject: [PATCH 34/60] Refine yields mobile cards and labels --- src/lib/yieldxyz/constants.ts | 3 + src/pages/Yields/YieldAssetDetails.tsx | 32 ++- src/pages/Yields/components/YieldItem.tsx | 160 ++++++++--- .../components/YieldOpportunityStats.tsx | 4 + src/pages/Yields/components/YieldsList.tsx | 271 +++++++++++------- 5 files changed, 312 insertions(+), 158 deletions(-) diff --git a/src/lib/yieldxyz/constants.ts b/src/lib/yieldxyz/constants.ts index 55a937d04ba..b97ba177b4d 100644 --- a/src/lib/yieldxyz/constants.ts +++ b/src/lib/yieldxyz/constants.ts @@ -91,3 +91,6 @@ export const COSMOS_ATOM_NATIVE_STAKING_YIELD_ID = 'cosmos-atom-native-staking' export const DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID: Partial> = { [cosmosChainId]: SHAPESHIFT_COSMOS_VALIDATOR_ADDRESS, } + + + diff --git a/src/pages/Yields/YieldAssetDetails.tsx b/src/pages/Yields/YieldAssetDetails.tsx index dfdda579e87..67a410d5471 100644 --- a/src/pages/Yields/YieldAssetDetails.tsx +++ b/src/pages/Yields/YieldAssetDetails.tsx @@ -10,6 +10,7 @@ import { SimpleGrid, Stat, Text, + useMediaQuery, } from '@chakra-ui/react' import type { ColumnDef, Row } from '@tanstack/react-table' import { getCoreRowModel, getSortedRowModel, useReactTable } from '@tanstack/react-table' @@ -41,6 +42,7 @@ export const YieldAssetDetails = memo(() => { const decodedSymbol = useMemo(() => decodeURIComponent(assetSymbol || ''), [assetSymbol]) const navigate = useNavigate() const translate = useTranslate() + const [isMobile] = useMediaQuery('(max-width: 768px)') const [searchParams, setSearchParams] = useSearchParams() const viewParam = useMemo(() => searchParams.get('view'), [searchParams]) @@ -339,9 +341,7 @@ export const YieldAssetDetails = memo(() => { {translate('yieldXYZ.assetYields', { asset: assetInfo.assetName })} - - {translate('yieldXYZ.opportunitiesAvailable', { count: assetYields.length })} - + ) @@ -349,9 +349,9 @@ export const YieldAssetDetails = memo(() => { const loadingGridElement = useMemo( () => ( - + {Array.from({ length: 6 }).map((_, i) => ( - + ))} ), @@ -371,7 +371,7 @@ export const YieldAssetDetails = memo(() => { const gridViewElement = useMemo( () => ( - + {sortedRows.map(row => ( { yieldItem: row.original, providerIcon: getProviderLogo(row.original.providerId), }} - variant='card' + variant={isMobile ? 'mobile' : 'card'} onEnter={() => handleYieldClick(row.original.id)} userBalanceUsd={ allBalances?.[row.original.id] ? allBalances[row.original.id].reduce( - (sum, b) => sum.plus(bnOrZero(b.amountUsd)), - bnOrZero(0), - ) + (sum, b) => sum.plus(bnOrZero(b.amountUsd)), + bnOrZero(0), + ) : undefined } /> @@ -408,11 +408,14 @@ export const YieldAssetDetails = memo(() => { [handleRowClick, sortedRows, table], ) + // Force grid view (which renders mobile cards) on mobile + const effectiveViewMode = isMobile ? 'grid' : viewMode + const contentElement = useMemo(() => { - if (isLoading) return viewMode === 'grid' ? loadingGridElement : loadingListElement + if (isLoading) return effectiveViewMode === 'grid' ? loadingGridElement : loadingListElement if (filteredYields.length === 0) return {translate('yieldXYZ.noYieldsMatchingFilters')} - return viewMode === 'grid' ? gridViewElement : listViewElement + return effectiveViewMode === 'grid' ? gridViewElement : listViewElement }, [ filteredYields.length, gridViewElement, @@ -421,11 +424,11 @@ export const YieldAssetDetails = memo(() => { loadingGridElement, loadingListElement, translate, - viewMode, + effectiveViewMode, ]) return ( - + - - {heroIcon} - - - {yieldItem.metadata.name} - - - {providerOrValidatorsElement} - - {chainElement} - - {yieldItem.metadata.description} - - - - - - - - - - - - - - - - - + + + + + + + + {isModalOpen && } ) }) diff --git a/src/pages/Yields/components/ValidatorBreakdown.tsx b/src/pages/Yields/components/ValidatorBreakdown.tsx deleted file mode 100644 index c6f107d82a2..00000000000 --- a/src/pages/Yields/components/ValidatorBreakdown.tsx +++ /dev/null @@ -1,454 +0,0 @@ -import { - Avatar, - Box, - Button, - Card, - CardBody, - Collapse, - Divider, - Flex, - Heading, - HStack, - Skeleton, - Text, - useDisclosure, - VStack, -} from '@chakra-ui/react' -import { fromAccountId } from '@shapeshiftoss/caip' -import type { FC } from 'react' -import { memo, useCallback, useMemo, useState } from 'react' -import { FaChevronDown, FaChevronUp } from 'react-icons/fa' -import { useTranslate } from 'react-polyglot' -import { useSearchParams } from 'react-router-dom' - -import { GradientApy } from './GradientApy' -import { YieldActionModal } from './YieldActionModal' - -import { Amount } from '@/components/Amount/Amount' -import { bnOrZero } from '@/lib/bignumber/bignumber' -import type { AugmentedYieldDto } from '@/lib/yieldxyz/types' -import { YieldBalanceType } from '@/lib/yieldxyz/types' -import { useYieldAccount } from '@/pages/Yields/YieldAccountContext' -import type { - AggregatedBalance, - NormalizedYieldBalances, - ValidatorSummary, -} from '@/react-queries/queries/yieldxyz/useAllYieldBalances' -import { - selectAccountIdByAccountNumberAndChainId, - selectUserCurrencyToUsdRate, -} from '@/state/slices/selectors' -import { useAppSelector } from '@/state/store' - -type ValidatorBreakdownProps = { - yieldItem: AugmentedYieldDto - balances: NormalizedYieldBalances | undefined - isBalancesLoading: boolean -} - -type ClaimModalData = { - validatorAddress: string - validatorName: string - validatorLogoURI: string | undefined - amount: string - assetSymbol: string - assetLogoURI: string | undefined - passthrough: string - manageActionType: string -} - -type ValidatorCardProps = { - validatorSummary: ValidatorSummary - isSelected: boolean - userCurrencyToUsdRate: string - onValidatorSwitch: (e: React.MouseEvent) => void - onClaimClick: (e: React.MouseEvent) => void - formatUnlockDate: (dateString: string | undefined) => string | null -} - -type BalanceRowProps = { - balance: AggregatedBalance | undefined - hasBalance: boolean - label: string - bg?: string - textColor?: string - dateColor?: string - valueColor?: string - showDate?: boolean - claimButton?: React.ReactNode -} - -const BalanceRow: FC = memo( - ({ balance, hasBalance, label, bg, textColor, valueColor, dateColor, showDate, claimButton }) => { - const translate = useTranslate() - const formatUnlockDate = useCallback((dateString: string | undefined) => { - if (!dateString) return null - const date = new Date(dateString) - return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) - }, []) - - if (!balance || !hasBalance) return null - - const isStyledRow = !!bg - - if (isStyledRow) { - return ( - - {claimButton ? ( - <> - - - {translate(label)} - - - - - - {claimButton} - - ) : ( - <> - - - {translate(label)} - - {showDate && balance.date && ( - - ({formatUnlockDate(balance.date)}) - - )} - - - - - - )} - - ) - } - - return ( - - - {translate(label)} - - - - - - ) - }, -) - -const ValidatorCard: FC = memo( - ({ validatorSummary, isSelected, userCurrencyToUsdRate, onValidatorSwitch, onClaimClick }) => { - const translate = useTranslate() - const { - validator, - byType, - totalUsd, - hasActive, - hasEntering, - hasExiting, - hasClaimable, - claimAction, - } = validatorSummary - - const activeBalance = byType[YieldBalanceType.Active] - const enteringBalance = byType[YieldBalanceType.Entering] - const exitingBalance = byType[YieldBalanceType.Exiting] - const claimableBalance = byType[YieldBalanceType.Claimable] - - const claimButton = useMemo( - () => - claimAction ? ( - - ) : null, - [claimAction, onClaimClick, translate], - ) - - return ( - - {!isSelected && ( - - )} - - - - - - {validator.name} - - {validator.apr !== undefined && bnOrZero(validator.apr).gt(0) && ( - - {bnOrZero(validator.apr).times(100).toFixed(2)}% APR - - )} - - - - - - - - - - - - - - ) - }, -) - -export const ValidatorBreakdown = memo( - ({ yieldItem, balances, isBalancesLoading }: ValidatorBreakdownProps) => { - const translate = useTranslate() - const { isOpen, onToggle } = useDisclosure({ defaultIsOpen: true }) - const [claimModalData, setClaimModalData] = useState(null) - const handleClaimClose = useCallback(() => setClaimModalData(null), []) - - const { chainId } = yieldItem - const { accountNumber } = useYieldAccount() - const accountId = useAppSelector(state => { - if (!chainId) return undefined - const accountIdsByNumberAndChain = selectAccountIdByAccountNumberAndChainId(state) - return accountIdsByNumberAndChain[accountNumber]?.[chainId] - }) - const userCurrencyToUsdRate = useAppSelector(selectUserCurrencyToUsdRate) - const address = useMemo( - () => (accountId ? fromAccountId(accountId).account : undefined), - [accountId], - ) - - const [searchParams, setSearchParams] = useSearchParams() - const selectedValidator = useMemo(() => searchParams.get('validator'), [searchParams]) - - const requiresValidatorSelection = useMemo( - () => yieldItem.mechanics.requiresValidatorSelection, - [yieldItem.mechanics.requiresValidatorSelection], - ) - - const validators = useMemo( - () => (requiresValidatorSelection ? balances?.validators ?? [] : []), - [balances?.validators, requiresValidatorSelection], - ) - - const hasValidatorPositions = useMemo( - () => (requiresValidatorSelection ? balances?.hasValidatorPositions ?? false : false), - [balances?.hasValidatorPositions, requiresValidatorSelection], - ) - - const allPositionsTotalUserCurrency = useMemo( - () => - bnOrZero(balances?.totalUsd) - .times(userCurrencyToUsdRate) - .toFixed(), - [balances?.totalUsd, userCurrencyToUsdRate], - ) - - const formatUnlockDate = useCallback((dateString: string | undefined) => { - if (!dateString) return null - const date = new Date(dateString) - return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) - }, []) - - const handleValidatorSwitch = useCallback( - (validatorAddress: string) => (e: React.MouseEvent) => { - e.stopPropagation() - setSearchParams(prev => { - const next = new URLSearchParams(prev) - next.set('validator', validatorAddress) - return next - }) - }, - [setSearchParams], - ) - - const handleClaimClick = useCallback( - (validatorSummary: ValidatorSummary, passthrough: string, manageActionType: string) => - (e: React.MouseEvent) => { - e.stopPropagation() - const claimableBalance = validatorSummary.byType[YieldBalanceType.Claimable] - setClaimModalData({ - validatorAddress: validatorSummary.validator.address, - validatorName: validatorSummary.validator.name, - validatorLogoURI: validatorSummary.validator.logoURI, - amount: claimableBalance?.aggregatedAmount ?? '0', - assetSymbol: claimableBalance?.token.symbol ?? '', - assetLogoURI: claimableBalance?.token.logoURI, - passthrough, - manageActionType, - }) - }, - [], - ) - - const loadingElement = useMemo( - () => ( - - - - - - - - - - ), - [], - ) - - const claimModalElement = useMemo(() => { - if (!claimModalData) return null - return ( - - ) - }, [claimModalData, handleClaimClose, yieldItem]) - - if (!requiresValidatorSelection || !address) return null - if (isBalancesLoading) return loadingElement - if (!hasValidatorPositions) return null - - return ( - - - - - - {translate('yieldXYZ.allPositions')} - - - - - - - {isOpen ? : } - - - - - {validators.map((validatorSummary, index) => { - const isSelected = validatorSummary.validator.address === selectedValidator - - return ( - - {index > 0 && } - - - ) - })} - - - - {claimModalElement} - - ) - }, -) diff --git a/src/pages/Yields/components/YieldEnterExit.tsx b/src/pages/Yields/components/YieldEnterExit.tsx deleted file mode 100644 index 71b93711b7e..00000000000 --- a/src/pages/Yields/components/YieldEnterExit.tsx +++ /dev/null @@ -1,629 +0,0 @@ -import { ChevronDownIcon } from '@chakra-ui/icons' -import { - Avatar, - Box, - Button, - Card, - CardBody, - Flex, - Icon, - Skeleton, - Tab, - TabList, - TabPanel, - TabPanels, - Tabs, - Text, -} from '@chakra-ui/react' -import { memo, useCallback, useEffect, useMemo, useState } from 'react' -import { FaMoneyBillWave } from 'react-icons/fa' -import { useTranslate } from 'react-polyglot' -import { useLocation, useSearchParams } from 'react-router-dom' - -import { Amount } from '@/components/Amount/Amount' -import { AssetInput } from '@/components/DeFi/components/AssetInput' -import { WalletActions } from '@/context/WalletProvider/actions' -import { useWallet } from '@/hooks/useWallet/useWallet' -import { bnOrZero } from '@/lib/bignumber/bignumber' -import { DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID } from '@/lib/yieldxyz/constants' -import type { AugmentedYieldDto, ValidatorDto } from '@/lib/yieldxyz/types' -import { YieldBalanceType } from '@/lib/yieldxyz/types' -import { GradientApy } from '@/pages/Yields/components/GradientApy' -import { YieldActionModal } from '@/pages/Yields/components/YieldActionModal' -import { YieldValidatorSelectModal } from '@/pages/Yields/components/YieldValidatorSelectModal' -import { useYieldAccount } from '@/pages/Yields/YieldAccountContext' -import type { - AugmentedYieldBalanceWithAccountId, - NormalizedYieldBalances, -} from '@/react-queries/queries/yieldxyz/useAllYieldBalances' -import { useYieldValidators } from '@/react-queries/queries/yieldxyz/useYieldValidators' -import { - selectAccountIdByAccountNumberAndChainId, - selectMarketDataByAssetIdUserCurrency, - selectPortfolioCryptoPrecisionBalanceByFilter, -} from '@/state/slices/selectors' -import { useAppSelector } from '@/state/store' - -type YieldEnterExitProps = { - yieldItem: AugmentedYieldDto - isQuoteLoading?: boolean - balances: NormalizedYieldBalances | undefined - isBalancesLoading: boolean -} - -const percentOptions = [0.25, 0.5, 0.75, 1] - -const YieldEnterExitSkeleton = memo(() => ( - - - - -)) - -const moneyBillWaveIcon = -const chevronDownIcon = -const buttonHoverSx = { transform: 'translateY(-1px)', boxShadow: 'lg' } - -export const YieldEnterExit = memo( - ({ yieldItem, isQuoteLoading, balances, isBalancesLoading }: YieldEnterExitProps) => { - const translate = useTranslate() - const location = useLocation() - const { accountNumber } = useYieldAccount() - const { state: walletState, dispatch } = useWallet() - const isConnected = useMemo(() => Boolean(walletState.walletInfo), [walletState.walletInfo]) - - const initialTab = useMemo(() => { - if (location.pathname.endsWith('/exit')) return 1 - if (location.pathname.endsWith('/enter')) return 0 - return 0 - }, [location.pathname]) - - const [tabIndex, setTabIndex] = useState(initialTab) - const [cryptoAmount, setCryptoAmount] = useState('') - const [isModalOpen, setIsModalOpen] = useState(false) - const [modalAction, setModalAction] = useState<'enter' | 'exit'>('enter') - const [isValidatorModalOpen, setIsValidatorModalOpen] = useState(false) - - const { chainId } = yieldItem - - const [searchParams, setSearchParams] = useSearchParams() - const validatorParam = useMemo(() => searchParams.get('validator'), [searchParams]) - - const shouldFetchValidators = useMemo( - () => - yieldItem.mechanics.type === 'staking' && yieldItem.mechanics.requiresValidatorSelection, - [yieldItem.mechanics.type, yieldItem.mechanics.requiresValidatorSelection], - ) - const { data: validators } = useYieldValidators(yieldItem.id, shouldFetchValidators) - - const defaultValidator = useMemo(() => { - if (chainId && DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID[chainId]) - return DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID[chainId] - return validators?.[0]?.address - }, [chainId, validators]) - - const selectedValidatorAddress = useMemo( - () => validatorParam || defaultValidator, - [validatorParam, defaultValidator], - ) - - const handleValidatorChange = useCallback( - (newAddress: string) => { - setSearchParams(params => { - params.set('validator', newAddress) - return params - }) - }, - [setSearchParams], - ) - - useEffect(() => { - if (!validatorParam && defaultValidator) { - setSearchParams( - params => { - params.set('validator', defaultValidator) - return params - }, - { replace: true }, - ) - } - }, [defaultValidator, validatorParam, setSearchParams]) - - const accountId = useAppSelector(state => { - if (!chainId) return undefined - const accountIdsByNumberAndChain = selectAccountIdByAccountNumberAndChainId(state) - return accountIdsByNumberAndChain[accountNumber]?.[chainId] - }) - - const validatorMetadata = useMemo(() => { - if (!selectedValidatorAddress) return undefined - - const foundInList = validators?.find(v => v.address === selectedValidatorAddress) - if (foundInList) return foundInList - - const foundInBalances = balances?.raw.find( - (b: AugmentedYieldBalanceWithAccountId) => - b.validator?.address === selectedValidatorAddress, - )?.validator - if (foundInBalances) - return { - ...foundInBalances, - apr: undefined, - commission: undefined, - } - - return { - name: `${selectedValidatorAddress.slice(0, 6)}...${selectedValidatorAddress.slice(-4)}`, - logoURI: '', - address: selectedValidatorAddress, - apr: '0', - commission: '0', - } - }, [validators, selectedValidatorAddress, balances]) - - const inputToken = yieldItem.inputTokens[0] - const inputTokenAssetId = inputToken?.assetId - - const inputTokenBalance = useAppSelector(state => - inputTokenAssetId && accountId - ? selectPortfolioCryptoPrecisionBalanceByFilter(state, { - assetId: inputTokenAssetId, - accountId, - }) - : '0', - ) - - const minDeposit = yieldItem.mechanics?.entryLimits?.minimum - - const isBelowMinimum = useMemo(() => { - if (!cryptoAmount || !minDeposit) return false - return bnOrZero(cryptoAmount).lt(minDeposit) - }, [cryptoAmount, minDeposit]) - - const isLoading = isBalancesLoading || isQuoteLoading - - const activeBalance = useMemo( - () => - balances?.raw.find((b: AugmentedYieldBalanceWithAccountId) => { - if (b.type !== YieldBalanceType.Active) return false - if (selectedValidatorAddress && b.validator) - return b.validator.address === selectedValidatorAddress - return true - }), - [balances?.raw, selectedValidatorAddress], - ) - - const withdrawableBalance = useMemo( - () => - balances?.raw.find((b: AugmentedYieldBalanceWithAccountId) => { - if (b.type !== YieldBalanceType.Withdrawable) return false - if (selectedValidatorAddress && b.validator) - return b.validator.address === selectedValidatorAddress - return true - }), - [balances?.raw, selectedValidatorAddress], - ) - - const exitBalance = useMemo( - () => activeBalance?.amount ?? withdrawableBalance?.amount ?? '0', - [activeBalance?.amount, withdrawableBalance?.amount], - ) - - const handlePercentClick = useCallback( - (percent: number) => { - const balance = tabIndex === 0 ? inputTokenBalance : exitBalance - const percentAmount = bnOrZero(balance).times(percent).toFixed() - setCryptoAmount(percentAmount) - }, - [inputTokenBalance, exitBalance, tabIndex], - ) - - const handleMaxClick = useCallback(async () => { - await Promise.resolve() - const balance = tabIndex === 0 ? inputTokenBalance : exitBalance - setCryptoAmount(balance) - }, [inputTokenBalance, exitBalance, tabIndex]) - - const handleEnterClick = useCallback(() => { - setModalAction('enter') - setIsModalOpen(true) - }, []) - - const handleExitClick = useCallback(() => { - setModalAction('exit') - setIsModalOpen(true) - }, []) - - const handleOpenValidatorModal = useCallback(() => setIsValidatorModalOpen(true), []) - const handleCloseValidatorModal = useCallback(() => setIsValidatorModalOpen(false), []) - const handleCloseModal = useCallback(() => setIsModalOpen(false), []) - - const handleConnectWallet = useCallback( - () => dispatch({ type: WalletActions.SET_WALLET_MODAL, payload: true }), - [dispatch], - ) - - const marketData = useAppSelector(state => - selectMarketDataByAssetIdUserCurrency(state, inputTokenAssetId ?? ''), - ) - - const apy = useMemo(() => bnOrZero(yieldItem.rewardRate.total), [yieldItem.rewardRate.total]) - - const estimatedYearlyEarnings = useMemo( - () => bnOrZero(cryptoAmount).times(apy), - [cryptoAmount, apy], - ) - - const estimatedYearlyEarningsFiat = useMemo( - () => estimatedYearlyEarnings.times(marketData?.price ?? 0), - [estimatedYearlyEarnings, marketData?.price], - ) - - const fiatAmount = useMemo( - () => - bnOrZero(cryptoAmount) - .times(marketData?.price ?? 0) - .toFixed(2), - [cryptoAmount, marketData?.price], - ) - - const hasAmount = bnOrZero(cryptoAmount).gt(0) - const inputSymbol = inputToken?.symbol ?? '' - - const uniqueValidatorCount = balances ? balances.validatorAddresses.length : 0 - const shouldShowValidatorPicker = uniqueValidatorCount > 1 - - const enterButtonDisabled = useMemo( - () => - isConnected && - (isLoading || - !yieldItem.status.enter || - !cryptoAmount || - isBelowMinimum || - !!isQuoteLoading), - [ - isConnected, - isLoading, - yieldItem.status.enter, - cryptoAmount, - isBelowMinimum, - isQuoteLoading, - ], - ) - - const exitButtonDisabled = useMemo( - () => isConnected && (isLoading || !yieldItem.status.exit || !cryptoAmount), - [isConnected, isLoading, yieldItem.status.exit, cryptoAmount], - ) - - const enterButtonText = useMemo(() => { - if (isQuoteLoading) return translate('common.loading') - if (isConnected) return translate('yieldXYZ.enter') - return translate('common.connectWallet') - }, [isQuoteLoading, isConnected, translate]) - - const exitButtonText = useMemo(() => { - if (isConnected) return translate('yieldXYZ.exit') - return translate('common.connectWallet') - }, [isConnected, translate]) - - const modalAssetSymbol = useMemo( - () => (modalAction === 'enter' ? inputToken?.symbol ?? '' : yieldItem.token.symbol), - [modalAction, inputToken?.symbol, yieldItem.token.symbol], - ) - - const enterTabDisabled = !yieldItem.status.enter - const exitTabDisabled = !yieldItem.status.exit - - const isPreferredValidator = useMemo( - () => (validatorMetadata as ValidatorDto | undefined)?.preferred === true, - [validatorMetadata], - ) - - const validatorRewardRate = useMemo(() => { - if (!validatorMetadata) return null - if (!('rewardRate' in validatorMetadata)) return null - const rate = (validatorMetadata as ValidatorDto).rewardRate?.total - if (!rate) return null - return (rate * 100).toFixed(2) - }, [validatorMetadata]) - - const apyDisplay = useMemo(() => `${apy.times(100).toFixed(2)}%`, [apy]) - - const estimatedYearlyEarningsDisplay = useMemo( - () => `${estimatedYearlyEarnings.decimalPlaces(4).toString()} ${inputSymbol}`, - [estimatedYearlyEarnings, inputSymbol], - ) - - const estimatedEarningsMarginBottom = hasAmount ? 2 : 0 - - const validatorPickerContent = useMemo(() => { - if (!shouldShowValidatorPicker) return null - - return ( - <> - - - - {validatorMetadata ? ( - <> - - - - {validatorMetadata.name} - - - {isPreferredValidator && ( - - {translate('yieldXYZ.preferred')} - - )} - {validatorRewardRate && ( - - {validatorRewardRate}% {translate('yieldXYZ.apr')} - - )} - - - - ) : ( - - {translate('yieldXYZ.selectValidator')} - - )} - - {chevronDownIcon} - - - - - ) - }, [ - shouldShowValidatorPicker, - handleOpenValidatorModal, - validatorMetadata, - isPreferredValidator, - translate, - validatorRewardRate, - isValidatorModalOpen, - handleCloseValidatorModal, - validators, - handleValidatorChange, - balances?.raw, - ]) - - const minDepositContent = useMemo(() => { - if (!minDeposit || isLoading) return null - - return ( - - - {moneyBillWaveIcon} - - {translate('yieldXYZ.minEnter')} - - - - {minDeposit} {inputToken?.symbol} - - - ) - }, [minDeposit, isLoading, translate, isBelowMinimum, inputToken?.symbol]) - - const estimatedYearlyEarningsContent = useMemo(() => { - if (!hasAmount) return null - - return ( - - - {translate('yieldXYZ.estYearlyEarnings')} - - - - {estimatedYearlyEarningsDisplay} - - - - - - - ) - }, [hasAmount, translate, estimatedYearlyEarningsDisplay, estimatedYearlyEarningsFiat]) - - const enterTabPanelContent = useMemo(() => { - if (isBalancesLoading) return - - return ( - - ) - }, [ - isBalancesLoading, - accountId, - inputTokenAssetId, - inputToken?.symbol, - yieldItem.metadata.logoURI, - cryptoAmount, - inputTokenBalance, - handlePercentClick, - handleMaxClick, - fiatAmount, - ]) - - const exitTabPanelContent = useMemo(() => { - if (isBalancesLoading) return - - return ( - - ) - }, [ - isBalancesLoading, - accountId, - inputTokenAssetId, - yieldItem.token.symbol, - yieldItem.metadata.logoURI, - cryptoAmount, - exitBalance, - handlePercentClick, - handleMaxClick, - fiatAmount, - ]) - - return ( - <> - - - {validatorPickerContent} - - - - {translate('yieldXYZ.enter')} - - - {translate('yieldXYZ.exit')} - - - - - - {enterTabPanelContent} - {minDepositContent} - - - - {translate('yieldXYZ.currentApy')} - - - {apyDisplay} - - - {estimatedYearlyEarningsContent} - - - - - - - {exitTabPanelContent} - - - - - - - - - - ) - }, -) diff --git a/src/pages/Yields/components/YieldForm.tsx b/src/pages/Yields/components/YieldForm.tsx new file mode 100644 index 00000000000..41df75a0459 --- /dev/null +++ b/src/pages/Yields/components/YieldForm.tsx @@ -0,0 +1,739 @@ +import { Avatar, Box, Button, Flex, HStack, Icon, Input, Skeleton, Text } from '@chakra-ui/react' +import { useQueryClient } from '@tanstack/react-query' +import { memo, useCallback, useEffect, useMemo, useState } from 'react' +import { TbSwitchVertical } from 'react-icons/tb' +import type { NumberFormatValues } from 'react-number-format' +import { NumericFormat } from 'react-number-format' +import { useTranslate } from 'react-polyglot' + +import { AccountSelector } from '@/components/AccountSelector/AccountSelector' +import { Amount } from '@/components/Amount/Amount' +import { AssetIcon } from '@/components/AssetIcon' +import { WalletActions } from '@/context/WalletProvider/actions' +import { useFeatureFlag } from '@/hooks/useFeatureFlag/useFeatureFlag' +import { useLocaleFormatter } from '@/hooks/useLocaleFormatter/useLocaleFormatter' +import { useWallet } from '@/hooks/useWallet/useWallet' +import { bnOrZero } from '@/lib/bignumber/bignumber' +import { + DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID, + SHAPESHIFT_COSMOS_VALIDATOR_ADDRESS, + SHAPESHIFT_VALIDATOR_LOGO, +} from '@/lib/yieldxyz/constants' +import type { AugmentedYieldDto } from '@/lib/yieldxyz/types' +import { YieldBalanceType } from '@/lib/yieldxyz/types' +import { getTransactionButtonText } from '@/lib/yieldxyz/utils' +import { GradientApy } from '@/pages/Yields/components/GradientApy' +import { TransactionStepsList } from '@/pages/Yields/components/TransactionStepsList' +import { YieldSuccess } from '@/pages/Yields/components/YieldSuccess' +import { ModalStep, useYieldTransactionFlow } from '@/pages/Yields/hooks/useYieldTransactionFlow' +import type { NormalizedYieldBalances } from '@/react-queries/queries/yieldxyz/useAllYieldBalances' +import { useYieldProviders } from '@/react-queries/queries/yieldxyz/useYieldProviders' +import { useYieldValidators } from '@/react-queries/queries/yieldxyz/useYieldValidators' +import { allowedDecimalSeparators } from '@/state/slices/preferencesSlice/preferencesSlice' +import { + selectAccountIdByAccountNumberAndChainId, + selectAssetById, + selectMarketDataByAssetIdUserCurrency, + selectPortfolioAccountIdsByAssetIdFilter, + selectPortfolioCryptoPrecisionBalanceByFilter, +} from '@/state/slices/selectors' +import { useAppSelector } from '@/state/store' + +type YieldFormProps = { + yieldItem: AugmentedYieldDto + balances?: NormalizedYieldBalances + action: 'enter' | 'exit' | 'claim' + validatorAddress?: string + accountNumber?: number + onClose: () => void + onDone?: () => void + isSubmitting?: boolean // Optional, if handled externally or to override +} + +const SHAPESHIFT_VALIDATOR_NAME = 'ShapeShift DAO' +const PRESET_PERCENTAGES = [0.25, 0.5, 0.75, 1] as const + +const getEnterActionTextKey = (yieldType: string | undefined): string => { + switch (yieldType) { + case 'native-staking': + case 'pooled-staking': + case 'liquid-staking': + case 'staking': + return 'defi.stake' + case 'vault': + return 'common.deposit' + case 'lending': + return 'common.supply' + default: + return 'common.deposit' + } +} + +const getExitActionTextKey = (yieldType: string | undefined): string => { + switch (yieldType) { + case 'native-staking': + case 'pooled-staking': + case 'liquid-staking': + case 'staking': + return 'defi.unstake' + case 'vault': + case 'lending': + default: + return 'common.withdraw' + } +} + +const INPUT_LENGTH_BREAKPOINTS = { + FOR_XS_FONT: 22, + FOR_SM_FONT: 14, + 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: React.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(() => ( + + + + +)) + +const selectedHoverSx = { bg: 'blue.600' } +const unselectedHoverSx = { bg: 'background.surface.raised.hover' } + +export const YieldForm = memo( + ({ + yieldItem, + balances, + action, + validatorAddress, + accountNumber = 0, + onClose, + onDone, + }: YieldFormProps) => { + const queryClient = useQueryClient() + const translate = useTranslate() + const { state: walletState, dispatch: walletDispatch } = useWallet() + const isConnected = useMemo(() => Boolean(walletState.walletInfo), [walletState.walletInfo]) + const isYieldMultiAccountEnabled = useFeatureFlag('YieldMultiAccount') + const { + number: { localeParts }, + } = useLocaleFormatter() + + const [cryptoAmount, setCryptoAmount] = useState('') + const [isFiat, setIsFiat] = useState(false) + const [selectedAccountId, setSelectedAccountId] = useState() + const [selectedPercent, setSelectedPercent] = useState(null) + + const { chainId } = yieldItem + const inputToken = yieldItem.inputTokens[0] + const inputTokenAssetId = inputToken?.assetId + + const claimableBalance = useMemo(() => balances?.byType[YieldBalanceType.Claimable], [balances]) + const claimableToken = claimableBalance?.token + const claimableAmount = claimableBalance?.aggregatedAmount ?? '0' + const isClaimAction = action === 'claim' + + const claimAction = useMemo( + () => claimableBalance?.pendingActions?.find(a => a.type.toUpperCase().includes('CLAIM')), + [claimableBalance], + ) + + const accountIdFilter = useMemo( + () => ({ assetId: inputTokenAssetId ?? '' }), + [inputTokenAssetId], + ) + const accountIds = useAppSelector(state => + selectPortfolioAccountIdsByAssetIdFilter(state, accountIdFilter), + ) + + const defaultAccountId = useAppSelector(state => { + if (!chainId) return undefined + const accountIdsByNumberAndChain = selectAccountIdByAccountNumberAndChainId(state) + return accountIdsByNumberAndChain[accountNumber]?.[chainId] + }) + + const accountId = selectedAccountId ?? defaultAccountId + const hasMultipleAccounts = accountIds.length > 1 + const isAccountSelectorDisabled = !isYieldMultiAccountEnabled || !hasMultipleAccounts + + const shouldFetchValidators = useMemo( + () => + yieldItem.mechanics.type === 'staking' && yieldItem.mechanics.requiresValidatorSelection, + [yieldItem.mechanics.type, yieldItem.mechanics.requiresValidatorSelection], + ) + const { data: validators, isLoading: isValidatorsLoading } = useYieldValidators( + yieldItem.id, + shouldFetchValidators, + ) + + const selectedValidatorAddress = useMemo(() => { + if (validatorAddress) return validatorAddress + if (chainId && DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID[chainId]) { + return DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID[chainId] + } + return validators?.[0]?.address + }, [chainId, validators, validatorAddress]) + + const { data: providers } = useYieldProviders() + + const isStaking = yieldItem.mechanics.type === 'staking' + + const selectedValidatorMetadata = useMemo(() => { + if (!isStaking || !selectedValidatorAddress) return null + const found = validators?.find(v => v.address === selectedValidatorAddress) + if (found) return found + if (selectedValidatorAddress === SHAPESHIFT_COSMOS_VALIDATOR_ADDRESS) { + return { + name: SHAPESHIFT_VALIDATOR_NAME, + logoURI: SHAPESHIFT_VALIDATOR_LOGO, + address: selectedValidatorAddress, + } + } + return null + }, [isStaking, selectedValidatorAddress, validators]) + + const providerMetadata = useMemo(() => { + if (!providers) return null + return providers[yieldItem.providerId] + }, [providers, yieldItem.providerId]) + + const inputTokenAsset = useAppSelector(state => selectAssetById(state, inputTokenAssetId ?? '')) + + const inputTokenBalance = useAppSelector(state => + inputTokenAssetId && accountId + ? selectPortfolioCryptoPrecisionBalanceByFilter(state, { + assetId: inputTokenAssetId, + accountId, + }) + : '0', + ) + + // Calculate maximum available balance for the action + // If Enter: Wallet Balance + // If Exit: Staked Balance (filtered by validator if applicable) + // If Claim: Not really relevant for amount input usually, but if so, claimable amount + const availableBalance = useMemo(() => { + if (action === 'enter') return inputTokenBalance + + if (action === 'exit' && balances) { + if (isStaking && selectedValidatorAddress) { + // Find specific validator entered balance + const validatorBalance = balances.raw.find( + b => + b.validator?.address === selectedValidatorAddress && + b.type === YieldBalanceType.Active, + ) + return validatorBalance?.amount ?? '0' + } + // Otherwise use total active balance + return balances.byType[YieldBalanceType.Active]?.aggregatedAmount ?? '0' + } + + return '0' // default fallback + }, [action, inputTokenBalance, balances, isStaking, selectedValidatorAddress]) + + // Proceeding with inputTokenBalance for now to match YieldEnterModal logic structure, but aware this is a nuance for Exit. + + const marketData = useAppSelector(state => + selectMarketDataByAssetIdUserCurrency(state, inputTokenAssetId ?? ''), + ) + + const minDeposit = yieldItem.mechanics?.entryLimits?.minimum + + const isBelowMinimum = useMemo(() => { + if (!cryptoAmount) return false + if (action === 'enter' && minDeposit) { + return bnOrZero(cryptoAmount).lt(minDeposit) + } + if (action === 'exit') { + // For exit, maybe ensure they don't exit more than they have? + // Though the transaction flow usually simulates and fails. + // But UI check is nice. + return bnOrZero(cryptoAmount).gt(availableBalance) + } + return false + }, [cryptoAmount, minDeposit, action, availableBalance]) + + const isLoading = isValidatorsLoading || !inputTokenAsset + + const fiatAmount = useMemo( + () => bnOrZero(cryptoAmount).times(marketData?.price ?? 0), + [cryptoAmount, marketData?.price], + ) + + const apy = useMemo(() => bnOrZero(yieldItem.rewardRate.total), [yieldItem.rewardRate.total]) + const apyDisplay = useMemo(() => `${apy.times(100).toFixed(2)}%`, [apy]) + + const estimatedYearlyEarnings = useMemo( + () => bnOrZero(cryptoAmount).times(apy), + [cryptoAmount, apy], + ) + + const estimatedYearlyEarningsFiat = useMemo( + () => estimatedYearlyEarnings.times(marketData?.price ?? 0), + [estimatedYearlyEarnings, marketData?.price], + ) + + const hasAmount = bnOrZero(cryptoAmount).gt(0) + + const displayPlaceholder = useMemo( + () => (isFiat ? `${localeParts.prefix}0` : '0'), + [isFiat, localeParts.prefix], + ) + + const handleInputChange = useCallback( + (values: NumberFormatValues) => { + setSelectedPercent(null) + if (isFiat) { + const crypto = bnOrZero(values.value) + .div(marketData?.price ?? 1) + .toFixed() + setCryptoAmount(crypto) + } else { + setCryptoAmount(values.value) + } + }, + [isFiat, marketData?.price], + ) + + const displayValue = useMemo(() => { + if (isFiat) { + return fiatAmount.toFixed(2) + } + return cryptoAmount + }, [isFiat, fiatAmount, cryptoAmount]) + + const toggleIsFiat = useCallback(() => setIsFiat(prev => !prev), []) + + const handlePercentClick = useCallback( + (percent: number) => { + const percentAmount = bnOrZero(availableBalance).times(percent).toFixed() + setCryptoAmount(percentAmount) + setSelectedPercent(percent) + }, + [availableBalance], + ) + + const handleConnectWallet = useCallback( + () => walletDispatch({ type: WalletActions.SET_WALLET_MODAL, payload: true }), + [walletDispatch], + ) + + const handleFormDone = useCallback(() => { + setCryptoAmount('') + setSelectedPercent(null) + setIsFiat(false) + setSelectedAccountId(undefined) + // Clear queries? + queryClient.removeQueries({ queryKey: ['yieldxyz', 'quote', action, yieldItem.id] }) + if (onDone) onDone() + else onClose() + }, [onClose, onDone, queryClient, yieldItem.id, action]) + + const handleAccountChange = useCallback((newAccountId: string) => { + setSelectedAccountId(newAccountId) + setCryptoAmount('') + setSelectedPercent(null) + }, []) + + // Map 'claim' -> 'manage' for useYieldTransactionFlow + const flowAction = action === 'claim' ? 'manage' : action + + const { + step, + transactionSteps, + displaySteps, + isSubmitting, + activeStepIndex, + handleConfirm, + isQuoteLoading, + quoteData, + isAllowanceCheckPending, + isUsdtResetRequired, + } = useYieldTransactionFlow({ + yieldItem, + action: flowAction, + amount: isClaimAction ? claimableAmount : cryptoAmount, + assetSymbol: isClaimAction ? claimableToken?.symbol ?? '' : inputTokenAsset?.symbol ?? '', + onClose: handleFormDone, + isOpen: true, + validatorAddress: selectedValidatorAddress, + accountId, + passthrough: claimAction?.passthrough, + manageActionType: claimAction?.type, + }) + + const isQuoteActive = isQuoteLoading || isAllowanceCheckPending + + useEffect(() => { + if (step === ModalStep.Success) { + // Here we could auto-close or let YieldSuccess handle it + } + }, [step]) + + const successProviderInfo = useMemo(() => { + if (isStaking && selectedValidatorMetadata) { + return { + name: selectedValidatorMetadata.name, + logoURI: selectedValidatorMetadata.logoURI, + } + } + if (providerMetadata) { + return { + name: providerMetadata.name, + logoURI: providerMetadata.logoURI, + } + } + return null + }, [isStaking, selectedValidatorMetadata, providerMetadata]) + + const buttonDisabled = useMemo(() => { + if (!isConnected) return false + if (isLoading) return true + if (isClaimAction) { + return !claimAction || !claimableAmount || bnOrZero(claimableAmount).lte(0) + } + return !cryptoAmount || isBelowMinimum || !quoteData + }, [ + isConnected, + isLoading, + isClaimAction, + claimAction, + claimableAmount, + cryptoAmount, + isBelowMinimum, + quoteData, + ]) + + const buttonText = useMemo(() => { + if (!isConnected) return translate('common.connectWallet') + if (isQuoteActive) return translate('yieldXYZ.loadingQuote') + + if (isSubmitting && transactionSteps.length > 0) { + const activeStep = transactionSteps.find(s => s.status !== 'success') + if (activeStep) return getTransactionButtonText(activeStep.type, activeStep.originalTitle) + } + + if (activeStepIndex >= 0 && transactionSteps[activeStepIndex]) { + const currentStep = transactionSteps[activeStepIndex] + return getTransactionButtonText(currentStep.type, currentStep.originalTitle) + } + + if (isUsdtResetRequired) { + return translate('yieldXYZ.resetAllowance') + } + + const firstCreatedTx = quoteData?.transactions?.find(tx => tx.status === 'CREATED') + if (firstCreatedTx) return getTransactionButtonText(firstCreatedTx.type, firstCreatedTx.title) + + const yieldType = yieldItem.mechanics.type + if (action === 'enter') { + const actionKey = getEnterActionTextKey(yieldType) + return `${translate(actionKey)} ${inputTokenAsset?.symbol ?? ''}` + } + if (action === 'exit') { + const actionKey = getExitActionTextKey(yieldType) + return `${translate(actionKey)} ${inputTokenAsset?.symbol ?? ''}` + } + if (action === 'claim') { + return `${translate('common.claim')} ${claimableToken?.symbol ?? ''}` + } + return translate('common.continue') + }, [ + isConnected, + isQuoteActive, + isSubmitting, + transactionSteps, + activeStepIndex, + isUsdtResetRequired, + quoteData, + translate, + inputTokenAsset?.symbol, + action, + yieldItem.mechanics.type, + claimableToken?.symbol, + ]) + + const percentButtons = useMemo( + () => ( + + {PRESET_PERCENTAGES.map(percent => { + const isSelected = selectedPercent === percent + return ( + + ) + })} + + ), + [selectedPercent, handlePercentClick, translate], + ) + + const statsContent = useMemo( + () => ( + + + + {translate('yieldXYZ.currentApy')} + + + {apyDisplay} + + + {hasAmount && ( + + + {translate('yieldXYZ.estYearlyEarnings')} + + + + {estimatedYearlyEarnings.decimalPlaces(4).toString()} {inputTokenAsset?.symbol} + + + + + + + )} + {isStaking && selectedValidatorMetadata && ( + + + {translate('yieldXYZ.validator')} + + + + + {selectedValidatorMetadata.name} + + + + )} + {!isStaking && providerMetadata && ( + + + {translate('yieldXYZ.provider')} + + + + + {providerMetadata.name} + + + + )} + {minDeposit && bnOrZero(minDeposit).gt(0) && action === 'enter' && ( + + + {translate('yieldXYZ.minEnter')} + + + {minDeposit} {inputTokenAsset?.symbol} + + + )} + + ), + [ + translate, + apyDisplay, + hasAmount, + estimatedYearlyEarnings, + inputTokenAsset?.symbol, + estimatedYearlyEarningsFiat, + isStaking, + selectedValidatorMetadata, + providerMetadata, + minDeposit, + isBelowMinimum, + action, + ], + ) + + const inputContent = useMemo(() => { + if (isLoading) return + + if (isClaimAction && claimableToken) { + return ( + + + + + + + {translate('yieldXYZ.claimableRewards')} + + + ) + } + + return ( + + {inputTokenAssetId && } + + + + {isFiat ? ( + + ) : ( + + )} + + + + + ) + }, [ + isLoading, + isClaimAction, + claimableToken, + claimableAmount, + translate, + inputTokenAssetId, + isFiat, + inputTokenAsset?.precision, + localeParts, + displayValue, + displayPlaceholder, + inputTokenAsset?.symbol, + handleInputChange, + toggleIsFiat, + cryptoAmount, + fiatAmount, + ]) + + const isSuccess = step === ModalStep.Success + + const stepsToShow = activeStepIndex >= 0 ? transactionSteps : displaySteps + + // If Success, render YieldSuccess + if (isSuccess) { + return ( + + ) + } + + return ( + + + {inputContent} + {!isClaimAction && percentButtons} + {!isClaimAction && inputTokenAssetId && accountId && ( + + + + )} + {!isClaimAction && statsContent} + {stepsToShow.length > 0 && } + + + + + + + ) + }, +) diff --git a/src/pages/Yields/components/YieldHero.tsx b/src/pages/Yields/components/YieldHero.tsx new file mode 100644 index 00000000000..0788591af19 --- /dev/null +++ b/src/pages/Yields/components/YieldHero.tsx @@ -0,0 +1,245 @@ +import { ArrowBackIcon, ArrowDownIcon, ArrowUpIcon } from '@chakra-ui/icons' +import { + Avatar, + Badge, + Box, + Button, + Flex, + HStack, + IconButton, + Text, + VStack, +} from '@chakra-ui/react' +import qs from 'qs' +import { memo, useCallback, useMemo } from 'react' +import { useTranslate } from 'react-polyglot' +import { useNavigate, useSearchParams } from 'react-router-dom' + +import { Amount } from '@/components/Amount/Amount' +import { AssetIcon } from '@/components/AssetIcon' +import { ChainIcon } from '@/components/ChainMenu' +import { useBrowserRouter } from '@/hooks/useBrowserRouter/useBrowserRouter' +import { bnOrZero } from '@/lib/bignumber/bignumber' +import type { AugmentedYieldDto } from '@/lib/yieldxyz/types' +import { resolveYieldInputAssetIcon } from '@/lib/yieldxyz/utils' + +const backIcon = +const enterIcon = +const exitIcon = + +type ValidatorOrProviderInfo = { + name: string + logoURI?: string +} | null + +type YieldHeroProps = { + yieldItem: AugmentedYieldDto + userBalanceUsd: string + userBalanceCrypto: string + validatorOrProvider: ValidatorOrProviderInfo +} + +export const YieldHero = memo( + ({ yieldItem, userBalanceUsd, userBalanceCrypto, validatorOrProvider }: YieldHeroProps) => { + const navigate = useNavigate() + const translate = useTranslate() + const { location } = useBrowserRouter() + + const iconSource = useMemo(() => resolveYieldInputAssetIcon(yieldItem), [yieldItem]) + const apy = useMemo( + () => bnOrZero(yieldItem.rewardRate.total).times(100).toFixed(2), + [yieldItem.rewardRate.total], + ) + const hasExitBalance = useMemo(() => bnOrZero(userBalanceCrypto).gt(0), [userBalanceCrypto]) + + const [searchParams] = useSearchParams() + const validator = useMemo(() => searchParams.get('validator'), [searchParams]) + + const handleBack = useCallback(() => navigate('/yields'), [navigate]) + + const handleAction = useCallback( + (action: 'enter' | 'exit') => { + navigate({ + pathname: location.pathname, + search: qs.stringify({ + action, + modal: 'yield', + ...(validator ? { validator } : {}), + }), + }) + }, + [navigate, location.pathname, validator], + ) + + const handleEnter = useCallback(() => handleAction('enter'), [handleAction]) + const handleExit = useCallback(() => handleAction('exit'), [handleAction]) + + const enterLabel = useMemo( + () => + yieldItem.mechanics.type === 'staking' + ? translate('defi.stake') + : translate('common.deposit'), + [yieldItem.mechanics.type, translate], + ) + + const exitLabel = useMemo( + () => + yieldItem.mechanics.type === 'staking' + ? translate('defi.unstake') + : translate('common.withdraw'), + [yieldItem.mechanics.type, translate], + ) + + const yieldTitle = yieldItem.metadata.name ?? yieldItem.token.symbol + + const stackedIconElement = useMemo(() => { + const assetIcon = iconSource.assetId ? ( + + ) : ( + + ) + + const hasOverlay = validatorOrProvider?.logoURI || yieldItem.chainId + + if (!hasOverlay) return assetIcon + + return ( + + {assetIcon} + {validatorOrProvider?.logoURI ? ( + + ) : yieldItem.chainId ? ( + + + + ) : null} + + ) + }, [iconSource, validatorOrProvider, yieldItem.chainId]) + + return ( + + + + + {yieldTitle} + + + + + + {stackedIconElement} + + {validatorOrProvider?.name ?? yieldItem.token.symbol} + + {yieldItem.chainId && ( + + + + {yieldItem.network} + + + )} + + + + {apy}% {translate('common.apy')} + + + {yieldItem.metadata.description && ( + + {yieldItem.metadata.description} + + )} + + + + + + + {yieldItem.token.symbol} + + + + + + + + + + + + ) + }, +) diff --git a/src/pages/Yields/components/YieldManager.tsx b/src/pages/Yields/components/YieldManager.tsx new file mode 100644 index 00000000000..7b8fa2bdfb5 --- /dev/null +++ b/src/pages/Yields/components/YieldManager.tsx @@ -0,0 +1,78 @@ +import { useMemo } from 'react' +import { useTranslate } from 'react-polyglot' +import { useNavigate, useParams, useSearchParams } from 'react-router-dom' + +import { YieldForm } from './YieldForm' + +import { Dialog } from '@/components/Modal/components/Dialog' +import { DialogBody } from '@/components/Modal/components/DialogBody' +import { DialogCloseButton } from '@/components/Modal/components/DialogCloseButton' +import { DialogHeader } from '@/components/Modal/components/DialogHeader' +import { DialogTitle } from '@/components/Modal/components/DialogTitle' +import { YieldBalanceType } from '@/lib/yieldxyz/types' +import { useYieldAccount } from '@/pages/Yields/YieldAccountContext' +import { useAllYieldBalances } from '@/react-queries/queries/yieldxyz/useAllYieldBalances' +import { useYield } from '@/react-queries/queries/yieldxyz/useYield' + +export const YieldManager = () => { + const translate = useTranslate() + const navigate = useNavigate() + const { yieldId } = useParams<{ yieldId: string }>() + const [searchParams] = useSearchParams() + + const action = searchParams.get('action') as 'enter' | 'exit' | 'claim' | undefined + const validatorAddress = searchParams.get('validator') ?? undefined + + const { data: yieldItem } = useYield(yieldId ?? '') + const { accountNumber } = useYieldAccount() + const { data: allBalancesData } = useAllYieldBalances() + const balances = yieldItem?.id ? allBalancesData?.normalized[yieldItem.id] : undefined + + const inputTokenSymbol = yieldItem?.inputTokens[0]?.symbol + const claimableTokenSymbol = balances?.byType[YieldBalanceType.Claimable]?.token?.symbol + const isStaking = yieldItem?.mechanics.type === 'staking' + + const title = useMemo(() => { + if (action === 'enter') { + return isStaking + ? translate('yieldXYZ.stakeSymbol', { symbol: inputTokenSymbol }) + : translate('yieldXYZ.depositSymbol', { symbol: inputTokenSymbol }) + } + if (action === 'exit') { + return isStaking + ? translate('yieldXYZ.unstakeSymbol', { symbol: inputTokenSymbol }) + : translate('yieldXYZ.withdrawSymbol', { symbol: inputTokenSymbol }) + } + if (action === 'claim') { + return translate('yieldXYZ.claimSymbol', { symbol: claimableTokenSymbol ?? '' }) + } + return translate('yieldXYZ.manage') + }, [action, isStaking, translate, inputTokenSymbol, claimableTokenSymbol]) + + if (!yieldItem) return null + + return ( + navigate(-1)} isFullScreen> + + {null} + + {title} + + + navigate(-1)} /> + + + + navigate(-1)} + onDone={() => navigate(-1)} + /> + + + ) +} diff --git a/src/pages/Yields/components/YieldPositionCard.tsx b/src/pages/Yields/components/YieldPositionCard.tsx index d47fb561843..8a35e04178f 100644 --- a/src/pages/Yields/components/YieldPositionCard.tsx +++ b/src/pages/Yields/components/YieldPositionCard.tsx @@ -1,6 +1,5 @@ import { Alert, - AlertIcon, Badge, Box, Button, @@ -15,13 +14,13 @@ import { VStack, } from '@chakra-ui/react' import { fromAccountId } from '@shapeshiftoss/caip' -import { memo, useCallback, useMemo, useState } from 'react' +import qs from 'qs' +import { memo, useCallback, useMemo } from 'react' import { useTranslate } from 'react-polyglot' -import { useSearchParams } from 'react-router-dom' - -import { YieldActionModal } from './YieldActionModal' +import { useNavigate, useSearchParams } from 'react-router-dom' import { Amount } from '@/components/Amount/Amount' +import { useBrowserRouter } from '@/hooks/useBrowserRouter/useBrowserRouter' import { bnOrZero } from '@/lib/bignumber/bignumber' import { DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID } from '@/lib/yieldxyz/constants' import type { AugmentedYieldDto } from '@/lib/yieldxyz/types' @@ -44,21 +43,11 @@ type YieldPositionCardProps = { isBalancesLoading: boolean } -type ClaimModalData = { - amount: string - assetSymbol: string - assetLogoURI: string | undefined - validatorAddress: string | undefined - validatorName: string | undefined - validatorLogoURI: string | undefined - passthrough: string | undefined - manageActionType: string | undefined -} - export const YieldPositionCard = memo( ({ yieldItem, balances, isBalancesLoading }: YieldPositionCardProps) => { - const [claimModalData, setClaimModalData] = useState(null) const translate = useTranslate() + const navigate = useNavigate() + const { location } = useBrowserRouter() const [searchParams] = useSearchParams() const validatorParam = searchParams.get('validator') @@ -180,19 +169,15 @@ export const YieldPositionCard = memo( const totalAmountFixed = useMemo(() => totalAmount.toFixed(), [totalAmount]) const handleClaimClick = useCallback(() => { - setClaimModalData({ - amount: claimableBalance?.amount ?? '0', - assetSymbol: claimableBalance?.token.symbol ?? '', - assetLogoURI: claimableBalance?.token.logoURI, - validatorAddress: selectedValidatorAddress, - validatorName: claimableBalance?.validator?.name, - validatorLogoURI: claimableBalance?.validator?.logoURI, - passthrough: claimAction?.passthrough, - manageActionType: claimAction?.type, + navigate({ + pathname: location.pathname, + search: qs.stringify({ + action: 'claim', + modal: 'yield', + ...(selectedValidatorAddress ? { validator: selectedValidatorAddress } : {}), + }), }) - }, [claimableBalance, selectedValidatorAddress, claimAction]) - - const handleClaimClose = useCallback(() => setClaimModalData(null), []) + }, [navigate, location.pathname, selectedValidatorAddress]) const showPendingActions = useMemo( () => hasEntering || hasExiting || hasWithdrawable || hasClaimable, @@ -209,28 +194,6 @@ export const YieldPositionCard = memo( [], ) - const emptyStateAlert = useMemo( - () => ( - - - - {translate('yieldXYZ.startEarning')} - - - {translate('yieldXYZ.enterYourToken', { symbol: yieldItem.token.symbol })} - - - ), - [yieldItem.token.symbol, translate], - ) - const enteringSection = useMemo(() => { if (!hasEntering) return null return ( @@ -360,11 +323,13 @@ export const YieldPositionCard = memo( ) }, [showPendingActions, enteringSection, exitingSection, withdrawableSection, claimableSection]) + if (!accountId) return null + if (isBalancesLoading) { return ( - - + + - {headingText} + {translate('yieldXYZ.myPosition')} - {addressBadge} {loadingState} @@ -382,10 +346,12 @@ export const YieldPositionCard = memo( ) } + if (!hasAnyPosition && !showPendingActions) return null + return ( - - + + {addressBadge} - + {translate('yieldXYZ.totalValue')} @@ -413,24 +379,7 @@ export const YieldPositionCard = memo( /> - {!hasAnyPosition && emptyStateAlert} {pendingActionsSection} - {claimModalData && ( - - )} diff --git a/src/pages/Yields/components/YieldStats.tsx b/src/pages/Yields/components/YieldStats.tsx index f072efab76f..a011ad75ed3 100644 --- a/src/pages/Yields/components/YieldStats.tsx +++ b/src/pages/Yields/components/YieldStats.tsx @@ -1,20 +1,5 @@ -import { - Avatar, - Box, - Card, - CardBody, - Divider, - Flex, - Heading, - Icon, - Stat, - StatLabel, - StatNumber, - Text, - Tooltip, -} from '@chakra-ui/react' +import { Avatar, Box, Flex, SimpleGrid, Text } from '@chakra-ui/react' import { memo, useMemo } from 'react' -import { FaClock, FaGasPump, FaLayerGroup, FaMoneyBillWave, FaUserShield } from 'react-icons/fa' import { useTranslate } from 'react-polyglot' import { useSearchParams } from 'react-router-dom' @@ -22,10 +7,7 @@ import { Amount } from '@/components/Amount/Amount' import { bnOrZero } from '@/lib/bignumber/bignumber' import { DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID } from '@/lib/yieldxyz/constants' import type { AugmentedYieldDto } from '@/lib/yieldxyz/types' -import type { - AugmentedYieldBalanceWithAccountId, - NormalizedYieldBalances, -} from '@/react-queries/queries/yieldxyz/useAllYieldBalances' +import type { NormalizedYieldBalances } from '@/react-queries/queries/yieldxyz/useAllYieldBalances' import { useYieldValidators } from '@/react-queries/queries/yieldxyz/useYieldValidators' import { selectMarketDataByAssetIdUserCurrency, @@ -33,12 +15,6 @@ import { } from '@/state/slices/selectors' import { useAppSelector } from '@/state/store' -const layerGroupIcon = -const userShieldIcon = -const clockIcon = -const gasPumpIcon = -const moneyBillWaveIcon = - type YieldStatsProps = { yieldItem: AugmentedYieldDto balances?: NormalizedYieldBalances @@ -76,9 +52,8 @@ export const YieldStats = memo(({ yieldItem, balances }: YieldStatsProps) => { if (!selectedValidatorAddress) return undefined const inList = validators?.find(v => v.address === selectedValidatorAddress) if (inList) return inList - const inBalances = balances?.raw.find( - (b: AugmentedYieldBalanceWithAccountId) => b.validator?.address === selectedValidatorAddress, - )?.validator + const inBalances = balances?.raw.find(b => b.validator?.address === selectedValidatorAddress) + ?.validator if (inBalances) return inBalances return undefined }, [validators, selectedValidatorAddress, balances]) @@ -98,18 +73,6 @@ export const YieldStats = memo(({ yieldItem, balances }: YieldStatsProps) => { .toFixed() }, [yieldItem.statistics?.tvlUsd, userCurrencyToUsdRate, tvl, inputTokenMarketData?.price]) - const apy = useMemo( - () => - bnOrZero( - selectedValidator && 'rewardRate' in selectedValidator && selectedValidator.rewardRate - ? selectedValidator.rewardRate.total - : yieldItem.rewardRate.total, - ) - .times(100) - .toNumber(), - [selectedValidator, yieldItem.rewardRate.total], - ) - const validatorMetadata = useMemo(() => { if (yieldItem.mechanics.type !== 'staking') return null if (selectedValidator) @@ -117,157 +80,48 @@ export const YieldStats = memo(({ yieldItem, balances }: YieldStatsProps) => { return null }, [yieldItem.mechanics.type, selectedValidator]) - const apyFormatted = useMemo(() => apy.toFixed(2), [apy]) - const tvlFormatted = useMemo(() => tvl.toFixed(), [tvl]) - - const rewardBreakdownContent = useMemo(() => { - if (yieldItem.rewardRate.components.length === 0) return null - return ( - - {yieldItem.rewardRate.components.map((component, idx) => ( - - - - - {component.yieldSource} - - - - {bnOrZero(component.rate).times(100).toFixed(2)}% - - - ))} - - ) - }, [yieldItem.rewardRate.components]) - - const validatorRowContent = useMemo(() => { - if (!validatorMetadata) return null - return ( - - - {userShieldIcon} - {translate('yieldXYZ.validator')} - - - {validatorMetadata.logoURI && ( - - )} - - {validatorMetadata.name} - - - - ) - }, [validatorMetadata, translate]) - - const minDepositRowContent = useMemo(() => { - if (!yieldItem.mechanics.entryLimits.minimum) return null - return ( - - - {moneyBillWaveIcon} - {translate('yieldXYZ.minEnter')} - - - - ) - }, [yieldItem.mechanics.entryLimits.minimum, yieldItem.token.symbol, translate]) - return ( - - - - {translate('yieldXYZ.stats')} - - - - - - - {translate('common.apy')} - - - {apyFormatted}% - - {yieldItem.rewardRate.rateType} - - - - - {rewardBreakdownContent} - - - - - {translate('yieldXYZ.tvl')} - - - - - - + + + + {translate('yieldXYZ.tvl')} + + + + + + + {translate('yieldXYZ.rewardSchedule')} + + + {yieldItem.mechanics.rewardSchedule} + + + + + + {translate('yieldXYZ.type')} + + + {yieldItem.mechanics.type} + + + + {validatorMetadata && ( + + + {translate('yieldXYZ.validator')} + + + {validatorMetadata.logoURI && ( + + )} + + {validatorMetadata.name} - - - - - - {layerGroupIcon} - {translate('yieldXYZ.type')} - - - {yieldItem.mechanics.type} - - - {validatorRowContent} - - - {clockIcon} - {translate('yieldXYZ.rewardSchedule')} - - - {yieldItem.mechanics.rewardSchedule} - - - - - {gasPumpIcon} - {translate('yieldXYZ.gasToken')} - - - {yieldItem.mechanics.gasFeeToken.symbol} - - - {minDepositRowContent} - - - - - + + + )} + ) }) diff --git a/src/pages/Yields/components/YieldValidatorSelectModal.tsx b/src/pages/Yields/components/YieldValidatorSelectModal.tsx deleted file mode 100644 index 72c315fea87..00000000000 --- a/src/pages/Yields/components/YieldValidatorSelectModal.tsx +++ /dev/null @@ -1,243 +0,0 @@ -import { - Avatar, - Box, - Flex, - Input, - InputGroup, - InputLeftElement, - Modal, - ModalBody, - ModalCloseButton, - ModalContent, - ModalHeader, - ModalOverlay, - Tab, - TabList, - TabPanel, - TabPanels, - Tabs, - Text, - VStack, -} from '@chakra-ui/react' -import type { ChangeEvent } from 'react' -import { memo, useCallback, useMemo, useState } from 'react' -import { FaSearch } from 'react-icons/fa' -import { useTranslate } from 'react-polyglot' - -import { Amount } from '@/components/Amount/Amount' -import { bnOrZero } from '@/lib/bignumber/bignumber' -import { SHAPESHIFT_COSMOS_VALIDATOR_ADDRESS } from '@/lib/yieldxyz/constants' -import type { ValidatorDto } from '@/lib/yieldxyz/types' -import { ensureValidatorApr, searchValidators, toUserCurrency } from '@/lib/yieldxyz/utils' -import { GradientApy } from '@/pages/Yields/components/GradientApy' -import type { AugmentedYieldBalanceWithAccountId } from '@/react-queries/queries/yieldxyz/useAllYieldBalances' -import { selectUserCurrencyToUsdRate } from '@/state/slices/selectors' -import { useAppSelector } from '@/state/store' - -const searchIcon = - -type YieldValidatorSelectModalProps = { - isOpen: boolean - onClose: () => void - validators: ValidatorDto[] - onSelect: (address: string) => void - balances?: AugmentedYieldBalanceWithAccountId[] -} - -export const YieldValidatorSelectModal = memo( - ({ isOpen, onClose, validators, onSelect, balances }: YieldValidatorSelectModalProps) => { - const translate = useTranslate() - const userCurrencyToUsdRate = useAppSelector(selectUserCurrencyToUsdRate) - const [searchQuery, setSearchQuery] = useState('') - - const balanceMap = useMemo(() => { - if (!balances) return new Map() - const map = new Map() - for (const balance of balances) { - if (!balance.validator || bnOrZero(balance.amount).lte(0)) continue - const addr = balance.validator.address - map.set( - addr, - bnOrZero(map.get(addr) || '0') - .plus(balance.amountUsd) - .toFixed(), - ) - } - return map - }, [balances]) - - const myValidators = useMemo(() => { - if (!balances) return [] - const seen = new Set() - const result: ValidatorDto[] = [] - for (const balance of balances) { - if ( - !balance.validator || - bnOrZero(balance.amount).lte(0) || - seen.has(balance.validator.address) - ) - continue - seen.add(balance.validator.address) - const full = validators.find(v => v.address === balance.validator?.address) - if (full) { - result.push(full) - } else { - result.push( - ensureValidatorApr({ - address: balance.validator.address, - name: balance.validator.name, - logoURI: balance.validator.logoURI, - preferred: false, - votingPower: 0, - commission: balance.validator.commission ?? 0, - status: balance.validator.status ?? 'active', - tvl: '0', - tvlRaw: '0', - rewardRate: { - total: balance.validator.apr ?? 0, - rateType: 'APR' as const, - components: [], - }, - }), - ) - } - } - return result - }, [balances, validators]) - - const allValidators = validators - - const filteredAll = useMemo( - () => searchValidators(allValidators, searchQuery), - [allValidators, searchQuery], - ) - - const filteredMy = useMemo( - () => searchValidators(myValidators, searchQuery), - [myValidators, searchQuery], - ) - - const handleSelect = useCallback( - (address: string) => { - onSelect(address) - onClose() - }, - [onSelect, onClose], - ) - - const handleSearchChange = useCallback( - (e: ChangeEvent) => setSearchQuery(e.target.value), - [], - ) - - const renderValidatorRow = useCallback( - (v: ValidatorDto) => { - const apr = v.rewardRate?.total ? `${(v.rewardRate.total * 100).toFixed(2)}%` : null - const usd = balanceMap.get(v.address) || '0' - const hasBalance = bnOrZero(usd).gt(0) - - return ( - handleSelect(v.address)} - > - - - - - {v.name} - {v.address === SHAPESHIFT_COSMOS_VALIDATOR_ADDRESS && ( - - {translate('yieldXYZ.preferred')} - - )} - - {hasBalance && ( - - - - )} - - - - {apr && ( - - {apr} {translate('yieldXYZ.apr')} - - )} - - - ) - }, - [balanceMap, userCurrencyToUsdRate, handleSelect, translate], - ) - - return ( - - - - {translate('yieldXYZ.selectValidator')} - - - - - {searchIcon} - - - - - - {`${translate('yieldXYZ.allValidators')} (${filteredAll.length})`} - {`${translate('yieldXYZ.myValidators')} (${filteredMy.length})`} - - - - - {filteredAll.length === 0 ? ( - - {translate('yieldXYZ.noValidatorsFound')} - - ) : ( - filteredAll.map(renderValidatorRow) - )} - - - - - {filteredMy.length === 0 ? ( - - {translate('yieldXYZ.noActiveValidators')} - - ) : ( - filteredMy.map(renderValidatorRow) - )} - - - - - - - - ) - }, -) From 966d2fdf1fb6fcf10940da349d0f8eca7d8b9c0d Mon Sep 17 00:00:00 2001 From: gomes <17035424+gomesalexandre@users.noreply.github.com> Date: Wed, 14 Jan 2026 19:41:29 +0100 Subject: [PATCH 38/60] fix: fml lint --- src/pages/Yields/YieldDetail.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/pages/Yields/YieldDetail.tsx b/src/pages/Yields/YieldDetail.tsx index e4131d98632..18b28b503b1 100644 --- a/src/pages/Yields/YieldDetail.tsx +++ b/src/pages/Yields/YieldDetail.tsx @@ -145,7 +145,10 @@ export const YieldDetail = memo(() => { return ( - + Date: Wed, 14 Jan 2026 22:26:43 +0100 Subject: [PATCH 39/60] fix(yields): show success step when staking from earn CTA Removed the useEffect that auto-closed the modal on success, allowing users to see the success screen with transaction details before dismissing. Co-Authored-By: Claude Opus 4.5 --- src/pages/Yields/components/YieldEnterModal.tsx | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/pages/Yields/components/YieldEnterModal.tsx b/src/pages/Yields/components/YieldEnterModal.tsx index e16dab4cfd6..88d751fef9a 100644 --- a/src/pages/Yields/components/YieldEnterModal.tsx +++ b/src/pages/Yields/components/YieldEnterModal.tsx @@ -1,6 +1,6 @@ import { Avatar, Box, Button, Flex, HStack, Icon, Input, Skeleton, Text } from '@chakra-ui/react' import { useQueryClient } from '@tanstack/react-query' -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' @@ -309,12 +309,6 @@ export const YieldEnterModal = memo( const isQuoteActive = isQuoteLoading || isAllowanceCheckPending - useEffect(() => { - if (step === ModalStep.Success) { - handleModalClose() - } - }, [step, handleModalClose]) - const successProviderInfo = useMemo(() => { if (isStaking && selectedValidatorMetadata) { return { From c0db87072eb05ef8d6b8c68656a0aef7a36f1a90 Mon Sep 17 00:00:00 2001 From: gomes <17035424+gomesalexandre@users.noreply.github.com> Date: Wed, 14 Jan 2026 22:28:03 +0100 Subject: [PATCH 40/60] fix(yields): remove duplicate close button on success step The YieldSuccess component already has its own Close button, so the additional DialogFooter with Close button was redundant. Fixes: shapeshift/web#11650 Co-Authored-By: Claude Opus 4.5 --- src/pages/Yields/components/YieldEnterModal.tsx | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/src/pages/Yields/components/YieldEnterModal.tsx b/src/pages/Yields/components/YieldEnterModal.tsx index 88d751fef9a..13d297f1d31 100644 --- a/src/pages/Yields/components/YieldEnterModal.tsx +++ b/src/pages/Yields/components/YieldEnterModal.tsx @@ -625,22 +625,6 @@ export const YieldEnterModal = memo( )} - {isSuccess && ( - - - - )} ) }, From ce363eb450fce3a966b094f645d7465af82b5c09 Mon Sep 17 00:00:00 2001 From: gomes <17035424+gomesalexandre@users.noreply.github.com> Date: Wed, 14 Jan 2026 23:38:46 +0100 Subject: [PATCH 41/60] feat(yields): show available balance for yield opportunities - Cards/mobile: show Available (green) when user has wallet balance but no staked position - List view: show both Balance and Available when user has both - Earn button now shows individual yields instead of filtered groups - Pass correct position balance vs wallet balance to YieldItem Co-Authored-By: Claude Opus 4.5 --- src/pages/Yields/components/YieldItem.tsx | 179 +++++++++++++--- src/pages/Yields/components/YieldsList.tsx | 233 +++++++++++++++++---- 2 files changed, 334 insertions(+), 78 deletions(-) diff --git a/src/pages/Yields/components/YieldItem.tsx b/src/pages/Yields/components/YieldItem.tsx index f9885643a9c..7bc29e8df81 100644 --- a/src/pages/Yields/components/YieldItem.tsx +++ b/src/pages/Yields/components/YieldItem.tsx @@ -48,13 +48,22 @@ type YieldItemProps = { data: SingleYieldData | GroupYieldData variant: 'card' | 'row' | 'mobile' userBalanceUsd?: BigNumber + availableBalanceUserCurrency?: BigNumber onEnter?: (yieldItem: AugmentedYieldDto) => void searchString?: string titleOverride?: string } export const YieldItem = memo( - ({ data, variant, userBalanceUsd, onEnter, searchString, titleOverride }: YieldItemProps) => { + ({ + data, + variant, + userBalanceUsd, + availableBalanceUserCurrency, + onEnter, + searchString, + titleOverride, + }: YieldItemProps) => { const navigate = useNavigate() const translate = useTranslate() const userCurrencyToUsdRate = useAppSelector(selectUserCurrencyToUsdRate) @@ -110,6 +119,7 @@ export const YieldItem = memo( ) const hasBalance = userBalanceUsd && userBalanceUsd.gt(0) + const hasAvailable = availableBalanceUserCurrency && availableBalanceUserCurrency.gt(0) const handleClick = useCallback(() => { if (isSingle) { @@ -155,6 +165,137 @@ export const YieldItem = memo( return data.assetSymbol }, [data, isSingle, titleOverride]) + const showAvailable = isSingle && hasAvailable && !hasBalance + + const cardStatElement = useMemo(() => { + if (hasBalance) { + return ( + <> + + {translate('yieldXYZ.balance')} + + + + + + ) + } + if (showAvailable) { + return ( + <> + + {translate('common.available')} + + + + + + ) + } + return ( + <> + + {translate('yieldXYZ.tvl')} + + + + + + ) + }, [ + hasBalance, + showAvailable, + userBalanceUserCurrency, + availableBalanceUserCurrency, + tvlUserCurrency, + translate, + ]) + + const showAvailableInRow = isSingle && hasAvailable + + const mobileBalanceLabelKey = useMemo(() => { + if (hasBalance) return 'yieldXYZ.balance' + if (showAvailable) return 'common.available' + return 'yieldXYZ.balance' + }, [hasBalance, showAvailable]) + + const mobileBalanceElement = useMemo(() => { + if (hasBalance) { + return ( + + + + ) + } + if (showAvailable) { + return ( + + + + ) + } + return ( + + — + + ) + }, [hasBalance, showAvailable, userBalanceUserCurrency, availableBalanceUserCurrency]) + + const rowBalanceElement = useMemo(() => { + if (hasBalance && showAvailableInRow) { + return ( + + + + + + + {translate('yieldXYZ.balance').toLowerCase()} + + + + + + + + {translate('common.available').toLowerCase()} + + + + ) + } + if (hasBalance) { + return ( + + + + ) + } + if (showAvailableInRow) { + return ( + <> + + {translate('common.available')} + + + + + + ) + } + return ( + + — + + ) + }, [ + hasBalance, + showAvailableInRow, + userBalanceUserCurrency, + availableBalanceUserCurrency, + translate, + ]) + if (variant === 'mobile') { return ( - {translate('yieldXYZ.balance')} - - - + {translate(mobileBalanceLabelKey)} + {mobileBalanceElement} - {hasBalance ? ( - - - - ) : ( - - — - - )} + {rowBalanceElement} @@ -363,25 +494,7 @@ export const YieldItem = memo( - {hasBalance ? ( - <> - - {translate('yieldXYZ.balance')} - - - - - - ) : ( - <> - - {translate('yieldXYZ.tvl')} - - - - - - )} + {cardStatElement} diff --git a/src/pages/Yields/components/YieldsList.tsx b/src/pages/Yields/components/YieldsList.tsx index 80196e3c5c8..1ca004347bf 100644 --- a/src/pages/Yields/components/YieldsList.tsx +++ b/src/pages/Yields/components/YieldsList.tsx @@ -312,6 +312,57 @@ export const YieldsList = memo(() => { .slice(0, 3) }, [isConnected, yields?.byInputAssetId, userCurrencyBalances, assetBalancesBaseUnit, assets]) + const availableYields = useMemo(() => { + if (!isConnected || !yields?.byInputAssetId || !userCurrencyBalances || !assetBalancesBaseUnit) + return [] + + const available: { + yield: AugmentedYieldDto + balanceFiat: ReturnType + }[] = [] + + for (const [assetId, balanceFiat] of Object.entries(userCurrencyBalances)) { + const yieldsForAsset = yields.byInputAssetId[assetId] + if (!yieldsForAsset?.length) continue + + const balance = bnOrZero(balanceFiat) + if (balance.lte(0)) continue + + const eligibleYields = yieldsForAsset.filter(y => { + const minDeposit = bnOrZero(y.mechanics?.entryLimits?.minimum) + if (minDeposit.gt(0)) { + const precision = assets[assetId]?.precision ?? 18 + const baseBalance = bnOrZero(assetBalancesBaseUnit[assetId]) + const balanceHuman = bnOrZero(fromBaseUnit(baseBalance, precision)) + if (balanceHuman.lt(minDeposit)) return false + } + if (selectedNetwork && y.network !== selectedNetwork) return false + if (selectedProvider && y.providerId !== selectedProvider) return false + if (selectedType && y.mechanics.type !== selectedType) return false + if (searchQuery && !searchYields([y], searchQuery).length) return false + return true + }) + + for (const yieldItem of eligibleYields) { + available.push({ yield: yieldItem, balanceFiat: balance }) + } + } + + return available.sort((a, b) => + bnOrZero(b.yield.rewardRate.total).minus(a.yield.rewardRate.total).toNumber(), + ) + }, [ + isConnected, + yields?.byInputAssetId, + userCurrencyBalances, + assetBalancesBaseUnit, + assets, + selectedNetwork, + selectedProvider, + selectedType, + searchQuery, + ]) + const myPositions = useMemo(() => { if (!yields?.all || !allBalances) return [] const positions = yields.all.filter(yieldItem => { @@ -553,12 +604,44 @@ export const YieldsList = memo(() => { [translate], ) - const allYieldsGridElement = useMemo( - () => ( + const allYieldsGridElement = useMemo(() => { + if (isMyOpportunities) { + return ( + + {availableYields.map(item => { + const yieldBalances = allBalances?.[item.yield.id] + const positionBalanceUsd = yieldBalances + ? yieldBalances.reduce((sum, b) => sum.plus(bnOrZero(b.amountUsd)), bnOrZero(0)) + : undefined + return ( + handleYieldClick(item.yield.id)} + /> + ) + })} + + ) + } + + return ( {yieldsByAsset.map(group => { if (group.yields.length === 1) { const singleYield = group.yields[0] + const inputAssetId = singleYield.inputTokens?.[0]?.assetId + const availableUsd = inputAssetId + ? bnOrZero(userCurrencyBalances[inputAssetId]) + : undefined return ( { }} variant={isMobile ? 'mobile' : 'card'} userBalanceUsd={group.userGroupBalanceUsd} + availableBalanceUserCurrency={availableUsd} titleOverride={group.assetSymbol} onEnter={() => handleYieldClick(singleYield.id)} /> @@ -593,52 +677,98 @@ export const YieldsList = memo(() => { ) })} - ), - [filterSearchString, yieldsByAsset, isMobile, getProviderLogo, handleYieldClick], - ) + ) + }, [ + isMyOpportunities, + availableYields, + allBalances, + filterSearchString, + yieldsByAsset, + isMobile, + getProviderLogo, + handleYieldClick, + userCurrencyBalances, + ]) - const allYieldsListElement = useMemo( - () => ( - - - + const allYieldsListElement = useMemo(() => { + const listHeader = ( + + + + {translate('yieldXYZ.asset')} + + + + - {translate('yieldXYZ.asset')} + {translate('yieldXYZ.maxApy')} - - - - - {translate('yieldXYZ.maxApy')} - - - - - {translate('yieldXYZ.tvl')} - - - - - {translate('yieldXYZ.providers')} - - - - - {translate('yieldXYZ.myBalance')} - - - + + + + {translate('yieldXYZ.tvl')} + + + + + {translate('yieldXYZ.providers')} + + + + + {translate('yieldXYZ.myBalance')} + + + + ) + + if (isMyOpportunities) { + return ( + + {listHeader} + {availableYields.map(item => { + const yieldBalances = allBalances?.[item.yield.id] + const positionBalanceUsd = yieldBalances + ? yieldBalances.reduce((sum, b) => sum.plus(bnOrZero(b.amountUsd)), bnOrZero(0)) + : undefined + return ( + handleYieldClick(item.yield.id)} + /> + ) + })} + + ) + } + + return ( + + {listHeader} {yieldsByAsset.map(group => { if (group.yields.length === 1) { const singleYield = group.yields[0] + const inputAssetId = singleYield.inputTokens?.[0]?.assetId + const availableUsd = inputAssetId + ? bnOrZero(userCurrencyBalances[inputAssetId]) + : undefined return ( { }} variant='row' userBalanceUsd={group.userGroupBalanceUsd} + availableBalanceUserCurrency={availableUsd} titleOverride={group.assetSymbol} onEnter={() => handleYieldClick(singleYield.id)} /> @@ -673,9 +804,18 @@ export const YieldsList = memo(() => { ) })} - ), - [filterSearchString, translate, yieldsByAsset, getProviderLogo, handleYieldClick], - ) + ) + }, [ + isMyOpportunities, + availableYields, + allBalances, + filterSearchString, + translate, + yieldsByAsset, + getProviderLogo, + handleYieldClick, + userCurrencyBalances, + ]) const recommendedStripElement = useMemo(() => { if (!isConnected || recommendedYields.length === 0 || isMyOpportunities) return null @@ -718,7 +858,8 @@ export const YieldsList = memo(() => { return viewMode === 'grid' || isMobile ? allYieldsLoadingGridElement : allYieldsLoadingListElement - if (yieldsByAsset.length === 0) return allYieldsEmptyElement + const isEmpty = isMyOpportunities ? availableYields.length === 0 : yieldsByAsset.length === 0 + if (isEmpty) return allYieldsEmptyElement return viewMode === 'grid' || isMobile ? allYieldsGridElement : allYieldsListElement }, [ allYieldsEmptyElement, @@ -727,6 +868,8 @@ export const YieldsList = memo(() => { allYieldsLoadingGridElement, allYieldsLoadingListElement, isLoading, + isMyOpportunities, + availableYields.length, viewMode, yieldsByAsset.length, isMobile, From bfee3e77b830544d516c7aae22b06ac8c45c9710 Mon Sep 17 00:00:00 2001 From: gomes <17035424+gomesalexandre@users.noreply.github.com> Date: Thu, 15 Jan 2026 11:06:20 +0100 Subject: [PATCH 42/60] fix: tackle @coderabbitai - resolve mad rabbit - Fix React.ChangeEvent import in YieldForm - Fix claim success showing wrong amount/message key - Fix polling completion bug (tx not found = complete) - Narrow allowance polling to USDT on Ethereum mainnet only - Fix division by zero when price is 0 - Fix invalid itemProp prop (should be fontWeight) - Use translation keys for yield type names Co-Authored-By: Claude Opus 4.5 --- src/assets/translations/en/main.json | 11 ++++++++++- .../Earn/components/YieldSelector.tsx | 17 +++++------------ .../Yields/components/YieldEnterModal.tsx | 2 +- src/pages/Yields/components/YieldForm.tsx | 19 +++++++++++++++---- src/pages/Yields/components/YieldItem.tsx | 2 +- .../Yields/hooks/useYieldTransactionFlow.ts | 9 +++++++-- 6 files changed, 39 insertions(+), 21 deletions(-) diff --git a/src/assets/translations/en/main.json b/src/assets/translations/en/main.json index 744ec45b3cb..509f171a914 100644 --- a/src/assets/translations/en/main.json +++ b/src/assets/translations/en/main.json @@ -2804,6 +2804,15 @@ "noYieldsAvailable": "No yield opportunities available for %{asset}", "estimatedYearlyEarnings": "Est. Yearly Earnings", "yieldType": "Yield Type", + "yieldTypes": { + "native-staking": "Native Staking", + "pooled-staking": "Pooled Staking", + "liquid-staking": "Liquid Staking", + "staking": "Staking", + "lending": "Lending", + "vault": "Vault", + "restaking": "Restaking" + }, "confirmEarn": "Confirm Stake", "earnWith": "Earn with", "belowMinimum": "Below minimum deposit", @@ -2822,4 +2831,4 @@ "lendingWithdraw": "When withdrawing, your assets will be available immediately" } } -} \ No newline at end of file +} diff --git a/src/components/MultiHopTrade/components/Earn/components/YieldSelector.tsx b/src/components/MultiHopTrade/components/Earn/components/YieldSelector.tsx index 556f316a57f..342db8e0023 100644 --- a/src/components/MultiHopTrade/components/Earn/components/YieldSelector.tsx +++ b/src/components/MultiHopTrade/components/Earn/components/YieldSelector.tsx @@ -51,17 +51,10 @@ const getProviderInfo = ( const hoverBg = { bg: 'background.surface.raised.hover' } -const getYieldTypeName = (type: string): string => { - const typeMap: Record = { - 'native-staking': 'Native Staking', - 'pooled-staking': 'Pooled Staking', - 'liquid-staking': 'Liquid Staking', - staking: 'Staking', - lending: 'Lending', - vault: 'Vault', - restaking: 'Restaking', - } - if (typeMap[type]) return typeMap[type] +const getYieldTypeName = (type: string, translate: (key: string) => string): string => { + const translationKey = `earn.yieldTypes.${type}` + const translated = translate(translationKey) + if (translated !== translationKey) return translated return type .split('-') .filter((word): word is string => Boolean(word)) @@ -270,7 +263,7 @@ export const YieldSelector = memo( Object.entries(groupedYields).map(([type, typeYields]) => ( - {getYieldTypeName(type).toUpperCase()} + {getYieldTypeName(type, translate).toUpperCase()} {typeYields.map(yieldItem => ( diff --git a/src/pages/Yields/components/YieldEnterModal.tsx b/src/pages/Yields/components/YieldEnterModal.tsx index 13d297f1d31..05af8dd2a96 100644 --- a/src/pages/Yields/components/YieldEnterModal.tsx +++ b/src/pages/Yields/components/YieldEnterModal.tsx @@ -236,7 +236,7 @@ export const YieldEnterModal = memo( setSelectedPercent(null) if (isFiat) { const crypto = bnOrZero(values.value) - .div(marketData?.price ?? 1) + .div(marketData?.price || 1) .toFixed() setCryptoAmount(crypto) } else { diff --git a/src/pages/Yields/components/YieldForm.tsx b/src/pages/Yields/components/YieldForm.tsx index 41df75a0459..911ae2b85f8 100644 --- a/src/pages/Yields/components/YieldForm.tsx +++ b/src/pages/Yields/components/YieldForm.tsx @@ -1,5 +1,6 @@ import { Avatar, Box, Button, Flex, HStack, Icon, Input, Skeleton, Text } from '@chakra-ui/react' import { useQueryClient } from '@tanstack/react-query' +import type { ChangeEvent } from 'react' import { memo, useCallback, useEffect, useMemo, useState } from 'react' import { TbSwitchVertical } from 'react-icons/tb' import type { NumberFormatValues } from 'react-number-format' @@ -98,7 +99,7 @@ const getInputFontSize = (length: number): string => { type CryptoAmountInputProps = { value?: string - onChange?: (e: React.ChangeEvent) => void + onChange?: (e: ChangeEvent) => void placeholder?: string [key: string]: unknown } @@ -682,15 +683,25 @@ export const YieldForm = memo( // If Success, render YieldSuccess if (isSuccess) { + const successAmount = isClaimAction ? claimableAmount : cryptoAmount + const successSymbol = isClaimAction + ? claimableToken?.symbol ?? '' + : inputTokenAsset?.symbol ?? '' + const successMessageKey = isClaimAction + ? 'successClaim' + : action === 'exit' + ? 'successExit' + : 'successEnter' + return ( ) } diff --git a/src/pages/Yields/components/YieldItem.tsx b/src/pages/Yields/components/YieldItem.tsx index 7bc29e8df81..79e0ba10f56 100644 --- a/src/pages/Yields/components/YieldItem.tsx +++ b/src/pages/Yields/components/YieldItem.tsx @@ -355,7 +355,7 @@ export const YieldItem = memo( > {isGroup ? translate('yieldXYZ.maxApy') : translate('yieldXYZ.apy')} - + {apyFormatted} diff --git a/src/pages/Yields/hooks/useYieldTransactionFlow.ts b/src/pages/Yields/hooks/useYieldTransactionFlow.ts index 801ee375854..49040aa1467 100644 --- a/src/pages/Yields/hooks/useYieldTransactionFlow.ts +++ b/src/pages/Yields/hooks/useYieldTransactionFlow.ts @@ -91,7 +91,8 @@ export const waitForTransactionConfirmation = ( () => fetchAction(actionId), action => { const tx = action.transactions.find(t => t.id === transactionId) - return tx?.status !== TransactionStatus.Created + if (!tx) return false + return tx.status !== TransactionStatus.Created }, action => { if (action.status === YieldActionStatus.Failed) return new Error('Action failed') @@ -291,7 +292,11 @@ export const useYieldTransactionFlow = ({ assetId: inputTokenAssetId, spender: approvalSpender ?? undefined, from: userAddress || undefined, - isDisabled: !approvalSpender || !isUsdtApprovalResetEnabled || action !== 'enter', + isDisabled: + !approvalSpender || + !isUsdtApprovalResetEnabled || + action !== 'enter' || + !isUsdtOnEthereumMainnet(inputTokenAssetId, yieldChainId), isRefetchEnabled: true, }) From 473b6a5d4d68a281d15683c1ca8bdf1ef028f9b4 Mon Sep 17 00:00:00 2001 From: gomes <17035424+gomesalexandre@users.noreply.github.com> Date: Thu, 15 Jan 2026 11:14:07 +0100 Subject: [PATCH 43/60] fix(yields): use || 1 for price division consistency Matches the fix in YieldEnterModal to handle price === 0 case. Co-Authored-By: Claude Opus 4.5 --- src/pages/Yields/components/YieldForm.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/Yields/components/YieldForm.tsx b/src/pages/Yields/components/YieldForm.tsx index 911ae2b85f8..9a1e0ae5f10 100644 --- a/src/pages/Yields/components/YieldForm.tsx +++ b/src/pages/Yields/components/YieldForm.tsx @@ -321,7 +321,7 @@ export const YieldForm = memo( setSelectedPercent(null) if (isFiat) { const crypto = bnOrZero(values.value) - .div(marketData?.price ?? 1) + .div(marketData?.price || 1) .toFixed() setCryptoAmount(crypto) } else { From 0d73b26240ac89ea25551f40377741f5d63516c6 Mon Sep 17 00:00:00 2001 From: gomes <17035424+gomesalexandre@users.noreply.github.com> Date: Thu, 15 Jan 2026 11:31:07 +0100 Subject: [PATCH 44/60] fix(yields): use translation key for /yr suffix Address @coderabbitai review comment about hardcoded English strings. Use `translate('yieldXYZ.perYear')` instead of hardcoded `/yr`. Co-Authored-By: Claude Opus 4.5 --- src/components/MultiHopTrade/components/Earn/EarnConfirm.tsx | 3 ++- src/pages/Yields/components/YieldActionModal.tsx | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/components/MultiHopTrade/components/Earn/EarnConfirm.tsx b/src/components/MultiHopTrade/components/Earn/EarnConfirm.tsx index 46acb7a2e9f..3b35fcb1df5 100644 --- a/src/components/MultiHopTrade/components/Earn/EarnConfirm.tsx +++ b/src/components/MultiHopTrade/components/Earn/EarnConfirm.tsx @@ -280,7 +280,8 @@ export const EarnConfirm = memo(() => { ) : ( - {estimatedYearlyEarnings} {sellAsset?.symbol ?? ''}/yr + {estimatedYearlyEarnings} {sellAsset?.symbol ?? ''} + {translate('yieldXYZ.perYear')} {sellAmountUserCurrency && ( diff --git a/src/pages/Yields/components/YieldActionModal.tsx b/src/pages/Yields/components/YieldActionModal.tsx index 20cc67088bf..062be83fe79 100644 --- a/src/pages/Yields/components/YieldActionModal.tsx +++ b/src/pages/Yields/components/YieldActionModal.tsx @@ -130,8 +130,8 @@ export const YieldActionModal = memo(function YieldActionModal({ `${bnOrZero(amount) .times(yieldItem.rewardRate.total) .decimalPlaces(4) - .toString()} ${assetSymbol}/yr`, - [amount, yieldItem.rewardRate.total, assetSymbol], + .toString()} ${assetSymbol}${translate('yieldXYZ.perYear')}`, + [amount, yieldItem.rewardRate.total, assetSymbol, translate], ) const estimatedEarningsFiat = useMemo( From 8567c07993fd66f766818afe58f610545e406d31 Mon Sep 17 00:00:00 2001 From: gomes <17035424+gomesalexandre@users.noreply.github.com> Date: Thu, 15 Jan 2026 11:48:15 +0100 Subject: [PATCH 45/60] fix(yields): improve consistency and translations @coderabbitai - Fix YieldSelector modal to show ShapeShift DAO for Cosmos native staking - Add earningsPerYear interpolated translation for proper i18n support - Fix rewardsSchedule translation to include "every" (e.g., "every epoch") - Add ShapeShift DAO fallback to YieldActionModal for consistency - Remove redundant "Earn with" section from EarnFooter - Clean up unused imports and props Co-Authored-By: Claude Opus 4.5 --- src/assets/translations/en/main.json | 3 +- .../components/Earn/EarnConfirm.tsx | 6 +- .../components/Earn/EarnInput.tsx | 4 +- .../components/Earn/components/EarnFooter.tsx | 39 +------------ .../Earn/components/YieldSelector.tsx | 56 ++++++++++++++----- .../Yields/components/YieldActionModal.tsx | 16 ++++-- 6 files changed, 63 insertions(+), 61 deletions(-) diff --git a/src/assets/translations/en/main.json b/src/assets/translations/en/main.json index 509f171a914..69a41d16d4e 100644 --- a/src/assets/translations/en/main.json +++ b/src/assets/translations/en/main.json @@ -2762,6 +2762,7 @@ "availableToEarnTooltip": "Total idle balance across assets that have yield opportunities available.", "apyTooltip": "Annual percentage yield shown by the provider. Rates may fluctuate over time.", "perYear": "/yr", + "earningsPerYear": "%{amount} %{symbol} /yr", "recommendedForYou": "Recommended for you", "earn": "Earn", "myBalance": "My Balance", @@ -2821,7 +2822,7 @@ "liquidStakingReceive": "You'll receive %{symbol} which you can trade at any time", "liquidStakingTrade": "You can trade your liquid staking token at any time", "liquidStakingWithdraw": "When withdrawing, your assets will be available immediately", - "rewardsSchedule": "Rewards are distributed %{schedule} and accrue automatically", + "rewardsSchedule": "Rewards are distributed every %{schedule} and accrue automatically", "stakingUnbonding": "When unstaking, there is a %{days} day unbonding period before tokens are available", "restakingYield": "Restaking rewards accrue to your position automatically", "restakingWithdraw": "When withdrawing, your assets may have an unbonding period", diff --git a/src/components/MultiHopTrade/components/Earn/EarnConfirm.tsx b/src/components/MultiHopTrade/components/Earn/EarnConfirm.tsx index 3b35fcb1df5..7efead01c6a 100644 --- a/src/components/MultiHopTrade/components/Earn/EarnConfirm.tsx +++ b/src/components/MultiHopTrade/components/Earn/EarnConfirm.tsx @@ -280,8 +280,10 @@ export const EarnConfirm = memo(() => { ) : ( - {estimatedYearlyEarnings} {sellAsset?.symbol ?? ''} - {translate('yieldXYZ.perYear')} + {translate('yieldXYZ.earningsPerYear', { + amount: estimatedYearlyEarnings, + symbol: sellAsset?.symbol ?? '', + })} {sellAmountUserCurrency && ( diff --git a/src/components/MultiHopTrade/components/Earn/EarnInput.tsx b/src/components/MultiHopTrade/components/Earn/EarnInput.tsx index b994d43ca15..cb65d403c78 100644 --- a/src/components/MultiHopTrade/components/Earn/EarnInput.tsx +++ b/src/components/MultiHopTrade/components/Earn/EarnInput.tsx @@ -451,6 +451,7 @@ export const EarnInput = memo( onYieldSelect={handleYieldSelect} isLoading={isLoadingYields} sellAsset={sellAsset} + selectedValidator={selectedValidator} /> @@ -472,6 +473,7 @@ export const EarnInput = memo( selectedYieldId, yieldsForAsset, handleYieldSelect, + selectedValidator, ], ) @@ -485,7 +487,6 @@ export const EarnInput = memo( estimatedYearlyEarnings={estimatedYearlyEarnings} estimatedYearlyEarningsUserCurrency={estimatedYearlyEarningsUserCurrency} isConnected={isConnected} - selectedValidator={selectedValidator} isBelowMinimum={isBelowMinimum} isInsufficientBalance={isInsufficientBalance} networkFeeFiatUserCurrency={networkFeeFiatUserCurrency} @@ -500,7 +501,6 @@ export const EarnInput = memo( estimatedYearlyEarnings, estimatedYearlyEarningsUserCurrency, isConnected, - selectedValidator, isBelowMinimum, isInsufficientBalance, networkFeeFiatUserCurrency, diff --git a/src/components/MultiHopTrade/components/Earn/components/EarnFooter.tsx b/src/components/MultiHopTrade/components/Earn/components/EarnFooter.tsx index 8e6b5d54a0d..77f994e6e5a 100644 --- a/src/components/MultiHopTrade/components/Earn/components/EarnFooter.tsx +++ b/src/components/MultiHopTrade/components/Earn/components/EarnFooter.tsx @@ -1,16 +1,6 @@ import { InfoIcon } from '@chakra-ui/icons' import type { CardFooterProps, FlexProps } from '@chakra-ui/react' -import { - Box, - CardFooter, - Flex, - HStack, - Icon, - Image, - Skeleton, - Text, - VStack, -} from '@chakra-ui/react' +import { Box, CardFooter, Flex, HStack, Icon, Skeleton, Text, VStack } from '@chakra-ui/react' import type { Asset } from '@shapeshiftoss/types' import { memo, useMemo } from 'react' import { FaGift } from 'react-icons/fa' @@ -20,7 +10,7 @@ import { useTranslate } from 'react-polyglot' import { Amount } from '@/components/Amount/Amount' import { ButtonWalletPredicate } from '@/components/ButtonWalletPredicate/ButtonWalletPredicate' import { bnOrZero } from '@/lib/bignumber/bignumber' -import type { AugmentedYieldDto, ValidatorDto } from '@/lib/yieldxyz/types' +import type { AugmentedYieldDto } from '@/lib/yieldxyz/types' import { GradientApy } from '@/pages/Yields/components/GradientApy' type EarnFooterProps = { @@ -31,16 +21,12 @@ type EarnFooterProps = { estimatedYearlyEarnings: string | undefined estimatedYearlyEarningsUserCurrency: string | undefined isConnected: boolean - selectedValidator: ValidatorDto | undefined isBelowMinimum: boolean isInsufficientBalance: boolean networkFeeFiatUserCurrency: string | undefined isQuoteLoading: boolean } -const isStakingType = (type: string | undefined): boolean => - ['native-staking', 'pooled-staking', 'staking'].includes(type ?? '') - const footerBgProp = { base: 'background.surface.base', md: 'transparent' } const footerPosition: CardFooterProps['position'] = { base: 'sticky', md: 'static' } @@ -137,7 +123,6 @@ export const EarnFooter = memo( estimatedYearlyEarnings, estimatedYearlyEarningsUserCurrency, isConnected, - selectedValidator, isBelowMinimum, isInsufficientBalance, networkFeeFiatUserCurrency, @@ -207,26 +192,6 @@ export const EarnFooter = memo( borderBottomRadius='xl' width='full' > - {selectedYield && isStakingType(selectedYield.mechanics.type) && ( - - - {translate('earn.earnWith')} - - - {selectedValidator?.name - - {selectedValidator?.name ?? selectedYield.metadata.name} - - - - )} - {selectedYield && hasUserEnteredAmount && ( diff --git a/src/components/MultiHopTrade/components/Earn/components/YieldSelector.tsx b/src/components/MultiHopTrade/components/Earn/components/YieldSelector.tsx index 342db8e0023..ed06c41e1e5 100644 --- a/src/components/MultiHopTrade/components/Earn/components/YieldSelector.tsx +++ b/src/components/MultiHopTrade/components/Earn/components/YieldSelector.tsx @@ -19,11 +19,13 @@ import { useDisclosure, VStack, } from '@chakra-ui/react' +import { cosmosChainId } from '@shapeshiftoss/caip' import type { Asset } from '@shapeshiftoss/types' import { memo, useCallback, useMemo, useState } from 'react' import { useTranslate } from 'react-polyglot' -import type { AugmentedYieldDto, ProviderDto } from '@/lib/yieldxyz/types' +import { SHAPESHIFT_VALIDATOR_LOGO, SHAPESHIFT_VALIDATOR_NAME } from '@/lib/yieldxyz/constants' +import type { AugmentedYieldDto, ProviderDto, ValidatorDto } from '@/lib/yieldxyz/types' import { GradientApy } from '@/pages/Yields/components/GradientApy' import { useYieldProviders } from '@/react-queries/queries/yieldxyz/useYieldProviders' @@ -36,12 +38,21 @@ type YieldSelectorProps = { onYieldSelect: (yieldId: string) => void isLoading: boolean sellAsset: Asset + selectedValidator?: ValidatorDto } -const getProviderInfo = ( +const isNativeStaking = (type: string | undefined): boolean => + ['native-staking', 'staking'].includes(type ?? '') + +const getDisplayInfo = ( yieldItem: AugmentedYieldDto, providers: Record | undefined, ): { name: string; logoURI: string | undefined } => { + // For Cosmos native staking, always show ShapeShift DAO + if (isNativeStaking(yieldItem.mechanics.type) && yieldItem.chainId === cosmosChainId) { + return { name: SHAPESHIFT_VALIDATOR_NAME, logoURI: SHAPESHIFT_VALIDATOR_LOGO } + } + // For other yields, show provider info const provider = providers?.[yieldItem.providerId] if (provider) { return { name: provider.name, logoURI: provider.logoURI } @@ -81,10 +92,7 @@ const YieldItem = memo( return `${apy.toFixed(2)}%` }, [yieldItem.rewardRate?.total]) - const providerInfo = useMemo( - () => getProviderInfo(yieldItem, providers), - [yieldItem, providers], - ) + const providerInfo = useMemo(() => getDisplayInfo(yieldItem, providers), [yieldItem, providers]) return ( + ['native-staking', 'pooled-staking', 'staking'].includes(type ?? '') + export const YieldSelector = memo( - ({ selectedYieldId, yields, onYieldSelect, isLoading, sellAsset }: YieldSelectorProps) => { + ({ + selectedYieldId, + yields, + onYieldSelect, + isLoading, + sellAsset, + selectedValidator, + }: YieldSelectorProps) => { const translate = useTranslate() const { isOpen, onOpen, onClose } = useDisclosure() const borderColor = useColorModeValue('gray.200', 'gray.700') @@ -177,10 +195,18 @@ export const YieldSelector = memo( return `${apy.toFixed(2)}%` }, [selectedYield]) - const selectedProviderInfo = useMemo( - () => (selectedYield ? getProviderInfo(selectedYield, providers) : null), - [selectedYield, providers], - ) + const selectedDisplayInfo = useMemo(() => { + if (!selectedYield) return null + // For staking yields with a validator, show validator info + if (isStakingType(selectedYield.mechanics.type) && selectedValidator) { + return { + name: selectedValidator.name, + logoURI: selectedValidator.logoURI, + } + } + // Otherwise show provider info + return getDisplayInfo(selectedYield, providers) + }, [selectedYield, selectedValidator, providers]) if (isLoading) { return @@ -213,17 +239,17 @@ export const YieldSelector = memo( rightIcon={chevronDownIcon} justifyContent='space-between' > - {selectedYield && selectedProviderInfo ? ( + {selectedYield && selectedDisplayInfo ? ( {selectedProviderInfo.name} - {selectedProviderInfo.name} + {selectedDisplayInfo.name} diff --git a/src/pages/Yields/components/YieldActionModal.tsx b/src/pages/Yields/components/YieldActionModal.tsx index 062be83fe79..719933082fe 100644 --- a/src/pages/Yields/components/YieldActionModal.tsx +++ b/src/pages/Yields/components/YieldActionModal.tsx @@ -10,6 +10,11 @@ import { DialogFooter } from '@/components/Modal/components/DialogFooter' import { DialogHeader } from '@/components/Modal/components/DialogHeader' import { DialogTitle } from '@/components/Modal/components/DialogTitle' import { bnOrZero } from '@/lib/bignumber/bignumber' +import { + SHAPESHIFT_COSMOS_VALIDATOR_ADDRESS, + SHAPESHIFT_VALIDATOR_LOGO, + SHAPESHIFT_VALIDATOR_NAME, +} from '@/lib/yieldxyz/constants' import type { AugmentedYieldDto } from '@/lib/yieldxyz/types' import { getTransactionButtonText } from '@/lib/yieldxyz/utils' import { GradientApy } from '@/pages/Yields/components/GradientApy' @@ -103,6 +108,9 @@ export const YieldActionModal = memo(function YieldActionModal({ if (yieldItem.mechanics.type === 'staking' && validatorAddress) { const validator = validators?.find(v => v.address === validatorAddress) if (validator) return { name: validator.name, logoURI: validator.logoURI } + if (validatorAddress === SHAPESHIFT_COSMOS_VALIDATOR_ADDRESS) { + return { name: SHAPESHIFT_VALIDATOR_NAME, logoURI: SHAPESHIFT_VALIDATOR_LOGO } + } if (validatorName) return { name: validatorName, logoURI: validatorLogoURI } } const provider = providers?.[yieldItem.providerId] @@ -127,10 +135,10 @@ export const YieldActionModal = memo(function YieldActionModal({ const estimatedEarningsAmount = useMemo( () => - `${bnOrZero(amount) - .times(yieldItem.rewardRate.total) - .decimalPlaces(4) - .toString()} ${assetSymbol}${translate('yieldXYZ.perYear')}`, + translate('yieldXYZ.earningsPerYear', { + amount: bnOrZero(amount).times(yieldItem.rewardRate.total).decimalPlaces(4).toString(), + symbol: assetSymbol, + }), [amount, yieldItem.rewardRate.total, assetSymbol, translate], ) From 8ce1b45cdc5d3a402f1ca959272e18a114f4c0a3 Mon Sep 17 00:00:00 2001 From: gomes <17035424+gomesalexandre@users.noreply.github.com> Date: Thu, 15 Jan 2026 11:50:42 +0100 Subject: [PATCH 46/60] fix(yields): fetch validators in modal for consistent display @coderabbitai - YieldItem now fetches validators via react-query for staking yields - Shows default/preferred validator name in modal (e.g., Luganodes for Solana) - Validators are cached by react-query, so subsequent renders are instant Co-Authored-By: Claude Opus 4.5 --- .../Earn/components/YieldSelector.tsx | 34 ++++++++++++++++--- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/src/components/MultiHopTrade/components/Earn/components/YieldSelector.tsx b/src/components/MultiHopTrade/components/Earn/components/YieldSelector.tsx index ed06c41e1e5..074102c40aa 100644 --- a/src/components/MultiHopTrade/components/Earn/components/YieldSelector.tsx +++ b/src/components/MultiHopTrade/components/Earn/components/YieldSelector.tsx @@ -24,10 +24,15 @@ import type { Asset } from '@shapeshiftoss/types' import { memo, useCallback, useMemo, useState } from 'react' import { useTranslate } from 'react-polyglot' -import { SHAPESHIFT_VALIDATOR_LOGO, SHAPESHIFT_VALIDATOR_NAME } from '@/lib/yieldxyz/constants' +import { + DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID, + SHAPESHIFT_VALIDATOR_LOGO, + SHAPESHIFT_VALIDATOR_NAME, +} from '@/lib/yieldxyz/constants' import type { AugmentedYieldDto, ProviderDto, ValidatorDto } from '@/lib/yieldxyz/types' import { GradientApy } from '@/pages/Yields/components/GradientApy' import { useYieldProviders } from '@/react-queries/queries/yieldxyz/useYieldProviders' +import { useYieldValidators } from '@/react-queries/queries/yieldxyz/useYieldValidators' const chevronDownIcon = const searchIcon = @@ -87,12 +92,31 @@ const YieldItem = memo( }) => { const selectedBg = useColorModeValue('blue.50', 'whiteAlpha.100') + const requiresValidator = yieldItem.mechanics.requiresValidatorSelection + const { data: validators } = useYieldValidators(yieldItem.id, requiresValidator) + const apyDisplay = useMemo(() => { const apy = (yieldItem.rewardRate?.total ?? 0) * 100 return `${apy.toFixed(2)}%` }, [yieldItem.rewardRate?.total]) - const providerInfo = useMemo(() => getDisplayInfo(yieldItem, providers), [yieldItem, providers]) + const displayInfo = useMemo(() => { + // For staking yields with validators, show the default validator + if (requiresValidator && validators?.length) { + const chainId = yieldItem.chainId + const defaultAddress = chainId ? DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID[chainId] : undefined + const defaultValidator = defaultAddress + ? validators.find(v => v.address === defaultAddress) + : undefined + const preferredValidator = validators.find(v => v.preferred) ?? validators[0] + const validator = defaultValidator ?? preferredValidator + if (validator) { + return { name: validator.name, logoURI: validator.logoURI } + } + } + // Fall back to the static display info (includes Cosmos ShapeShift DAO fallback) + return getDisplayInfo(yieldItem, providers) + }, [requiresValidator, validators, yieldItem, providers]) return ( {providerInfo.name} - {providerInfo.name} + {displayInfo.name} From bf72ef90b0678fb868177297632b6756ace09b38 Mon Sep 17 00:00:00 2001 From: gomes <17035424+gomesalexandre@users.noreply.github.com> Date: Thu, 15 Jan 2026 12:38:56 +0100 Subject: [PATCH 47/60] feat(yields): consistent validator/provider display across cards and detail pages - Add "Native Staking" title for all native staking yields (ATOM, SOL, etc.) - Show ShapeShift DAO for Cosmos, Figment for Solana native staking - Use provider info (from yieldProviders) for non-native staking yields - Fix yield cards showing wrong provider name (e.g., "Stakekit" instead of "Morpho") - Add titleOverride prop to YieldHero and YieldItem for consistent naming - Add Figment validator constants for Solana @coderabbitai - Addressed your comments: polling completion now properly handles undefined tx (returns false instead of true), and display info now correctly uses yieldProviders for non-native-staking yields. Co-Authored-By: Claude Opus 4.5 --- src/assets/translations/en/main.json | 1 + src/lib/yieldxyz/constants.ts | 7 + src/pages/Yields/YieldAssetDetails.tsx | 92 +++++-- src/pages/Yields/YieldDetail.tsx | 52 +++- src/pages/Yields/components/YieldHero.tsx | 11 +- src/pages/Yields/components/YieldItem.tsx | 3 +- src/pages/Yields/components/YieldManager.tsx | 21 +- src/pages/Yields/components/YieldStats.tsx | 21 +- src/pages/Yields/components/YieldsList.tsx | 245 ++++++++++++------- 9 files changed, 325 insertions(+), 128 deletions(-) diff --git a/src/assets/translations/en/main.json b/src/assets/translations/en/main.json index 69a41d16d4e..ca2f8482065 100644 --- a/src/assets/translations/en/main.json +++ b/src/assets/translations/en/main.json @@ -2699,6 +2699,7 @@ "earnUpToOnBalance": "You could earn up to %{apy} on your balance", "startEarning": "Start earning", "maxApy": "Max APY", + "nativeStaking": "Native Staking", "validator": "Validator", "entered": "Entered", "claimable": "Claimable", diff --git a/src/lib/yieldxyz/constants.ts b/src/lib/yieldxyz/constants.ts index 55a937d04ba..bd8cc1d2c52 100644 --- a/src/lib/yieldxyz/constants.ts +++ b/src/lib/yieldxyz/constants.ts @@ -87,7 +87,14 @@ export const SHAPESHIFT_VALIDATOR: ValidatorDto = { export const COSMOS_DECIMALS = 6 export const COSMOS_ATOM_NATIVE_STAKING_YIELD_ID = 'cosmos-atom-native-staking' +export const SOLANA_SOL_NATIVE_MULTIVALIDATOR_STAKING_YIELD_ID = + 'solana-sol-native-multivalidator-staking' + +export const FIGMENT_SOLANA_VALIDATOR_ADDRESS = 'CcaHc2L43ZWjwCHART3oZoJvHLAe9hzT2DJNUpBzoTN1' +export const FIGMENT_VALIDATOR_NAME = 'Figment' +export const FIGMENT_VALIDATOR_LOGO = 'https://assets.stakek.it/validators/figment.png' export const DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID: Partial> = { [cosmosChainId]: SHAPESHIFT_COSMOS_VALIDATOR_ADDRESS, + [solanaChainId]: FIGMENT_SOLANA_VALIDATOR_ADDRESS, } diff --git a/src/pages/Yields/YieldAssetDetails.tsx b/src/pages/Yields/YieldAssetDetails.tsx index 8e0b66fafe1..6cf98a882c3 100644 --- a/src/pages/Yields/YieldAssetDetails.tsx +++ b/src/pages/Yields/YieldAssetDetails.tsx @@ -22,7 +22,15 @@ import { Amount } from '@/components/Amount/Amount' import { AssetIcon } from '@/components/AssetIcon' import { ChainIcon } from '@/components/ChainMenu' import { bnOrZero } from '@/lib/bignumber/bignumber' -import { YIELD_NETWORK_TO_CHAIN_ID } from '@/lib/yieldxyz/constants' +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 type { AugmentedYieldDto, YieldNetwork } from '@/lib/yieldxyz/types' import { resolveYieldInputAssetIcon } from '@/lib/yieldxyz/utils' import { GradientApy } from '@/pages/Yields/components/GradientApy' @@ -85,6 +93,41 @@ export const YieldAssetDetails = memo(() => { [yieldProviders], ) + const getYieldDisplayInfo = useCallback( + (yieldItem: AugmentedYieldDto) => { + const isNativeStaking = + yieldItem.mechanics.type === 'staking' && 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 assetYields = useMemo( () => (yields?.byAssetSymbol && decodedSymbol ? yields.byAssetSymbol[decodedSymbol] || [] : []), [yields, decodedSymbol], @@ -371,29 +414,34 @@ export const YieldAssetDetails = memo(() => { const gridViewElement = useMemo( () => ( - {sortedRows.map(row => ( - handleYieldClick(row.original.id)} - userBalanceUsd={ - allBalances?.[row.original.id] - ? allBalances[row.original.id].reduce( - (sum, b) => sum.plus(bnOrZero(b.amountUsd)), - bnOrZero(0), - ) - : undefined - } - /> - ))} + {sortedRows.map(row => { + const displayInfo = getYieldDisplayInfo(row.original) + return ( + handleYieldClick(row.original.id)} + titleOverride={displayInfo.title} + userBalanceUsd={ + allBalances?.[row.original.id] + ? allBalances[row.original.id].reduce( + (sum, b) => sum.plus(bnOrZero(b.amountUsd)), + bnOrZero(0), + ) + : undefined + } + /> + ) + })} ), - [allBalances, getProviderLogo, handleYieldClick, isMobile, sortedRows], + [allBalances, getYieldDisplayInfo, handleYieldClick, isMobile, sortedRows], ) const listViewElement = useMemo( diff --git a/src/pages/Yields/YieldDetail.tsx b/src/pages/Yields/YieldDetail.tsx index 18b28b503b1..705688a8c6d 100644 --- a/src/pages/Yields/YieldDetail.tsx +++ b/src/pages/Yields/YieldDetail.tsx @@ -5,9 +5,11 @@ import { useNavigate, useParams, useSearchParams } from 'react-router-dom' import { bnOrZero } from '@/lib/bignumber/bignumber' import { + COSMOS_ATOM_NATIVE_STAKING_YIELD_ID, DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID, SHAPESHIFT_COSMOS_VALIDATOR_ADDRESS, SHAPESHIFT_VALIDATOR_LOGO, + SOLANA_SOL_NATIVE_MULTIVALIDATOR_STAKING_YIELD_ID, } from '@/lib/yieldxyz/constants' import { YieldBalanceType } from '@/lib/yieldxyz/types' import { YieldHero } from '@/pages/Yields/components/YieldHero' @@ -16,7 +18,6 @@ import { YieldPositionCard } from '@/pages/Yields/components/YieldPositionCard' import { YieldStats } from '@/pages/Yields/components/YieldStats' import { useAllYieldBalances } from '@/react-queries/queries/yieldxyz/useAllYieldBalances' import { useYield } from '@/react-queries/queries/yieldxyz/useYield' -import { useYieldProviders } from '@/react-queries/queries/yieldxyz/useYieldProviders' import { useYieldValidators } from '@/react-queries/queries/yieldxyz/useYieldValidators' import { selectUserCurrencyToUsdRate } from '@/state/slices/selectors' import { useAppSelector } from '@/state/store' @@ -41,10 +42,17 @@ export const YieldDetail = memo(() => { yieldItem?.chainId ? DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID[yieldItem.chainId] : undefined, [yieldItem?.chainId], ) - const selectedValidatorAddress = useMemo( - () => validatorParam || defaultValidator, - [validatorParam, defaultValidator], - ) + const selectedValidatorAddress = useMemo(() => { + // For native staking with hardcoded defaults, always use the default validator (ignore URL param) + if ( + yieldId === COSMOS_ATOM_NATIVE_STAKING_YIELD_ID || + yieldId === SOLANA_SOL_NATIVE_MULTIVALIDATOR_STAKING_YIELD_ID || + (yieldId?.includes('solana') && yieldId?.includes('native')) + ) { + return defaultValidator + } + return validatorParam || defaultValidator + }, [yieldId, validatorParam, defaultValidator]) const isStaking = yieldItem?.mechanics.type === 'staking' const shouldFetchValidators = useMemo( @@ -52,7 +60,6 @@ export const YieldDetail = memo(() => { [isStaking, yieldItem?.mechanics.requiresValidatorSelection], ) const { data: validators } = useYieldValidators(yieldItem?.id ?? '', shouldFetchValidators) - const { data: providers } = useYieldProviders() const validatorOrProvider = useMemo(() => { if (isStaking && selectedValidatorAddress) { @@ -62,12 +69,36 @@ export const YieldDetail = memo(() => { return { name: SHAPESHIFT_VALIDATOR_NAME, logoURI: SHAPESHIFT_VALIDATOR_LOGO } } } - if (!isStaking && yieldItem && providers) { - const provider = providers[yieldItem.providerId] - if (provider) return { name: provider.name, logoURI: provider.logoURI } + if (!isStaking && yieldItem) { + // Extract protocol name from metadata (e.g., "JustLend" from "JustLend Staked TRX") + const metadataName = yieldItem.metadata.name + const stakedParts = metadataName.split(' Staked ') + if (stakedParts.length > 1 && stakedParts[0]) { + return { name: stakedParts[0], logoURI: yieldItem.metadata.logoURI } + } + const onParts = metadataName.split(' on ') + if (onParts.length > 1 && onParts[1]) { + return { name: onParts[1], logoURI: yieldItem.metadata.logoURI } + } + // Fallback: capitalize the yield type + const yieldType = yieldItem.mechanics.type + .split('-') + .filter((word): word is string => Boolean(word)) + .map(word => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' ') + return { name: yieldType, logoURI: yieldItem.metadata.logoURI } } return null - }, [isStaking, selectedValidatorAddress, validators, yieldItem, providers]) + }, [isStaking, selectedValidatorAddress, validators, yieldItem]) + + const titleOverride = useMemo(() => { + if (!yieldItem) return undefined + const isNativeStaking = + yieldItem.mechanics.type === 'staking' && yieldItem.mechanics.requiresValidatorSelection + if (isNativeStaking) return translate('yieldXYZ.nativeStaking') + // For non-native staking, use token symbol (consistent with cards) + return yieldItem.token.symbol + }, [yieldItem, translate]) const userBalances = useMemo(() => { if (!balances) return { usd: '0', crypto: '0' } @@ -154,6 +185,7 @@ export const YieldDetail = memo(() => { userBalanceUsd={userBalances.usd} userBalanceCrypto={userBalances.crypto} validatorOrProvider={validatorOrProvider} + titleOverride={titleOverride} /> diff --git a/src/pages/Yields/components/YieldHero.tsx b/src/pages/Yields/components/YieldHero.tsx index 0788591af19..6d1552c1f96 100644 --- a/src/pages/Yields/components/YieldHero.tsx +++ b/src/pages/Yields/components/YieldHero.tsx @@ -37,10 +37,17 @@ type YieldHeroProps = { userBalanceUsd: string userBalanceCrypto: string validatorOrProvider: ValidatorOrProviderInfo + titleOverride?: string } export const YieldHero = memo( - ({ yieldItem, userBalanceUsd, userBalanceCrypto, validatorOrProvider }: YieldHeroProps) => { + ({ + yieldItem, + userBalanceUsd, + userBalanceCrypto, + validatorOrProvider, + titleOverride, + }: YieldHeroProps) => { const navigate = useNavigate() const translate = useTranslate() const { location } = useBrowserRouter() @@ -90,7 +97,7 @@ export const YieldHero = memo( [yieldItem.mechanics.type, translate], ) - const yieldTitle = yieldItem.metadata.name ?? yieldItem.token.symbol + const yieldTitle = titleOverride ?? yieldItem.metadata.name ?? yieldItem.token.symbol const stackedIconElement = useMemo(() => { const assetIcon = iconSource.assetId ? ( diff --git a/src/pages/Yields/components/YieldItem.tsx b/src/pages/Yields/components/YieldItem.tsx index 79e0ba10f56..22049d633c6 100644 --- a/src/pages/Yields/components/YieldItem.tsx +++ b/src/pages/Yields/components/YieldItem.tsx @@ -33,6 +33,7 @@ type SingleYieldData = { type: 'single' yieldItem: AugmentedYieldDto providerIcon?: string + providerName?: string } type GroupYieldData = { @@ -152,7 +153,7 @@ export const YieldItem = memo( const subtitle = useMemo(() => { if (isSingle) { - return data.yieldItem.providerId + return data.providerName ?? data.yieldItem.providerId } return `${stats.count} ${ stats.count === 1 ? translate('yieldXYZ.market') : translate('yieldXYZ.markets') diff --git a/src/pages/Yields/components/YieldManager.tsx b/src/pages/Yields/components/YieldManager.tsx index 7b8fa2bdfb5..70dd74070bd 100644 --- a/src/pages/Yields/components/YieldManager.tsx +++ b/src/pages/Yields/components/YieldManager.tsx @@ -9,6 +9,11 @@ import { DialogBody } from '@/components/Modal/components/DialogBody' import { DialogCloseButton } from '@/components/Modal/components/DialogCloseButton' import { DialogHeader } from '@/components/Modal/components/DialogHeader' import { DialogTitle } from '@/components/Modal/components/DialogTitle' +import { + COSMOS_ATOM_NATIVE_STAKING_YIELD_ID, + DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID, + SOLANA_SOL_NATIVE_MULTIVALIDATOR_STAKING_YIELD_ID, +} from '@/lib/yieldxyz/constants' import { YieldBalanceType } from '@/lib/yieldxyz/types' import { useYieldAccount } from '@/pages/Yields/YieldAccountContext' import { useAllYieldBalances } from '@/react-queries/queries/yieldxyz/useAllYieldBalances' @@ -21,9 +26,23 @@ export const YieldManager = () => { const [searchParams] = useSearchParams() const action = searchParams.get('action') as 'enter' | 'exit' | 'claim' | undefined - const validatorAddress = searchParams.get('validator') ?? undefined + const validatorParam = searchParams.get('validator') ?? undefined const { data: yieldItem } = useYield(yieldId ?? '') + + const validatorAddress = useMemo(() => { + // For native staking with hardcoded defaults, always use the default validator (ignore URL param) + if ( + yieldId === COSMOS_ATOM_NATIVE_STAKING_YIELD_ID || + yieldId === SOLANA_SOL_NATIVE_MULTIVALIDATOR_STAKING_YIELD_ID || + (yieldId?.includes('solana') && yieldId?.includes('native')) + ) { + return yieldItem?.chainId + ? DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID[yieldItem.chainId] + : undefined + } + return validatorParam + }, [yieldId, yieldItem?.chainId, validatorParam]) const { accountNumber } = useYieldAccount() const { data: allBalancesData } = useAllYieldBalances() const balances = yieldItem?.id ? allBalancesData?.normalized[yieldItem.id] : undefined diff --git a/src/pages/Yields/components/YieldStats.tsx b/src/pages/Yields/components/YieldStats.tsx index a011ad75ed3..981e3ca7375 100644 --- a/src/pages/Yields/components/YieldStats.tsx +++ b/src/pages/Yields/components/YieldStats.tsx @@ -5,7 +5,11 @@ import { useSearchParams } from 'react-router-dom' import { Amount } from '@/components/Amount/Amount' import { bnOrZero } from '@/lib/bignumber/bignumber' -import { DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID } from '@/lib/yieldxyz/constants' +import { + COSMOS_ATOM_NATIVE_STAKING_YIELD_ID, + DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID, + SOLANA_SOL_NATIVE_MULTIVALIDATOR_STAKING_YIELD_ID, +} from '@/lib/yieldxyz/constants' import type { AugmentedYieldDto } from '@/lib/yieldxyz/types' import type { NormalizedYieldBalances } from '@/react-queries/queries/yieldxyz/useAllYieldBalances' import { useYieldValidators } from '@/react-queries/queries/yieldxyz/useYieldValidators' @@ -43,10 +47,17 @@ export const YieldStats = memo(({ yieldItem, balances }: YieldStatsProps) => { return validators?.[0]?.address }, [yieldItem.chainId, validators]) - const selectedValidatorAddress = useMemo( - () => validatorParam || defaultValidator, - [validatorParam, defaultValidator], - ) + const selectedValidatorAddress = useMemo(() => { + // For native staking with hardcoded defaults, always use the default validator (ignore URL param) + if ( + yieldItem.id === COSMOS_ATOM_NATIVE_STAKING_YIELD_ID || + yieldItem.id === SOLANA_SOL_NATIVE_MULTIVALIDATOR_STAKING_YIELD_ID || + (yieldItem.id.includes('solana') && yieldItem.id.includes('native')) + ) { + return defaultValidator + } + return validatorParam || defaultValidator + }, [yieldItem.id, validatorParam, defaultValidator]) const selectedValidator = useMemo(() => { if (!selectedValidatorAddress) return undefined diff --git a/src/pages/Yields/components/YieldsList.tsx b/src/pages/Yields/components/YieldsList.tsx index 98a2acb1254..64087a0a4b5 100644 --- a/src/pages/Yields/components/YieldsList.tsx +++ b/src/pages/Yields/components/YieldsList.tsx @@ -33,7 +33,17 @@ import { ResultsEmptyNoWallet } from '@/components/ResultsEmptyNoWallet' import { useWallet } from '@/hooks/useWallet/useWallet' import { bnOrZero } from '@/lib/bignumber/bignumber' import { fromBaseUnit } from '@/lib/math' -import { YIELD_NETWORK_TO_CHAIN_ID } from '@/lib/yieldxyz/constants' +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, + YIELD_NETWORK_TO_CHAIN_ID, +} from '@/lib/yieldxyz/constants' import type { AugmentedYieldDto, YieldNetwork } from '@/lib/yieldxyz/types' import { resolveYieldInputAssetIcon, searchYields } from '@/lib/yieldxyz/utils' import { YieldFilters } from '@/pages/Yields/components/YieldFilters' @@ -143,6 +153,70 @@ export const YieldsList = memo(() => { [yieldProviders], ) + const getYieldDisplayInfo = useCallback( + (yieldItem: AugmentedYieldDto) => { + const isNativeStaking = + yieldItem.mechanics.type === 'staking' && 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] + 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 + return yieldBalances.reduce((sum, b) => sum.plus(bnOrZero(b.amountUsd)), bnOrZero(0)) + }, + [allBalances], + ) + const handleSearchChange = useCallback( (e: React.ChangeEvent) => setSearchQuery(e.target.value), [], @@ -205,9 +279,9 @@ export const YieldsList = memo(() => { if (filteredYields.length === 0) return null const userGroupBalanceUsd = filteredYields.reduce((acc, y) => { - const balances = allBalances?.[y.id] - if (!balances) return acc - return balances.reduce((sum, b) => sum.plus(bnOrZero(b.amountUsd)), acc) + const balance = getYieldPositionBalanceUsd(y.id) + if (!balance) return acc + return acc.plus(balance) }, bnOrZero(0)) return { @@ -260,7 +334,7 @@ export const YieldsList = memo(() => { selectedProvider, selectedType, searchQuery, - allBalances, + getYieldPositionBalanceUsd, sortOption, userCurrencyBalances, ]) @@ -448,18 +522,17 @@ export const YieldsList = memo(() => { accessorFn: row => row.providerId, enableSorting: true, sortingFn: 'alphanumeric', - cell: ({ row }) => ( - - - - {row.original.providerId} - - - ), + cell: ({ row }) => { + const tableDisplayInfo = getYieldDisplayInfo(row.original) + return ( + + + + {tableDisplayInfo.name ?? row.original.providerId} + + + ) + }, meta: { display: { base: 'none', md: 'table-cell' } }, }, { @@ -518,29 +591,17 @@ export const YieldsList = memo(() => { header: translate('yieldXYZ.yourBalance'), id: 'balance', accessorFn: row => { - const balances = allBalances?.[row.id] - if (!balances) return 0 - return balances - .reduce((sum, b) => sum.plus(bnOrZero(b.amountUsd)), bnOrZero(0)) - .toNumber() + const balance = getYieldPositionBalanceUsd(row.id) + return balance?.toNumber() ?? 0 }, enableSorting: true, sortingFn: (rowA, rowB) => { - const balancesA = allBalances?.[rowA.original.id] - const balancesB = allBalances?.[rowB.original.id] - const a = balancesA - ? balancesA.reduce((sum, b) => sum.plus(bnOrZero(b.amountUsd)), bnOrZero(0)).toNumber() - : 0 - const b = balancesB - ? balancesB.reduce((sum, b) => sum.plus(bnOrZero(b.amountUsd)), bnOrZero(0)).toNumber() - : 0 + const a = getYieldPositionBalanceUsd(rowA.original.id)?.toNumber() ?? 0 + const b = getYieldPositionBalanceUsd(rowB.original.id)?.toNumber() ?? 0 return a === b ? 0 : a > b ? 1 : -1 }, cell: ({ row }) => { - const balances = allBalances?.[row.original.id] - const totalUsd = balances - ? balances.reduce((sum, b) => sum.plus(bnOrZero(b.amountUsd)), bnOrZero(0)) - : bnOrZero(0) + const totalUsd = getYieldPositionBalanceUsd(row.original.id) ?? bnOrZero(0) if (totalUsd.lte(0)) return null const totalUserCurrency = totalUsd.times(userCurrencyToUsdRate).toFixed() return ( @@ -552,7 +613,7 @@ export const YieldsList = memo(() => { meta: { display: { base: 'none', lg: 'table-cell' } }, }, ], - [translate, getProviderLogo, allBalances, userCurrencyToUsdRate], + [translate, getYieldDisplayInfo, getYieldPositionBalanceUsd, userCurrencyToUsdRate], ) const positionsTable = useReactTable({ @@ -611,17 +672,16 @@ export const YieldsList = memo(() => { return ( {availableYields.map(item => { - const yieldBalances = allBalances?.[item.yield.id] - const positionBalanceUsd = yieldBalances - ? yieldBalances.reduce((sum, b) => sum.plus(bnOrZero(b.amountUsd)), bnOrZero(0)) - : undefined + const positionBalanceUsd = getYieldPositionBalanceUsd(item.yield.id) + const displayInfo = getYieldDisplayInfo(item.yield) return ( { const availableUsd = inputAssetId ? bnOrZero(userCurrencyBalances[inputAssetId]) : undefined + const singleDisplayInfo = getYieldDisplayInfo(singleYield) return ( { }, [ isMyOpportunities, availableYields, - allBalances, + getYieldPositionBalanceUsd, filterSearchString, yieldsByAsset, isMobile, - getProviderLogo, + getYieldDisplayInfo, handleYieldClick, userCurrencyBalances, ]) @@ -737,17 +799,16 @@ export const YieldsList = memo(() => { {listHeader} {availableYields.map(item => { - const yieldBalances = allBalances?.[item.yield.id] - const positionBalanceUsd = yieldBalances - ? yieldBalances.reduce((sum, b) => sum.plus(bnOrZero(b.amountUsd)), bnOrZero(0)) - : undefined + const positionBalanceUsd = getYieldPositionBalanceUsd(item.yield.id) + const rowDisplayInfo = getYieldDisplayInfo(item.yield) return ( { const availableUsd = inputAssetId ? bnOrZero(userCurrencyBalances[inputAssetId]) : undefined + const rowSingleDisplayInfo = getYieldDisplayInfo(singleYield) return ( { }, [ isMyOpportunities, availableYields, - allBalances, + getYieldPositionBalanceUsd, filterSearchString, translate, yieldsByAsset, - getProviderLogo, + getYieldDisplayInfo, handleYieldClick, userCurrencyBalances, ]) @@ -828,20 +891,24 @@ export const YieldsList = memo(() => { {translate('yieldXYZ.recommendedForYou')} - {recommendedYields.map(rec => ( - handleYieldClick(rec.yield.id)} - /> - ))} + {recommendedYields.map(rec => { + const recDisplayInfo = getYieldDisplayInfo(rec.yield) + return ( + handleYieldClick(rec.yield.id)} + /> + ) + })} ) @@ -850,7 +917,7 @@ export const YieldsList = memo(() => { recommendedYields, isMyOpportunities, translate, - getProviderLogo, + getYieldDisplayInfo, handleYieldClick, isMobile, ]) @@ -905,29 +972,33 @@ export const YieldsList = memo(() => { const positionsGridElement = useMemo( () => ( - {positionsTable.getRowModel().rows.map(row => ( - handleYieldClick(row.original.id)} - userBalanceUsd={ - allBalances?.[row.original.id] - ? allBalances[row.original.id].reduce( - (sum, b) => sum.plus(bnOrZero(b.amountUsd)), - bnOrZero(0), - ) - : undefined - } - /> - ))} + {positionsTable.getRowModel().rows.map(row => { + const posDisplayInfo = getYieldDisplayInfo(row.original) + return ( + handleYieldClick(row.original.id)} + userBalanceUsd={ + allBalances?.[row.original.id] + ? allBalances[row.original.id].reduce( + (sum, b) => sum.plus(bnOrZero(b.amountUsd)), + bnOrZero(0), + ) + : undefined + } + /> + ) + })} ), - [allBalances, getProviderLogo, handleYieldClick, positionsTable, isMobile], + [allBalances, getYieldDisplayInfo, handleYieldClick, positionsTable, isMobile], ) const positionsListElement = useMemo( From ba791fb0a2e07042e54f1ea44d567d22cac226a2 Mon Sep 17 00:00:00 2001 From: gomes <17035424+gomesalexandre@users.noreply.github.com> Date: Thu, 15 Jan 2026 12:41:52 +0100 Subject: [PATCH 48/60] refactor: remove duplicate selectEarnHasUserEnteredAmount selector Use selectHasUserEnteredAmount from base selectors instead of maintaining a duplicate wrapper selector that performs identical logic. @coderabbitai - Addressed your comment about duplicate selector. Co-Authored-By: Claude Opus 4.5 --- src/components/MultiHopTrade/components/Earn/EarnInput.tsx | 4 ++-- src/state/slices/tradeEarnInputSlice/selectors.ts | 6 ------ 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/src/components/MultiHopTrade/components/Earn/EarnInput.tsx b/src/components/MultiHopTrade/components/Earn/EarnInput.tsx index cb65d403c78..54be93ac2ab 100644 --- a/src/components/MultiHopTrade/components/Earn/EarnInput.tsx +++ b/src/components/MultiHopTrade/components/Earn/EarnInput.tsx @@ -36,7 +36,7 @@ import { selectPortfolioCryptoPrecisionBalanceByFilter, } from '@/state/slices/selectors' import { - selectEarnHasUserEnteredAmount, + selectHasUserEnteredAmount, selectInputSellAmountCryptoBaseUnit, selectInputSellAmountCryptoPrecision, selectInputSellAmountUserCurrency, @@ -81,7 +81,7 @@ export const EarnInput = memo( const sellAmountCryptoPrecision = useAppSelector(selectInputSellAmountCryptoPrecision) const sellAmountUserCurrency = useAppSelector(selectInputSellAmountUserCurrency) const isInputtingFiatSellAmount = useAppSelector(selectIsInputtingFiatSellAmount) - const hasUserEnteredAmount = useAppSelector(selectEarnHasUserEnteredAmount) + const hasUserEnteredAmount = useAppSelector(selectHasUserEnteredAmount) const selectedYieldId = useAppSelector(selectSelectedYieldId) const { data: yieldsData, isLoading: isLoadingYields } = useYields() diff --git a/src/state/slices/tradeEarnInputSlice/selectors.ts b/src/state/slices/tradeEarnInputSlice/selectors.ts index 31c23613557..4b53de57683 100644 --- a/src/state/slices/tradeEarnInputSlice/selectors.ts +++ b/src/state/slices/tradeEarnInputSlice/selectors.ts @@ -2,7 +2,6 @@ import { createSelector } from '@reduxjs/toolkit' import { createTradeInputBaseSelectors } from '../common/tradeInputBase/createTradeInputBaseSelectors' -import { bnOrZero } from '@/lib/bignumber/bignumber' import type { TradeEarnInputState } from '@/state/slices/tradeEarnInputSlice/tradeEarnInputSlice' export const { @@ -34,8 +33,3 @@ export const selectSelectedYieldId = createSelector( selectBaseSlice, tradeEarnInput => tradeEarnInput.selectedYieldId, ) - -export const selectEarnHasUserEnteredAmount = createSelector( - selectInputSellAmountCryptoPrecision, - sellAmountCryptoPrecision => bnOrZero(sellAmountCryptoPrecision).gt(0), -) From d71e6e6a6665d84e1602f7773c1f9af21b900123 Mon Sep 17 00:00:00 2001 From: gomes <17035424+gomesalexandre@users.noreply.github.com> Date: Thu, 15 Jan 2026 12:47:17 +0100 Subject: [PATCH 49/60] refactor: address CodeRabbit review comments - Remove unused isLoading prop from SharedConfirm component and call sites - Use relative path 'confirm' for EarnRoutePaths.Confirm (matches TradeRoutePaths pattern) - Import SHAPESHIFT_VALIDATOR_NAME from constants instead of duplicating - Rename userBalances.usd to userBalances.userCurrency for clarity @coderabbitai - Addressed remaining review comments. Co-Authored-By: Claude Opus 4.5 --- .../MultiHopTrade/components/Earn/EarnConfirm.tsx | 3 --- src/components/MultiHopTrade/components/Earn/types.ts | 2 +- .../components/SharedConfirm/SharedConfirm.tsx | 1 - src/pages/Yields/YieldDetail.tsx | 9 ++++----- 4 files changed, 5 insertions(+), 10 deletions(-) diff --git a/src/components/MultiHopTrade/components/Earn/EarnConfirm.tsx b/src/components/MultiHopTrade/components/Earn/EarnConfirm.tsx index 7efead01c6a..8a808e551be 100644 --- a/src/components/MultiHopTrade/components/Earn/EarnConfirm.tsx +++ b/src/components/MultiHopTrade/components/Earn/EarnConfirm.tsx @@ -193,7 +193,6 @@ export const EarnConfirm = memo(() => { } footerContent={null} - isLoading={false} onBack={handleBack} headerTranslation='earn.confirmEarn' /> @@ -216,7 +215,6 @@ export const EarnConfirm = memo(() => { } footerContent={null} - isLoading={false} onBack={handleBack} headerTranslation='yieldXYZ.success' /> @@ -344,7 +342,6 @@ export const EarnConfirm = memo(() => { diff --git a/src/components/MultiHopTrade/components/Earn/types.ts b/src/components/MultiHopTrade/components/Earn/types.ts index 145e4eaf27e..14e7ecfb6c5 100644 --- a/src/components/MultiHopTrade/components/Earn/types.ts +++ b/src/components/MultiHopTrade/components/Earn/types.ts @@ -1,4 +1,4 @@ export enum EarnRoutePaths { Input = '/earn', - Confirm = '/earn/confirm', + Confirm = 'confirm', } diff --git a/src/components/MultiHopTrade/components/SharedConfirm/SharedConfirm.tsx b/src/components/MultiHopTrade/components/SharedConfirm/SharedConfirm.tsx index aadcedd75a3..5db8aeea78a 100644 --- a/src/components/MultiHopTrade/components/SharedConfirm/SharedConfirm.tsx +++ b/src/components/MultiHopTrade/components/SharedConfirm/SharedConfirm.tsx @@ -12,7 +12,6 @@ import type { TextPropTypes } from '@/components/Text/Text' type SharedConfirmProps = { bodyContent: JSX.Element footerContent: JSX.Element | null - isLoading: boolean onBack: () => void headerTranslation: TextPropTypes['translation'] } diff --git a/src/pages/Yields/YieldDetail.tsx b/src/pages/Yields/YieldDetail.tsx index 705688a8c6d..3431375bb10 100644 --- a/src/pages/Yields/YieldDetail.tsx +++ b/src/pages/Yields/YieldDetail.tsx @@ -9,6 +9,7 @@ import { DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID, SHAPESHIFT_COSMOS_VALIDATOR_ADDRESS, SHAPESHIFT_VALIDATOR_LOGO, + SHAPESHIFT_VALIDATOR_NAME, SOLANA_SOL_NATIVE_MULTIVALIDATOR_STAKING_YIELD_ID, } from '@/lib/yieldxyz/constants' import { YieldBalanceType } from '@/lib/yieldxyz/types' @@ -22,8 +23,6 @@ import { useYieldValidators } from '@/react-queries/queries/yieldxyz/useYieldVal import { selectUserCurrencyToUsdRate } from '@/state/slices/selectors' import { useAppSelector } from '@/state/store' -const SHAPESHIFT_VALIDATOR_NAME = 'ShapeShift DAO' - export const YieldDetail = memo(() => { const { yieldId } = useParams<{ yieldId: string }>() const [searchParams] = useSearchParams() @@ -101,7 +100,7 @@ export const YieldDetail = memo(() => { }, [yieldItem, translate]) const userBalances = useMemo(() => { - if (!balances) return { usd: '0', crypto: '0' } + if (!balances) return { userCurrency: '0', crypto: '0' } const balancesByType = selectedValidatorAddress ? balances.byValidatorAddress[selectedValidatorAddress] ?? balances.byType @@ -125,7 +124,7 @@ export const YieldDetail = memo(() => { ) return { - usd: totalUsd.times(userCurrencyToUsdRate).toFixed(), + userCurrency: totalUsd.times(userCurrencyToUsdRate).toFixed(), crypto: totalCrypto.toFixed(), } }, [balances, selectedValidatorAddress, userCurrencyToUsdRate]) @@ -182,7 +181,7 @@ export const YieldDetail = memo(() => { > Date: Thu, 15 Jan 2026 12:50:06 +0100 Subject: [PATCH 50/60] refactor(yields): use yieldProviders instead of parsing metadata strings Replace fragile string parsing on metadata.name with useYieldProviders() hook to properly map providerId to provider data. Also add Figment validator fallback for Solana native staking. @coderabbitai - Addressed comment about using providerId with provider mapping. Co-Authored-By: Claude Opus 4.5 --- src/pages/Yields/YieldDetail.tsx | 29 +++++++++++------------------ 1 file changed, 11 insertions(+), 18 deletions(-) diff --git a/src/pages/Yields/YieldDetail.tsx b/src/pages/Yields/YieldDetail.tsx index 3431375bb10..6495750ec93 100644 --- a/src/pages/Yields/YieldDetail.tsx +++ b/src/pages/Yields/YieldDetail.tsx @@ -7,6 +7,9 @@ import { bnOrZero } from '@/lib/bignumber/bignumber' import { COSMOS_ATOM_NATIVE_STAKING_YIELD_ID, DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID, + FIGMENT_SOLANA_VALIDATOR_ADDRESS, + FIGMENT_VALIDATOR_LOGO, + FIGMENT_VALIDATOR_NAME, SHAPESHIFT_COSMOS_VALIDATOR_ADDRESS, SHAPESHIFT_VALIDATOR_LOGO, SHAPESHIFT_VALIDATOR_NAME, @@ -19,6 +22,7 @@ import { YieldPositionCard } from '@/pages/Yields/components/YieldPositionCard' import { YieldStats } from '@/pages/Yields/components/YieldStats' import { useAllYieldBalances } from '@/react-queries/queries/yieldxyz/useAllYieldBalances' import { useYield } from '@/react-queries/queries/yieldxyz/useYield' +import { useYieldProviders } from '@/react-queries/queries/yieldxyz/useYieldProviders' import { useYieldValidators } from '@/react-queries/queries/yieldxyz/useYieldValidators' import { selectUserCurrencyToUsdRate } from '@/state/slices/selectors' import { useAppSelector } from '@/state/store' @@ -59,6 +63,7 @@ export const YieldDetail = memo(() => { [isStaking, yieldItem?.mechanics.requiresValidatorSelection], ) const { data: validators } = useYieldValidators(yieldItem?.id ?? '', shouldFetchValidators) + const { data: yieldProviders } = useYieldProviders() const validatorOrProvider = useMemo(() => { if (isStaking && selectedValidatorAddress) { @@ -67,28 +72,16 @@ export const YieldDetail = memo(() => { if (selectedValidatorAddress === SHAPESHIFT_COSMOS_VALIDATOR_ADDRESS) { return { name: SHAPESHIFT_VALIDATOR_NAME, logoURI: SHAPESHIFT_VALIDATOR_LOGO } } + if (selectedValidatorAddress === FIGMENT_SOLANA_VALIDATOR_ADDRESS) { + return { name: FIGMENT_VALIDATOR_NAME, logoURI: FIGMENT_VALIDATOR_LOGO } + } } if (!isStaking && yieldItem) { - // Extract protocol name from metadata (e.g., "JustLend" from "JustLend Staked TRX") - const metadataName = yieldItem.metadata.name - const stakedParts = metadataName.split(' Staked ') - if (stakedParts.length > 1 && stakedParts[0]) { - return { name: stakedParts[0], logoURI: yieldItem.metadata.logoURI } - } - const onParts = metadataName.split(' on ') - if (onParts.length > 1 && onParts[1]) { - return { name: onParts[1], logoURI: yieldItem.metadata.logoURI } - } - // Fallback: capitalize the yield type - const yieldType = yieldItem.mechanics.type - .split('-') - .filter((word): word is string => Boolean(word)) - .map(word => word.charAt(0).toUpperCase() + word.slice(1)) - .join(' ') - return { name: yieldType, logoURI: yieldItem.metadata.logoURI } + const provider = yieldProviders?.[yieldItem.providerId] + if (provider) return { name: provider.name, logoURI: provider.logoURI } } return null - }, [isStaking, selectedValidatorAddress, validators, yieldItem]) + }, [isStaking, selectedValidatorAddress, validators, yieldItem, yieldProviders]) const titleOverride = useMemo(() => { if (!yieldItem) return undefined From e621081f1334a4c4c9f0f11aaf32830dfd95b53a Mon Sep 17 00:00:00 2001 From: gomes <17035424+gomesalexandre@users.noreply.github.com> Date: Thu, 15 Jan 2026 12:53:21 +0100 Subject: [PATCH 51/60] fix(yields): recommended cards show Available instead of Balance - Use availableBalanceUserCurrency instead of userBalanceUsd for recommended yields (user has no position yet, only wallet balance to stake) - Use input token symbol (ETH) instead of output token symbol (stETH) for title consistency across all yield cards Co-Authored-By: Claude Opus 4.5 --- src/pages/Yields/components/YieldsList.tsx | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/pages/Yields/components/YieldsList.tsx b/src/pages/Yields/components/YieldsList.tsx index 64087a0a4b5..2b4cb68b625 100644 --- a/src/pages/Yields/components/YieldsList.tsx +++ b/src/pages/Yields/components/YieldsList.tsx @@ -674,6 +674,7 @@ export const YieldsList = memo(() => { {availableYields.map(item => { const positionBalanceUsd = getYieldPositionBalanceUsd(item.yield.id) const displayInfo = getYieldDisplayInfo(item.yield) + const inputSymbol = item.yield.inputTokens?.[0]?.symbol ?? item.yield.token.symbol return ( { variant={isMobile ? 'mobile' : 'card'} userBalanceUsd={positionBalanceUsd} availableBalanceUserCurrency={item.balanceFiat} - titleOverride={item.yield.token.symbol} + titleOverride={inputSymbol} onEnter={() => handleYieldClick(item.yield.id)} /> ) @@ -801,6 +802,7 @@ export const YieldsList = memo(() => { {availableYields.map(item => { const positionBalanceUsd = getYieldPositionBalanceUsd(item.yield.id) const rowDisplayInfo = getYieldDisplayInfo(item.yield) + const rowInputSymbol = item.yield.inputTokens?.[0]?.symbol ?? item.yield.token.symbol return ( { variant='row' userBalanceUsd={positionBalanceUsd} availableBalanceUserCurrency={item.balanceFiat} - titleOverride={item.yield.token.symbol} + titleOverride={rowInputSymbol} onEnter={() => handleYieldClick(item.yield.id)} /> ) @@ -893,6 +895,7 @@ export const YieldsList = memo(() => { {recommendedYields.map(rec => { const recDisplayInfo = getYieldDisplayInfo(rec.yield) + const inputSymbol = rec.yield.inputTokens?.[0]?.symbol ?? rec.yield.token.symbol return ( { providerName: recDisplayInfo.name, }} variant={isMobile ? 'mobile' : 'card'} - userBalanceUsd={rec.balanceFiat} - titleOverride={rec.yield.token.symbol} + availableBalanceUserCurrency={rec.balanceFiat} + titleOverride={inputSymbol} onEnter={() => handleYieldClick(rec.yield.id)} /> ) From dec7ccb0d73a3fbf81979ee9d6d9ffe1124eacba Mon Sep 17 00:00:00 2001 From: gomes <17035424+gomesalexandre@users.noreply.github.com> Date: Thu, 15 Jan 2026 13:06:31 +0100 Subject: [PATCH 52/60] feat(yields): convert YieldSelector to Dialog pattern, change "Trade from" to "Enter from" - Convert YieldSelector from Chakra Modal to Dialog pattern for responsive behavior (drawer on mobile, modal on desktop) - Change sell asset dialog title from "Trade from" to "Enter from" in Earn tab - Add "earn.enterFrom" translation key Co-Authored-By: Claude Opus 4.5 --- src/assets/translations/en/main.json | 1 + .../components/Earn/EarnInput.tsx | 2 +- .../Earn/components/YieldSelector.tsx | 128 +++++++++--------- 3 files changed, 68 insertions(+), 63 deletions(-) diff --git a/src/assets/translations/en/main.json b/src/assets/translations/en/main.json index ca2f8482065..174b32e10e5 100644 --- a/src/assets/translations/en/main.json +++ b/src/assets/translations/en/main.json @@ -2800,6 +2800,7 @@ } }, "earn": { + "enterFrom": "Enter from", "stakeAmount": "Stake Amount", "selectYieldOpportunity": "Select yield opportunity", "selectYieldFor": "Select yield for %{asset}", diff --git a/src/components/MultiHopTrade/components/Earn/EarnInput.tsx b/src/components/MultiHopTrade/components/Earn/EarnInput.tsx index 54be93ac2ab..16b568e326c 100644 --- a/src/components/MultiHopTrade/components/Earn/EarnInput.tsx +++ b/src/components/MultiHopTrade/components/Earn/EarnInput.tsx @@ -302,7 +302,7 @@ export const EarnInput = memo( onAssetClick: (asset: Asset) => { dispatch(tradeEarnInput.actions.setSellAssetWithYieldReset(asset)) }, - title: 'trade.tradeFrom', + title: 'earn.enterFrom', assetFilterPredicate, chainIdFilterPredicate, }) diff --git a/src/components/MultiHopTrade/components/Earn/components/YieldSelector.tsx b/src/components/MultiHopTrade/components/Earn/components/YieldSelector.tsx index 074102c40aa..d5aed92a99c 100644 --- a/src/components/MultiHopTrade/components/Earn/components/YieldSelector.tsx +++ b/src/components/MultiHopTrade/components/Earn/components/YieldSelector.tsx @@ -7,16 +7,9 @@ import { Input, InputGroup, InputLeftElement, - Modal, - ModalBody, - ModalCloseButton, - ModalContent, - ModalHeader, - ModalOverlay, Skeleton, Text, useColorModeValue, - useDisclosure, VStack, } from '@chakra-ui/react' import { cosmosChainId } from '@shapeshiftoss/caip' @@ -24,6 +17,11 @@ import type { Asset } from '@shapeshiftoss/types' import { memo, useCallback, useMemo, useState } from 'react' import { useTranslate } from 'react-polyglot' +import { Dialog } from '@/components/Modal/components/Dialog' +import { DialogBody } from '@/components/Modal/components/DialogBody' +import { DialogCloseButton } from '@/components/Modal/components/DialogCloseButton' +import { DialogHeader } from '@/components/Modal/components/DialogHeader' +import { DialogTitle } from '@/components/Modal/components/DialogTitle' import { DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID, SHAPESHIFT_VALIDATOR_LOGO, @@ -166,7 +164,7 @@ export const YieldSelector = memo( selectedValidator, }: YieldSelectorProps) => { const translate = useTranslate() - const { isOpen, onOpen, onClose } = useDisclosure() + const [isOpen, setIsOpen] = useState(false) const borderColor = useColorModeValue('gray.200', 'gray.700') const [searchQuery, setSearchQuery] = useState('') const { data: providers } = useYieldProviders() @@ -199,20 +197,22 @@ export const YieldSelector = memo( return groups }, [filteredYields]) + const handleOpen = useCallback(() => setIsOpen(true), []) + + const handleClose = useCallback(() => { + setSearchQuery('') + setIsOpen(false) + }, []) + const handleYieldClick = useCallback( (yieldId: string) => { onYieldSelect(yieldId) setSearchQuery('') - onClose() + setIsOpen(false) }, - [onYieldSelect, onClose], + [onYieldSelect], ) - const handleClose = useCallback(() => { - setSearchQuery('') - onClose() - }, [onClose]) - const selectedApyDisplay = useMemo(() => { if (!selectedYield) return '0.00%' const apy = (selectedYield.rewardRate?.total ?? 0) * 100 @@ -259,7 +259,7 @@ export const YieldSelector = memo( width='full' height='auto' p={4} - onClick={onOpen} + onClick={handleOpen} rightIcon={chevronDownIcon} justifyContent='space-between' > @@ -286,53 +286,57 @@ export const YieldSelector = memo( )} - - - - - {translate('earn.selectYieldFor', { asset: sellAsset?.symbol ?? '' })} - - - - - - {searchIcon} - setSearchQuery(e.target.value)} - variant='filled' - /> - + + + {null} + + + {translate('earn.selectYieldFor', { asset: sellAsset?.symbol ?? '' })} + + + + + + + + + + {searchIcon} + setSearchQuery(e.target.value)} + variant='filled' + /> + - {Object.entries(groupedYields).length === 0 ? ( - - {translate('common.noResultsFound')} - - ) : ( - Object.entries(groupedYields).map(([type, typeYields]) => ( - - - {getYieldTypeName(type, translate).toUpperCase()} - - - {typeYields.map(yieldItem => ( - handleYieldClick(yieldItem.id)} - providers={providers} - /> - ))} - - - )) - )} - - - - + {Object.entries(groupedYields).length === 0 ? ( + + {translate('common.noResultsFound')} + + ) : ( + Object.entries(groupedYields).map(([type, typeYields]) => ( + + + {getYieldTypeName(type, translate).toUpperCase()} + + + {typeYields.map(yieldItem => ( + handleYieldClick(yieldItem.id)} + providers={providers} + /> + ))} + + + )) + )} + + + ) }, From be9a8a03cc03d57b6e4432ae60ce913f67009c67 Mon Sep 17 00:00:00 2001 From: gomes <17035424+gomesalexandre@users.noreply.github.com> Date: Thu, 15 Jan 2026 13:18:38 +0100 Subject: [PATCH 53/60] fix: address @NeOMakinG review comments - Add TradingErrorBoundary to EarnTab for error handling - Make useYieldTransactionFlow accept undefined yieldItem (removes unsafe {} as cast) - Consolidate duplicate handleBack/handleClose in EarnConfirm - Extract debounce magic number to SELL_AMOUNT_DEBOUNCE_MS constant - Inline chainIdFilterPredicate in modal open call - Restore isLoading optional prop in SharedConfirmProps for backwards compatibility Co-Authored-By: Claude Opus 4.5 --- .../components/Earn/EarnConfirm.tsx | 10 ++---- .../components/Earn/EarnInput.tsx | 11 +++---- .../SharedConfirm/SharedConfirm.tsx | 1 + src/pages/Trade/tabs/EarnTab.tsx | 26 +++++++++++----- .../Yields/hooks/useYieldTransactionFlow.ts | 31 +++++++------------ 5 files changed, 37 insertions(+), 42 deletions(-) diff --git a/src/components/MultiHopTrade/components/Earn/EarnConfirm.tsx b/src/components/MultiHopTrade/components/Earn/EarnConfirm.tsx index 8a808e551be..65151b1b2ad 100644 --- a/src/components/MultiHopTrade/components/Earn/EarnConfirm.tsx +++ b/src/components/MultiHopTrade/components/Earn/EarnConfirm.tsx @@ -9,7 +9,6 @@ import { EarnRoutePaths } from './types' import { Amount } from '@/components/Amount/Amount' import { bnOrZero } from '@/lib/bignumber/bignumber' import { DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID } from '@/lib/yieldxyz/constants' -import type { AugmentedYieldDto } from '@/lib/yieldxyz/types' import { getTransactionButtonText } from '@/lib/yieldxyz/utils' import { GradientApy } from '@/pages/Yields/components/GradientApy' import { TransactionStepsList } from '@/pages/Yields/components/TransactionStepsList' @@ -32,8 +31,6 @@ import { } from '@/state/slices/tradeEarnInputSlice/selectors' import { useAppSelector } from '@/state/store' -const defaultYieldItem = {} as AugmentedYieldDto - export const EarnConfirm = memo(() => { const translate = useTranslate() const navigate = useNavigate() @@ -104,7 +101,6 @@ export const EarnConfirm = memo(() => { }, [sellAmountCryptoPrecision, sellAssetUserCurrencyRate]) const handleBack = useCallback(() => navigate(EarnRoutePaths.Input), [navigate]) - const handleClose = useCallback(() => navigate(EarnRoutePaths.Input), [navigate]) const apy = useMemo( () => (selectedYield ? (selectedYield.rewardRate?.total ?? 0) * 100 : 0), @@ -132,11 +128,11 @@ export const EarnConfirm = memo(() => { isAllowanceCheckPending, isUsdtResetRequired, } = useYieldTransactionFlow({ - yieldItem: selectedYield ?? defaultYieldItem, + yieldItem: selectedYield, action: 'enter', amount: sellAmountCryptoPrecision, assetSymbol: sellAsset?.symbol ?? '', - onClose: handleClose, + onClose: handleBack, isOpen: Boolean(selectedYield), validatorAddress: selectedValidatorAddress, accountId: accountIdToUse, @@ -210,7 +206,7 @@ export const EarnConfirm = memo(() => { providerInfo={providerInfo} transactionSteps={transactionSteps} yieldId={selectedYieldId} - onDone={handleClose} + onDone={handleBack} /> } diff --git a/src/components/MultiHopTrade/components/Earn/EarnInput.tsx b/src/components/MultiHopTrade/components/Earn/EarnInput.tsx index 16b568e326c..2898554f2d8 100644 --- a/src/components/MultiHopTrade/components/Earn/EarnInput.tsx +++ b/src/components/MultiHopTrade/components/Earn/EarnInput.tsx @@ -57,6 +57,7 @@ export type EarnInputProps = { defaultSellAmountCryptoBaseUnit?: string } +const SELL_AMOUNT_DEBOUNCE_MS = 500 const EmptySideComponent: React.FC = () => null export const EarnInput = memo( @@ -186,7 +187,7 @@ export const EarnInput = memo( : undefined, ) - const debouncedAmount = useDebounce(sellAmountCryptoPrecision, 500) + const debouncedAmount = useDebounce(sellAmountCryptoPrecision, SELL_AMOUNT_DEBOUNCE_MS) const txArguments = useMemo(() => { if (!selectedYield || !userAddress || !yieldChainId || !debouncedAmount) return null @@ -295,8 +296,6 @@ export const EarnInput = memo( [availableAssetIds], ) - const chainIdFilterPredicate = useCallback(() => true, []) - const handleSellAssetClick = useCallback(() => { sellAssetSearch.open({ onAssetClick: (asset: Asset) => { @@ -304,9 +303,9 @@ export const EarnInput = memo( }, title: 'earn.enterFrom', assetFilterPredicate, - chainIdFilterPredicate, + chainIdFilterPredicate: () => true, }) - }, [assetFilterPredicate, chainIdFilterPredicate, dispatch, sellAssetSearch]) + }, [assetFilterPredicate, dispatch, sellAssetSearch]) const setSellAsset = useCallback( (asset: Asset) => { @@ -366,7 +365,6 @@ export const EarnInput = memo( onAssetChange={setSellAsset} onlyConnectedChains={true} assetFilterPredicate={assetFilterPredicate} - chainIdFilterPredicate={chainIdFilterPredicate} showChainDropdown={!isSmallerThanMd} buttonProps={assetSelectButtonProps} mb={isSmallerThanMd ? 0 : 4} @@ -377,7 +375,6 @@ export const EarnInput = memo( handleSellAssetClick, setSellAsset, assetFilterPredicate, - chainIdFilterPredicate, isSmallerThanMd, assetSelectButtonProps, ], diff --git a/src/components/MultiHopTrade/components/SharedConfirm/SharedConfirm.tsx b/src/components/MultiHopTrade/components/SharedConfirm/SharedConfirm.tsx index 5db8aeea78a..01d678d0397 100644 --- a/src/components/MultiHopTrade/components/SharedConfirm/SharedConfirm.tsx +++ b/src/components/MultiHopTrade/components/SharedConfirm/SharedConfirm.tsx @@ -14,6 +14,7 @@ type SharedConfirmProps = { footerContent: JSX.Element | null onBack: () => void headerTranslation: TextPropTypes['translation'] + isLoading?: boolean } const cardMinHeight = { base: 'calc(100vh - var(--mobile-nav-offset))', md: 'initial' } diff --git a/src/pages/Trade/tabs/EarnTab.tsx b/src/pages/Trade/tabs/EarnTab.tsx index 482931cea10..8f69caf54a5 100644 --- a/src/pages/Trade/tabs/EarnTab.tsx +++ b/src/pages/Trade/tabs/EarnTab.tsx @@ -4,6 +4,7 @@ import { FormProvider, useForm } from 'react-hook-form' import { useTranslate } from 'react-polyglot' import { matchPath, Route, Routes, useLocation, useNavigate } from 'react-router-dom' +import { TradingErrorBoundary } from '@/components/ErrorBoundary' import { Main } from '@/components/Layout/Main' import { SEO } from '@/components/Layout/Seo' import { EarnConfirm } from '@/components/MultiHopTrade/components/Earn/EarnConfirm' @@ -85,18 +86,27 @@ export const EarnTab = memo(function EarnTab() { const earnInputElement = useMemo( () => ( - + + + ), [handleChangeTab, defaultSellAssetId, defaultYieldId, defaultSellAmountCryptoBaseUnit], ) - const earnConfirmElement = useMemo(() => , []) + const earnConfirmElement = useMemo( + () => ( + + + + ), + [], + ) return (
diff --git a/src/pages/Yields/hooks/useYieldTransactionFlow.ts b/src/pages/Yields/hooks/useYieldTransactionFlow.ts index 49040aa1467..3e26bdfb700 100644 --- a/src/pages/Yields/hooks/useYieldTransactionFlow.ts +++ b/src/pages/Yields/hooks/useYieldTransactionFlow.ts @@ -137,7 +137,7 @@ export const isUsdtOnEthereumMainnet = ( } type UseYieldTransactionFlowProps = { - yieldItem: AugmentedYieldDto + yieldItem: AugmentedYieldDto | undefined action: 'enter' | 'exit' | 'manage' amount: string assetSymbol: string @@ -180,9 +180,9 @@ export const useYieldTransactionFlow = ({ const isUsdtApprovalResetEnabled = useFeatureFlag('UsdtApprovalReset') const submitHashMutation = useSubmitYieldTransactionHash() - const inputTokenAssetId = useMemo(() => yieldItem.inputTokens?.[0]?.assetId, [yieldItem]) + const inputTokenAssetId = useMemo(() => yieldItem?.inputTokens?.[0]?.assetId, [yieldItem]) - const { chainId: yieldChainId } = yieldItem + const yieldChainId = yieldItem?.chainId const { accountNumber: contextAccountNumber } = useYieldAccount() const derivedAccountId = useAppSelector(state => { @@ -252,9 +252,9 @@ export const useYieldTransactionFlow = ({ isLoading: isQuoteLoading, error: quoteError, } = useQuery({ - queryKey: ['yieldxyz', 'quote', action, yieldItem.id, userAddress, txArguments], + queryKey: ['yieldxyz', 'quote', action, yieldItem?.id, userAddress, txArguments], queryFn: () => { - if (!txArguments || !userAddress || !yieldItem.id) throw new Error('Missing arguments') + if (!txArguments || !userAddress || !yieldItem?.id) throw new Error('Missing arguments') if (action === 'manage') { if (!passthrough) throw new Error('Missing passthrough for manage action') @@ -270,7 +270,7 @@ export const useYieldTransactionFlow = ({ const fn = action === 'enter' ? enterYield : exitYield return fn({ yieldId: yieldItem.id, address: userAddress, arguments: txArguments }) }, - enabled: !!txArguments && !!wallet && !!accountId && canSubmit && isOpen, + enabled: !!txArguments && !!wallet && !!accountId && !!yieldItem && canSubmit && isOpen, staleTime: 0, gcTime: 0, retry: false, @@ -390,7 +390,7 @@ export const useYieldTransactionFlow = ({ const dispatchNotification = useCallback( (tx: TransactionDto, txHash: string) => { - if (!yieldChainId || !accountId) return + if (!yieldChainId || !accountId || !yieldItem) return if (!yieldItem.token.assetId) { console.warn('[useYieldTransactionFlow] Cannot dispatch notification: missing assetId') return @@ -444,21 +444,11 @@ export const useYieldTransactionFlow = ({ }), ) }, - [ - dispatch, - yieldChainId, - accountId, - action, - yieldItem.token.assetId, - yieldItem.metadata.name, - yieldItem.network, - assetSymbol, - amount, - ], + [dispatch, yieldChainId, accountId, action, yieldItem, assetSymbol, amount], ) const buildCosmosStakeArgs = useCallback((): CosmosStakeArgs | undefined => { - if (yieldChainId !== cosmosChainId) return undefined + if (yieldChainId !== cosmosChainId || !yieldItem) return undefined const validator = validatorAddress || DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID[cosmosChainId] if (!validator) return undefined @@ -575,6 +565,7 @@ export const useYieldTransactionFlow = ({ loadingMessage: translate('common.confirming'), }) + if (!yieldItem?.id) throw new Error('Missing yield item') await submitHashMutation.mutateAsync({ transactionId: tx.id, hash: txHash, @@ -654,7 +645,7 @@ export const useYieldTransactionFlow = ({ userAddress, accountMetadata?.bip44Params, feeAsset, - yieldItem.id, + yieldItem, translate, updateStepStatus, buildCosmosStakeArgs, From 10be817caa2d2b79f0c63418bd78b252cec47f85 Mon Sep 17 00:00:00 2001 From: gomes <17035424+gomesalexandre@users.noreply.github.com> Date: Thu, 15 Jan 2026 13:30:54 +0100 Subject: [PATCH 54/60] fix(yields): address review feedback - code quality improvements - useYieldFilters: replace useState+useEffect with useMemo for sorting state - useYieldTransactionFlow: use toBaseUnit from @/lib/math instead of manual conversion - EarnInput: use fromBaseUnit from @/lib/math for base unit conversion - tradeEarnInputSlice: fix initial state consistency ('' -> '0') - YieldEnterModal/YieldForm: add accessibility attrs to fiat toggle (role, tabIndex, aria-label, keyboard support) Co-Authored-By: Claude Opus 4.5 --- .../components/Earn/EarnInput.tsx | 5 +- .../Yields/components/YieldEnterModal.tsx | 17 ++++- src/pages/Yields/components/YieldForm.tsx | 16 ++++- src/pages/Yields/hooks/useYieldFilters.ts | 69 +++++++++++-------- .../Yields/hooks/useYieldTransactionFlow.ts | 3 +- .../tradeEarnInputSlice.ts | 2 +- 6 files changed, 77 insertions(+), 35 deletions(-) diff --git a/src/components/MultiHopTrade/components/Earn/EarnInput.tsx b/src/components/MultiHopTrade/components/Earn/EarnInput.tsx index 2898554f2d8..c87512c3ee4 100644 --- a/src/components/MultiHopTrade/components/Earn/EarnInput.tsx +++ b/src/components/MultiHopTrade/components/Earn/EarnInput.tsx @@ -23,6 +23,7 @@ import { useDebounce } from '@/hooks/useDebounce/useDebounce' import { useModal } from '@/hooks/useModal/useModal' import { useWallet } from '@/hooks/useWallet/useWallet' import { bnOrZero, positiveOrZero } from '@/lib/bignumber/bignumber' +import { fromBaseUnit } from '@/lib/math' import { enterYield } from '@/lib/yieldxyz/api' import { DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID } from '@/lib/yieldxyz/constants' import { useYields } from '@/react-queries/queries/yieldxyz/useYields' @@ -122,9 +123,7 @@ export const EarnInput = memo( useEffect(() => { if (defaultSellAmountCryptoBaseUnit && defaultSellAsset && !sellAmountCryptoPrecision) { const precision = defaultSellAsset.precision ?? 18 - const amountCryptoPrecision = bnOrZero(defaultSellAmountCryptoBaseUnit) - .div(bnOrZero(10).pow(precision)) - .toString() + const amountCryptoPrecision = fromBaseUnit(defaultSellAmountCryptoBaseUnit, precision) dispatch(tradeEarnInput.actions.setSellAmountCryptoPrecision(amountCryptoPrecision)) } }, [defaultSellAmountCryptoBaseUnit, defaultSellAsset, sellAmountCryptoPrecision, dispatch]) diff --git a/src/pages/Yields/components/YieldEnterModal.tsx b/src/pages/Yields/components/YieldEnterModal.tsx index 05af8dd2a96..ddacc95614e 100644 --- a/src/pages/Yields/components/YieldEnterModal.tsx +++ b/src/pages/Yields/components/YieldEnterModal.tsx @@ -513,7 +513,21 @@ export const YieldEnterModal = memo( suffix={isFiat ? '' : ` ${inputTokenAsset?.symbol}`} onValueChange={handleInputChange} /> - + { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + toggleIsFiat() + } + }} + > {isFiat ? ( @@ -538,6 +552,7 @@ export const YieldEnterModal = memo( toggleIsFiat, cryptoAmount, fiatAmount, + translate, ]) const isInProgress = step === ModalStep.InProgress diff --git a/src/pages/Yields/components/YieldForm.tsx b/src/pages/Yields/components/YieldForm.tsx index 9a1e0ae5f10..d7a4ac80226 100644 --- a/src/pages/Yields/components/YieldForm.tsx +++ b/src/pages/Yields/components/YieldForm.tsx @@ -646,7 +646,21 @@ export const YieldForm = memo( suffix={isFiat ? '' : ` ${inputTokenAsset?.symbol}`} onValueChange={handleInputChange} /> - + { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + toggleIsFiat() + } + }} + > {isFiat ? ( diff --git a/src/pages/Yields/hooks/useYieldFilters.ts b/src/pages/Yields/hooks/useYieldFilters.ts index af615661d79..2d9354d1140 100644 --- a/src/pages/Yields/hooks/useYieldFilters.ts +++ b/src/pages/Yields/hooks/useYieldFilters.ts @@ -1,12 +1,30 @@ -import type { SortingState } from '@tanstack/react-table' -import { useCallback, useEffect, useMemo, useState } from 'react' +import type { OnChangeFn, SortingState, Updater } from '@tanstack/react-table' +import { useCallback, useMemo } from 'react' import { useSearchParams } from 'react-router-dom' import type { SortOption } from '@/pages/Yields/components/YieldFilters' +const getSortingFromOption = (sortOption: SortOption): SortingState => { + switch (sortOption) { + case 'apy-desc': + return [{ id: 'apy', desc: true }] + case 'apy-asc': + return [{ id: 'apy', desc: false }] + case 'tvl-desc': + return [{ id: 'tvl', desc: true }] + case 'tvl-asc': + return [{ id: 'tvl', desc: false }] + case 'name-asc': + return [{ id: 'pool', desc: false }] + case 'name-desc': + return [{ id: 'pool', desc: true }] + default: + return [{ id: 'apy', desc: true }] + } +} + export const useYieldFilters = () => { const [searchParams, setSearchParams] = useSearchParams() - const [sorting, setSorting] = useState([{ id: 'apy', desc: true }]) const selectedNetwork = useMemo(() => searchParams.get('network'), [searchParams]) const selectedProvider = useMemo(() => searchParams.get('provider'), [searchParams]) @@ -16,6 +34,26 @@ export const useYieldFilters = () => { [searchParams], ) + const sorting = useMemo(() => getSortingFromOption(sortOption), [sortOption]) + + const setSorting: OnChangeFn = useCallback( + (updaterOrValue: Updater) => { + const newSorting = + typeof updaterOrValue === 'function' ? updaterOrValue(sorting) : updaterOrValue + const sort = newSorting[0] + if (!sort) return + const option = `${sort.id === 'pool' ? 'name' : sort.id}-${ + sort.desc ? 'desc' : 'asc' + }` as SortOption + setSearchParams(prev => { + const next = new URLSearchParams(prev) + next.set('sort', option) + return next + }) + }, + [setSearchParams, sorting], + ) + const handleNetworkChange = useCallback( (network: string | null) => { setSearchParams(prev => { @@ -63,31 +101,6 @@ export const useYieldFilters = () => { [setSearchParams], ) - useEffect(() => { - switch (sortOption) { - case 'apy-desc': - setSorting([{ id: 'apy', desc: true }]) - break - case 'apy-asc': - setSorting([{ id: 'apy', desc: false }]) - break - case 'tvl-desc': - setSorting([{ id: 'tvl', desc: true }]) - break - case 'tvl-asc': - setSorting([{ id: 'tvl', desc: false }]) - break - case 'name-asc': - setSorting([{ id: 'pool', desc: false }]) - break - case 'name-desc': - setSorting([{ id: 'pool', desc: true }]) - break - default: - break - } - }, [sortOption]) - return { selectedNetwork, selectedProvider, diff --git a/src/pages/Yields/hooks/useYieldTransactionFlow.ts b/src/pages/Yields/hooks/useYieldTransactionFlow.ts index 3e26bdfb700..81da1f75a1f 100644 --- a/src/pages/Yields/hooks/useYieldTransactionFlow.ts +++ b/src/pages/Yields/hooks/useYieldTransactionFlow.ts @@ -13,6 +13,7 @@ import { SECOND_CLASS_CHAINS } from '@/constants/chains' import { useFeatureFlag } from '@/hooks/useFeatureFlag/useFeatureFlag' import { useWallet } from '@/hooks/useWallet/useWallet' import { bnOrZero } from '@/lib/bignumber/bignumber' +import { toBaseUnit } from '@/lib/math' import { enterYield, exitYield, fetchAction, manageYield } from '@/lib/yieldxyz/api' import { DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID, @@ -458,7 +459,7 @@ export const useYieldTransactionFlow = ({ return { validator, - amountCryptoBaseUnit: bnOrZero(amount).times(bnOrZero(10).pow(inputTokenDecimals)).toFixed(0), + amountCryptoBaseUnit: toBaseUnit(amount, inputTokenDecimals), action: action === 'enter' ? 'stake' : action === 'exit' ? 'unstake' : 'claim', } }, [yieldChainId, validatorAddress, amount, yieldItem, action]) diff --git a/src/state/slices/tradeEarnInputSlice/tradeEarnInputSlice.ts b/src/state/slices/tradeEarnInputSlice/tradeEarnInputSlice.ts index 8d471081070..ca6666bcb53 100644 --- a/src/state/slices/tradeEarnInputSlice/tradeEarnInputSlice.ts +++ b/src/state/slices/tradeEarnInputSlice/tradeEarnInputSlice.ts @@ -27,7 +27,7 @@ const initialState: TradeEarnInputState = { sellAsset: defaultAsset, sellAccountId: undefined, buyAccountId: undefined, - sellAmountCryptoPrecision: '', + sellAmountCryptoPrecision: '0', isInputtingFiatSellAmount: false, manualReceiveAddress: undefined, isManualReceiveAddressValidating: false, From d9acae3e436bdc249c226c956422dd4c0f340a3a Mon Sep 17 00:00:00 2001 From: gomes <17035424+gomesalexandre@users.noreply.github.com> Date: Thu, 15 Jan 2026 13:34:07 +0100 Subject: [PATCH 55/60] fix(yields): import SHAPESHIFT_VALIDATOR_NAME from constants Remove duplicate constant definition from YieldForm and YieldEnterModal, import from @/lib/yieldxyz/constants instead. 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 ddacc95614e..15ccf010db7 100644 --- a/src/pages/Yields/components/YieldEnterModal.tsx +++ b/src/pages/Yields/components/YieldEnterModal.tsx @@ -24,6 +24,7 @@ import { DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID, SHAPESHIFT_COSMOS_VALIDATOR_ADDRESS, SHAPESHIFT_VALIDATOR_LOGO, + SHAPESHIFT_VALIDATOR_NAME, } from '@/lib/yieldxyz/constants' import type { AugmentedYieldDto } from '@/lib/yieldxyz/types' import { getTransactionButtonText } from '@/lib/yieldxyz/utils' @@ -51,7 +52,6 @@ type YieldEnterModalProps = { } const PRESET_PERCENTAGES = [0.25, 0.5, 0.75, 1] as const -const SHAPESHIFT_VALIDATOR_NAME = 'ShapeShift DAO' const INPUT_LENGTH_BREAKPOINTS = { FOR_XS_FONT: 22, diff --git a/src/pages/Yields/components/YieldForm.tsx b/src/pages/Yields/components/YieldForm.tsx index d7a4ac80226..01e725c24f3 100644 --- a/src/pages/Yields/components/YieldForm.tsx +++ b/src/pages/Yields/components/YieldForm.tsx @@ -19,6 +19,7 @@ import { DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID, SHAPESHIFT_COSMOS_VALIDATOR_ADDRESS, SHAPESHIFT_VALIDATOR_LOGO, + SHAPESHIFT_VALIDATOR_NAME, } from '@/lib/yieldxyz/constants' import type { AugmentedYieldDto } from '@/lib/yieldxyz/types' import { YieldBalanceType } from '@/lib/yieldxyz/types' @@ -51,7 +52,6 @@ type YieldFormProps = { isSubmitting?: boolean // Optional, if handled externally or to override } -const SHAPESHIFT_VALIDATOR_NAME = 'ShapeShift DAO' const PRESET_PERCENTAGES = [0.25, 0.5, 0.75, 1] as const const getEnterActionTextKey = (yieldType: string | undefined): string => { From 9e8f42cf17369b00a7655293cf7032b6f6825423 Mon Sep 17 00:00:00 2001 From: gomes <17035424+gomesalexandre@users.noreply.github.com> Date: Thu, 15 Jan 2026 13:44:55 +0100 Subject: [PATCH 56/60] fix(yields): handle base64-encoded Solana transactions from yield.xyz The yield.xyz API returns Solana transactions as base64-encoded strings (following Solana conventions), but the code was trying to decode them as hex, causing deserialization failures. Use isHex from viem to detect encoding format - if hex use hex decoding, otherwise use base64 (Solana convention). Fixes "cannot get quote" error when depositing SOL into Kamino and other Solana-based yield providers. Co-Authored-By: Claude Opus 4.5 --- src/lib/yieldxyz/executeTransaction.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/lib/yieldxyz/executeTransaction.ts b/src/lib/yieldxyz/executeTransaction.ts index e511321f63a..45a5d77cea3 100644 --- a/src/lib/yieldxyz/executeTransaction.ts +++ b/src/lib/yieldxyz/executeTransaction.ts @@ -343,13 +343,14 @@ const executeSolanaTransaction = async ({ }: ExecuteSolanaTransactionInput): Promise => { const adapter = assertGetSolanaChainAdapter(chainId) const accountNumber = bip44Params?.accountNumber ?? 0 - const txData = unsignedTransaction.startsWith('0x') - ? unsignedTransaction.slice(2) - : unsignedTransaction - const versionedTransaction = VersionedTransaction.deserialize( - new Uint8Array(Buffer.from(txData, 'hex')), - ) + // Yield.xyz returns base64 for Solana transactions (Solana convention) + // Use isHex from viem to detect hex-encoded transactions + const txBytes = isHex(unsignedTransaction) + ? new Uint8Array(Buffer.from(unsignedTransaction.slice(2), 'hex')) + : new Uint8Array(Buffer.from(unsignedTransaction, 'base64')) + + const versionedTransaction = VersionedTransaction.deserialize(txBytes) const addressLookupTableAccountKeys = versionedTransaction.message.addressTableLookups.map( lookup => lookup.accountKey.toString(), From 68f5dd618cc22af3e5e310c12a28782cacd07fe5 Mon Sep 17 00:00:00 2001 From: gomes <17035424+gomesalexandre@users.noreply.github.com> Date: Thu, 15 Jan 2026 13:53:40 +0100 Subject: [PATCH 57/60] fix(yields): fix Solana compute unit budget for yield transactions - Add SOLANA_YIELD_COMPUTE_UNIT_MARGIN_MULTIPLIER (1.6) constant - Apply safety margin to estimated compute units (same as Jupiter swapper) - Remove broken STAKE_COMPUTE_UNIT_BUFFER static minimum approach Fixes "Computational budget exceeded" error when depositing SOL into Kamino and other Solana yield providers. Co-Authored-By: Claude Opus 4.5 --- src/lib/yieldxyz/constants.ts | 4 ++++ src/lib/yieldxyz/executeTransaction.ts | 11 ++++++----- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/lib/yieldxyz/constants.ts b/src/lib/yieldxyz/constants.ts index bd8cc1d2c52..a7690c61975 100644 --- a/src/lib/yieldxyz/constants.ts +++ b/src/lib/yieldxyz/constants.ts @@ -55,6 +55,10 @@ export const isSupportedYieldNetwork = (network: string): network is YieldNetwor export const YIELD_POLL_INTERVAL_MS = 5000 export const YIELD_MAX_POLL_ATTEMPTS = 120 +// Compute unit safety margin for Solana yield transactions +// Same as Jupiter swapper (1.6) to handle network volatility +export const SOLANA_YIELD_COMPUTE_UNIT_MARGIN_MULTIPLIER = 1.6 + export const SHAPESHIFT_COSMOS_VALIDATOR_ADDRESS = 'cosmosvaloper199mlc7fr6ll5t54w7tts7f4s0cvnqgc59nmuxf' diff --git a/src/lib/yieldxyz/executeTransaction.ts b/src/lib/yieldxyz/executeTransaction.ts index 45a5d77cea3..172ea3983ff 100644 --- a/src/lib/yieldxyz/executeTransaction.ts +++ b/src/lib/yieldxyz/executeTransaction.ts @@ -16,8 +16,10 @@ import { import type { Hex } from 'viem' import { isHex, toHex } from 'viem' +import { SOLANA_YIELD_COMPUTE_UNIT_MARGIN_MULTIPLIER } from './constants' import type { TransactionDto } from './types' +import { bnOrZero } from '@/lib/bignumber/bignumber' import { toBaseUnit } from '@/lib/math' import { assertGetCosmosSdkChainAdapter } from '@/lib/utils/cosmosSdk' import { assertGetEvmChainAdapter, signAndBroadcast as evmSignAndBroadcast } from '@/lib/utils/evm' @@ -393,11 +395,10 @@ const executeSolanaTransaction = async ({ adapter.convertInstruction(instruction), ) - const STAKE_COMPUTE_UNIT_BUFFER = 50000 - const estimatedComputeUnits = Math.max( - Number(fast.chainSpecific.computeUnits), - STAKE_COMPUTE_UNIT_BUFFER, - ) + // Apply safety margin to estimated compute units (same approach as Jupiter swapper) + const estimatedComputeUnits = bnOrZero(fast.chainSpecific.computeUnits) + .times(SOLANA_YIELD_COMPUTE_UNIT_MARGIN_MULTIPLIER) + .toFixed(0) const txToSign = await adapter.buildSendApiTransaction({ from, From 8d8a8f0c47e17831f678ca28784a77f61b5c7819 Mon Sep 17 00:00:00 2001 From: gomes <17035424+gomesalexandre@users.noreply.github.com> Date: Thu, 15 Jan 2026 14:10:36 +0100 Subject: [PATCH 58/60] fix(yields): show individual unstaking entries with countdown - Display each exiting balance as separate row with completion date - Use dayjs relative time for "available in X days" countdown - Invalidate yield queries when transactions complete in background - Add yieldXYZ.unstaking and yieldXYZ.availableDate translations - Align unstaking/claimable sections consistently (badge top, action below) Co-Authored-By: Claude Opus 4.5 --- src/assets/translations/en/main.json | 2 + .../useGenericTransactionSubscriber.tsx | 6 ++ .../Yields/components/YieldPositionCard.tsx | 89 +++++++++++++------ 3 files changed, 69 insertions(+), 28 deletions(-) diff --git a/src/assets/translations/en/main.json b/src/assets/translations/en/main.json index 174b32e10e5..a926e3a4742 100644 --- a/src/assets/translations/en/main.json +++ b/src/assets/translations/en/main.json @@ -2690,6 +2690,8 @@ "gasToken": "Gas Token", "entering": "Entering...", "exiting": "Exiting...", + "unstaking": "Unstaking", + "availableDate": "available %{date}", "withdrawable": "Withdrawable", "type": "Type", "totalValue": "Total Value", diff --git a/src/hooks/useActionCenterSubscribers/useGenericTransactionSubscriber.tsx b/src/hooks/useActionCenterSubscribers/useGenericTransactionSubscriber.tsx index d9440ce96bb..15c41be6836 100644 --- a/src/hooks/useActionCenterSubscribers/useGenericTransactionSubscriber.tsx +++ b/src/hooks/useActionCenterSubscribers/useGenericTransactionSubscriber.tsx @@ -158,6 +158,12 @@ export const useGenericTransactionSubscriber = () => { }) } + // Invalidate yield balances when yield transactions complete in background + if (action.transactionMetadata.displayType === GenericTransactionDisplayType.Yield) { + queryClient.invalidateQueries({ queryKey: ['yieldxyz', 'allBalances'] }) + queryClient.invalidateQueries({ queryKey: ['yieldxyz', 'yields'] }) + } + // No double-toasty if (toast.isActive(action.transactionMetadata.txHash)) return diff --git a/src/pages/Yields/components/YieldPositionCard.tsx b/src/pages/Yields/components/YieldPositionCard.tsx index 8a35e04178f..3595f47aea2 100644 --- a/src/pages/Yields/components/YieldPositionCard.tsx +++ b/src/pages/Yields/components/YieldPositionCard.tsx @@ -8,12 +8,12 @@ import { Divider, Flex, Heading, - HStack, Skeleton, Text, VStack, } from '@chakra-ui/react' import { fromAccountId } from '@shapeshiftoss/caip' +import dayjs from 'dayjs' import qs from 'qs' import { memo, useCallback, useMemo } from 'react' import { useTranslate } from 'react-polyglot' @@ -106,10 +106,18 @@ export const YieldPositionCard = memo( () => enteringBalance && bnOrZero(enteringBalance.aggregatedAmount).gt(0), [enteringBalance], ) - const hasExiting = useMemo( - () => exitingBalance && bnOrZero(exitingBalance.aggregatedAmount).gt(0), - [exitingBalance], - ) + + const exitingEntries = useMemo(() => { + if (!balances?.raw) return [] + return balances.raw + .filter(b => b.type === YieldBalanceType.Exiting && bnOrZero(b.amount).gt(0)) + .filter(b => { + if (!selectedValidatorAddress) return true + return b.validator?.address === selectedValidatorAddress + }) + }, [balances?.raw, selectedValidatorAddress]) + + const hasExiting = useMemo(() => exitingEntries.length > 0, [exitingEntries]) const hasWithdrawable = useMemo( () => withdrawableBalance && bnOrZero(withdrawableBalance.aggregatedAmount).gt(0), [withdrawableBalance], @@ -215,26 +223,45 @@ export const YieldPositionCard = memo( ) }, [hasEntering, translate, formatBalance, enteringBalance]) - const exitingSection = useMemo(() => { + const unstakingSection = useMemo(() => { if (!hasExiting) return null - return ( - - - - - {translate('yieldXYZ.exiting')} - - - {formatBalance(exitingBalance)} - - - - {translate('yieldXYZ.pending')} - - - - ) - }, [hasExiting, translate, formatBalance, exitingBalance]) + + return exitingEntries.map((entry, index) => { + const completionDate = entry.date ? dayjs(entry.date) : null + const availableDateText = completionDate ? dayjs().to(completionDate) : null + + return ( + + + + + {translate('yieldXYZ.unstaking')} + + + + + + + + {translate('yieldXYZ.pending')} + + {availableDateText && ( + + {translate('yieldXYZ.availableDate', { date: availableDateText })} + + )} + + + + ) + }) + }, [hasExiting, exitingEntries, translate]) const withdrawableSection = useMemo(() => { if (!hasWithdrawable) return null @@ -270,7 +297,7 @@ export const YieldPositionCard = memo( {formatBalance(claimableBalance)} - + {translate('yieldXYZ.reward')} @@ -285,7 +312,7 @@ export const YieldPositionCard = memo( {translate('common.claim')} )} - + ) @@ -315,13 +342,19 @@ export const YieldPositionCard = memo( {enteringSection} - {exitingSection} + {unstakingSection} {withdrawableSection} {claimableSection} ) - }, [showPendingActions, enteringSection, exitingSection, withdrawableSection, claimableSection]) + }, [ + showPendingActions, + enteringSection, + unstakingSection, + withdrawableSection, + claimableSection, + ]) if (!accountId) return null From 4e305390e7fedf3726892ed913182dfe0ea78ad4 Mon Sep 17 00:00:00 2001 From: gomes <17035424+gomesalexandre@users.noreply.github.com> Date: Thu, 15 Jan 2026 14:25:50 +0100 Subject: [PATCH 59/60] fix(yields): address CodeRabbit PR review feedback - Use locale-aware currency symbol for fiat placeholder - URL-encode sellAsset.assetId in earn route - Handle undefined validatorAddress properly - Fix React.ChangeEvent type import Co-Authored-By: Claude Opus 4.5 --- .../components/Earn/EarnInput.tsx | 21 +++++++++++++++---- .../Yields/components/YieldEnterModal.tsx | 3 ++- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/src/components/MultiHopTrade/components/Earn/EarnInput.tsx b/src/components/MultiHopTrade/components/Earn/EarnInput.tsx index c87512c3ee4..3fcda2ca603 100644 --- a/src/components/MultiHopTrade/components/Earn/EarnInput.tsx +++ b/src/components/MultiHopTrade/components/Earn/EarnInput.tsx @@ -20,6 +20,7 @@ import { TradeAssetSelect } from '@/components/AssetSelection/AssetSelection' import { FormDivider } from '@/components/FormDivider' import { TradeInputTab } from '@/components/MultiHopTrade/types' import { useDebounce } from '@/hooks/useDebounce/useDebounce' +import { useLocaleFormatter } from '@/hooks/useLocaleFormatter/useLocaleFormatter' import { useModal } from '@/hooks/useModal/useModal' import { useWallet } from '@/hooks/useWallet/useWallet' import { bnOrZero, positiveOrZero } from '@/lib/bignumber/bignumber' @@ -73,6 +74,9 @@ export const EarnInput = memo( const navigate = useNavigate() const dispatch = useAppDispatch() const [isSmallerThanMd] = useMediaQuery(`(max-width: ${breakpoints.md})`, { ssr: false }) + const { + number: { toFiat }, + } = useLocaleFormatter() const { state: { isConnected, wallet }, @@ -133,10 +137,11 @@ export const EarnInput = memo( useEffect(() => { if (!sellAsset.assetId || !selectedYieldId) return + const encodedAssetId = encodeURIComponent(sellAsset.assetId) const encodedYieldId = encodeURIComponent(selectedYieldId) const baseUnit = sellAmountCryptoBaseUnit ?? '0' - navigate(`/earn/${sellAsset.assetId}/${encodedYieldId}/${baseUnit}`, { replace: true }) + navigate(`/earn/${encodedAssetId}/${encodedYieldId}/${baseUnit}`, { replace: true }) }, [sellAsset.assetId, selectedYieldId, sellAmountCryptoBaseUnit, navigate]) const selectedYield = useMemo(() => { @@ -201,8 +206,11 @@ export const EarnInput = memo( } if (fieldNames.has('validatorAddress') && yieldChainId) { - args.validatorAddress = - selectedValidatorAddress || DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID[yieldChainId] + const validatorAddress = + selectedValidatorAddress ?? DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID[yieldChainId] + if (validatorAddress) { + args.validatorAddress = validatorAddress + } } if (fieldNames.has('cosmosPubKey') && yieldChainId === cosmosChainId) { @@ -420,6 +428,10 @@ export const EarnInput = memo( .toString() }, [estimatedYearlyEarnings, sellAssetUserCurrencyRate]) + const placeholder = useMemo(() => { + return toFiat(0, { omitDecimalTrailingZeros: true }) + }, [toFiat]) + const bodyContent = useMemo( () => ( @@ -429,7 +441,7 @@ export const EarnInput = memo( asset={sellAsset} isInputtingFiatSellAmount={isInputtingFiatSellAmount} isLoading={isLoadingYields} - placeholder={isInputtingFiatSellAmount ? '$0.00' : '0'} + placeholder={isInputtingFiatSellAmount ? placeholder : '0'} label={translate('earn.stakeAmount')} labelPostFix={sellTradeAssetSelect} percentOptions={percentOptions} @@ -458,6 +470,7 @@ export const EarnInput = memo( sellAsset, isInputtingFiatSellAmount, isLoadingYields, + placeholder, translate, sellTradeAssetSelect, percentOptions, diff --git a/src/pages/Yields/components/YieldEnterModal.tsx b/src/pages/Yields/components/YieldEnterModal.tsx index 15ccf010db7..64ee396aafb 100644 --- a/src/pages/Yields/components/YieldEnterModal.tsx +++ b/src/pages/Yields/components/YieldEnterModal.tsx @@ -1,5 +1,6 @@ import { Avatar, Box, Button, Flex, HStack, Icon, Input, Skeleton, Text } from '@chakra-ui/react' import { useQueryClient } from '@tanstack/react-query' +import type { ChangeEvent } from 'react' import { memo, useCallback, useMemo, useState } from 'react' import { TbSwitchVertical } from 'react-icons/tb' import type { NumberFormatValues } from 'react-number-format' @@ -71,7 +72,7 @@ const unselectedHoverSx = { bg: 'background.surface.raised.hover' } type CryptoAmountInputProps = { value?: string - onChange?: (e: React.ChangeEvent) => void + onChange?: (e: ChangeEvent) => void placeholder?: string [key: string]: unknown } From 7d3886b195646e22971dd60fc6fa0945e585880b Mon Sep 17 00:00:00 2001 From: gomes <17035424+gomesalexandre@users.noreply.github.com> Date: Thu, 15 Jan 2026 14:31:41 +0100 Subject: [PATCH 60/60] fix(yields): use Avatar component instead of external fallback image Replace Image component with Avatar to avoid external CoinGecko fallback URL. Avatar uses built-in fallback showing initials from name prop. Co-Authored-By: Claude Opus 4.5 --- .../Earn/components/YieldSelector.tsx | 18 +++--------------- 1 file changed, 3 insertions(+), 15 deletions(-) diff --git a/src/components/MultiHopTrade/components/Earn/components/YieldSelector.tsx b/src/components/MultiHopTrade/components/Earn/components/YieldSelector.tsx index d5aed92a99c..f2f9445e730 100644 --- a/src/components/MultiHopTrade/components/Earn/components/YieldSelector.tsx +++ b/src/components/MultiHopTrade/components/Earn/components/YieldSelector.tsx @@ -1,9 +1,9 @@ import { ChevronDownIcon, SearchIcon } from '@chakra-ui/icons' import { + Avatar, Box, Button, HStack, - Image, Input, InputGroup, InputLeftElement, @@ -130,13 +130,7 @@ const YieldItem = memo( textAlign='left' > - {displayInfo.name} + {displayInfo.name} @@ -265,13 +259,7 @@ export const YieldSelector = memo( > {selectedYield && selectedDisplayInfo ? ( - {selectedDisplayInfo.name} + {selectedDisplayInfo.name}