diff --git a/.env b/.env index 3a07d42760c..36759da0efc 100644 --- a/.env +++ b/.env @@ -306,3 +306,8 @@ VITE_HYPEREVM_NODE_URL=https://rpc.hyperliquid.xyz/evm VITE_FEATURE_HYPEREVM=true VITE_FEATURE_NEAR=false VITE_FEATURE_KATANA=false + +# Yield.xyz Feature Flag +VITE_FEATURE_YIELD_XYZ=false +VITE_YIELD_XYZ_API_KEY=06903960-e442-4870-81eb-03ff3ad4c035 +VITE_FEATURE_YIELD_MULTI_ACCOUNT=false diff --git a/.env.development b/.env.development index 35fba2b5ad8..2c0d8104231 100644 --- a/.env.development +++ b/.env.development @@ -96,3 +96,4 @@ VITE_FEATURE_CETUS_SWAP=true VITE_FEATURE_AVNU_SWAP=true VITE_FEATURE_NEAR=true VITE_FEATURE_KATANA=true +VITE_FEATURE_YIELD_XYZ=true diff --git a/headers/csps/index.ts b/headers/csps/index.ts index 40bcea276ba..777f3aa416c 100644 --- a/headers/csps/index.ts +++ b/headers/csps/index.ts @@ -74,6 +74,7 @@ import { csp as metamask } from './wallets/metamask' import { csp as walletConnect } from './wallets/walletConnect' import { csp as walletMigration } from './wallets/walletMigration' import { csp as webflow } from './webflow' +import { csp as yieldxyz } from './yieldxyz' export const csps = [ base, @@ -152,4 +153,5 @@ export const csps = [ relay, railway, discord, + yieldxyz, ] diff --git a/headers/csps/yieldxyz.ts b/headers/csps/yieldxyz.ts new file mode 100644 index 00000000000..580f1929af0 --- /dev/null +++ b/headers/csps/yieldxyz.ts @@ -0,0 +1,6 @@ +import type { Csp } from '../types' + +export const csp: Csp = { + 'connect-src': ['https://api.yield.xyz'], + 'img-src': ['https://assets.stakek.it'], +} diff --git a/public/images/providers/yield-xyz.png b/public/images/providers/yield-xyz.png new file mode 100644 index 00000000000..d1864b9e34c Binary files /dev/null and b/public/images/providers/yield-xyz.png differ diff --git a/src/Routes/RoutesCommon.tsx b/src/Routes/RoutesCommon.tsx index b3402738719..a3af4334438 100644 --- a/src/Routes/RoutesCommon.tsx +++ b/src/Routes/RoutesCommon.tsx @@ -128,6 +128,16 @@ const MarketsPage = makeSuspenseful( true, ) +const YieldsPage = makeSuspenseful( + lazy(() => + import('@/pages/Yields/Yields').then(({ Yields }) => ({ + default: Yields, + })), + ), + {}, + true, +) + const WalletConnectDeepLink = makeSuspenseful( lazy(() => import('@/pages/WalletConnectDeepLink/WalletConnectDeepLink').then( @@ -228,6 +238,16 @@ export const routes: Route[] = [ mobileNav: false, disable: !getConfig().VITE_FEATURE_MARKETS, }, + { + path: '/yields/*', + label: 'navBar.yields', + icon: , + main: YieldsPage, + category: RouteCategory.Featured, + priority: 3, + mobileNav: false, + disable: !getConfig().VITE_FEATURE_YIELD_XYZ, + }, { path: '/ramp/*', label: 'navBar.buyCrypto', diff --git a/src/assets/translations/en/main.json b/src/assets/translations/en/main.json index f4e9cb3d813..94c411e4918 100644 --- a/src/assets/translations/en/main.json +++ b/src/assets/translations/en/main.json @@ -51,6 +51,7 @@ "withdraw": "Withdraw", "withdrawal": "Withdrawal", "claim": "Claim", + "claiming": "Claiming...", "withdrawAndClaim": "Withdraw & Claim", "overview": "Overview", "connectWallet": "Connect Wallet", @@ -345,6 +346,7 @@ } }, "defi": { + "yourBalance": "Your Balance", "modals": { "learnMore": { "next": "Next", @@ -516,7 +518,8 @@ "ecosystem": "Ecosystem", "markets": "Markets", "tokens": "Tokens", - "swap": "Swap" + "swap": "Swap", + "yields": "Yields" }, "shapeShiftMenu": { "products": "Products", @@ -2661,5 +2664,149 @@ "description": "Your reward of %{amountAndSymbol} is complete." } } + }, + "yieldXYZ": { + "pageTitle": "Yields", + "pageSubtitle": "Discover and manage yield opportunities across multiple chains", + "enter": "Enter", + "exit": "Exit", + "yield": "Yield", + "apy": "APY", + "apr": "APR", + "tvl": "TVL", + "asset": "Asset", + "provider": "Provider", + "balance": "Balance", + "yourBalance": "Your Balance", + "noYields": "No yield opportunities available", + "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", + "startEarning": "Start earning", + "maxApy": "Max APY", + "validator": "Validator", + "validatorBreakdown": "Validator Breakdown", + "staked": "Staked", + "exiting": "Exiting", + "claimable": "Claimable", + "loadingQuote": "Loading Quote...", + "depositing": "Depositing...", + "withdrawing": "Withdrawing...", + "selectValidator": "Select Validator", + "allValidators": "All Validators", + "myValidators": "My Validators", + "noValidatorsFound": "No validators found", + "preferred": "Preferred", + "pending": "Pending", + "ready": "Ready", + "highestApy": "Highest APY", + "lowestApy": "Lowest APY", + "highestTvl": "Highest TVL", + "lowestTvl": "Lowest TVL", + "nameAZ": "Name (A-Z)", + "nameZA": "Name (Z-A)", + "allNetworks": "All Networks", + "allProviders": "All Providers", + "showAll": "Show All", + "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", + "estYearlyEarnings": "Est. Yearly Earnings", + "allPositions": "All Positions", + "switch": "Switch", + "supplySymbol": "Supply %{symbol}", + "withdrawSymbol": "Withdraw %{symbol}", + "claimSymbol": "Claim %{symbol}", + "noActivePositions": "You do not have any active yield positions.", + "connectWalletPositions": "Connect a wallet to view your active yield positions.", + "view": "View", + "close": "Close", + "estEarnings": "Est. Earnings", + "network": "Network", + "market": "market", + "markets": "markets", + "protocol": "protocol", + "protocols": "protocols", + "chain": "chain", + "chains": "chains", + "reward": "Reward", + "assetYields": "%{asset} Yields", + "opportunitiesAvailable": "%{count} opportunities available", + "noYieldsMatchingFilters": "No yields found matching filters.", + "activeDeposits": "Active Deposits", + "acrossPositions": "Across %{count} positions", + "availableToEarn": "Available to Earn", + "idleAssetsEarning": "Idle assets that could be earning up to %{apy}% APY", + "potentialEarnings": "Potential Earnings", + "perYear": "/yr", + "earn": "Earn", + "myBalance": "My Balance", + "providers": "Providers", + "deposit": "Deposit", + "withdraw": "Withdraw", + "successDeposit": "You successfully deposited %{amount} %{symbol}", + "successWithdraw": "You successfully withdrew %{amount} %{symbol}", + "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.", + "unsupportedNetworkTitle": "Unsupported network", + "unsupportedNetworkDescription": "This yield network is not supported yet.", + "walletNotConnectedTitle": "Wallet not connected", + "walletNotConnectedDescription": "Connect a wallet that supports this network to continue.", + "enterAmountTitle": "Enter an amount", + "enterAmountDescription": "Amount must be greater than zero.", + "initiateFailedTitle": "Error", + "initiateFailedDescription": "Failed to initiate transaction sequence.", + "quoteFailedTitle": "Quote failed", + "quoteFailedDescription": "Unable to get a quote for this transaction. Please try again." + } } -} +} \ No newline at end of file diff --git a/src/components/AssetAccountDetails/AssetAccountDetails.tsx b/src/components/AssetAccountDetails/AssetAccountDetails.tsx index 8df30adcedd..5a2fdeef772 100644 --- a/src/components/AssetAccountDetails/AssetAccountDetails.tsx +++ b/src/components/AssetAccountDetails/AssetAccountDetails.tsx @@ -18,7 +18,9 @@ import { SpamWarningBanner } from './components/SpamWarningBanner' import { AssetTransactionHistory } from '@/components/TransactionHistory/AssetTransactionHistory' import { getChainAdapterManager } from '@/context/PluginProvider/chainAdapterSingleton' +import { useFeatureFlag } from '@/hooks/useFeatureFlag/useFeatureFlag' import { StandaloneTrade } from '@/pages/Trade/StandaloneTrade' +import { YieldAssetSection } from '@/pages/Yields/components/YieldAssetSection' import { selectIsSpamMarkedByAssetId } from '@/state/slices/preferencesSlice/selectors' import { selectMarketDataByAssetIdUserCurrency } from '@/state/slices/selectors' import { useAppSelector } from '@/state/store' @@ -34,6 +36,7 @@ const display = { base: 'none', md: 'block' } const contentPaddingY = { base: 0, md: 8 } export const AssetAccountDetails = ({ assetId, accountId }: AssetDetailsProps) => { + const isYieldXyzEnabled = useFeatureFlag('YieldXyz') const marketData = useAppSelector(state => selectMarketDataByAssetIdUserCurrency(state, assetId)) const isSpamMarked = useAppSelector(state => selectIsSpamMarkedByAssetId(state, assetId)) const assetIds = useMemo(() => [assetId], [assetId]) @@ -58,6 +61,7 @@ export const AssetAccountDetails = ({ assetId, accountId }: AssetDetailsProps) = {accountId && } + {isYieldXyzEnabled && } diff --git a/src/components/Layout/Header/Header.tsx b/src/components/Layout/Header/Header.tsx index 88cbd977e63..712933d3154 100644 --- a/src/components/Layout/Header/Header.tsx +++ b/src/components/Layout/Header/Header.tsx @@ -10,6 +10,7 @@ import { TbPool, TbRefresh, TbStack, + TbTrendingUp, } from 'react-icons/tb' import { useTranslate } from 'react-polyglot' import { useSelector } from 'react-redux' @@ -67,16 +68,11 @@ const exploreSubMenuItems = [ { label: 'navBar.markets', path: '/markets', icon: TbGraph }, ] -const earnSubMenuItems = [ - { label: 'navBar.tcy', path: '/tcy', icon: TCYIcon }, - { label: 'navBar.pools', path: '/pools', icon: TbPool }, - { label: 'navBar.lending', path: '/lending', icon: TbBuildingBank }, -] - export const Header = memo(() => { const isDegradedState = useSelector(selectPortfolioDegradedState) const translate = useTranslate() const [isLargerThanMd] = useMediaQuery(`(min-width: ${breakpoints['md']})`) + const isYieldXyzEnabled = useFeatureFlag('YieldXyz') const navigate = useNavigate() const { @@ -117,6 +113,17 @@ export const Header = memo(() => { const { degradedChainIds } = useDiscoverAccounts() const hasWallet = Boolean(walletInfo?.deviceId) + const earnSubMenuItems = useMemo( + () => [ + { label: 'navBar.tcy', path: '/tcy', icon: TCYIcon }, + { label: 'navBar.pools', path: '/pools', icon: TbPool }, + { label: 'navBar.lending', path: '/lending', icon: TbBuildingBank }, + ...(isYieldXyzEnabled + ? [{ label: 'navBar.yields', path: '/yields', icon: TbTrendingUp }] + : []), + ], + [isYieldXyzEnabled], + ) /** * FOR DEVELOPERS: diff --git a/src/config.ts b/src/config.ts index b66578e5d95..6e55bf894bf 100644 --- a/src/config.ts +++ b/src/config.ts @@ -234,6 +234,10 @@ const validators = { VITE_NOTIFICATIONS_SERVER_URL: url({ default: '' }), VITE_FEATURE_ADDRESS_BOOK: bool({ default: false }), VITE_FEATURE_APP_RATING: bool({ default: false }), + VITE_FEATURE_YIELD_XYZ: 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 }), } function reporter({ errors }: envalid.ReporterOptions) { diff --git a/src/hooks/useActionCenterSubscribers/useGenericTransactionSubscriber.tsx b/src/hooks/useActionCenterSubscribers/useGenericTransactionSubscriber.tsx index cf0f2fb63b5..d9440ce96bb 100644 --- a/src/hooks/useActionCenterSubscribers/useGenericTransactionSubscriber.tsx +++ b/src/hooks/useActionCenterSubscribers/useGenericTransactionSubscriber.tsx @@ -34,11 +34,13 @@ const displayTypeMessagesMap: Partial> [GenericTransactionDisplayType.RFOX]: 'RFOX.stakeSuccess', [GenericTransactionDisplayType.TCY]: 'actionCenter.tcy.stakeComplete', [GenericTransactionDisplayType.FoxFarm]: 'actionCenter.deposit.complete', + [GenericTransactionDisplayType.Yield]: 'actionCenter.deposit.complete', }, [ActionType.Withdraw]: { [GenericTransactionDisplayType.RFOX]: 'RFOX.unstakeSuccess', [GenericTransactionDisplayType.TCY]: 'actionCenter.tcy.unstakeComplete', [GenericTransactionDisplayType.FoxFarm]: 'actionCenter.withdrawal.complete', + [GenericTransactionDisplayType.Yield]: 'actionCenter.withdrawal.complete', }, [ActionType.Claim]: { [GenericTransactionDisplayType.FoxFarm]: 'actionCenter.claim.complete', @@ -73,6 +75,7 @@ export const useGenericTransactionSubscriber = () => { GenericTransactionDisplayType.TCY, GenericTransactionDisplayType.FoxFarm, GenericTransactionDisplayType.Approve, + GenericTransactionDisplayType.Yield, ].includes(action.transactionMetadata.displayType) ) { return diff --git a/src/lib/yieldxyz/api.ts b/src/lib/yieldxyz/api.ts new file mode 100644 index 00000000000..06525c2c698 --- /dev/null +++ b/src/lib/yieldxyz/api.ts @@ -0,0 +1,139 @@ +import type { AxiosInstance } from 'axios' +import axios from 'axios' + +import type { + ActionDto, + ProvidersResponse, + YieldBalancesResponse, + YieldDto, + YieldsResponse, + YieldValidatorsResponse, +} from './types' + +import { getConfig } from '@/config' + +const BASE_URL = getConfig().VITE_YIELD_XYZ_BASE_URL +const API_KEY = getConfig().VITE_YIELD_XYZ_API_KEY + +const instance: AxiosInstance = axios.create({ + baseURL: BASE_URL, + timeout: 30000, + headers: { + 'X-API-KEY': API_KEY, + 'Content-Type': 'application/json', + }, +}) + +export const fetchYields = async (params?: { + network?: string + networks?: string[] + provider?: string + limit?: number + offset?: number +}) => { + const { networks, ...restParams } = params ?? {} + const queryParams: Record = { + ...restParams, + ...(networks && { networks: networks.join(',') }), + } + const response = await instance.get('/yields', { params: queryParams }) + return response.data +} + +export const fetchYield = async (yieldId: string) => { + const response = await instance.get(`/yields/${yieldId}`) + return response.data +} + +export const fetchProviders = async (params?: { limit?: number; offset?: number }) => { + const response = await instance.get('/providers', { params }) + return response.data +} + +export const fetchAggregateBalances = async ( + queries: { address: string; network: string; yieldId?: string }[], +) => { + const response = await instance.post<{ + items: YieldBalancesResponse[] + errors: { query: (typeof queries)[0]; error: string }[] + }>('/yields/balances', { queries }) + return response.data +} + +export const fetchYieldValidators = async (yieldId: string) => { + const response = await instance.get(`/yields/${yieldId}/validators`) + return response.data +} + +export const enterYield = async ({ + yieldId, + address, + arguments: arguments_, +}: { + yieldId: string + address: string + arguments: Record +}) => { + const response = await instance.post('/actions/enter', { + yieldId, + address, + arguments: arguments_, + }) + return response.data +} + +export const exitYield = async ({ + yieldId, + address, + arguments: arguments_, +}: { + yieldId: string + address: string + arguments: Record +}) => { + const response = await instance.post('/actions/exit', { + yieldId, + address, + arguments: arguments_, + }) + return response.data +} + +export const manageYield = async ({ + yieldId, + address, + action, + passthrough, + arguments: arguments_, +}: { + yieldId: string + address: string + action: string + passthrough: string + arguments?: Record +}) => { + const response = await instance.post('/actions/manage', { + yieldId, + address, + action, + passthrough, + arguments: arguments_, + }) + return response.data +} + +export const fetchAction = async (actionId: string) => { + const response = await instance.get(`/actions/${actionId}`) + return response.data +} + +export const submitTransactionHash = async ({ + transactionId, + hash, +}: { + transactionId: string + hash: string +}) => { + const response = await instance.put(`/transactions/${transactionId}/submit-hash`, { hash }) + return response.data +} diff --git a/src/lib/yieldxyz/augment.ts b/src/lib/yieldxyz/augment.ts new file mode 100644 index 00000000000..99b7df42f6a --- /dev/null +++ b/src/lib/yieldxyz/augment.ts @@ -0,0 +1,159 @@ +import type { AssetId, AssetNamespace, ChainId, ChainReference } from '@shapeshiftoss/caip' +import { + ASSET_NAMESPACE, + CHAIN_NAMESPACE, + fromChainId, + toAssetId, + toChainId, +} from '@shapeshiftoss/caip' + +import type { + AugmentedYieldBalance, + AugmentedYieldDto, + AugmentedYieldMechanics, + AugmentedYieldRewardRate, + AugmentedYieldRewardRateComponent, + AugmentedYieldToken, + YieldBalance, + YieldDto, + YieldMechanics, + YieldRewardRate, + YieldRewardRateComponent, + YieldToken, +} from './types' +import { yieldNetworkToChainId } from './utils' + +import { getChainAdapterManager } from '@/context/PluginProvider/chainAdapterSingleton' + +const tokenToAssetId = (token: YieldToken, chainId: ChainId | undefined): AssetId | undefined => { + if (!chainId) return undefined + + // 1. If we don't have a specific token address, it's the native asset of the chain. + // We use the ChainAdapter to get the fee asset ID (native asset). + if (!token.address) { + const adapter = getChainAdapterManager().get(chainId) + return adapter?.getFeeAssetId() + } + + // 2. If we DO have an address, we construct the AssetId. + // We determine the namespace based on the chain namespace (eip155 vs cosmos vs solana). + // Note: This requires 'chainId' to be a valid CAIP-2 ChainId string. + + const { chainNamespace } = fromChainId(chainId) + + const assetNamespace = ((): AssetNamespace | undefined => { + switch (chainNamespace) { + case CHAIN_NAMESPACE.Evm: + return ASSET_NAMESPACE.erc20 + case CHAIN_NAMESPACE.Solana: + return ASSET_NAMESPACE.splToken + default: + return undefined + } + })() + + if (!assetNamespace) return undefined + + try { + return toAssetId({ + chainId, + assetNamespace, + assetReference: token.address, + }) + } catch (e) { + console.error(`Failed to construct AssetId for ${token.symbol} on ${chainId}`, e) + return undefined + } +} + +// Parse numeric EVM network ID from API's chainId field (e.g., "1" for Ethereum) +// Returns string like "1", "137", etc. - must be validated against CHAIN_REFERENCE +const parseEvmNetworkId = (chainIdStr: string): string | undefined => { + const parsed = parseInt(chainIdStr, 10) + return Number.isFinite(parsed) ? String(parsed) : undefined +} + +const chainIdFromYieldDto = (yieldDto: YieldDto): ChainId | undefined => { + const fromNetwork = yieldNetworkToChainId(yieldDto.network) + if (fromNetwork) return fromNetwork + + const evmNetworkId = parseEvmNetworkId(yieldDto.chainId) + if (evmNetworkId) { + try { + return toChainId({ + chainNamespace: CHAIN_NAMESPACE.Evm, + chainReference: evmNetworkId as ChainReference, + }) + } catch { + return undefined + } + } + + return undefined +} + +export const augmentYieldToken = ( + token: YieldToken, + fallbackChainId?: ChainId, +): AugmentedYieldToken => { + const chainId = yieldNetworkToChainId(token.network) ?? fallbackChainId + const assetId = tokenToAssetId(token, chainId) + return { ...token, chainId, assetId } +} + +const augmentRewardRateComponent = ( + component: YieldRewardRateComponent, + fallbackChainId?: ChainId, +): AugmentedYieldRewardRateComponent => ({ + ...component, + token: augmentYieldToken(component.token, fallbackChainId), +}) + +const augmentRewardRate = ( + rewardRate: YieldRewardRate, + fallbackChainId?: ChainId, +): AugmentedYieldRewardRate => ({ + ...rewardRate, + components: rewardRate.components.map(c => augmentRewardRateComponent(c, fallbackChainId)), +}) + +const augmentMechanics = ( + mechanics: YieldMechanics, + fallbackChainId?: ChainId, +): AugmentedYieldMechanics => ({ + ...mechanics, + gasFeeToken: augmentYieldToken(mechanics.gasFeeToken, fallbackChainId), +}) + +export const augmentYield = (yieldDto: YieldDto): AugmentedYieldDto => { + const chainId = chainIdFromYieldDto(yieldDto) + const evmNetworkId = parseEvmNetworkId(yieldDto.chainId) + + return { + ...yieldDto, + chainId, + evmNetworkId, + token: augmentYieldToken(yieldDto.token, chainId), + inputTokens: yieldDto.inputTokens.map(t => augmentYieldToken(t, chainId)), + outputToken: yieldDto.outputToken + ? augmentYieldToken(yieldDto.outputToken, chainId) + : undefined, + rewardRate: augmentRewardRate(yieldDto.rewardRate, chainId), + mechanics: augmentMechanics(yieldDto.mechanics, chainId), + tokens: yieldDto.tokens?.map(t => augmentYieldToken(t, chainId)) ?? [], + state: yieldDto.state, + } +} + +export const augmentYieldBalance = ( + balance: YieldBalance, + fallbackChainId?: ChainId, +): AugmentedYieldBalance => ({ + ...balance, + token: augmentYieldToken(balance.token, fallbackChainId), +}) + +export const augmentYieldBalances = ( + balances: YieldBalance[], + fallbackChainId?: ChainId, +): AugmentedYieldBalance[] => balances.map(b => augmentYieldBalance(b, fallbackChainId)) diff --git a/src/lib/yieldxyz/constants.ts b/src/lib/yieldxyz/constants.ts new file mode 100644 index 00000000000..44b4773b531 --- /dev/null +++ b/src/lib/yieldxyz/constants.ts @@ -0,0 +1,71 @@ +import type { ChainId } from '@shapeshiftoss/caip' +import { + arbitrumChainId, + avalancheChainId, + baseChainId, + bscChainId, + cosmosChainId, + ethChainId, + gnosisChainId, + hyperEvmChainId, + katanaChainId, + monadChainId, + nearChainId, + optimismChainId, + plasmaChainId, + polygonChainId, + solanaChainId, + suiChainId, + tronChainId, +} from '@shapeshiftoss/caip' +import invert from 'lodash/invert' + +import { YieldNetwork } from './types' + +export const CHAIN_ID_TO_YIELD_NETWORK: Partial> = { + [ethChainId]: YieldNetwork.Ethereum, + [arbitrumChainId]: YieldNetwork.Arbitrum, + [baseChainId]: YieldNetwork.Base, + [optimismChainId]: YieldNetwork.Optimism, + [polygonChainId]: YieldNetwork.Polygon, + [bscChainId]: YieldNetwork.Binance, + [avalancheChainId]: YieldNetwork.AvalancheC, + [gnosisChainId]: YieldNetwork.Gnosis, + [cosmosChainId]: YieldNetwork.Cosmos, + [solanaChainId]: YieldNetwork.Solana, + [suiChainId]: YieldNetwork.Sui, + [monadChainId]: YieldNetwork.Monad, + [tronChainId]: YieldNetwork.Tron, + [hyperEvmChainId]: YieldNetwork.Hyperevm, + [nearChainId]: YieldNetwork.Near, + [plasmaChainId]: YieldNetwork.Plasma, + [katanaChainId]: YieldNetwork.Katana, +} + +export const YIELD_NETWORK_TO_CHAIN_ID: Partial> = invert( + CHAIN_ID_TO_YIELD_NETWORK, +) as Partial> + +export const SUPPORTED_YIELD_NETWORKS = Object.values(CHAIN_ID_TO_YIELD_NETWORK) + +export const isSupportedYieldNetwork = (network: string): network is YieldNetwork => + Object.values(CHAIN_ID_TO_YIELD_NETWORK).includes(network as YieldNetwork) + +export const YIELD_POLL_INTERVAL_MS = 5000 +export const YIELD_MAX_POLL_ATTEMPTS = 120 + +export const SHAPESHIFT_COSMOS_VALIDATOR_ADDRESS = + 'cosmosvaloper199mlc7fr6ll5t54w7tts7f4s0cvnqgc59nmuxf' + +export const SHAPESHIFT_VALIDATOR_LOGO = + 'https://raw.githubusercontent.com/cosmostation/chainlist/main/chain/cosmos/moniker/cosmosvaloper199mlc7fr6ll5t54w7tts7f4s0cvnqgc59nmuxf.png' + +export const COSMOS_SHAPESHIFT_FALLBACK_APR = '0.1425' + +export const COSMOS_DECIMALS = 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/lib/yieldxyz/executeTransaction.ts b/src/lib/yieldxyz/executeTransaction.ts new file mode 100644 index 00000000000..e511321f63a --- /dev/null +++ b/src/lib/yieldxyz/executeTransaction.ts @@ -0,0 +1,539 @@ +import { Transaction as SuiTransaction } from '@mysten/sui/transactions' +import { Transaction as NearTransaction } from '@near-js/transactions' +import type { ChainId } from '@shapeshiftoss/caip' +import { CHAIN_NAMESPACE, fromChainId } from '@shapeshiftoss/caip' +import type { SignTx } from '@shapeshiftoss/chain-adapters' +import { CONTRACT_INTERACTION, toAddressNList } from '@shapeshiftoss/chain-adapters' +import type { HDWallet } from '@shapeshiftoss/hdwallet-core' +import type { EvmChainId } from '@shapeshiftoss/types' +import { + AddressLookupTableAccount, + ComputeBudgetProgram, + PublicKey, + TransactionMessage, + VersionedTransaction, +} from '@solana/web3.js' +import type { Hex } from 'viem' +import { isHex, toHex } from 'viem' + +import type { TransactionDto } from './types' + +import { toBaseUnit } from '@/lib/math' +import { assertGetCosmosSdkChainAdapter } from '@/lib/utils/cosmosSdk' +import { assertGetEvmChainAdapter, signAndBroadcast as evmSignAndBroadcast } from '@/lib/utils/evm' +import { assertGetNearChainAdapter } from '@/lib/utils/near' +import { assertGetSolanaChainAdapter } from '@/lib/utils/solana' +import { assertGetSuiChainAdapter } from '@/lib/utils/sui' +import { assertGetTronChainAdapter } from '@/lib/utils/tron' +import { isStakingChainAdapter } from '@/plugins/cosmos/components/modals/Staking/StakingCommon' + +type ParsedEvmTransaction = { + to: string + from: string + data: string + value?: string + gasLimit?: string + gasPrice?: string + maxFeePerGas?: string + maxPriorityFeePerGas?: string + nonce: number + chainId: number + type?: number +} + +type CosmosGasEstimate = { + amount: string + gasLimit: string + token: { + name: string + network: string + decimals: number + symbol: string + } +} + +export type CosmosStakeArgs = { + validator: string + amountCryptoBaseUnit: string + action: 'stake' | 'unstake' | 'claim' +} + +type ExecuteTransactionInput = { + tx: TransactionDto + chainId: ChainId + wallet: HDWallet + accountId: string + userAddress: string + bip44Params?: { purpose: number; coinType: number; accountNumber: number } + cosmosStakeArgs?: CosmosStakeArgs +} + +export const executeTransaction = async ({ + tx, + chainId, + wallet, + bip44Params, + cosmosStakeArgs, +}: ExecuteTransactionInput): Promise => { + const { chainNamespace } = fromChainId(chainId) + + switch (chainNamespace) { + case CHAIN_NAMESPACE.Evm: { + const parsed: ParsedEvmTransaction = JSON.parse(tx.unsignedTransaction) + return await executeEvmTransaction({ parsed, chainId, wallet, bip44Params }) + } + case CHAIN_NAMESPACE.CosmosSdk: { + if (!cosmosStakeArgs) { + throw new Error('cosmosStakeArgs required for CosmosSdk transactions') + } + return await executeCosmosTransaction({ + gasEstimate: tx.gasEstimate, + chainId, + wallet, + bip44Params, + cosmosStakeArgs, + }) + } + case CHAIN_NAMESPACE.Sui: { + return await executeSuiTransaction({ + unsignedTransaction: tx.unsignedTransaction, + chainId, + wallet, + bip44Params, + }) + } + case CHAIN_NAMESPACE.Solana: { + return await executeSolanaTransaction({ + unsignedTransaction: tx.unsignedTransaction, + chainId, + wallet, + bip44Params, + }) + } + case CHAIN_NAMESPACE.Tron: { + return await executeTronTransaction({ + unsignedTransaction: tx.unsignedTransaction, + chainId, + wallet, + bip44Params, + }) + } + case CHAIN_NAMESPACE.Near: { + return await executeNearTransaction({ + unsignedTransaction: tx.unsignedTransaction, + chainId, + wallet, + bip44Params, + }) + } + default: + throw new Error(`Unsupported chain namespace: ${chainNamespace} for chainId: ${chainId}`) + } +} + +type ExecuteEvmTransactionInput = { + parsed: ParsedEvmTransaction + chainId: ChainId + wallet: HDWallet + bip44Params?: { purpose: number; coinType: number; accountNumber: number } +} + +const toHexOrDefault = (value: string | number | undefined, fallback: Hex): Hex => { + if (value === undefined || value === null || value === '') return fallback + if (typeof value === 'number') return toHex(value) + if (isHex(value)) return value as Hex + try { + return toHex(BigInt(value)) + } catch { + return fallback + } +} + +const toHexData = (value: string | undefined): Hex => { + if (!value) return '0x' + return isHex(value) ? (value as Hex) : value.startsWith('0x') ? (value as Hex) : '0x' +} + +const executeEvmTransaction = async ({ + parsed, + chainId, + wallet, + bip44Params, +}: ExecuteEvmTransactionInput): Promise => { + const adapter = assertGetEvmChainAdapter(chainId) + + const addressNList = bip44Params ? toAddressNList(adapter.getBip44Params(bip44Params)) : undefined + + if (!addressNList) throw new Error('Failed to get address derivation path') + + const account = await adapter.getAccount(parsed.from) + const currentNonce = account.chainSpecific.nonce + + const baseTxToSign = { + to: toHexData(parsed.to), + data: toHexData(parsed.data), + value: toHexOrDefault(parsed.value, '0x0'), + gasLimit: toHexOrDefault(parsed.gasLimit, '0x0'), + nonce: toHex(currentNonce), + chainId: parsed.chainId, + type: parsed.type, + addressNList, + } + + const txToSign: SignTx = + parsed.maxFeePerGas || parsed.maxPriorityFeePerGas + ? { + ...baseTxToSign, + maxFeePerGas: toHexOrDefault(parsed.maxFeePerGas, '0x0'), + maxPriorityFeePerGas: toHexOrDefault(parsed.maxPriorityFeePerGas, '0x0'), + } + : { + ...baseTxToSign, + gasPrice: toHexOrDefault(parsed.gasPrice ?? '0', '0x0'), + } + + const txHash = await evmSignAndBroadcast({ + adapter, + txToSign, + wallet, + senderAddress: parsed.from, + receiverAddress: parsed.to, + }) + + if (!txHash) throw new Error('Failed to broadcast EVM transaction') + return txHash +} + +type ExecuteCosmosTransactionInput = { + gasEstimate: string + chainId: ChainId + wallet: HDWallet + bip44Params?: { purpose: number; coinType: number; accountNumber: number } + cosmosStakeArgs: CosmosStakeArgs +} + +const executeCosmosTransaction = async ({ + gasEstimate, + chainId, + wallet, + bip44Params, + cosmosStakeArgs, +}: ExecuteCosmosTransactionInput): Promise => { + const adapter = assertGetCosmosSdkChainAdapter(chainId) + + if (!isStakingChainAdapter(adapter)) { + throw new Error(`Chain adapter does not support staking for chainId: ${chainId}`) + } + + const gas: CosmosGasEstimate = JSON.parse(gasEstimate) + const accountNumber = bip44Params?.accountNumber ?? 0 + + const { validator, amountCryptoBaseUnit, action } = cosmosStakeArgs + + const feeCryptoBaseUnit = toBaseUnit(gas.amount, gas.token.decimals) + + const chainSpecific = { + gas: gas.gasLimit, + fee: feeCryptoBaseUnit, + } + + const address = await adapter.getAddress({ accountNumber, wallet }) + + const buildTxFn = (() => { + switch (action) { + case 'stake': + return adapter.buildDelegateTransaction({ + accountNumber, + wallet, + validator, + value: amountCryptoBaseUnit, + chainSpecific, + memo: '', + }) + case 'unstake': + return adapter.buildUndelegateTransaction({ + accountNumber, + wallet, + validator, + value: amountCryptoBaseUnit, + chainSpecific, + memo: '', + }) + case 'claim': + return adapter.buildClaimRewardsTransaction({ + accountNumber, + wallet, + validator, + chainSpecific, + memo: '', + }) + default: + throw new Error(`Unsupported cosmos action: ${action}`) + } + })() + + const { txToSign } = await buildTxFn + + const txHash = await adapter.signAndBroadcastTransaction({ + senderAddress: address, + receiverAddress: action === 'stake' ? CONTRACT_INTERACTION : address, + signTxInput: { txToSign, wallet }, + }) + + if (!txHash) throw new Error('Failed to broadcast Cosmos transaction') + return txHash +} + +type ExecuteSuiTransactionInput = { + unsignedTransaction: string + chainId: ChainId + wallet: HDWallet + bip44Params?: { purpose: number; coinType: number; accountNumber: number } +} + +const executeSuiTransaction = async ({ + unsignedTransaction, + chainId, + wallet, + bip44Params, +}: ExecuteSuiTransactionInput): Promise => { + const adapter = assertGetSuiChainAdapter(chainId) + const accountNumber = bip44Params?.accountNumber ?? 0 + + const txJson = Buffer.from(unsignedTransaction, 'base64').toString('utf-8') + const tx = SuiTransaction.from(txJson) + + const client = adapter.getSuiClient() + const transactionBytes = await tx.build({ client }) + + const intentMessage = new Uint8Array(3 + transactionBytes.length) + intentMessage[0] = 0 // TransactionData intent scope + intentMessage[1] = 0 // Version + intentMessage[2] = 0 // AppId + intentMessage.set(transactionBytes, 3) + + const txToSign = { + addressNList: toAddressNList(adapter.getBip44Params({ accountNumber })), + intentMessageBytes: intentMessage, + transactionJson: '{}', // Added to satisfy SuiSignTx type requirement + } + + const txHash = await adapter.signAndBroadcastTransaction({ + senderAddress: '', + receiverAddress: '', + signTxInput: { txToSign, wallet }, + }) + + if (!txHash) throw new Error('Failed to broadcast Sui transaction') + return txHash +} + +type ExecuteSolanaTransactionInput = { + unsignedTransaction: string + chainId: ChainId + wallet: HDWallet + bip44Params?: { purpose: number; coinType: number; accountNumber: number } +} + +const executeSolanaTransaction = async ({ + unsignedTransaction, + chainId, + wallet, + bip44Params, +}: 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')), + ) + + const addressLookupTableAccountKeys = versionedTransaction.message.addressTableLookups.map( + lookup => lookup.accountKey.toString(), + ) + + const addressLookupTableAccountsInfos = await adapter.getAddressLookupTableAccounts( + addressLookupTableAccountKeys, + ) + + const addressLookupTableAccounts = addressLookupTableAccountsInfos.map( + info => + new AddressLookupTableAccount({ + key: new PublicKey(info.key), + state: AddressLookupTableAccount.deserialize(new Uint8Array(info.data)), + }), + ) + + const decompiledMessage = TransactionMessage.decompile(versionedTransaction.message, { + addressLookupTableAccounts, + }) + + const computeBudgetProgramId = ComputeBudgetProgram.programId.toString() + const nonComputeBudgetInstructions = decompiledMessage.instructions.filter( + ix => ix.programId.toString() !== computeBudgetProgramId, + ) + + const from = await adapter.getAddress({ accountNumber, wallet }) + + const { fast } = await adapter.getFeeData({ + to: '', + value: '0', + chainSpecific: { + from, + addressLookupTableAccounts: addressLookupTableAccountKeys, + instructions: nonComputeBudgetInstructions, + }, + }) + + const convertedInstructions = nonComputeBudgetInstructions.map(instruction => + adapter.convertInstruction(instruction), + ) + + const STAKE_COMPUTE_UNIT_BUFFER = 50000 + const estimatedComputeUnits = Math.max( + Number(fast.chainSpecific.computeUnits), + STAKE_COMPUTE_UNIT_BUFFER, + ) + + const txToSign = await adapter.buildSendApiTransaction({ + from, + to: '', + value: '0', + accountNumber, + chainSpecific: { + addressLookupTableAccounts: addressLookupTableAccountKeys, + instructions: convertedInstructions, + computeUnitLimit: String(estimatedComputeUnits), + computeUnitPrice: fast.chainSpecific.priorityFee, + }, + }) + + const signedTx = await adapter.signTransaction({ txToSign, wallet }) + + if (!signedTx) throw new Error('Failed to sign Solana transaction') + + try { + const txHash = await adapter.broadcastTransaction({ + senderAddress: from, + receiverAddress: CONTRACT_INTERACTION, + hex: signedTx, + }) + + if (!txHash) throw new Error('Failed to broadcast Solana transaction') + return txHash + } catch (err) { + throw err + } +} + +type ExecuteTronTransactionInput = { + unsignedTransaction: string + chainId: ChainId + wallet: HDWallet + bip44Params?: { purpose: number; coinType: number; accountNumber: number } +} + +/** + * Executes a Tron transaction for YieldXYZ staking. + * + * The `unsignedTransaction` from YieldXYZ is a JSON string containing a raw Tron transaction object. + * We parse this, wrap it into a `txToSign` object compatible with the Tron adapter, sign, and broadcast. + */ +const executeTronTransaction = async ({ + unsignedTransaction, + chainId, + wallet, + bip44Params, +}: ExecuteTronTransactionInput): Promise => { + const adapter = assertGetTronChainAdapter(chainId) + const accountNumber = bip44Params?.accountNumber ?? 0 + + // Parse the raw transaction JSON from YieldXYZ + const rawTx = JSON.parse(unsignedTransaction) + + // Build addressNList from bip44Params (same pattern as approveTron) + const adapterBip44Params = adapter.getBip44Params({ accountNumber }) + const addressNList = toAddressNList(adapterBip44Params) + + // Extract rawDataHex (may be a string or buffer) + const rawDataHex = + typeof rawTx.raw_data_hex === 'string' + ? rawTx.raw_data_hex + : Buffer.isBuffer(rawTx.raw_data_hex) + ? (rawTx.raw_data_hex as Buffer).toString('hex') + : Array.isArray(rawTx.raw_data_hex) + ? Buffer.from(rawTx.raw_data_hex as number[]).toString('hex') + : (() => { + throw new Error(`Unexpected raw_data_hex type: ${typeof rawTx.raw_data_hex}`) + })() + + // Build HDWallet-compatible transaction object + // The adapter.signTransaction expects: { txToSign: { addressNList, rawDataHex, transaction } } + const txToSign = { + addressNList, + rawDataHex, + transaction: rawTx, // The full Tron transaction object + } + + const from = await adapter.getAddress({ accountNumber, wallet }) + + const signedTx = await adapter.signTransaction({ txToSign, wallet }) + + if (!signedTx) throw new Error('Failed to sign Tron transaction') + + const txHash = await adapter.broadcastTransaction({ + senderAddress: from, + receiverAddress: CONTRACT_INTERACTION, + hex: signedTx, + }) + + if (!txHash) throw new Error('Failed to broadcast Tron transaction') + return txHash +} + +type ExecuteNearTransactionInput = { + unsignedTransaction: string + chainId: ChainId + wallet: HDWallet + bip44Params?: { purpose: number; coinType: number; accountNumber: number } +} + +const executeNearTransaction = async ({ + unsignedTransaction, + chainId, + wallet, + bip44Params, +}: ExecuteNearTransactionInput): Promise => { + const adapter = assertGetNearChainAdapter(chainId) + const accountNumber = bip44Params?.accountNumber ?? 0 + + const txBytes = new Uint8Array(Buffer.from(unsignedTransaction, 'hex')) + const transaction = NearTransaction.decode(txBytes) + + const adapterBip44Params = adapter.getBip44Params({ accountNumber }) + const addressNList = toAddressNList(adapterBip44Params) + + const txToSign = { + addressNList, + transaction, + txBytes, + } + + const from = await adapter.getAddress({ accountNumber, wallet }) + + const signedTx = await adapter.signTransaction({ txToSign, wallet }) + + if (!signedTx) throw new Error('Failed to sign NEAR transaction') + + const txHash = await adapter.broadcastTransaction({ + senderAddress: from, + receiverAddress: CONTRACT_INTERACTION, + hex: signedTx, + }) + + if (!txHash) throw new Error('Failed to broadcast NEAR transaction') + return txHash +} diff --git a/src/lib/yieldxyz/types.ts b/src/lib/yieldxyz/types.ts new file mode 100644 index 00000000000..df7c880d734 --- /dev/null +++ b/src/lib/yieldxyz/types.ts @@ -0,0 +1,412 @@ +// ============================================================================ +// Enums (from API docs) +// ============================================================================ + +import type { AssetId, ChainId } from '@shapeshiftoss/caip' + +export enum YieldNetwork { + Ethereum = 'ethereum', + Arbitrum = 'arbitrum', + Base = 'base', + Gnosis = 'gnosis', + Optimism = 'optimism', + Polygon = 'polygon', + AvalancheC = 'avalanche-c', + Binance = 'binance', + Solana = 'solana', + Cosmos = 'cosmos', + Sui = 'sui', + Monad = 'monad', + Tron = 'tron', + Hyperevm = 'hyperevm', + Near = 'near', + Plasma = 'plasma', + Katana = 'katana', +} + +export enum ActionIntent { + Enter = 'enter', + Exit = 'exit', + Manage = 'manage', +} + +export enum ActionStatus { + Canceled = 'CANCELED', + Created = 'CREATED', + WaitingForNext = 'WAITING_FOR_NEXT', + Processing = 'PROCESSING', + Failed = 'FAILED', + Success = 'SUCCESS', + Stale = 'STALE', +} + +export enum TransactionStatus { + NotFound = 'NOT_FOUND', + Created = 'CREATED', + Blocked = 'BLOCKED', + WaitingForSignature = 'WAITING_FOR_SIGNATURE', + Signed = 'SIGNED', + Broadcasted = 'BROADCASTED', + Pending = 'PENDING', + Confirmed = 'CONFIRMED', + Failed = 'FAILED', + Skipped = 'SKIPPED', +} + +// ============================================================================ +// Token Types +// ============================================================================ + +export type YieldToken = { + address?: string + symbol: string + name: string + decimals: number + network: YieldNetwork + logoURI: string + coinGeckoId?: string + isPoints?: boolean +} + +// ============================================================================ +// Balance Types +// ============================================================================ + +export enum YieldBalanceType { + Active = 'active', + Entering = 'entering', + Exiting = 'exiting', + Withdrawable = 'withdrawable', + Claimable = 'claimable', + Locked = 'locked', +} + +export type YieldBalanceValidator = { + address: string + name: string + logoURI: string + status?: string + apr?: number + commission?: number +} + +export type YieldBalance = { + address: string + amount: string + amountRaw: string + amountUsd: string + type: YieldBalanceType + token: YieldToken + isEarning: boolean + date?: string + pendingActions: { + type: string + passthrough: string + }[] + validator?: YieldBalanceValidator +} + +export type YieldBalancesResponse = { + yieldId: string + balances: YieldBalance[] +} + +// ============================================================================ +// Transaction Types +// ============================================================================ + +export type TransactionDto = { + id: string + title: string + network: YieldNetwork + status: TransactionStatus + type: string + hash: string | null + createdAt: string + broadcastedAt: string | null + signedTransaction: string | null + unsignedTransaction: string + stepIndex: number + gasEstimate: string + explorerUrl?: string | null + description?: string + error?: string | null + annotatedTransaction?: Record | null + isMessage?: boolean +} + +// ============================================================================ +// Action Types +// ============================================================================ + +export type ActionDto = { + id: string + intent: ActionIntent + type: string + yieldId: string + address: string + amount: string | null + amountRaw: string | null + amountUsd: string | null + transactions: TransactionDto[] + executionPattern: 'synchronous' | 'asynchronous' | 'batch' + rawArguments: Record | null + status: ActionStatus + createdAt: string + completedAt: string | null +} + +export type ActionsResponse = { + items: ActionDto[] + total: number + offset: number + limit: number +} + +// ============================================================================ +// Yield Types (from GET /v1/yields/{yieldId}) +// ============================================================================ + +export type YieldArgumentField = { + name: string + type: 'string' | 'number' | 'boolean' + label: string + description: string + required: boolean + placeholder?: string + minimum?: string + maximum?: string | null + isArray: boolean + options?: string[] + optionsRef?: string +} + +export type YieldArguments = { + enter: { fields: YieldArgumentField[] } + exit: { fields: YieldArgumentField[] } +} + +export type YieldRewardRateComponent = { + rate: number + rateType: 'APY' | 'APR' + token: YieldToken + yieldSource: string + description: string +} + +export type YieldRewardRate = { + total: number + rateType: 'APY' | 'APR' + components: YieldRewardRateComponent[] +} + +export type YieldStatistics = { + tvlUsd: string + tvl: string + tvlRaw?: string + uniqueUsers?: number | null + averagePositionSizeUsd?: string | null + averagePositionSize?: string | null +} + +export type YieldMetadata = { + name: string + description: string + logoURI: string + documentation?: string + underMaintenance: boolean + deprecated: boolean + supportedStandards?: string[] +} + +export type YieldStatus = { + enter: boolean + exit: boolean +} + +export type YieldEntryLimits = { + minimum: string + maximum: string | null +} + +export type YieldMechanics = { + type: string + requiresValidatorSelection: boolean + rewardSchedule: string + rewardClaiming: string + gasFeeToken: YieldToken + entryLimits: YieldEntryLimits + arguments: YieldArguments + supportsLedgerWalletApi?: boolean + possibleFeeTakingMechanisms?: { + depositFee: boolean + managementFee: boolean + performanceFee: boolean + validatorRebates: boolean + } +} + +export type YieldDto = { + id: string + network: YieldNetwork + chainId: string + providerId: string + token: YieldToken + inputTokens: YieldToken[] + outputToken?: YieldToken + rewardRate: YieldRewardRate + statistics: YieldStatistics + status: YieldStatus + metadata: YieldMetadata + mechanics: YieldMechanics + tags: string[] + tokens: YieldToken[] + state?: { + capacityState?: { + current: string + max: string + remaining: string + } + } +} + +export type YieldsResponse = { + items: YieldDto[] + total: number + offset: number + limit: number +} + +// ============================================================================ +// Validator Types +// ============================================================================ + +export type ValidatorDto = { + address: string + preferred: boolean + name: string + logoURI: string + website?: string + commission: number + votingPower: number + status: string + tvl: string + tvlRaw: string + rewardRate: YieldRewardRate +} + +export type YieldValidatorsResponse = { + items: ValidatorDto[] + total: number + offset: number + limit: number +} + +// ============================================================================ +// Provider Types +// ============================================================================ + +export type ProviderDto = { + id: string + name: string + logoURI: string + description?: string + documentation?: string +} + +export type ProvidersResponse = { + items: ProviderDto[] + total: number + offset: number + limit: number +} + +// ============================================================================ +// Network Types +// ============================================================================ + +export type NetworkDto = { + id: string + name: string + category: string + logoURI: string + chainId?: number +} + +export type NetworksResponse = NetworkDto[] + +// ============================================================================ +// Augmented Types (ShapeShift-specific, derived from API types) +// These types add CAIP-2 ChainId and CAIP-19 AssetId for ShapeShift integration +// ============================================================================ + +export type AugmentedYieldToken = YieldToken & { + chainId: ChainId | undefined + assetId: AssetId | undefined +} + +export type AugmentedYieldRewardRateComponent = Omit & { + token: AugmentedYieldToken +} + +export type AugmentedYieldRewardRate = Omit & { + components: AugmentedYieldRewardRateComponent[] +} + +export type AugmentedYieldMechanics = Omit & { + gasFeeToken: AugmentedYieldToken +} + +export type AugmentedYieldBalance = Omit & { + token: AugmentedYieldToken + highestAmountUsdValidator?: string +} + +export type AugmentedYieldDto = Omit< + YieldDto, + 'chainId' | 'token' | 'inputTokens' | 'outputToken' | 'rewardRate' | 'mechanics' | 'tokens' +> & { + chainId: ChainId | undefined + evmNetworkId: string | undefined + token: AugmentedYieldToken + inputTokens: AugmentedYieldToken[] + outputToken: AugmentedYieldToken | undefined + rewardRate: AugmentedYieldRewardRate + mechanics: AugmentedYieldMechanics + tokens: AugmentedYieldToken[] +} + +// ============================================================================ +// Parsed Types (for utils) +// ============================================================================ + +export type ParsedGasEstimate = { + token: { + name: string + symbol: string + logoURI: string + network: YieldNetwork + decimals: number + coinGeckoId?: string + } + amount: string + gasLimit: string +} + +export type YieldIconSource = { + assetId: string | undefined + src: string | undefined +} + +export type YieldAssetGroup = { + symbol: string + name: string + icon: string + assetId?: string + yields: AugmentedYieldDto[] + count: number + maxApy: number + totalTvlUsd: string + providerIds: string[] + chainIds: string[] +} diff --git a/src/lib/yieldxyz/utils.ts b/src/lib/yieldxyz/utils.ts new file mode 100644 index 00000000000..3fe6b72688f --- /dev/null +++ b/src/lib/yieldxyz/utils.ts @@ -0,0 +1,99 @@ +import type { ChainId } from '@shapeshiftoss/caip' + +import { + isSupportedYieldNetwork, + SHAPESHIFT_COSMOS_VALIDATOR_ADDRESS, + YIELD_NETWORK_TO_CHAIN_ID, +} from './constants' +import type { AugmentedYieldDto, ValidatorDto, YieldIconSource } from './types' + +import { bnOrZero } from '@/lib/bignumber/bignumber' + +export const yieldNetworkToChainId = (network: string): ChainId | undefined => { + if (!isSupportedYieldNetwork(network)) return undefined + return YIELD_NETWORK_TO_CHAIN_ID[network] +} + +const TX_TITLE_PATTERNS: [RegExp, string][] = [ + [/approv/i, 'Approve'], + [/supply|deposit|enter/i, 'Deposit'], + [/withdraw|exit/i, 'Withdraw'], + [/claim/i, 'Claim'], + [/unstake/i, 'Unstake'], + [/stake/i, 'Stake'], +] + +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)) + if (match) return `${match[1]} ${assetSymbol}` + return normalized.charAt(0).toUpperCase() + normalized.slice(1) +} + +type YieldItemForIcon = { + inputTokens: { assetId?: string; logoURI?: string }[] + token: { assetId?: string; logoURI?: string } + metadata: { logoURI?: string } +} + +// HACK: yield.xyz SVG logos often fail to load in browser, so we prefer our local asset icons. +// Priority: inputToken.assetId > token.assetId > inputToken.logoURI > metadata.logoURI +export const resolveYieldInputAssetIcon = (yieldItem: YieldItemForIcon): YieldIconSource => { + const inputToken = yieldItem.inputTokens[0] + const inputTokenAssetId = inputToken?.assetId + const vaultTokenAssetId = yieldItem.token?.assetId + const inputTokenLogoURI = inputToken?.logoURI + const metadataLogoURI = yieldItem.metadata?.logoURI + + if (inputTokenAssetId) return { assetId: inputTokenAssetId, src: undefined } + if (vaultTokenAssetId) return { assetId: vaultTokenAssetId, src: undefined } + if (inputTokenLogoURI) return { assetId: undefined, src: inputTokenLogoURI } + return { assetId: undefined, src: metadataLogoURI } +} + +export const searchValidators = (validators: ValidatorDto[], query: string): ValidatorDto[] => { + if (!query) return validators + const search = query.toLowerCase() + return validators.filter( + v => + (v.name || '').toLowerCase().includes(search) || + (v.address || '').toLowerCase().includes(search), + ) +} + +export const searchYields = (yields: AugmentedYieldDto[], query: string): AugmentedYieldDto[] => { + if (!query) return yields + const search = query.toLowerCase() + return yields.filter( + y => + y.metadata.name.toLowerCase().includes(search) || + y.token.symbol.toLowerCase().includes(search) || + y.token.name.toLowerCase().includes(search) || + y.providerId.toLowerCase().includes(search), + ) +} + +type ValidatorSortOptions = { + shapeShiftFirst?: boolean + preferredFirst?: boolean +} + +export const sortValidators = ( + validators: ValidatorDto[], + options: ValidatorSortOptions = { shapeShiftFirst: true, preferredFirst: true }, +): ValidatorDto[] => { + return [...validators].sort((a, b) => { + if (options.shapeShiftFirst) { + if (a.address === SHAPESHIFT_COSMOS_VALIDATOR_ADDRESS) return -1 + if (b.address === SHAPESHIFT_COSMOS_VALIDATOR_ADDRESS) return 1 + } + if (options.preferredFirst) { + if (a.preferred && !b.preferred) return -1 + if (!a.preferred && b.preferred) return 1 + } + return 0 + }) +} + +export const toUserCurrency = (usdAmount: string | number, rate: string | number): string => + bnOrZero(usdAmount).times(rate).toFixed() diff --git a/src/pages/Accounts/AccountToken/AccountToken.tsx b/src/pages/Accounts/AccountToken/AccountToken.tsx index f657a4a2960..4b99fb6fb5b 100644 --- a/src/pages/Accounts/AccountToken/AccountToken.tsx +++ b/src/pages/Accounts/AccountToken/AccountToken.tsx @@ -16,6 +16,7 @@ import { EarnOpportunities } from '@/components/StakingVaults/EarnOpportunities' import { AssetTransactionHistory } from '@/components/TransactionHistory/AssetTransactionHistory' import { getChainAdapterManager } from '@/context/PluginProvider/chainAdapterSingleton' import { StandaloneTrade } from '@/pages/Trade/StandaloneTrade' +import { YieldAssetSection } from '@/pages/Yields/components/YieldAssetSection' import { selectEnabledWalletAccountIds } from '@/state/slices/selectors' import { breakpoints } from '@/theme/theme' @@ -68,6 +69,7 @@ export const AccountToken = () => { + void +} + +const YieldAccountContext = createContext(undefined) + +export const YieldAccountProvider: React.FC<{ children: React.ReactNode }> = memo( + ({ children }) => { + const [accountNumber, setAccountNumberState] = useState(0) + + const setAccountNumber = useCallback((accountNumber: number) => { + setAccountNumberState(accountNumber) + }, []) + + const value = useMemo( + () => ({ accountNumber, setAccountNumber }), + [accountNumber, setAccountNumber], + ) + + return {children} + }, +) + +export const useYieldAccount = () => { + const context = useContext(YieldAccountContext) + // Fallback to account 0 when used outside YieldAccountProvider (e.g., YieldAssetSection on asset pages) + if (context === undefined) { + return { accountNumber: 0, setAccountNumber: () => {} } + } + return context +} diff --git a/src/pages/Yields/YieldAssetDetails.tsx b/src/pages/Yields/YieldAssetDetails.tsx new file mode 100644 index 00000000000..644a8fae98a --- /dev/null +++ b/src/pages/Yields/YieldAssetDetails.tsx @@ -0,0 +1,443 @@ +import { ArrowBackIcon } from '@chakra-ui/icons' +import { + Avatar, + Box, + Button, + Container, + Flex, + Heading, + HStack, + SimpleGrid, + Stat, + Text, +} from '@chakra-ui/react' +import type { ColumnDef, Row } from '@tanstack/react-table' +import { getCoreRowModel, getSortedRowModel, useReactTable } from '@tanstack/react-table' +import { memo, useCallback, useMemo } from 'react' +import { useTranslate } from 'react-polyglot' +import { useNavigate, useParams, useSearchParams } from 'react-router-dom' + +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 type { AugmentedYieldDto, YieldNetwork } from '@/lib/yieldxyz/types' +import { resolveYieldInputAssetIcon } from '@/lib/yieldxyz/utils' +import { GradientApy } from '@/pages/Yields/components/GradientApy' +import { YieldFilters } from '@/pages/Yields/components/YieldFilters' +import { YieldItem, YieldItemSkeleton } from '@/pages/Yields/components/YieldItem' +import { YieldTable } from '@/pages/Yields/components/YieldTable' +import { ViewToggle } from '@/pages/Yields/components/YieldViewHelpers' +import { useYieldFilters } from '@/pages/Yields/hooks/useYieldFilters' +import { useAllYieldBalances } from '@/react-queries/queries/yieldxyz/useAllYieldBalances' +import { useYieldProviders } from '@/react-queries/queries/yieldxyz/useYieldProviders' +import { useYields } from '@/react-queries/queries/yieldxyz/useYields' +import { selectUserCurrencyToUsdRate } from '@/state/slices/selectors' +import { useAppSelector } from '@/state/store' + +export const YieldAssetDetails = memo(() => { + const { assetId: assetSymbol } = useParams<{ assetId: string }>() + const decodedSymbol = useMemo(() => decodeURIComponent(assetSymbol || ''), [assetSymbol]) + const navigate = useNavigate() + const translate = useTranslate() + const [searchParams, setSearchParams] = useSearchParams() + + const viewParam = useMemo(() => searchParams.get('view'), [searchParams]) + const viewMode = useMemo<'grid' | 'list'>( + () => (viewParam === 'list' ? 'list' : 'grid'), + [viewParam], + ) + const setViewMode = useCallback( + (mode: 'grid' | 'list') => { + setSearchParams(prev => { + if (mode === 'grid') prev.delete('view') + else prev.set('view', mode) + return prev + }) + }, + [setSearchParams], + ) + const { + selectedNetwork, + selectedProvider, + sortOption, + sorting, + setSorting, + handleNetworkChange, + handleProviderChange, + handleSortChange, + } = useYieldFilters() + + const { data: yields, isLoading } = useYields() + const { data: yieldProviders } = useYieldProviders() + const userCurrencyToUsdRate = useAppSelector(selectUserCurrencyToUsdRate) + const { data: allBalancesData } = useAllYieldBalances() + const allBalances = allBalancesData?.byYieldId + + const getProviderLogo = useCallback( + (providerId: string) => yieldProviders?.[providerId]?.logoURI, + [yieldProviders], + ) + + const assetYields = useMemo( + () => (yields?.byAssetSymbol && decodedSymbol ? yields.byAssetSymbol[decodedSymbol] || [] : []), + [yields, decodedSymbol], + ) + + const networks = useMemo( + () => + Array.from(new Set(assetYields.map(y => y.network))).map(net => ({ + id: net, + name: net.charAt(0).toUpperCase() + net.slice(1), + chainId: YIELD_NETWORK_TO_CHAIN_ID[net as YieldNetwork], + })), + [assetYields], + ) + + const providers = useMemo( + () => + Array.from(new Set(assetYields.map(y => y.providerId))).map(pId => ({ + id: pId, + name: pId.charAt(0).toUpperCase() + pId.slice(1), + icon: getProviderLogo(pId), + })), + [assetYields, getProviderLogo], + ) + + const filteredYields = useMemo( + () => + assetYields.filter(y => { + if (selectedNetwork && y.network !== selectedNetwork) return false + if (selectedProvider && y.providerId !== selectedProvider) return false + return true + }), + [assetYields, selectedNetwork, selectedProvider], + ) + + const assetInfo = useMemo(() => { + const group = yields?.assetGroups?.find(g => g.symbol === decodedSymbol) + if (!group) return null + return { assetName: group.name, assetIcon: group.icon, assetId: group.assetId } + }, [yields?.assetGroups, decodedSymbol]) + + const columns = useMemo[]>( + () => [ + { + header: translate('yieldXYZ.yield'), + id: 'pool', + accessorFn: row => row.metadata.name, + enableSorting: true, + sortingFn: 'alphanumeric', + cell: ({ row }) => { + const iconSource = resolveYieldInputAssetIcon(row.original) + return ( + + {iconSource.assetId ? ( + + ) : ( + + )} + + + {row.original.metadata.name} + + + {row.original.chainId && } + + + + {row.original.providerId} + + + + + + ) + }, + meta: { display: { base: 'table-cell' } }, + }, + { + header: translate('yieldXYZ.apy'), + id: 'apy', + accessorFn: row => row.rewardRate.total, + enableSorting: true, + sortingFn: (rowA, rowB) => { + const a = bnOrZero(rowA.original.rewardRate.total).toNumber() + const b = bnOrZero(rowB.original.rewardRate.total).toNumber() + return a === b ? 0 : a > b ? 1 : -1 + }, + cell: ({ row }) => { + const apy = bnOrZero(row.original.rewardRate.total).times(100).toNumber() + return ( + + + {apy.toFixed(2)}% + + + {row.original.rewardRate.rateType} + + + ) + }, + meta: { display: { base: 'table-cell' } }, + }, + { + header: translate('yieldXYZ.tvl'), + id: 'tvl', + accessorFn: row => row.statistics?.tvlUsd, + enableSorting: true, + sortingFn: (rowA, rowB) => { + const a = bnOrZero(rowA.original.statistics?.tvlUsd).toNumber() + const b = bnOrZero(rowB.original.statistics?.tvlUsd).toNumber() + return a === b ? 0 : a > b ? 1 : -1 + }, + cell: ({ row }) => { + const tvlUserCurrency = bnOrZero(row.original.statistics?.tvlUsd) + .times(userCurrencyToUsdRate) + .toFixed() + return ( + + + + + + {translate('yieldXYZ.tvl')} + + + ) + }, + meta: { display: { base: 'none', md: 'table-cell' } }, + }, + { + header: translate('yieldXYZ.provider'), + id: 'provider', + accessorFn: row => row.providerId, + enableSorting: true, + sortingFn: 'alphanumeric', + cell: ({ row }) => ( + + + + {row.original.providerId} + + + ), + meta: { display: { base: 'none', md: 'table-cell' } }, + }, + { + 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() + }, + 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 + 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) + if (totalUsd.lte(0)) return null + const totalUserCurrency = totalUsd.times(userCurrencyToUsdRate).toFixed() + return ( + + + + + + ) + }, + meta: { display: { base: 'none', lg: 'table-cell' } }, + }, + ], + [translate, userCurrencyToUsdRate, getProviderLogo, allBalances], + ) + + const table = useReactTable({ + data: filteredYields, + columns, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getRowId: row => row.id, + enableSorting: true, + state: { sorting }, + onSortingChange: setSorting, + }) + + const handleYieldClick = useCallback( + (yieldId: string) => { + const balances = allBalances?.[yieldId] + const highestAmountValidator = balances?.[0]?.highestAmountUsdValidator + const url = highestAmountValidator + ? `/yields/${yieldId}?validator=${highestAmountValidator}` + : `/yields/${yieldId}` + navigate(url) + }, + [allBalances, navigate], + ) + + const handleRowClick = useCallback( + (row: Row) => { + if (!row.original.status.enter) return + handleYieldClick(row.original.id) + }, + [handleYieldClick], + ) + + const assetHeaderElement = useMemo(() => { + if (!assetInfo) return null + return ( + + + + + {translate('yieldXYZ.assetYields', { asset: assetInfo.assetName })} + + + {translate('yieldXYZ.opportunitiesAvailable', { count: assetYields.length })} + + + + ) + }, [assetInfo, assetYields.length, translate]) + + const loadingGridElement = useMemo( + () => ( + + {Array.from({ length: 6 }).map((_, i) => ( + + ))} + + ), + [], + ) + + const loadingListElement = useMemo( + () => ( + + {Array.from({ length: 5 }).map((_, i) => ( + + ))} + + ), + [], + ) + + const gridViewElement = useMemo( + () => ( + + {table.getSortedRowModel().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 + } + /> + ))} + + ), + [allBalances, getProviderLogo, handleYieldClick, table], + ) + + const listViewElement = useMemo( + () => ( + + + + ), + [handleRowClick, table], + ) + + const contentElement = useMemo(() => { + if (isLoading) return viewMode === 'grid' ? loadingGridElement : loadingListElement + if (filteredYields.length === 0) + return {translate('yieldXYZ.noYieldsMatchingFilters')} + return viewMode === 'grid' ? gridViewElement : listViewElement + }, [ + filteredYields.length, + gridViewElement, + isLoading, + listViewElement, + loadingGridElement, + loadingListElement, + translate, + viewMode, + ]) + + return ( + + + {assetHeaderElement} + + + + + + + + {contentElement} + + ) +}) diff --git a/src/pages/Yields/YieldDetail.tsx b/src/pages/Yields/YieldDetail.tsx new file mode 100644 index 00000000000..43c2e759690 --- /dev/null +++ b/src/pages/Yields/YieldDetail.tsx @@ -0,0 +1,239 @@ +import { + Avatar, + AvatarGroup, + Box, + Button, + Container, + Flex, + Heading, + HStack, + Text, + useColorModeValue, +} from '@chakra-ui/react' +import { memo, useEffect, useMemo } from 'react' +import { FaChevronLeft } from 'react-icons/fa' +import { useTranslate } from 'react-polyglot' +import { useNavigate, useParams } from 'react-router-dom' + +import { AssetIcon } from '@/components/AssetIcon' +import { ChainIcon } from '@/components/ChainMenu' +import { resolveYieldInputAssetIcon } from '@/lib/yieldxyz/utils' +import { ValidatorBreakdown } from '@/pages/Yields/components/ValidatorBreakdown' +import { YieldEnterExit } from '@/pages/Yields/components/YieldEnterExit' +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' + +export const YieldDetail = memo(() => { + const { yieldId } = useParams<{ yieldId: string }>() + const navigate = useNavigate() + const translate = useTranslate() + + const { data: yieldItem, isLoading, isFetching, error } = useYield(yieldId ?? '') + const { data: yieldProviders } = useYieldProviders() + + const shouldFetchValidators = useMemo( + () => + yieldItem?.mechanics.type === 'staking' && yieldItem?.mechanics.requiresValidatorSelection, + [yieldItem?.mechanics.type, yieldItem?.mechanics.requiresValidatorSelection], + ) + const { data: validators } = useYieldValidators(yieldId ?? '', shouldFetchValidators) + + const providerLogo = useMemo( + () => + yieldItem?.providerId && yieldProviders + ? yieldProviders[yieldItem.providerId]?.logoURI + : undefined, + [yieldItem?.providerId, yieldProviders], + ) + + const bgColor = useColorModeValue('gray.50', 'gray.900') + const borderColor = useColorModeValue('gray.200', 'gray.800') + const heroBg = useColorModeValue('gray.100', 'gray.900') + const heroTextColor = useColorModeValue('gray.900', 'white') + const heroSubtleColor = useColorModeValue('gray.600', 'gray.400') + const heroIconBorderColor = useColorModeValue('gray.200', 'gray.800') + + const { data: allBalancesData, isFetching: isBalancesFetching } = useAllYieldBalances() + const balances = yieldItem?.id ? allBalancesData?.normalized[yieldItem.id] : undefined + const isBalancesLoading = !allBalancesData && isBalancesFetching + const uniqueValidatorCount = balances?.validatorAddresses.length ?? 0 + + useEffect(() => { + if (!yieldId) navigate('/yields') + }, [yieldId, navigate]) + + const loadingElement = useMemo( + () => ( + + + + {translate('common.loadingText')} + + + + ), + [translate], + ) + + const errorElement = useMemo( + () => ( + + + + {translate('common.error')} + + + {error ? String(error) : translate('common.noResultsFound')} + + + + + ), + [error, heroBg, navigate, translate], + ) + + const heroIcon = useMemo(() => { + if (!yieldItem) return null + const iconSource = resolveYieldInputAssetIcon(yieldItem) + if (iconSource.assetId) + return ( + + ) + return ( + + ) + }, [heroIconBorderColor, yieldItem]) + + const providerOrValidatorsElement = useMemo(() => { + if (!yieldItem) return null + if (shouldFetchValidators && validators && validators.length > 0 && uniqueValidatorCount > 1) + return ( + + + {validators.map(v => ( + + ))} + + + + {validators.length > 3 ? `${validators.length} Validators` : 'Validators'} + + + + ) + return ( + + + + + {yieldItem.providerId} + + + + ) + }, [ + heroTextColor, + providerLogo, + shouldFetchValidators, + uniqueValidatorCount, + validators, + yieldItem, + ]) + + const chainElement = useMemo(() => { + if (!yieldItem?.chainId) return null + return ( + + + + {yieldItem.network} + + + ) + }, [heroTextColor, yieldItem?.chainId, yieldItem?.network]) + + if (isLoading) return loadingElement + if (error || !yieldItem) return errorElement + + return ( + + + + + + {heroIcon} + + + {yieldItem.metadata.name} + + + {providerOrValidatorsElement} + + {chainElement} + + {yieldItem.metadata.description} + + + + + + + + + + + + + + + + + + + + + ) +}) diff --git a/src/pages/Yields/Yields.tsx b/src/pages/Yields/Yields.tsx new file mode 100644 index 00000000000..0ade7c454e9 --- /dev/null +++ b/src/pages/Yields/Yields.tsx @@ -0,0 +1,19 @@ +import { memo } from 'react' +import { Route, Routes } from 'react-router-dom' + +import { YieldsList } from '@/pages/Yields/components/YieldsList' +import { YieldAccountProvider } from '@/pages/Yields/YieldAccountContext' +import { YieldAssetDetails } from '@/pages/Yields/YieldAssetDetails' +import { YieldDetail } from '@/pages/Yields/YieldDetail' + +export const Yields = memo(() => ( + + + } /> + } /> + } /> + } /> + } /> + + +)) diff --git a/src/pages/Yields/components/GradientApy.tsx b/src/pages/Yields/components/GradientApy.tsx new file mode 100644 index 00000000000..48560937417 --- /dev/null +++ b/src/pages/Yields/components/GradientApy.tsx @@ -0,0 +1,20 @@ +import type { TextProps } from '@chakra-ui/react' +import { Text } from '@chakra-ui/react' +import { memo } from 'react' + +type GradientApyProps = TextProps & { + children: React.ReactNode +} + +export const GradientApy = memo(({ children, ...textProps }: GradientApyProps) => { + return ( + + {children} + + ) +}) diff --git a/src/pages/Yields/components/ValidatorBreakdown.tsx b/src/pages/Yields/components/ValidatorBreakdown.tsx new file mode 100644 index 00000000000..067df5509a1 --- /dev/null +++ b/src/pages/Yields/components/ValidatorBreakdown.tsx @@ -0,0 +1,478 @@ +import { + Avatar, + Box, + Button, + Card, + CardBody, + Collapse, + Divider, + Flex, + Heading, + HStack, + Skeleton, + Text, + useColorModeValue, + 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 + hoverBg: 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, + hoverBg, + 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, + 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 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 => { + 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 => { + prev.set('validator', validatorAddress) + return prev + }) + }, + [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( + () => ( + + + + + + + + + + ), + [borderColor, cardBg], + ) + + 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/YieldActionModal.tsx b/src/pages/Yields/components/YieldActionModal.tsx new file mode 100644 index 00000000000..45924cb3f17 --- /dev/null +++ b/src/pages/Yields/components/YieldActionModal.tsx @@ -0,0 +1,727 @@ +import { + Avatar, + Box, + Button, + Flex, + Heading, + Icon, + Link, + Modal, + ModalBody, + ModalCloseButton, + ModalContent, + ModalOverlay, + Spinner, + Text, + useColorModeValue, + 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 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' + +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 { GradientApy } from '@/pages/Yields/components/GradientApy' +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 { + selectFeeAssetByChainId, + selectMarketDataByAssetIdUserCurrency, +} from '@/state/slices/selectors' +import { useAppSelector } from '@/state/store' + +const walletIcon = +const checkIconBox = ( + + + +) + +type YieldActionModalProps = { + isOpen: boolean + onClose: () => void + yieldItem: AugmentedYieldDto + action: 'enter' | 'exit' | 'manage' + amount: string + assetSymbol: string + assetLogoURI?: string + validatorAddress?: string + validatorName?: string + validatorLogoURI?: string + passthrough?: string + manageActionType?: string +} + +export const YieldActionModal = memo(function YieldActionModal({ + isOpen, + onClose, + yieldItem, + action, + amount, + assetSymbol, + assetLogoURI, + validatorAddress, + validatorName, + validatorLogoURI, + passthrough, + ...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, + transactionSteps, + isSubmitting, + canSubmit, + handleConfirm, + handleClose, + isQuoteLoading, + } = useYieldTransactionFlow({ + yieldItem, + action, + amount, + assetSymbol, + onClose, + isOpen, + validatorAddress, + passthrough, + manageActionType: props.manageActionType, + }) + + const shouldFetchValidators = useMemo( + () => yieldItem.mechanics.type === 'staking' && yieldItem.mechanics.requiresValidatorSelection, + [yieldItem.mechanics.type, yieldItem.mechanics.requiresValidatorSelection], + ) + + const { data: validators } = useYieldValidators(yieldItem.id, shouldFetchValidators) + + const { data: providers } = useYieldProviders() + const inputTokenAssetId = useMemo( + () => yieldItem.inputTokens[0]?.assetId ?? '', + [yieldItem.inputTokens], + ) + const marketData = useAppSelector(state => + selectMarketDataByAssetIdUserCurrency(state, inputTokenAssetId), + ) + + const vaultMetadata = useMemo(() => { + if (yieldItem.mechanics.type === 'staking' && validatorAddress) { + const validator = validators?.find(v => v.address === validatorAddress) + if (validator) return { name: validator.name, logoURI: validator.logoURI } + if (validatorName) return { name: validatorName, logoURI: validatorLogoURI } + } + const provider = providers?.[yieldItem.providerId] + if (provider) return { name: provider.name, logoURI: provider.logoURI } + return { name: 'Vault', logoURI: yieldItem.metadata.logoURI } + }, [yieldItem, validatorAddress, validatorName, validatorLogoURI, validators, providers]) + + 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], + ) + + const aprFormatted = useMemo( + () => `${bnOrZero(yieldItem.rewardRate.total).times(100).toFixed(2)}%`, + [yieldItem.rewardRate.total], + ) + + const showEstimatedEarnings = useMemo(() => bnOrZero(amount).gt(0), [amount]) + + const estimatedEarningsAmount = useMemo( + () => + `${bnOrZero(amount) + .times(yieldItem.rewardRate.total) + .decimalPlaces(4) + .toString()} ${assetSymbol}/yr`, + [amount, yieldItem.rewardRate.total, assetSymbol], + ) + + const estimatedEarningsFiat = useMemo( + () => + bnOrZero(amount) + .times(yieldItem.rewardRate.total) + .times(marketData?.price ?? 0) + .toString(), + [amount, yieldItem.rewardRate.total, marketData?.price], + ) + + const isStaking = useMemo( + () => yieldItem.mechanics.type === 'staking', + [yieldItem.mechanics.type], + ) + + const showValidatorRow = useMemo( + () => isStaking && vaultMetadata.name !== 'Vault', + [isStaking, vaultMetadata.name], + ) + + const isButtonDisabled = useMemo( + () => !canSubmit || isSubmitting || isQuoteLoading, + [canSubmit, isSubmitting, isQuoteLoading], + ) + + const isButtonLoading = useMemo( + () => isSubmitting || isQuoteLoading, + [isSubmitting, isQuoteLoading], + ) + + const loadingText = useMemo(() => { + if (isQuoteLoading) return translate('yieldXYZ.loadingQuote') + if (action === 'enter') return translate('yieldXYZ.depositing') + if (action === 'exit') return translate('yieldXYZ.withdrawing') + return translate('common.claiming') + }, [isQuoteLoading, action, translate]) + + const buttonText = useMemo(() => { + if (action === 'enter') return translate('yieldXYZ.deposit') + if (action === 'exit') return translate('yieldXYZ.withdraw') + return translate('common.claim') + }, [action, translate]) + + const modalHeading = useMemo(() => { + if (action === 'enter') return translate('yieldXYZ.supplySymbol', { symbol: assetSymbol }) + if (action === 'exit') return translate('yieldXYZ.withdrawSymbol', { 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.successClaim', { symbol: assetSymbol, amount }) + }, [action, assetSymbol, amount, translate]) + + const networkAvatarSrc = useMemo( + () => feeAsset?.networkIcon ?? feeAsset?.icon, + [feeAsset?.networkIcon, feeAsset?.icon], + ) + + const statusCard = useMemo( + () => ( + + + + + + + + + + + + {assetSymbol} + + + + + + + + + + + + {vaultMetadata.name} + + + + + {action === 'enter' && ( + <> + + + {translate('yieldXYZ.apr')} + + + {aprFormatted} + + + {showEstimatedEarnings && ( + + + {translate('yieldXYZ.estEarnings')} + + + + + {estimatedEarningsAmount} + + + + + + + + )} + + )} + {showValidatorRow && ( + + + {translate('yieldXYZ.validator')} + + + + + {vaultMetadata.name} + + + + )} + {!isStaking && ( + + + {translate('yieldXYZ.provider')} + + + + + {vaultMetadata.name} + + + + )} + + + {translate('yieldXYZ.network')} + + + {feeAsset && } + + {yieldItem.network} + + + + + + {transactionSteps.map((s, idx) => ( + + + {s.status === 'success' ? ( + + ) : s.status === 'loading' ? ( + + ) : ( + + )} + + {s.title} + + + {s.txHash ? ( + + + + ) : ( + + {s.status === 'success' + ? translate('yieldXYZ.loading.done') + : s.status === 'loading' + ? '' + : translate('yieldXYZ.loading.waiting')} + + )} + + ))} + + + ), + [ + cardBg, + cardBorderColor, + amount, + assetSymbol, + flexDirection, + avatarBg, + assetAvatarSrc, + subtleTextColor, + horizontalScroll, + vaultMetadata.logoURI, + vaultMetadata.name, + action, + translate, + aprFormatted, + showEstimatedEarnings, + estimatedEarningsAmount, + estimatedEarningsFiat, + showValidatorRow, + isStaking, + feeAsset, + networkAvatarSrc, + yieldItem.network, + transactionSteps, + ], + ) + + const actionContent = useMemo( + () => ( + + {statusCard} + + + ), + [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]) + + useEffect(() => { + if (step === ModalStep.Success) fireConfetti() + }, [step, fireConfetti]) + + const successContent = useMemo( + () => ( + + + + + + + {translate('yieldXYZ.success')} + + + {successMessage} + + + + + + {translate('yieldXYZ.transactions')} + + {transactionSteps.map((s, idx) => ( + + + + + {s.title} + + + {s.txHash && ( + + {translate('yieldXYZ.view')} + + )} + + ))} + + + + + ), + [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]) + + const headerContent = useMemo(() => { + if (!isNotSuccess) return null + return ( + + + {modalHeading} + + + ) + }, [isNotSuccess, modalHeading]) + + return ( + <> + + + + + + {headerContent} + {isInProgress && actionContent} + {isSuccess && successContent} + + + + + + ) +}) diff --git a/src/pages/Yields/components/YieldActivePositions.tsx b/src/pages/Yields/components/YieldActivePositions.tsx new file mode 100644 index 00000000000..f4859f227c4 --- /dev/null +++ b/src/pages/Yields/components/YieldActivePositions.tsx @@ -0,0 +1,276 @@ +import { + Avatar, + Box, + HStack, + Table, + TableContainer, + Tbody, + Td, + Text, + Th, + Thead, + Tr, + useColorModeValue, +} from '@chakra-ui/react' +import type { AssetId } from '@shapeshiftoss/caip' +import { memo, useCallback, useMemo } from 'react' +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 type { YieldBalanceAggregate } from '@/react-queries/queries/yieldxyz/useAllYieldBalances' +import { useYieldProviders } from '@/react-queries/queries/yieldxyz/useYieldProviders' +import { selectAssetById, selectUserCurrencyToUsdRate } from '@/state/slices/selectors' +import { useAppSelector } from '@/state/store' + +type YieldActivePositionsProps = { + aggregated: Record + yields: AugmentedYieldDto[] + assetId: AssetId +} + +export const YieldActivePositions = memo( + ({ aggregated, yields, assetId }: YieldActivePositionsProps) => { + const translate = useTranslate() + 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() + + const getProviderLogo = useCallback( + (providerId: string) => providers?.[providerId]?.logoURI, + [providers], + ) + + const activeYields = useMemo( + () => yields.filter(y => aggregated[y.id] && bnOrZero(aggregated[y.id].totalUsd).gt(0)), + [yields, aggregated], + ) + + const handleRowClick = useCallback( + (yieldId: string, validatorAddress?: string) => { + const url = validatorAddress + ? `/yields/${yieldId}?validator=${validatorAddress}` + : `/yields/${yieldId}` + navigate(url) + }, + [navigate], + ) + + const hasValidators = useMemo( + () => activeYields.some(y => aggregated[y.id]?.hasValidators), + [activeYields, aggregated], + ) + + const assetColumnHeader = useMemo(() => translate('yieldXYZ.asset') ?? 'Asset', [translate]) + + const providerColumnHeader = useMemo( + () => + hasValidators + ? translate('yieldXYZ.validator') ?? 'Validator' + : translate('yieldXYZ.provider') ?? 'Provider', + [hasValidators, translate], + ) + + const apyColumnHeader = useMemo(() => translate('yieldXYZ.apy') ?? 'APY', [translate]) + + const tvlColumnHeader = useMemo(() => translate('yieldXYZ.tvl') ?? 'TVL', [translate]) + + const balanceColumnHeader = useMemo( + () => translate('yieldXYZ.balance') ?? 'Balance', + [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 + + return activeYields.flatMap(yieldItem => { + const yieldAggregate = aggregated[yieldItem.id] + if (!yieldAggregate) return [] + + const apy = bnOrZero(yieldItem.rewardRate.total).times(100).toNumber() + const { byValidator, hasValidators, totalUsd, totalCrypto } = yieldAggregate + + if (hasValidators) { + return Object.values(byValidator).map( + ({ validator, totalUsd: validatorUsd, totalCrypto: validatorCrypto }) => { + const totalUserCurrency = toUserCurrency(validatorUsd, userCurrencyToUsdRate) + + return ( + handleRowClick(yieldItem.id, validator.address)} + > + + + {renderAssetIcon(yieldItem)} + + {yieldItem.metadata.name} + + + + + + {validator.logoURI ? ( + + ) : ( + + )} + + {validator.name || yieldItem.providerId} + + + + + + {apy.toFixed(2)}% + + + + + - + + + + + + + + + + ) + }, + ) + } + + const totalUserCurrency = toUserCurrency(totalUsd, userCurrencyToUsdRate) + const tvlUsd = yieldItem.statistics?.tvlUsd + const tvlUserCurrency = toUserCurrency(tvlUsd, userCurrencyToUsdRate) + + return ( + handleRowClick(yieldItem.id)} + > + + + {renderAssetIcon(yieldItem)} + + {yieldItem.metadata.name} + + + + + + + + {yieldItem.providerId} + + + + + + {apy.toFixed(2)}% + + + + + {tvlUsd ? : '-'} + + + + + + + + + + ) + }) + }, [ + activeYields, + aggregated, + asset, + getProviderLogo, + handleRowClick, + hoverBg, + renderAssetIcon, + userCurrencyToUsdRate, + ]) + + if (!asset) return null + if (activeYields.length === 0) return null + + return ( + + + {yourBalanceLabel} + + + + + + + + + + + + + {tableRows} +
{assetColumnHeader}{providerColumnHeader}{apyColumnHeader}{tvlColumnHeader}{balanceColumnHeader}
+
+
+ ) + }, +) diff --git a/src/pages/Yields/components/YieldAssetSection.tsx b/src/pages/Yields/components/YieldAssetSection.tsx new file mode 100644 index 00000000000..636e039abf5 --- /dev/null +++ b/src/pages/Yields/components/YieldAssetSection.tsx @@ -0,0 +1,125 @@ +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 { useTranslate } from 'react-polyglot' +import { useNavigate } from 'react-router-dom' + +import { YieldActivePositions } from './YieldActivePositions' +import { YieldItemSkeleton } from './YieldItem' +import { YieldOpportunityCard } from './YieldOpportunityCard' + +import { getConfig } from '@/config' +import { useFeatureFlag } from '@/hooks/useFeatureFlag/useFeatureFlag' +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' + +type YieldAssetSectionProps = { + assetId: AssetId + accountId?: AccountId +} + +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() + const balanceOptions = useMemo(() => (accountId ? { accountIds: [accountId] } : {}), [accountId]) + const { data: allBalancesData, isLoading: isBalancesLoading } = + useAllYieldBalances(balanceOptions) + const isLoading = isYieldsLoading || isBalancesLoading + + const yields = useMemo(() => { + if (!yieldsData?.all || !asset) 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]) + + const aggregated = useMemo(() => { + const multiAccountEnabled = getConfig().VITE_FEATURE_YIELD_MULTI_ACCOUNT + if (multiAccountEnabled && !accountId) { + console.warn('[YieldAssetSection] Multi-account yield enabled but no accountId provided') + return {} + } + if (!allBalancesData?.aggregated || !yields.length) return {} + + const accountFilter = accountId ? fromAccountId(accountId).account.toLowerCase() : null + const allBalances = allBalancesData.byYieldId + + return yields.reduce( + (acc, yieldItem) => { + const aggregate = allBalancesData.aggregated[yieldItem.id] + if (!aggregate) return acc + if (accountFilter) { + const itemBalances = allBalances?.[yieldItem.id] || [] + if (!itemBalances.some(b => b.address.toLowerCase() === accountFilter)) return acc + } + acc[yieldItem.id] = aggregate + return acc + }, + {} as Record, + ) + }, [allBalancesData, yields, accountId]) + + const sortedYields = useMemo( + () => [...yields].sort((a, b) => b.rewardRate.total - a.rewardRate.total), + [yields], + ) + + const bestYield = sortedYields[0] + + const hasActivePositions = Object.keys(aggregated).length > 0 + + const handleOpportunityClick = useCallback( + (yieldItem: AugmentedYieldDto) => { + navigate(`/yields/${yieldItem.id}`) + }, + [navigate], + ) + + 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 + + return ( + + + {yieldHeading} + + + {hasActivePositions && activePositionsContent} + {isLoading && loadingContent} + {!isLoading && !hasActivePositions && opportunityCardContent} + + + ) +}) diff --git a/src/pages/Yields/components/YieldEnterExit.tsx b/src/pages/Yields/components/YieldEnterExit.tsx new file mode 100644 index 00000000000..14578245e42 --- /dev/null +++ b/src/pages/Yields/components/YieldEnterExit.tsx @@ -0,0 +1,666 @@ +import { ChevronDownIcon } from '@chakra-ui/icons' +import { + Avatar, + Box, + Button, + Flex, + Icon, + Skeleton, + Tab, + TabList, + TabPanel, + TabPanels, + Tabs, + Text, + useColorModeValue, +} 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 = + +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 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 + 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 enterTabSelectedStyle = useMemo( + () => ({ + color: 'blue.400', + bg: cardBg, + borderBottomColor: cardBg, + borderTopColor: 'blue.400', + borderTopWidth: 2, + }), + [cardBg], + ) + + const tabFocusStyle = useMemo(() => ({ boxShadow: 'none' }), []) + const buttonHoverStyle = useMemo(() => ({ transform: 'translateY(-1px)', boxShadow: 'lg' }), []) + + 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 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], + ) + + 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, + [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, + borderColor, + validatorPickerBg, + validatorPickerHoverBg, + handleOpenValidatorModal, + validatorMetadata, + isPreferredValidator, + translate, + validatorRewardRate, + isValidatorModalOpen, + handleCloseValidatorModal, + validators, + handleValidatorChange, + balances?.raw, + ]) + + const minDepositContent = useMemo(() => { + if (!minDeposit || isLoading) return null + + return ( + + + {moneyBillWaveIcon} + + {translate('yieldXYZ.minDeposit')} + + + + {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/YieldFilters.tsx b/src/pages/Yields/components/YieldFilters.tsx new file mode 100644 index 00000000000..aea9951210b --- /dev/null +++ b/src/pages/Yields/components/YieldFilters.tsx @@ -0,0 +1,263 @@ +import { ChevronDownIcon } from '@chakra-ui/icons' +import type { StackProps } from '@chakra-ui/react' +import { + Button, + HStack, + IconButton, + Menu, + MenuButton, + MenuItem, + MenuList, + Stack, + Text, + Tooltip, + useColorModeValue, +} from '@chakra-ui/react' +import type { ChainId } from '@shapeshiftoss/caip' +import React, { memo, useCallback, useMemo } from 'react' +import { FaSortAlphaDown, FaSortAlphaUp, FaSortAmountDown, FaSortAmountUp } from 'react-icons/fa' +import { useTranslate } from 'react-polyglot' + +import { AssetIcon } from '@/components/AssetIcon' +import { ChainIcon } from '@/components/ChainMenu' + +export type SortOption = 'apy-desc' | 'apy-asc' | 'tvl-desc' | 'tvl-asc' | 'name-asc' | 'name-desc' + +export type NetworkOption = { + id: string + name: string + icon?: string + chainId?: ChainId +} + +export type ProviderOption = { + id: string + name: string + icon?: string +} + +type FilterMenuProps = { + label: string + value: string | null + options: { id: string; name: string; icon?: string; chainId?: ChainId }[] + onSelect: (id: string | null) => void + renderIcon?: (opt: { + id: string + name: string + icon?: string + chainId?: ChainId + }) => React.ReactElement +} + +const chevronDownIcon = + +const FilterMenu = memo(({ label, value, options, onSelect, renderIcon }: FilterMenuProps) => { + const selectedOption = useMemo(() => options.find(o => o.id === value), [options, value]) + const displayLabel = useMemo( + () => (selectedOption ? selectedOption.name : label), + [selectedOption, label], + ) + const bg = useColorModeValue('white', 'gray.800') + const borderColor = useColorModeValue('gray.200', 'gray.700') + const selectedBg = useColorModeValue('blue.50', 'blue.900') + const selectedColor = useColorModeValue('blue.600', 'blue.200') + 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 allItemBg = useMemo(() => (value === null ? selectedBg : undefined), [value, selectedBg]) + const allItemColor = useMemo( + () => (value === null ? selectedColor : undefined), + [value, selectedColor], + ) + const allItemFontWeight = useMemo(() => (value === null ? 'semibold' : undefined), [value]) + + const menuItems = useMemo( + () => + options.map(opt => { + const isSelected = value === opt.id + return ( + onSelect(opt.id)} + bg={isSelected ? selectedBg : undefined} + color={isSelected ? selectedColor : undefined} + fontWeight={isSelected ? 'semibold' : undefined} + > + + {renderIcon && renderIcon(opt)} + {opt.name} + + + ) + }), + [options, value, selectedBg, selectedColor, renderIcon, onSelect], + ) + + return ( + + + + {selectedIcon} + + {displayLabel} + + + + + + {label} + + {menuItems} + + + ) +}) + +type YieldFiltersProps = { + networks: NetworkOption[] + selectedNetwork: string | null + onSelectNetwork: (id: string | null) => void + providers: ProviderOption[] + selectedProvider: string | null + onSelectProvider: (id: string | null) => void + sortOption: SortOption + onSortChange: (option: SortOption) => void +} & StackProps + +export const YieldFilters = memo( + ({ + networks, + selectedNetwork, + onSelectNetwork, + providers, + selectedProvider, + onSelectProvider, + sortOption, + onSortChange, + ...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( + () => [ + { value: 'apy-desc' as const, label: translate('yieldXYZ.highestApy') }, + { value: 'apy-asc' as const, label: translate('yieldXYZ.lowestApy') }, + { value: 'tvl-desc' as const, label: translate('yieldXYZ.highestTvl') }, + { value: 'tvl-asc' as const, label: translate('yieldXYZ.lowestTvl') }, + { value: 'name-asc' as const, label: translate('yieldXYZ.nameAZ') }, + { value: 'name-desc' as const, label: translate('yieldXYZ.nameZA') }, + ], + [translate], + ) + + const allNetworksLabel = useMemo(() => translate('yieldXYZ.allNetworks'), [translate]) + const allProvidersLabel = useMemo(() => translate('yieldXYZ.allProviders'), [translate]) + + const renderNetworkIcon = useCallback( + (opt: { id: string; name: string; icon?: string; chainId?: ChainId }) => { + if (opt.chainId) return + return + }, + [], + ) + + const renderProviderIcon = useCallback( + (opt: { id: string; name: string; icon?: string }) => , + [], + ) + + const sortIcon = useMemo(() => { + if (sortOption === 'name-asc') return + if (sortOption === 'name-desc') return + if (sortOption.includes('asc')) return + 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'} + > + {opt.label} + + )), + [sortOptions, sortOption, onSortChange], + ) + + return ( + + + + + + + + + {sortMenuItems} + + + + ) + }, +) diff --git a/src/pages/Yields/components/YieldItem.tsx b/src/pages/Yields/components/YieldItem.tsx new file mode 100644 index 00000000000..cbf6e465903 --- /dev/null +++ b/src/pages/Yields/components/YieldItem.tsx @@ -0,0 +1,447 @@ +import { + Avatar, + AvatarGroup, + Box, + Card, + CardBody, + Flex, + HStack, + Skeleton, + SkeletonCircle, + Stat, + StatLabel, + StatNumber, + Text, + useColorModeValue, +} from '@chakra-ui/react' +import type BigNumber from 'bignumber.js' +import { memo, useCallback, useMemo } from 'react' +import { useTranslate } from 'react-polyglot' +import { useNavigate } from 'react-router-dom' + +import { Amount } from '@/components/Amount/Amount' +import { AssetIcon } from '@/components/AssetIcon' +import { ChainIcon } from '@/components/ChainMenu' +import { bnOrZero } from '@/lib/bignumber/bignumber' +import type { AugmentedYieldDto } from '@/lib/yieldxyz/types' +import { resolveYieldInputAssetIcon } from '@/lib/yieldxyz/utils' +import { useYieldProviders } from '@/react-queries/queries/yieldxyz/useYieldProviders' +import { selectUserCurrencyToUsdRate } from '@/state/slices/selectors' +import { useAppSelector } from '@/state/store' + +type SingleYieldData = { + type: 'single' + yieldItem: AugmentedYieldDto + providerIcon?: string +} + +type GroupYieldData = { + type: 'group' + assetSymbol: string + assetName: string + assetIcon: string + assetId?: string + yields: AugmentedYieldDto[] +} + +type YieldItemProps = { + data: SingleYieldData | GroupYieldData + variant: 'card' | 'row' + userBalanceUsd?: BigNumber + onEnter?: (yieldItem: AugmentedYieldDto) => void +} + +export const YieldItem = memo(({ data, variant, userBalanceUsd, onEnter }: 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 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 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]) + + const apyFormatted = useMemo(() => `${(stats.apy * 100).toFixed(2)}%`, [stats.apy]) + + const tvlUserCurrency = useMemo( + () => bnOrZero(stats.tvlUsd).times(userCurrencyToUsdRate).toFixed(), + [stats.tvlUsd, userCurrencyToUsdRate], + ) + + const userBalanceUserCurrency = useMemo( + () => (userBalanceUsd ? userBalanceUsd.times(userCurrencyToUsdRate).toFixed() : undefined), + [userBalanceUsd, userCurrencyToUsdRate], + ) + + const hasBalance = userBalanceUsd && userBalanceUsd.gt(0) + + const handleClick = useCallback(() => { + if (isSingle) { + if (stats.canEnter && onEnter) { + onEnter(data.yieldItem) + } else { + navigate(`/yields/${data.yieldItem.id}`) + } + } else { + navigate(`/yields/asset/${encodeURIComponent(data.assetSymbol)}`) + } + }, [data, isSingle, navigate, onEnter, stats.canEnter]) + + 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 (data.assetId) { + return ( + + ) + } + 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} + + + + + + + {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 ( + + + + + {iconElement} + + + {title} + + + {isSingle && data.providerIcon && ( + + )} + + {subtitle} + + + + + + + + + + {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') + const cardBg = useColorModeValue('white', 'gray.800') + + if (variant === 'row') { + return ( + + + + + + + + + + + + + ) + } + + return ( + + + + + + + + + + + + + + + + + + + + + + ) +}) diff --git a/src/pages/Yields/components/YieldOpportunityCard.tsx b/src/pages/Yields/components/YieldOpportunityCard.tsx new file mode 100644 index 00000000000..5dc0e10a6ae --- /dev/null +++ b/src/pages/Yields/components/YieldOpportunityCard.tsx @@ -0,0 +1,70 @@ +import { Box, Button, Flex, Heading, Text, useColorModeValue } from '@chakra-ui/react' +import { memo, useCallback, useMemo } from 'react' +import { useTranslate } from 'react-polyglot' + +import { bnOrZero } from '@/lib/bignumber/bignumber' +import type { AugmentedYieldDto } from '@/lib/yieldxyz/types' + +type YieldOpportunityCardProps = { + maxApyYield: AugmentedYieldDto + onClick: (yieldItem: AugmentedYieldDto) => void +} + +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), + [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]) + + return ( + + + + + {earnUpToText} + + + {apy}% APY + + + + + + ) +}) diff --git a/src/pages/Yields/components/YieldOpportunityStats.tsx b/src/pages/Yields/components/YieldOpportunityStats.tsx new file mode 100644 index 00000000000..b661e16e6f6 --- /dev/null +++ b/src/pages/Yields/components/YieldOpportunityStats.tsx @@ -0,0 +1,252 @@ +import { + Box, + Button, + Flex, + Icon, + SimpleGrid, + Stat, + StatHelpText, + StatLabel, + StatNumber, + Text, +} from '@chakra-ui/react' +import { memo, useMemo } from 'react' +import { FaChartPie, FaMoon } from 'react-icons/fa' +import { useTranslate } from 'react-polyglot' + +import { Amount } from '@/components/Amount/Amount' +import { bnOrZero } from '@/lib/bignumber/bignumber' +import type { AugmentedYieldDto, YieldBalancesResponse } from '@/lib/yieldxyz/types' +import { useYields } from '@/react-queries/queries/yieldxyz/useYields' +import { selectPortfolioUserCurrencyBalances } from '@/state/slices/common-selectors' +import { selectUserCurrencyToUsdRate } from '@/state/slices/selectors' +import { useAppSelector } from '@/state/store' + +const chartPieIcon = +const moonIcon = + +type YieldOpportunityStatsProps = { + positions: AugmentedYieldDto[] + balances: Record | undefined + allYields: AugmentedYieldDto[] | undefined + isMyOpportunities?: boolean + onToggleMyOpportunities?: () => void +} + +export const YieldOpportunityStats = memo(function YieldOpportunityStats({ + positions, + balances, + allYields, + isMyOpportunities, + onToggleMyOpportunities, +}: YieldOpportunityStatsProps) { + const translate = useTranslate() + const userCurrencyToUsdRate = useAppSelector(selectUserCurrencyToUsdRate) + const portfolioBalances = useAppSelector(selectPortfolioUserCurrencyBalances) + const { data: yields } = useYields() + + const activeValueUsd = useMemo(() => { + return positions.reduce((acc, position) => { + const positionBalances = balances?.[position.id] + if (!positionBalances) return acc + const activeBalance = positionBalances.find(b => b.type === 'active' || b.type === 'locked') + return acc.plus(bnOrZero(activeBalance?.amountUsd)) + }, bnOrZero(0)) + }, [positions, balances]) + + const idleValueUsd = useMemo(() => { + if (!allYields) return bnOrZero(0) + + const yieldableAssetIds = new Set( + allYields.flatMap( + y => + [...(y.inputTokens?.map(t => t.assetId).filter(Boolean) ?? []), y.token.assetId].filter( + Boolean, + ) as string[], + ), + ) + + return [...yieldableAssetIds].reduce((totalIdle, assetId) => { + const bal = portfolioBalances[assetId] + return bal ? totalIdle.plus(bnOrZero(bal)) : totalIdle + }, bnOrZero(0)) + }, [allYields, portfolioBalances]) + + // Calculate weighted APY and potential earnings based on user's actual held assets + const { weightedApy, potentialEarningsValue } = useMemo(() => { + if (!yields?.byInputAssetId || !portfolioBalances) { + return { weightedApy: 0, potentialEarningsValue: bnOrZero(0) } + } + + let totalEarnings = bnOrZero(0) + let totalActionableBalance = bnOrZero(0) + + for (const [assetId, balanceFiat] of Object.entries(portfolioBalances)) { + const yieldsForAsset = yields.byInputAssetId[assetId] + if (!yieldsForAsset?.length) continue // Early bail - no yield for this asset + + const balance = bnOrZero(balanceFiat) + const bestApy = Math.max(...yieldsForAsset.map(y => y.rewardRate.total)) + + totalEarnings = totalEarnings.plus(balance.times(bestApy)) + totalActionableBalance = totalActionableBalance.plus(balance) + } + + const avgApy = totalActionableBalance.gt(0) + ? totalEarnings.div(totalActionableBalance).times(100).toNumber() + : 0 + + return { weightedApy: avgApy, potentialEarningsValue: totalEarnings } + }, [yields?.byInputAssetId, portfolioBalances]) + + const hasActiveDeposits = useMemo(() => activeValueUsd.gt(0), [activeValueUsd]) + + const activeValueFormatted = useMemo( + () => activeValueUsd.times(userCurrencyToUsdRate).toFixed(), + [activeValueUsd, userCurrencyToUsdRate], + ) + + const idleValueFormatted = useMemo(() => idleValueUsd.toFixed(), [idleValueUsd]) + + const potentialEarnings = useMemo( + () => potentialEarningsValue.toFixed(), + [potentialEarningsValue], + ) + + const weightedApyFormatted = useMemo(() => weightedApy.toFixed(2), [weightedApy]) + + const positionsCount = useMemo(() => positions.length, [positions.length]) + + const gridColumn = useMemo( + () => ({ md: hasActiveDeposits ? 'span 2' : 'span 3' }), + [hasActiveDeposits], + ) + + const buttonBg = useMemo( + () => (isMyOpportunities ? 'whiteAlpha.300' : 'blue.500'), + [isMyOpportunities], + ) + + const buttonHoverBg = useMemo( + () => ({ bg: isMyOpportunities ? 'whiteAlpha.400' : 'blue.400' }), + [isMyOpportunities], + ) + + const buttonText = useMemo( + () => (isMyOpportunities ? translate('yieldXYZ.showAll') : translate('yieldXYZ.earn')), + [isMyOpportunities, translate], + ) + + const activeDepositsCard = useMemo(() => { + if (!hasActiveDeposits) return null + return ( + + + {chartPieIcon} + + + + {translate('yieldXYZ.activeDeposits')} + + + + + + {translate('yieldXYZ.acrossPositions', { count: positionsCount })} + + + + ) + }, [hasActiveDeposits, activeValueFormatted, positionsCount, translate]) + + const toggleButton = useMemo(() => { + if (!onToggleMyOpportunities) return null + return ( + + ) + }, [onToggleMyOpportunities, buttonBg, buttonHoverBg, buttonText]) + + return ( + + {activeDepositsCard} + + + {moonIcon} + + + + + {translate('yieldXYZ.availableToEarn')} + + + + + + {translate('yieldXYZ.idleAssetsEarning', { apy: weightedApyFormatted })} + + + + + + {translate('yieldXYZ.potentialEarnings')} + + + + {translate('yieldXYZ.perYear')} + + + {toggleButton} + + + + + ) +}) diff --git a/src/pages/Yields/components/YieldPositionCard.tsx b/src/pages/Yields/components/YieldPositionCard.tsx new file mode 100644 index 00000000000..645f3e1eb3e --- /dev/null +++ b/src/pages/Yields/components/YieldPositionCard.tsx @@ -0,0 +1,564 @@ +import { + Alert, + AlertIcon, + Badge, + Box, + Button, + Card, + CardBody, + Divider, + Flex, + Heading, + HStack, + Skeleton, + Text, + useColorModeValue, + VStack, +} from '@chakra-ui/react' +import { fromAccountId } from '@shapeshiftoss/caip' +import { memo, useCallback, useMemo, useState } from 'react' +import { useTranslate } from 'react-polyglot' +import { useSearchParams } from 'react-router-dom' + +import { YieldActionModal } from './YieldActionModal' + +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 { YieldBalanceType } from '@/lib/yieldxyz/types' +import { useYieldAccount } from '@/pages/Yields/YieldAccountContext' +import type { + AggregatedBalance, + NormalizedYieldBalances, +} from '@/react-queries/queries/yieldxyz/useAllYieldBalances' +import { useYieldValidators } from '@/react-queries/queries/yieldxyz/useYieldValidators' +import { + selectAccountIdByAccountNumberAndChainId, + selectUserCurrencyToUsdRate, +} from '@/state/slices/selectors' +import { useAppSelector } from '@/state/store' + +type YieldPositionCardProps = { + yieldItem: AugmentedYieldDto + balances: NormalizedYieldBalances | undefined + 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 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') + + const { chainId } = yieldItem + const { accountNumber } = useYieldAccount() + + const defaultValidator = chainId ? DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID[chainId] : undefined + const selectedValidatorAddress = validatorParam || defaultValidator + + 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 balancesByType = useMemo(() => { + if (!balances) return undefined + if (selectedValidatorAddress && balances.byValidatorAddress[selectedValidatorAddress]) + return balances.byValidatorAddress[selectedValidatorAddress] + return balances.byType + }, [balances, selectedValidatorAddress]) + + const activeBalance = balancesByType?.[YieldBalanceType.Active] + const enteringBalance = balancesByType?.[YieldBalanceType.Entering] + const exitingBalance = balancesByType?.[YieldBalanceType.Exiting] + const withdrawableBalance = balancesByType?.[YieldBalanceType.Withdrawable] + const claimableBalance = balancesByType?.[YieldBalanceType.Claimable] + + const claimAction = useMemo( + () => + claimableBalance?.pendingActions?.find(action => + action.type.toUpperCase().includes('CLAIM'), + ), + [claimableBalance], + ) + + const canClaim = useMemo( + () => Boolean(claimAction && bnOrZero(claimableBalance?.aggregatedAmount).gt(0)), + [claimAction, claimableBalance?.aggregatedAmount], + ) + + const formatBalance = useCallback((balance: AggregatedBalance | undefined) => { + if (!balance) return '0' + return ( + + ) + }, []) + + const hasEntering = useMemo( + () => enteringBalance && bnOrZero(enteringBalance.aggregatedAmount).gt(0), + [enteringBalance], + ) + const hasExiting = useMemo( + () => exitingBalance && bnOrZero(exitingBalance.aggregatedAmount).gt(0), + [exitingBalance], + ) + const hasWithdrawable = useMemo( + () => withdrawableBalance && bnOrZero(withdrawableBalance.aggregatedAmount).gt(0), + [withdrawableBalance], + ) + const hasClaimable = useMemo(() => Boolean(claimableBalance), [claimableBalance]) + + const totalValueUsd = useMemo( + () => + [activeBalance, enteringBalance, exitingBalance, withdrawableBalance].reduce( + (sum, b) => sum.plus(bnOrZero(b?.aggregatedAmountUsd)), + bnOrZero(0), + ), + [activeBalance, enteringBalance, exitingBalance, withdrawableBalance], + ) + + const totalValueUserCurrency = useMemo( + () => totalValueUsd.times(userCurrencyToUsdRate).toFixed(), + [totalValueUsd, userCurrencyToUsdRate], + ) + + const totalAmount = useMemo( + () => + [activeBalance, enteringBalance, exitingBalance, withdrawableBalance].reduce( + (sum, b) => sum.plus(bnOrZero(b?.aggregatedAmount)), + bnOrZero(0), + ), + [activeBalance, enteringBalance, exitingBalance, withdrawableBalance], + ) + + const hasAnyPosition = useMemo(() => totalAmount.gt(0), [totalAmount]) + + const { data: validators } = useYieldValidators(yieldItem.id) + + const selectedValidatorName = useMemo(() => { + if (!selectedValidatorAddress) return undefined + const found = validators?.find(v => v.address === selectedValidatorAddress) + if (found) return found.name + const foundInBalances = balances?.raw.find( + b => b.validator?.address === selectedValidatorAddress, + ) + return foundInBalances?.validator?.name + }, [validators, selectedValidatorAddress, balances]) + + const headingText = useMemo( + () => + selectedValidatorName + ? translate('yieldXYZ.myValidatorPosition', { validator: selectedValidatorName }) + : translate('yieldXYZ.myPosition'), + [selectedValidatorName, translate], + ) + + const addressBadgeText = useMemo( + () => (address ? `${address.slice(0, 4)}...${address.slice(-4)}` : ''), + [address], + ) + + const 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, + }) + }, [claimableBalance, selectedValidatorAddress, claimAction]) + + const handleClaimClose = useCallback(() => setClaimModalData(null), []) + + const showPendingActions = useMemo( + () => hasEntering || hasExiting || hasWithdrawable || hasClaimable, + [hasEntering, hasExiting, hasWithdrawable, hasClaimable], + ) + + const loadingState = useMemo( + () => ( + + + + + ), + [], + ) + + const emptyStateAlert = useMemo( + () => ( + + + + + {translate('yieldXYZ.startEarning')} + + + + {translate('yieldXYZ.depositYourToken', { symbol: yieldItem.token.symbol })} + + + ), + [ + emptyStateBg, + emptyStateBorderColor, + emptyStateTextColor, + emptyStateTitleColor, + yieldItem.token.symbol, + translate, + ], + ) + + const enteringSection = useMemo(() => { + if (!hasEntering) return null + return ( + + + + {translate('yieldXYZ.entering')} + + + {formatBalance(enteringBalance)} + + + + {translate('yieldXYZ.pending')} + + + ) + }, [ + hasEntering, + enteringBg, + enteringBorderColor, + enteringTextColor, + translate, + formatBalance, + enteringBalance, + ]) + + const exitingSection = useMemo(() => { + if (!hasExiting) return null + return ( + + + + {translate('yieldXYZ.exiting')} + + + {formatBalance(exitingBalance)} + + + + {translate('yieldXYZ.pending')} + + + ) + }, [ + hasExiting, + exitingBg, + exitingBorderColor, + exitingTextColor, + translate, + formatBalance, + exitingBalance, + ]) + + const withdrawableSection = useMemo(() => { + if (!hasWithdrawable) return null + return ( + + + + {translate('yieldXYZ.withdrawable')} + + + {formatBalance(withdrawableBalance)} + + + + {translate('yieldXYZ.ready')} + + + ) + }, [ + hasWithdrawable, + withdrawableBg, + withdrawableBorderColor, + withdrawableTextColor, + translate, + formatBalance, + withdrawableBalance, + ]) + + const claimableSection = useMemo(() => { + if (!hasClaimable) return null + return ( + + + + {translate('yieldXYZ.claimable')} + + + {formatBalance(claimableBalance)} + + + + + {translate('yieldXYZ.reward')} + + {claimAction && ( + + )} + + + ) + }, [ + hasClaimable, + claimableBg, + claimableBorderColor, + claimableTextColor, + translate, + formatBalance, + claimableBalance, + claimAction, + handleClaimClick, + canClaim, + ]) + + const addressBadge = useMemo(() => { + if (!address) return null + return ( + + {addressBadgeText} + + ) + }, [address, badgeBg, badgeColor, addressBadgeText]) + + const pendingActionsSection = useMemo(() => { + if (!showPendingActions) return null + return ( + <> + + + {enteringSection} + {exitingSection} + {withdrawableSection} + {claimableSection} + + + ) + }, [ + showPendingActions, + borderColor, + enteringSection, + exitingSection, + withdrawableSection, + claimableSection, + ]) + + if (isBalancesLoading) { + return ( + + + + + {headingText} + + {addressBadge} + + {loadingState} + + + ) + } + + return ( + + + + + {headingText} + + {addressBadge} + + + + + {translate('yieldXYZ.totalValue')} + + + + + + + + + {!hasAnyPosition && emptyStateAlert} + {pendingActionsSection} + {claimModalData && ( + + )} + + + + ) + }, +) diff --git a/src/pages/Yields/components/YieldStats.tsx b/src/pages/Yields/components/YieldStats.tsx new file mode 100644 index 00000000000..251e5a9211a --- /dev/null +++ b/src/pages/Yields/components/YieldStats.tsx @@ -0,0 +1,271 @@ +import { + Avatar, + Box, + Card, + CardBody, + Divider, + Flex, + Heading, + Icon, + Stat, + StatLabel, + StatNumber, + Text, + Tooltip, + useColorModeValue, +} 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' + +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 { useYieldValidators } from '@/react-queries/queries/yieldxyz/useYieldValidators' +import { + selectMarketDataByAssetIdUserCurrency, + selectUserCurrencyToUsdRate, +} 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 +} + +export const YieldStats = memo(({ yieldItem, balances }: YieldStatsProps) => { + const translate = useTranslate() + const userCurrencyToUsdRate = useAppSelector(selectUserCurrencyToUsdRate) + const inputTokenAssetId = yieldItem.inputTokens[0]?.assetId ?? '' + 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]) + + 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 (yieldItem.chainId && DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID[yieldItem.chainId]) + return DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID[yieldItem.chainId] + return validators?.[0]?.address + }, [yieldItem.chainId, validators]) + + const selectedValidatorAddress = useMemo( + () => validatorParam || defaultValidator, + [validatorParam, defaultValidator], + ) + + const selectedValidator = useMemo(() => { + 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 + if (inBalances) return inBalances + return undefined + }, [validators, selectedValidatorAddress, balances]) + + const tvl = useMemo(() => { + const validatorTvl = + selectedValidator && 'tvl' in selectedValidator ? selectedValidator.tvl : undefined + return bnOrZero(yieldItem.statistics?.tvl ?? validatorTvl).toNumber() + }, [selectedValidator, yieldItem.statistics?.tvl]) + + const tvlUserCurrency = useMemo(() => { + if (yieldItem.statistics?.tvlUsd) { + return bnOrZero(yieldItem.statistics.tvlUsd).times(userCurrencyToUsdRate).toFixed() + } + return bnOrZero(tvl) + .times(bnOrZero(inputTokenMarketData?.price)) + .toFixed() + }, [yieldItem.statistics?.tvlUsd, userCurrencyToUsdRate, tvl, inputTokenMarketData?.price]) + + const 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) + return { name: selectedValidator.name, logoURI: selectedValidator.logoURI } + 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, rewardBreakdownBg]) + + 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.minDeposit')} + + + + ) + }, [yieldItem.mechanics.entryLimits.minimum, yieldItem.token.symbol, translate]) + + return ( + + + + {translate('yieldXYZ.stats')} + + + + + + + {translate('common.apy')} + + + {apyFormatted}% + + {yieldItem.rewardRate.rateType} + + + + + {rewardBreakdownContent} + + + + + {translate('yieldXYZ.tvl')} + + + + + + + + + + + + + {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/YieldTable.tsx b/src/pages/Yields/components/YieldTable.tsx new file mode 100644 index 00000000000..239576bbf49 --- /dev/null +++ b/src/pages/Yields/components/YieldTable.tsx @@ -0,0 +1,138 @@ +import { ArrowDownIcon, ArrowUpIcon } from '@chakra-ui/icons' +import { + Flex, + Skeleton, + Table, + Tbody, + Td, + Th, + Thead, + Tr, + useColorModeValue, +} 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' + +import type { AugmentedYieldDto } from '@/lib/yieldxyz/types' + +type YieldColumnMeta = { + display?: Record + textAlign?: 'left' | 'right' | 'center' + justifyContent?: string +} + +type YieldTableProps = { + table: TanstackTable + isLoading: boolean + onRowClick: (row: Row) => void +} + +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]) + + const handleRowClick = useCallback( + (row: Row) => { + if (!row.original.status.enter) return + onRowClick(row) + }, + [onRowClick], + ) + + const loadingRows = useMemo( + () => + Array.from({ length: SKELETON_ROWS }).map((_, rowIndex) => ( + + {columns.map(column => ( + + + + ))} + + )), + [columns], + ) + + const dataRows = useMemo( + () => + rows.map(row => { + const isClickable = row.original.status.enter + return ( + handleRowClick(row)} + _hover={isClickable ? { bg: hoverBg } : undefined} + > + {row.getVisibleCells().map(cell => { + const meta = cell.column.columnDef.meta as YieldColumnMeta | undefined + return ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ) + })} + + ) + }), + [rows, handleRowClick, hoverBg], + ) + + const tbodyContent = useMemo( + () => (isLoading ? loadingRows : dataRows), + [isLoading, loadingRows, dataRows], + ) + + return ( + + + {headerGroups.map(headerGroup => ( + + {headerGroup.headers.map(header => { + const meta = header.column.columnDef.meta as YieldColumnMeta | undefined + const canSort = header.column.getCanSort() + const sortingState = header.column.getIsSorted() + const sortingHandler = header.column.getToggleSortingHandler() + return ( + + ) + })} + + ))} + + {tbodyContent} +
+ + {header.isPlaceholder + ? null + : flexRender(header.column.columnDef.header, header.getContext())} + {sortingState ? ( + sortingState === 'desc' ? ( + + ) : ( + + ) + ) : null} + +
+ ) +}) diff --git a/src/pages/Yields/components/YieldValidatorSelectModal.tsx b/src/pages/Yields/components/YieldValidatorSelectModal.tsx new file mode 100644 index 00000000000..62a41ecd2c0 --- /dev/null +++ b/src/pages/Yields/components/YieldValidatorSelectModal.tsx @@ -0,0 +1,243 @@ +import { + Avatar, + Box, + Flex, + Input, + InputGroup, + InputLeftElement, + Modal, + ModalBody, + ModalCloseButton, + ModalContent, + ModalHeader, + ModalOverlay, + Tab, + TabList, + TabPanel, + TabPanels, + Tabs, + Text, + useColorModeValue, + 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 { searchValidators, sortValidators, 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 bgColor = useColorModeValue('white', 'gray.800') + const borderColor = useColorModeValue('gray.100', 'gray.750') + const hoverBg = useColorModeValue('gray.50', 'whiteAlpha.50') + + 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) + result.push( + full ?? { + 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( + () => sortValidators(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, hoverBg, 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) + )} + + + + + + + + ) + }, +) diff --git a/src/pages/Yields/components/YieldViewHelpers.tsx b/src/pages/Yields/components/YieldViewHelpers.tsx new file mode 100644 index 00000000000..d8a068e1060 --- /dev/null +++ b/src/pages/Yields/components/YieldViewHelpers.tsx @@ -0,0 +1,87 @@ +import { Box, ButtonGroup, Flex, IconButton } from '@chakra-ui/react' +import { memo, useCallback, useMemo } from 'react' +import { FaList, FaThLarge } from 'react-icons/fa' +import { useTranslate } from 'react-polyglot' + +const gridIcon = +const listIcon = + +type ViewToggleProps = { + viewMode: 'grid' | 'list' + setViewMode: (mode: 'grid' | 'list') => void +} + +export const ViewToggle = memo(({ viewMode, setViewMode }: ViewToggleProps) => { + const isGridActive = useMemo(() => viewMode === 'grid', [viewMode]) + const isListActive = useMemo(() => viewMode === 'list', [viewMode]) + + const handleSetGridView = useCallback(() => setViewMode('grid'), [setViewMode]) + const handleSetListView = useCallback(() => setViewMode('list'), [setViewMode]) + + return ( + + + + + + + ) +}) + +const typeDisplayStyle = { base: 'none', lg: 'block' } +const tvlDisplayStyle = { base: 'none', md: 'block' } + +type ListHeaderProps = { + isGroup?: boolean +} + +export const ListHeader = memo(({ isGroup = true }: ListHeaderProps) => { + const translate = useTranslate() + + const assetText = useMemo(() => translate('yieldXYZ.asset') ?? 'Asset', [translate]) + const apyText = useMemo( + () => (isGroup ? translate('yieldXYZ.maxApy') : translate('yieldXYZ.apy')), + [translate, isGroup], + ) + const tvlText = useMemo(() => translate('yieldXYZ.tvl'), [translate]) + const balanceText = useMemo(() => translate('yieldXYZ.myBalance'), [translate]) + const providerText = useMemo( + () => (isGroup ? translate('yieldXYZ.providers') : translate('yieldXYZ.provider')), + [translate, isGroup], + ) + + return ( + + + {assetText} + + {apyText} + + {tvlText} + + + {balanceText} + + + {providerText} + + + ) +}) diff --git a/src/pages/Yields/components/YieldsList.tsx b/src/pages/Yields/components/YieldsList.tsx new file mode 100644 index 00000000000..9df01b97dba --- /dev/null +++ b/src/pages/Yields/components/YieldsList.tsx @@ -0,0 +1,748 @@ +import { SearchIcon } from '@chakra-ui/icons' +import { + Avatar, + Box, + Container, + Flex, + Heading, + HStack, + Input, + InputGroup, + InputLeftElement, + SimpleGrid, + Stat, + StatNumber, + Tab, + TabList, + TabPanel, + TabPanels, + Tabs, + Text, + useColorModeValue, +} from '@chakra-ui/react' +import type { ColumnDef, Row } from '@tanstack/react-table' +import { getCoreRowModel, getSortedRowModel, useReactTable } from '@tanstack/react-table' +import { memo, useCallback, useMemo, useState } 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 { ResultsEmptyNoWallet } from '@/components/ResultsEmptyNoWallet' +import { useWallet } from '@/hooks/useWallet/useWallet' +import { bnOrZero } from '@/lib/bignumber/bignumber' +import { YIELD_NETWORK_TO_CHAIN_ID } from '@/lib/yieldxyz/constants' +import type { AugmentedYieldDto, YieldNetwork } from '@/lib/yieldxyz/types' +import { resolveYieldInputAssetIcon, searchYields } from '@/lib/yieldxyz/utils' +import { YieldFilters } from '@/pages/Yields/components/YieldFilters' +import { YieldItem, YieldItemSkeleton } from '@/pages/Yields/components/YieldItem' +import { YieldOpportunityStats } from '@/pages/Yields/components/YieldOpportunityStats' +import { YieldTable } from '@/pages/Yields/components/YieldTable' +import { ViewToggle } from '@/pages/Yields/components/YieldViewHelpers' +import { useYieldFilters } from '@/pages/Yields/hooks/useYieldFilters' +import { useAllYieldBalances } from '@/react-queries/queries/yieldxyz/useAllYieldBalances' +import { useYieldProviders } from '@/react-queries/queries/yieldxyz/useYieldProviders' +import { useYields } from '@/react-queries/queries/yieldxyz/useYields' +import { + selectPortfolioUserCurrencyBalances, + selectUserCurrencyToUsdRate, +} from '@/state/slices/selectors' +import { useAppSelector } from '@/state/store' + +export const YieldsList = memo(() => { + const translate = useTranslate() + 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]) + const filterOption = useMemo(() => searchParams.get('filter'), [searchParams]) + const isMyOpportunities = useMemo(() => filterOption === 'my-assets', [filterOption]) + const viewParam = useMemo(() => searchParams.get('view'), [searchParams]) + const viewMode = useMemo<'grid' | 'list'>( + () => (viewParam === 'list' ? 'list' : 'grid'), + [viewParam], + ) + const setViewMode = useCallback( + (mode: 'grid' | 'list') => { + setSearchParams(prev => { + if (mode === 'grid') prev.delete('view') + else prev.set('view', mode) + return prev + }) + }, + [setSearchParams], + ) + const [searchQuery, setSearchQuery] = useState('') + + const { + selectedNetwork, + selectedProvider, + sortOption, + sorting: positionsSorting, + setSorting: setPositionsSorting, + handleNetworkChange, + handleProviderChange, + handleSortChange, + } = useYieldFilters() + + const userCurrencyBalances = useAppSelector(selectPortfolioUserCurrencyBalances) + const userCurrencyToUsdRate = useAppSelector(selectUserCurrencyToUsdRate) + + const { + data: yields, + isFetching: isLoading, + error, + } = useYields({ + network: selectedNetwork || undefined, + provider: selectedProvider || undefined, + }) + + // TODO: Multi-account support - currently defaulting to account 0 + const { data: allBalancesData, isFetching: isLoadingBalances } = useAllYieldBalances() + const allBalances = allBalancesData?.byYieldId + const { data: yieldProviders } = useYieldProviders() + + const handleTabChange = useCallback( + (index: number) => { + setSearchParams(prev => { + if (index === 0) prev.delete('tab') + else prev.set('tab', 'my-positions') + return prev + }) + }, + [setSearchParams], + ) + + const handleToggleMyOpportunities = useCallback(() => { + setSearchParams(prev => { + if (isMyOpportunities) prev.delete('filter') + else prev.set('filter', 'my-assets') + return prev + }) + }, [isMyOpportunities, setSearchParams]) + + const getProviderLogo = useCallback( + (providerId: string) => yieldProviders?.[providerId]?.logoURI, + [yieldProviders], + ) + + const handleSearchChange = useCallback( + (e: React.ChangeEvent) => setSearchQuery(e.target.value), + [], + ) + + const networks = useMemo( + () => + yields?.meta?.networks + ? yields.meta.networks.map(net => ({ + id: net, + name: net.charAt(0).toUpperCase() + net.slice(1), + chainId: YIELD_NETWORK_TO_CHAIN_ID[net as YieldNetwork], + })) + : [], + [yields], + ) + + const providers = useMemo( + () => + yields?.meta?.providers + ? yields.meta.providers.map(pId => ({ + id: pId, + name: pId.charAt(0).toUpperCase() + pId.slice(1), + icon: getProviderLogo(pId), + })) + : [], + [yields, getProviderLogo], + ) + + const yieldsByAsset = useMemo(() => { + if (!yields?.assetGroups) return [] + + const hasUserBalance = (y: AugmentedYieldDto) => { + if (y.inputTokens?.some(t => bnOrZero(userCurrencyBalances[t.assetId || '']).gt(0))) + return true + return bnOrZero(userCurrencyBalances[y.token.assetId || '']).gt(0) + } + + return yields.assetGroups + .map(group => { + let filteredYields = group.yields + if (isMyOpportunities) filteredYields = filteredYields.filter(hasUserBalance) + if (selectedNetwork) + filteredYields = filteredYields.filter(y => y.network === selectedNetwork) + if (selectedProvider) + filteredYields = filteredYields.filter(y => y.providerId === selectedProvider) + if (searchQuery) filteredYields = searchYields(filteredYields, searchQuery) + + 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) + }, bnOrZero(0)) + + return { + yields: filteredYields, + assetSymbol: group.symbol, + assetName: group.name, + assetIcon: group.icon, + assetId: group.assetId, + userGroupBalanceUsd, + maxApy: Math.max(0, ...filteredYields.map(y => y.rewardRate.total)), + totalTvlUsd: filteredYields.reduce( + (acc, y) => acc.plus(bnOrZero(y.statistics?.tvlUsd)), + bnOrZero(0), + ), + } + }) + .filter(Boolean) + .sort((a, b) => { + if (!a || !b) return 0 + switch (sortOption) { + case 'apy-desc': + return b.maxApy - a.maxApy + case 'apy-asc': + return a.maxApy - b.maxApy + case 'tvl-desc': + return b.totalTvlUsd.minus(a.totalTvlUsd).toNumber() + case 'tvl-asc': + return a.totalTvlUsd.minus(b.totalTvlUsd).toNumber() + case 'name-asc': + return a.assetName.localeCompare(b.assetName) + case 'name-desc': + return b.assetName.localeCompare(a.assetName) + default: + return 0 + } + }) as { + yields: AugmentedYieldDto[] + assetSymbol: string + assetName: string + assetIcon: string + assetId: string | undefined + userGroupBalanceUsd: ReturnType + maxApy: number + totalTvlUsd: ReturnType + }[] + }, [ + yields?.assetGroups, + isMyOpportunities, + selectedNetwork, + selectedProvider, + searchQuery, + allBalances, + sortOption, + userCurrencyBalances, + ]) + + const myPositions = useMemo(() => { + if (!yields?.all || !allBalances) return [] + const positions = yields.all.filter(yieldItem => { + const balances = allBalances[yieldItem.id] + if (!balances) return false + return balances.some(b => bnOrZero(b.amount).gt(0)) + }) + + return positions.filter(y => { + if (selectedNetwork && y.network !== selectedNetwork) return false + if (selectedProvider && y.providerId !== selectedProvider) return false + if (searchQuery) { + const q = searchQuery.toLowerCase() + if ( + !y.metadata.name.toLowerCase().includes(q) && + !y.token.symbol.toLowerCase().includes(q) && + !y.token.name.toLowerCase().includes(q) && + !y.providerId.toLowerCase().includes(q) + ) + return false + } + return true + }) + }, [yields, allBalances, selectedNetwork, selectedProvider, searchQuery]) + + const handleYieldClick = useCallback( + (yieldId: string) => { + const balances = allBalances?.[yieldId] + const highestAmountValidator = balances?.[0]?.highestAmountUsdValidator + const url = highestAmountValidator + ? `/yields/${yieldId}?validator=${highestAmountValidator}` + : `/yields/${yieldId}` + navigate(url) + }, + [navigate, allBalances], + ) + + const handleRowClick = useCallback( + (row: Row) => { + if (!row.original.status.enter) return + handleYieldClick(row.original.id) + }, + [handleYieldClick], + ) + + const columns = useMemo[]>( + () => [ + { + header: translate('yieldXYZ.yield'), + id: 'pool', + accessorFn: row => row.token.symbol, + enableSorting: true, + sortingFn: 'alphanumeric', + cell: ({ row }) => { + const iconSource = resolveYieldInputAssetIcon(row.original) + return ( + + {iconSource.assetId ? ( + + ) : ( + + )} + + + {row.original.token.symbol} + + {row.original.chainId && ( + + + + )} + + + ) + }, + meta: { display: { base: 'table-cell' } }, + }, + { + header: translate('yieldXYZ.provider'), + id: 'provider', + accessorFn: row => row.providerId, + enableSorting: true, + sortingFn: 'alphanumeric', + cell: ({ row }) => ( + + + + {row.original.providerId} + + + ), + meta: { display: { base: 'none', md: 'table-cell' } }, + }, + { + header: translate('yieldXYZ.apy'), + id: 'apy', + accessorFn: row => row.rewardRate.total, + enableSorting: true, + sortingFn: (rowA, rowB) => { + const a = bnOrZero(rowA.original.rewardRate.total).toNumber() + const b = bnOrZero(rowB.original.rewardRate.total).toNumber() + return a === b ? 0 : a > b ? 1 : -1 + }, + cell: ({ row }) => { + const apy = bnOrZero(row.original.rewardRate.total).times(100).toNumber() + return ( + + + {apy.toFixed(2)}% + + + {row.original.rewardRate.rateType} + + + ) + }, + meta: { display: { base: 'table-cell' } }, + }, + { + header: translate('yieldXYZ.tvl'), + id: 'tvl', + accessorFn: row => row.statistics?.tvlUsd, + enableSorting: true, + sortingFn: (rowA, rowB) => { + const a = bnOrZero(rowA.original.statistics?.tvlUsd).toNumber() + const b = bnOrZero(rowB.original.statistics?.tvlUsd).toNumber() + return a === b ? 0 : a > b ? 1 : -1 + }, + cell: ({ row }) => { + const tvlUserCurrency = bnOrZero(row.original.statistics?.tvlUsd) + .times(userCurrencyToUsdRate) + .toFixed() + return ( + + + + + + TVL + + + ) + }, + meta: { display: { base: 'none', md: 'table-cell' } }, + }, + { + 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() + }, + 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 + 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) + if (totalUsd.lte(0)) return null + const totalUserCurrency = totalUsd.times(userCurrencyToUsdRate).toFixed() + return ( + + + + ) + }, + meta: { display: { base: 'none', lg: 'table-cell' } }, + }, + ], + [translate, getProviderLogo, allBalances, userCurrencyToUsdRate], + ) + + const positionsTable = useReactTable({ + data: myPositions, + columns, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getRowId: row => row.id, + enableSorting: true, + state: { sorting: positionsSorting }, + onSortingChange: setPositionsSorting, + }) + + const errorElement = useMemo(() => { + if (!error) return null + return ( + + Error loading yields: {String(error)} + + ) + }, [error]) + + const allYieldsLoadingGridElement = useMemo( + () => ( + + {Array.from({ length: 6 }).map((_, i) => ( + + ))} + + ), + [], + ) + + const allYieldsLoadingListElement = useMemo( + () => ( + + {Array.from({ length: 8 }).map((_, i) => ( + + ))} + + ), + [], + ) + + const allYieldsEmptyElement = useMemo( + () => ( + + {translate('yieldXYZ.noYields')} + + ), + [translate], + ) + + const allYieldsGridElement = useMemo( + () => ( + + {yieldsByAsset.map(group => ( + + ))} + + ), + [yieldsByAsset], + ) + + const allYieldsListElement = useMemo( + () => ( + + + + + {translate('yieldXYZ.asset')} + + + + + + {translate('yieldXYZ.maxApy')} + + + + + {translate('yieldXYZ.tvl')} + + + + + {translate('yieldXYZ.providers')} + + + + + {translate('yieldXYZ.myBalance')} + + + + + {yieldsByAsset.map(group => ( + + ))} + + ), + [headerBg, translate, yieldsByAsset], + ) + + const allYieldsContentElement = useMemo(() => { + if (isLoading) + return viewMode === 'grid' ? allYieldsLoadingGridElement : allYieldsLoadingListElement + if (yieldsByAsset.length === 0) return allYieldsEmptyElement + return viewMode === 'grid' ? allYieldsGridElement : allYieldsListElement + }, [ + allYieldsEmptyElement, + allYieldsGridElement, + allYieldsListElement, + allYieldsLoadingGridElement, + allYieldsLoadingListElement, + isLoading, + viewMode, + yieldsByAsset.length, + ]) + + const positionsLoadingElement = useMemo( + () => ( + + {Array.from({ length: 3 }).map((_, i) => ( + + ))} + + ), + [], + ) + + const positionsEmptyElement = useMemo( + () => ( + + + {translate('yieldXYZ.noYields')} + + + {translate('yieldXYZ.noActivePositions')} + + + ), + [translate], + ) + + 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 + } + /> + ))} + + ), + [allBalances, getProviderLogo, handleYieldClick, positionsTable], + ) + + const positionsListElement = useMemo( + () => ( + + `${s.id}-${s.desc}`).join(',')} + table={positionsTable} + isLoading={false} + onRowClick={handleRowClick} + /> + + ), + [handleRowClick, positionsSorting, positionsTable], + ) + + const positionsContentElement = useMemo(() => { + if (!isConnected) + return ( + + ) + if (isLoading || isLoadingBalances) return positionsLoadingElement + if (myPositions.length > 0) + return viewMode === 'grid' ? positionsGridElement : positionsListElement + return positionsEmptyElement + }, [ + isConnected, + isLoading, + isLoadingBalances, + myPositions.length, + positionsEmptyElement, + positionsGridElement, + positionsListElement, + positionsLoadingElement, + viewMode, + ]) + + return ( + + + + {translate('yieldXYZ.pageTitle')} + + {translate('yieldXYZ.pageSubtitle')} + + {errorElement} + + + + {translate('common.all')} + + {translate('yieldXYZ.myPosition')} ({myPositions.length}) + + + + + + + + + + + + + + + + {allYieldsContentElement} + {positionsContentElement} + + + + ) +}) diff --git a/src/pages/Yields/hooks/useYieldFilters.ts b/src/pages/Yields/hooks/useYieldFilters.ts new file mode 100644 index 00000000000..e9bb091ba37 --- /dev/null +++ b/src/pages/Yields/hooks/useYieldFilters.ts @@ -0,0 +1,85 @@ +import type { SortingState } from '@tanstack/react-table' +import { useCallback, useEffect, useMemo, useState } from 'react' +import { useSearchParams } from 'react-router-dom' + +import type { SortOption } from '@/pages/Yields/components/YieldFilters' + +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]) + const sortOption = useMemo( + () => (searchParams.get('sort') as SortOption) || 'apy-desc', + [searchParams], + ) + + const handleNetworkChange = useCallback( + (network: string | null) => { + setSearchParams(prev => { + if (!network) prev.delete('network') + else prev.set('network', network) + return prev + }) + }, + [setSearchParams], + ) + + const handleProviderChange = useCallback( + (provider: string | null) => { + setSearchParams(prev => { + if (!provider) prev.delete('provider') + else prev.set('provider', provider) + return prev + }) + }, + [setSearchParams], + ) + + const handleSortChange = useCallback( + (option: SortOption) => { + setSearchParams(prev => { + prev.set('sort', option) + return prev + }) + }, + [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, + sortOption, + sorting, + setSorting, + handleNetworkChange, + handleProviderChange, + handleSortChange, + } +} diff --git a/src/pages/Yields/hooks/useYieldTransactionFlow.ts b/src/pages/Yields/hooks/useYieldTransactionFlow.ts new file mode 100644 index 00000000000..8349bc90348 --- /dev/null +++ b/src/pages/Yields/hooks/useYieldTransactionFlow.ts @@ -0,0 +1,531 @@ +import { useToast } from '@chakra-ui/react' +import type { AssetId } from '@shapeshiftoss/caip' +import { cosmosChainId, fromAccountId } from '@shapeshiftoss/caip' +import { useQuery, useQueryClient } from '@tanstack/react-query' +import { uuidv4 } from '@walletconnect/utils' +import { useCallback, useMemo, useState } from 'react' +import { useTranslate } from 'react-polyglot' + +import { useWallet } from '@/hooks/useWallet/useWallet' +import { bnOrZero } from '@/lib/bignumber/bignumber' +import { enterYield, exitYield, fetchAction, manageYield } from '@/lib/yieldxyz/api' +import { + DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID, + YIELD_MAX_POLL_ATTEMPTS, + YIELD_POLL_INTERVAL_MS, +} from '@/lib/yieldxyz/constants' +import type { CosmosStakeArgs } from '@/lib/yieldxyz/executeTransaction' +import { executeTransaction } from '@/lib/yieldxyz/executeTransaction' +import type { ActionDto, AugmentedYieldDto, TransactionDto } from '@/lib/yieldxyz/types' +import { ActionStatus as YieldActionStatus, TransactionStatus } from '@/lib/yieldxyz/types' +import { formatYieldTxTitle } from '@/lib/yieldxyz/utils' +import { useYieldAccount } from '@/pages/Yields/YieldAccountContext' +import { useSubmitYieldTransactionHash } from '@/react-queries/queries/yieldxyz/useSubmitYieldTransactionHash' +import { actionSlice } from '@/state/slices/actionSlice/actionSlice' +import { + ActionStatus, + ActionType, + GenericTransactionDisplayType, +} from '@/state/slices/actionSlice/types' +import { selectPortfolioAccountMetadataByAccountId } from '@/state/slices/portfolioSlice/selectors' +import { + selectAccountIdByAccountNumberAndChainId, + selectFeeAssetByChainId, +} from '@/state/slices/selectors' +import { useAppDispatch, useAppSelector } from '@/state/store' + +export enum ModalStep { + InProgress = 'in_progress', + Success = 'success', +} + +export type TransactionStep = { + title: string + status: 'pending' | 'success' | 'loading' + originalTitle: string + txHash?: string + txUrl?: string + loadingMessage?: string +} + +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') +} + +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 filterExecutableTransactions = (transactions: TransactionDto[]): TransactionDto[] => { + const seen = new Set() + return transactions.filter(tx => { + if (tx.status !== TransactionStatus.Created) return false + if (seen.has(tx.id)) return false + seen.add(tx.id) + return true + }) +} + +type UseYieldTransactionFlowProps = { + yieldItem: AugmentedYieldDto + action: 'enter' | 'exit' | 'manage' + amount: string + assetSymbol: string + onClose: () => void + isOpen?: boolean + validatorAddress?: string + passthrough?: string + manageActionType?: string +} + +export const useYieldTransactionFlow = ({ + yieldItem, + action, + amount, + assetSymbol, + onClose, + isOpen, + validatorAddress, + passthrough, + manageActionType, +}: UseYieldTransactionFlowProps) => { + const dispatch = useAppDispatch() + const queryClient = useQueryClient() + const toast = useToast() + const translate = useTranslate() + const { + state: { wallet }, + } = useWallet() + + const [step, setStep] = useState(ModalStep.InProgress) + const [rawTransactions, setRawTransactions] = useState([]) + const [transactionSteps, setTransactionSteps] = useState([]) + const [isSubmitting, setIsSubmitting] = useState(false) + const [activeStepIndex, setActiveStepIndex] = useState(-1) + const [currentActionId, setCurrentActionId] = useState(null) + + const submitHashMutation = useSubmitYieldTransactionHash() + + const { chainId: yieldChainId } = yieldItem + const { accountNumber } = useYieldAccount() + + const accountId = useAppSelector(state => { + if (!yieldChainId) return undefined + return selectAccountIdByAccountNumberAndChainId(state)[accountNumber]?.[yieldChainId] + }) + + const feeAsset = useAppSelector(state => + yieldChainId ? selectFeeAssetByChainId(state, yieldChainId) : undefined, + ) + + const accountMetadata = useAppSelector(state => + accountId ? selectPortfolioAccountMetadataByAccountId(state, { accountId }) : undefined, + ) + + const userAddress = useMemo( + () => (accountId ? fromAccountId(accountId).account : ''), + [accountId], + ) + + const canSubmit = useMemo( + () => + Boolean( + wallet && accountId && yieldChainId && (action === 'manage' || bnOrZero(amount).gt(0)), + ), + [wallet, accountId, yieldChainId, action, amount], + ) + + const txArguments = useMemo(() => { + if (!yieldItem || !userAddress || !yieldChainId) return null + if (action !== 'manage' && !amount) return null + + const fields = + action === 'enter' + ? yieldItem.mechanics.arguments.enter.fields + : action === 'exit' + ? yieldItem.mechanics.arguments.exit.fields + : [] + + const fieldNames = new Set(fields.map(field => field.name)) + const args: Record = {} + + if (action !== 'manage' && amount) { + args.amount = amount + } + + if (fieldNames.has('receiverAddress')) { + args.receiverAddress = userAddress + } + + if (fieldNames.has('validatorAddress') && yieldChainId) { + args.validatorAddress = validatorAddress || DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID[yieldChainId] + } + + if (fieldNames.has('cosmosPubKey') && yieldChainId === cosmosChainId) { + args.cosmosPubKey = userAddress + } + + return args + }, [yieldItem, action, amount, userAddress, yieldChainId, validatorAddress]) + + const { + data: quoteData, + isLoading: isQuoteLoading, + error: quoteError, + } = useQuery({ + queryKey: ['yieldxyz', 'quote', action, yieldItem.id, userAddress, txArguments], + queryFn: () => { + if (!txArguments || !userAddress || !yieldItem.id) throw new Error('Missing arguments') + + if (action === 'manage') { + if (!passthrough) throw new Error('Missing passthrough for manage action') + return manageYield({ + yieldId: yieldItem.id, + address: userAddress, + action: manageActionType || 'CLAIM_REWARDS', + passthrough, + arguments: txArguments, + }) + } + + const fn = action === 'enter' ? enterYield : exitYield + return fn({ yieldId: yieldItem.id, address: userAddress, arguments: txArguments }) + }, + enabled: !!txArguments && !!wallet && !!accountId && canSubmit && isOpen, + staleTime: 60_000, + retry: false, + }) + + const updateStepStatus = useCallback((index: number, updates: Partial) => { + setTransactionSteps(prev => prev.map((s, i) => (i === index ? { ...s, ...updates } : s))) + }, []) + + const showErrorToast = useCallback( + (titleKey: string, descriptionKey: string) => { + toast({ + title: translate(titleKey), + description: translate(descriptionKey), + status: 'error', + duration: 5000, + isClosable: true, + }) + }, + [toast, translate], + ) + + const dispatchNotification = useCallback( + (tx: TransactionDto, txHash: string) => { + if (!yieldChainId || !accountId) return + if (!yieldItem.token.assetId) { + console.warn('[useYieldTransactionFlow] Cannot dispatch notification: missing assetId') + return + } + + const isApproval = tx.title?.toLowerCase().includes('approv') + const actionType = isApproval + ? ActionType.Approve + : action === 'enter' + ? ActionType.Deposit + : action === 'exit' + ? ActionType.Withdraw + : ActionType.Claim + + const displayType = isApproval + ? GenericTransactionDisplayType.Approve + : action === 'manage' + ? GenericTransactionDisplayType.Claim + : GenericTransactionDisplayType.Yield + + // TODO(gomes): handle claim notifications - there's more logic TBD here (e.g. unbonding periods). + // For now, KISS and simply don't handle claims in action center. + if (action === 'manage') return + + dispatch( + actionSlice.actions.upsertAction({ + id: uuidv4(), + type: actionType, + status: ActionStatus.Pending, + createdAt: Date.now(), + updatedAt: Date.now(), + transactionMetadata: { + displayType, + txHash, + chainId: yieldChainId, + assetId: yieldItem.token.assetId as AssetId, + accountId, + message: formatYieldTxTitle(tx.title || 'Transaction', assetSymbol), + amountCryptoPrecision: amount, + }, + }), + ) + }, + [dispatch, yieldChainId, accountId, action, yieldItem.token.assetId, assetSymbol, amount], + ) + + const buildCosmosStakeArgs = useCallback((): CosmosStakeArgs | undefined => { + if (yieldChainId !== cosmosChainId) return undefined + + const validator = validatorAddress || DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID[cosmosChainId] + if (!validator) return undefined + + const inputTokenDecimals = yieldItem.inputTokens[0]?.decimals ?? yieldItem.token.decimals + + 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, + ]) + + const executeSingleTransaction = useCallback( + async ( + tx: TransactionDto, + index: number, + allTransactions: TransactionDto[], + actionId: string, + ) => { + if (!wallet || !accountId || !yieldChainId) { + throw new Error(translate('yieldXYZ.errors.walletNotConnected')) + } + + updateStepStatus(index, { + status: 'loading', + loadingMessage: translate('yieldXYZ.loading.signInWallet'), + }) + setIsSubmitting(true) + + try { + const txHash = await executeTransaction({ + tx, + chainId: yieldChainId, + wallet, + accountId, + userAddress, + bip44Params: accountMetadata?.bip44Params, + cosmosStakeArgs: buildCosmosStakeArgs(), + }) + + if (!txHash) throw new Error(translate('yieldXYZ.errors.broadcastFailed')) + + const txUrl = feeAsset ? `${feeAsset.explorerTxLink}${txHash}` : '' + + updateStepStatus(index, { txHash, txUrl, loadingMessage: translate('common.confirming') }) + + await submitHashMutation.mutateAsync({ + transactionId: tx.id, + hash: txHash, + yieldId: yieldItem.id, + address: userAddress, + }) + + const isLastTransaction = index + 1 >= allTransactions.length + + if (isLastTransaction) { + await waitForActionCompletion(actionId) + queryClient.invalidateQueries({ queryKey: ['yieldxyz', 'allBalances'] }) + queryClient.invalidateQueries({ queryKey: ['yieldxyz', 'yields'] }) + dispatchNotification(tx, txHash) + updateStepStatus(index, { status: 'success', loadingMessage: undefined }) + setStep(ModalStep.Success) + } else { + 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 { + await waitForActionCompletion(actionId) + queryClient.invalidateQueries({ queryKey: ['yieldxyz', 'allBalances'] }) + queryClient.invalidateQueries({ queryKey: ['yieldxyz', 'yields'] }) + dispatchNotification(tx, txHash) + updateStepStatus(index, { status: 'success', loadingMessage: undefined }) + setStep(ModalStep.Success) + } + } + } catch (error) { + console.error('Transaction execution failed:', error) + showErrorToast( + 'yieldXYZ.errors.transactionFailedTitle', + 'yieldXYZ.errors.transactionFailedDescription', + ) + updateStepStatus(index, { status: 'pending', loadingMessage: undefined }) + } finally { + setIsSubmitting(false) + } + }, + [ + wallet, + accountId, + yieldChainId, + userAddress, + accountMetadata?.bip44Params, + feeAsset, + yieldItem.id, + translate, + updateStepStatus, + buildCosmosStakeArgs, + submitHashMutation, + queryClient, + dispatchNotification, + showErrorToast, + ], + ) + + const handleClose = useCallback(() => { + if (isSubmitting) return + setStep(ModalStep.InProgress) + setTransactionSteps([]) + setRawTransactions([]) + setActiveStepIndex(-1) + setCurrentActionId(null) + onClose() + }, [isSubmitting, onClose]) + + const handleConfirm = useCallback(async () => { + if (activeStepIndex >= 0 && rawTransactions[activeStepIndex] && currentActionId) { + await executeSingleTransaction( + rawTransactions[activeStepIndex], + activeStepIndex, + rawTransactions, + currentActionId, + ) + return + } + + if (!yieldChainId) { + showErrorToast( + 'yieldXYZ.errors.unsupportedNetworkTitle', + 'yieldXYZ.errors.unsupportedNetworkDescription', + ) + return + } + + if (!wallet || !accountId) { + showErrorToast( + 'yieldXYZ.errors.walletNotConnectedTitle', + 'yieldXYZ.errors.walletNotConnectedDescription', + ) + return + } + + if (action !== 'manage' && !bnOrZero(amount).gt(0)) { + showErrorToast('yieldXYZ.errors.enterAmountTitle', 'yieldXYZ.errors.enterAmountDescription') + return + } + + if (quoteError) { + showErrorToast('yieldXYZ.errors.quoteFailedTitle', 'yieldXYZ.errors.quoteFailedDescription') + return + } + + if (!quoteData) return + + setIsSubmitting(true) + setTransactionSteps([ + { + title: translate('yieldXYZ.loading.preparingTransaction'), + status: 'loading', + originalTitle: '', + }, + ]) + + try { + const transactions = filterExecutableTransactions(quoteData.transactions) + + if (transactions.length === 0) { + setStep(ModalStep.Success) + setIsSubmitting(false) + return + } + + setCurrentActionId(quoteData.id) + setRawTransactions(transactions) + setTransactionSteps( + transactions.map((tx, i) => ({ + title: formatYieldTxTitle(tx.title || `Transaction ${i + 1}`, assetSymbol), + originalTitle: tx.title || '', + status: 'pending' as const, + })), + ) + setActiveStepIndex(0) + + await executeSingleTransaction(transactions[0], 0, transactions, quoteData.id) + } catch (error) { + console.error('Failed to initiate action:', error) + showErrorToast( + 'yieldXYZ.errors.initiateFailedTitle', + 'yieldXYZ.errors.initiateFailedDescription', + ) + setIsSubmitting(false) + setTransactionSteps([]) + } + }, [ + activeStepIndex, + rawTransactions, + currentActionId, + yieldChainId, + wallet, + accountId, + action, + amount, + quoteError, + quoteData, + assetSymbol, + translate, + showErrorToast, + executeSingleTransaction, + ]) + + return useMemo( + () => ({ + step, + transactionSteps, + isSubmitting, + activeStepIndex, + canSubmit, + handleConfirm, + handleClose, + isQuoteLoading, + }), + [ + step, + transactionSteps, + isSubmitting, + activeStepIndex, + canSubmit, + handleConfirm, + handleClose, + isQuoteLoading, + ], + ) +} diff --git a/src/react-queries/queries/yieldxyz/useAllYieldBalances.ts b/src/react-queries/queries/yieldxyz/useAllYieldBalances.ts new file mode 100644 index 00000000000..3dff9bf86c0 --- /dev/null +++ b/src/react-queries/queries/yieldxyz/useAllYieldBalances.ts @@ -0,0 +1,381 @@ +import type { AccountId, ChainId } from '@shapeshiftoss/caip' +import { fromAccountId, toAccountId } from '@shapeshiftoss/caip' +import { skipToken, useQuery } from '@tanstack/react-query' +import { useMemo } from 'react' + +import { useWallet } from '@/hooks/useWallet/useWallet' +import { bnOrZero } from '@/lib/bignumber/bignumber' +import { isSome } from '@/lib/utils' +import { fetchAggregateBalances } from '@/lib/yieldxyz/api' +import { augmentYieldBalances } from '@/lib/yieldxyz/augment' +import { CHAIN_ID_TO_YIELD_NETWORK, SUPPORTED_YIELD_NETWORKS } from '@/lib/yieldxyz/constants' +import type { + AugmentedYieldBalance, + YieldBalanceType, + YieldBalanceValidator, + YieldNetwork, +} from '@/lib/yieldxyz/types' +import { YieldBalanceType as YieldBalanceTypeEnum } from '@/lib/yieldxyz/types' +import { useYieldAccount } from '@/pages/Yields/YieldAccountContext' +import { selectAccountIdsByAccountNumberAndChainId } from '@/state/slices/selectors' +import { useAppSelector } from '@/state/store' + +type UseAllYieldBalancesOptions = { + networks?: YieldNetwork[] + accountIds?: string[] +} + +export type AugmentedYieldBalanceWithAccountId = AugmentedYieldBalance & { + accountId: AccountId + highestAmountUsdValidator?: string +} + +export type ValidatorBalanceAggregate = { + validator: YieldBalanceValidator + totalUsd: string + totalCrypto: string +} + +export type YieldBalanceAggregate = { + totalUsd: string + totalCrypto: string + hasValidators: boolean + byValidator: Record +} + +export type AggregatedBalance = AugmentedYieldBalanceWithAccountId & { + aggregatedAmount: string + aggregatedAmountUsd: string +} + +type BalancesByType = Partial> + +type PendingAction = { + type: string + passthrough: string +} + +export type ValidatorSummary = { + address: string + validator: YieldBalanceValidator + byType: BalancesByType + totalUsd: string + hasActive: boolean + hasEntering: boolean + hasExiting: boolean + hasClaimable: boolean + claimAction: PendingAction | undefined +} + +export type NormalizedYieldBalances = { + raw: AugmentedYieldBalanceWithAccountId[] + byType: BalancesByType + byValidatorAddress: Record + validatorAddresses: string[] + byValidator: Record + validators: ValidatorSummary[] + hasValidatorPositions: boolean + totalUsd: string +} + +const EMPTY_NORMALIZED: NormalizedYieldBalances = { + raw: [], + byType: {}, + byValidatorAddress: {}, + validatorAddresses: [], + byValidator: {}, + validators: [], + hasValidatorPositions: false, + totalUsd: '0', +} + +const normalizeBalances = ( + rawBalances: AugmentedYieldBalanceWithAccountId[], +): NormalizedYieldBalances => { + if (rawBalances.length === 0) return EMPTY_NORMALIZED + + const byType: BalancesByType = {} + const byValidatorAddress: Record = {} + const validatorAddressSet = new Set() + + for (const balance of rawBalances) { + const type = balance.type as YieldBalanceType + const validatorAddr = balance.validator?.address + + const existingByType = byType[type] + if (!existingByType) { + byType[type] = { + ...balance, + aggregatedAmount: balance.amount, + aggregatedAmountUsd: balance.amountUsd, + } + } else { + byType[type] = { + ...existingByType, + aggregatedAmount: bnOrZero(existingByType.aggregatedAmount).plus(balance.amount).toFixed(), + aggregatedAmountUsd: bnOrZero(existingByType.aggregatedAmountUsd) + .plus(balance.amountUsd) + .toFixed(), + } + } + + if (validatorAddr) { + validatorAddressSet.add(validatorAddr) + if (!byValidatorAddress[validatorAddr]) byValidatorAddress[validatorAddr] = {} + + const validatorBalances = byValidatorAddress[validatorAddr] + const existingValidatorByType = validatorBalances[type] + + if (!existingValidatorByType) { + validatorBalances[type] = { + ...balance, + aggregatedAmount: balance.amount, + aggregatedAmountUsd: balance.amountUsd, + } + } else { + validatorBalances[type] = { + ...existingValidatorByType, + aggregatedAmount: bnOrZero(existingValidatorByType.aggregatedAmount) + .plus(balance.amount) + .toFixed(), + aggregatedAmountUsd: bnOrZero(existingValidatorByType.aggregatedAmountUsd) + .plus(balance.amountUsd) + .toFixed(), + } + } + } + } + + const validatorAddresses = Array.from(validatorAddressSet) + + const validatorMetaMap = new Map() + for (const balance of rawBalances) { + if (balance.validator && !validatorMetaMap.has(balance.validator.address)) { + validatorMetaMap.set(balance.validator.address, balance.validator) + } + } + + const byValidator: Record = {} + let totalUsdAccumulator = bnOrZero(0) + + for (const address of validatorAddresses) { + const balancesByType = byValidatorAddress[address] + const validator = validatorMetaMap.get(address) + if (!validator) continue + + const activeBalance = balancesByType[YieldBalanceTypeEnum.Active] + const enteringBalance = balancesByType[YieldBalanceTypeEnum.Entering] + const exitingBalance = balancesByType[YieldBalanceTypeEnum.Exiting] + const claimableBalance = balancesByType[YieldBalanceTypeEnum.Claimable] + + const hasActive = bnOrZero(activeBalance?.aggregatedAmount).gt(0) + const hasEntering = bnOrZero(enteringBalance?.aggregatedAmount).gt(0) + const hasExiting = bnOrZero(exitingBalance?.aggregatedAmount).gt(0) + const hasClaimable = bnOrZero(claimableBalance?.aggregatedAmount).gt(0) + + const validatorTotalUsd = Object.values(balancesByType).reduce( + (acc, b) => acc.plus(bnOrZero(b?.aggregatedAmountUsd)), + bnOrZero(0), + ) + + const claimAction = claimableBalance?.pendingActions?.find(a => + a.type.toUpperCase().includes('CLAIM'), + ) + const hasAnyPosition = hasActive || hasEntering || hasExiting || hasClaimable + if (!hasAnyPosition) continue + + totalUsdAccumulator = totalUsdAccumulator.plus(validatorTotalUsd) + + byValidator[address] = { + address, + validator, + byType: balancesByType, + totalUsd: validatorTotalUsd.toFixed(), + hasActive, + hasEntering, + hasExiting, + hasClaimable, + claimAction, + } + } + + const validators = Object.values(byValidator).sort((a, b) => + bnOrZero(b.totalUsd).minus(bnOrZero(a.totalUsd)).toNumber(), + ) + + return { + raw: rawBalances, + byType, + byValidatorAddress, + validatorAddresses, + byValidator, + validators, + hasValidatorPositions: validators.length > 1, + totalUsd: totalUsdAccumulator.toFixed(), + } +} + +export const useAllYieldBalances = (options: UseAllYieldBalancesOptions = {}) => { + const { networks = SUPPORTED_YIELD_NETWORKS, accountIds: filterAccountIds } = options + const { state: walletState } = useWallet() + const isConnected = Boolean(walletState.walletInfo) + const { accountNumber } = useYieldAccount() + const accountIdsByAccountNumberAndChainId = useAppSelector( + selectAccountIdsByAccountNumberAndChainId, + ) + + const accountIdsForAccountNumber = useMemo((): AccountId[] => { + const byChainId = accountIdsByAccountNumberAndChainId[accountNumber] + if (!byChainId) return [] + return Object.values(byChainId).flat().filter(isSome) + }, [accountIdsByAccountNumberAndChainId, accountNumber]) + + const queryPayloads = useMemo(() => { + if (!isConnected || accountIdsForAccountNumber.length === 0) return [] + + const targetAccountIds = filterAccountIds ?? accountIdsForAccountNumber + const payloads: { address: string; network: string; chainId: ChainId; accountId: AccountId }[] = + [] + + for (const accountId of targetAccountIds) { + if (!accountIdsForAccountNumber.includes(accountId)) continue + + const { chainId, account } = fromAccountId(accountId) + const network = CHAIN_ID_TO_YIELD_NETWORK[chainId] + + if (network && networks.includes(network)) { + payloads.push({ address: account, network, chainId, accountId }) + } + } + + return payloads + }, [isConnected, accountIdsForAccountNumber, filterAccountIds, networks]) + + const { addressToAccountId, addressToChainId } = useMemo(() => { + const accountIdMap: Record = {} + const chainIdMap: Record = {} + for (const payload of queryPayloads) { + const key = payload.address.toLowerCase() + accountIdMap[`${key}:${payload.network}`] = payload.accountId + chainIdMap[key] = payload.chainId + } + return { addressToAccountId: accountIdMap, addressToChainId: chainIdMap } + }, [queryPayloads]) + + const { data: rawData, ...queryResult } = useQuery< + Record + >({ + queryKey: ['yieldxyz', 'allBalances', queryPayloads], + queryFn: + queryPayloads.length > 0 + ? async () => { + const uniqueQueries = queryPayloads.map(({ address, network }) => ({ + address, + network, + })) + + const response = await fetchAggregateBalances(uniqueQueries) + const balanceMap: Record = {} + + for (const item of response.items) { + const firstBalance = item.balances[0] + if (!firstBalance) continue + + const chainId = addressToChainId[firstBalance.address.toLowerCase()] + + const augmentedBalances = augmentYieldBalances(item.balances, chainId) + + let highestAmountUsd = bnOrZero(0) + let highestAmountUsdValidator: string | undefined + + for (const balance of augmentedBalances) { + const usd = bnOrZero(balance.amountUsd) + if (balance.validator?.address && usd.gt(highestAmountUsd)) { + highestAmountUsd = usd + highestAmountUsdValidator = balance.validator.address + } + } + + if (!balanceMap[item.yieldId]) { + balanceMap[item.yieldId] = [] + } + + for (const balance of augmentedBalances) { + const network = item.yieldId.split('-')[0] + const lookupKey = `${balance.address.toLowerCase()}:${network}` + let accountId = addressToAccountId[lookupKey] + + if (!accountId && chainId) { + accountId = toAccountId({ chainId, account: balance.address }) + } + + if (!accountId) continue + + balanceMap[item.yieldId].push({ + ...balance, + accountId, + highestAmountUsdValidator, + }) + } + } + + return balanceMap + } + : skipToken, + staleTime: 60000, + }) + + const data = useMemo(() => { + if (!rawData) return undefined + + const aggregatedByYield: Record = {} + const normalizedByYield: Record = {} + + for (const [yieldId, balances] of Object.entries(rawData)) { + let totalUsd = bnOrZero(0) + let totalCrypto = bnOrZero(0) + const byValidator: Record = {} + + for (const balance of balances) { + const amount = bnOrZero(balance.amount) + const amountUsd = bnOrZero(balance.amountUsd) + if (amount.lte(0)) continue + + totalUsd = totalUsd.plus(amountUsd) + totalCrypto = totalCrypto.plus(amount) + + if (!balance.validator) continue + + const addr = balance.validator.address + const existing = byValidator[addr] + if (existing) { + existing.totalUsd = bnOrZero(existing.totalUsd).plus(amountUsd).toFixed() + existing.totalCrypto = bnOrZero(existing.totalCrypto).plus(amount).toFixed() + } else { + byValidator[addr] = { + validator: balance.validator, + totalUsd: amountUsd.toFixed(), + totalCrypto: amount.toFixed(), + } + } + } + + aggregatedByYield[yieldId] = { + totalUsd: totalUsd.toFixed(), + totalCrypto: totalCrypto.toFixed(), + hasValidators: Object.keys(byValidator).length > 0, + byValidator, + } + + normalizedByYield[yieldId] = normalizeBalances(balances) + } + + return { + byYieldId: rawData, + aggregated: aggregatedByYield, + normalized: normalizedByYield, + } + }, [rawData]) + + return { data, ...queryResult } +} diff --git a/src/react-queries/queries/yieldxyz/useSubmitYieldTransactionHash.ts b/src/react-queries/queries/yieldxyz/useSubmitYieldTransactionHash.ts new file mode 100644 index 00000000000..850c960fd75 --- /dev/null +++ b/src/react-queries/queries/yieldxyz/useSubmitYieldTransactionHash.ts @@ -0,0 +1,28 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' + +import { submitTransactionHash } from '@/lib/yieldxyz/api' + +export const useSubmitYieldTransactionHash = () => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: ({ + transactionId, + hash, + }: { + transactionId: string + hash: string + yieldId?: string + address?: string + }) => submitTransactionHash({ transactionId, hash }), + onSuccess: (_, variables) => { + if (variables.yieldId && variables.address) { + queryClient.invalidateQueries({ + queryKey: ['yieldxyz', 'balances', variables.yieldId, variables.address], + }) + } + queryClient.invalidateQueries({ queryKey: ['yieldxyz', 'allBalances'] }) + queryClient.invalidateQueries({ queryKey: ['yieldxyz', 'yields'] }) + }, + }) +} diff --git a/src/react-queries/queries/yieldxyz/useYield.ts b/src/react-queries/queries/yieldxyz/useYield.ts new file mode 100644 index 00000000000..cfcfcc1a636 --- /dev/null +++ b/src/react-queries/queries/yieldxyz/useYield.ts @@ -0,0 +1,32 @@ +import { skipToken, useQuery, useQueryClient } from '@tanstack/react-query' + +import { fetchYield } from '@/lib/yieldxyz/api' +import { augmentYield } from '@/lib/yieldxyz/augment' +import type { AugmentedYieldDto } from '@/lib/yieldxyz/types' + +export const useYield = (yieldId: string) => { + const queryClient = useQueryClient() + + const getCachedYield = (): AugmentedYieldDto | undefined => { + const cachedYields = queryClient.getQueryData(['yieldxyz', 'yields']) + return cachedYields?.find(y => y.id === yieldId) + } + + return useQuery({ + queryKey: ['yieldxyz', 'yield', yieldId], + queryFn: yieldId + ? async () => { + const cached = getCachedYield() + if (cached) return cached + + const result = await fetchYield(yieldId) + return augmentYield(result) + } + : skipToken, + staleTime: 60 * 1000, + initialData: getCachedYield, + initialDataUpdatedAt: () => { + return queryClient.getQueryState(['yieldxyz', 'yields'])?.dataUpdatedAt + }, + }) +} diff --git a/src/react-queries/queries/yieldxyz/useYieldProviders.ts b/src/react-queries/queries/yieldxyz/useYieldProviders.ts new file mode 100644 index 00000000000..1d6b2045255 --- /dev/null +++ b/src/react-queries/queries/yieldxyz/useYieldProviders.ts @@ -0,0 +1,44 @@ +import { useQuery } from '@tanstack/react-query' + +import { fetchProviders } from '@/lib/yieldxyz/api' +import type { ProviderDto } from '@/lib/yieldxyz/types' + +const YIELD_XYZ_PROVIDER_ID = 'yield-xyz' +// The yield-xyz provider logoURI from the API (https://assets.stakek.it/providers/yield-xyz.svg) returns 403 +const YIELD_XYZ_LOCAL_LOGO_URI = '/images/providers/yield-xyz.png' + +export const useYieldProviders = () => { + return useQuery>({ + queryKey: ['yieldxyz', 'providers'], + queryFn: async () => { + const allItems: ProviderDto[] = [] + let offset = 0 + const limit = 100 + const maxPages = 20 // Safety limit to prevent infinite loops + + for (let page = 0; page < maxPages; page++) { + const data = await fetchProviders({ limit, offset }) + allItems.push(...data.items) + if (data.items.length < limit) break + offset += limit + } + + return allItems + }, + select: providers => { + return providers.reduce( + (acc, provider) => { + const p = + provider.id === YIELD_XYZ_PROVIDER_ID + ? { ...provider, logoURI: YIELD_XYZ_LOCAL_LOGO_URI } + : provider + acc[p.id] = p + return acc + }, + {} as Record, + ) + }, + staleTime: Infinity, + gcTime: Infinity, + }) +} diff --git a/src/react-queries/queries/yieldxyz/useYieldValidators.ts b/src/react-queries/queries/yieldxyz/useYieldValidators.ts new file mode 100644 index 00000000000..0962b595ddf --- /dev/null +++ b/src/react-queries/queries/yieldxyz/useYieldValidators.ts @@ -0,0 +1,19 @@ +import { skipToken, useQuery } from '@tanstack/react-query' + +import { fetchYieldValidators } from '@/lib/yieldxyz/api' +import type { ValidatorDto } from '@/lib/yieldxyz/types' + +export const useYieldValidators = (yieldId: string, enabled: boolean = true) => { + return useQuery({ + queryKey: ['yieldxyz', 'validators', yieldId], + queryFn: + yieldId && enabled + ? async () => { + const data = await fetchYieldValidators(yieldId) + return data.items + } + : skipToken, + staleTime: 1000 * 60 * 60, + gcTime: 1000 * 60 * 60 * 24, + }) +} diff --git a/src/react-queries/queries/yieldxyz/useYields.ts b/src/react-queries/queries/yieldxyz/useYields.ts new file mode 100644 index 00000000000..1648414cc75 --- /dev/null +++ b/src/react-queries/queries/yieldxyz/useYields.ts @@ -0,0 +1,182 @@ +import { useQuery } from '@tanstack/react-query' +import { useMemo } from 'react' + +import { bnOrZero } from '@/lib/bignumber/bignumber' +import { fetchYields } from '@/lib/yieldxyz/api' +import { augmentYield } from '@/lib/yieldxyz/augment' +import { isSupportedYieldNetwork, SUPPORTED_YIELD_NETWORKS } from '@/lib/yieldxyz/constants' +import type { AugmentedYieldDto, YieldAssetGroup, YieldDto } from '@/lib/yieldxyz/types' +import { selectAssets } from '@/state/slices/selectors' +import { useAppSelector } from '@/state/store' + +// Find the "best" yield in a group to use as representative for icon/name +// Priority: has known assetId > is native asset > shorter name (less verbose) +const findRepresentativeYield = ( + yields: AugmentedYieldDto[], + assets: Record, +): AugmentedYieldDto => { + return yields.reduce((prev, current) => { + const prevToken = prev.inputTokens?.[0] || prev.token + const currToken = current.inputTokens?.[0] || current.token + + const prevHasAsset = prevToken.assetId && assets[prevToken.assetId] + const currHasAsset = currToken.assetId && assets[currToken.assetId] + + if (currHasAsset && !prevHasAsset) return current + if (prevHasAsset && !currHasAsset) return prev + + const prevIsNative = prevToken.assetId?.includes('slip44') + const currIsNative = currToken.assetId?.includes('slip44') + if (currIsNative && !prevIsNative) return current + if (prevIsNative && !currIsNative) return prev + + if (currToken.name && prevToken.name) { + if (currToken.name.length < prevToken.name.length) return current + if (prevToken.name.length < currToken.name.length) return prev + } + return prev + }, yields[0]) +} + +const isLowQualityYield = (yieldItem: YieldDto): boolean => { + const tvl = Number(yieldItem.statistics?.tvlUsd ?? 0) + const apy = yieldItem.rewardRate?.total ?? 0 + + // Keep zero TVL (upstream bug), high TVL, or decent APY + if (tvl === 0) return false // keep - likely indexing bug + if (tvl >= 100000) return false // keep - significant TVL + if (apy >= 0.01) return false // keep - decent APY (1%+) + + return true // filter out - low TVL AND low APY +} + +export const useYields = (params?: { network?: string; provider?: string }) => { + const { data: allYields, ...queryResult } = useQuery({ + queryKey: ['yieldxyz', 'yields'], + queryFn: async () => { + const allItems: YieldDto[] = [] + let offset = 0 + const limit = 100 + const maxPages = 50 // Safety limit to prevent infinite loops + + for (let page = 0; page < maxPages; page++) { + const data = await fetchYields({ + networks: SUPPORTED_YIELD_NETWORKS as string[], + limit, + offset, + }) + allItems.push(...data.items) + if (data.items.length < limit) break + offset += limit + } + + const qualityYields = allItems.filter(item => !isLowQualityYield(item)) + + // Sort by TVL descending (Highest TVL first) + qualityYields.sort((a, b) => { + const tvlA = Number(a.statistics?.tvlUsd ?? 0) + const tvlB = Number(b.statistics?.tvlUsd ?? 0) + return tvlB - tvlA + }) + + return qualityYields.filter(item => isSupportedYieldNetwork(item.network)).map(augmentYield) + }, + staleTime: 5 * 60 * 1000, + }) + + const assets = useAppSelector(selectAssets) + + const data = useMemo(() => { + if (!allYields) return undefined + + // Apply Filters Client-Side + let filtered = allYields + if (params?.network) { + filtered = filtered.filter(y => y.network === params.network) + } + if (params?.provider) { + filtered = filtered.filter(y => y.providerId === params.provider) + } + + // Build Indices + const byId = filtered.reduce( + (acc, item) => { + acc[item.id] = item + return acc + }, + {} as Record, + ) + + const ids = filtered.map(item => item.id) + + // Use GLOBAL networks/providers so dropdowns don't shrink when filtered + const globalNetworks = [...new Set(allYields.map(item => item.network))] + const globalProviders = [...new Set(allYields.map(item => item.providerId))] + + // Group yields by asset symbol + const byAssetSymbol = filtered.reduce>((acc, item) => { + const symbol = (item.inputTokens?.[0] || item.token).symbol + if (symbol) { + if (!acc[symbol]) acc[symbol] = [] + acc[symbol].push(item) + } + return acc + }, {}) + + // Group yields by input token assetId for accurate portfolio matching + const byInputAssetId = filtered.reduce>((acc, item) => { + const assetId = item.inputTokens?.[0]?.assetId + if (assetId) { + if (!acc[assetId]) acc[assetId] = [] + acc[assetId].push(item) + } + return acc + }, {}) + + // Pre-compute asset groups with all derived metadata + // Consumers no longer need to compute this themselves + const assetGroups: YieldAssetGroup[] = Object.entries(byAssetSymbol).map( + ([symbol, groupYields]) => { + const bestYield = findRepresentativeYield(groupYields, assets) + const representativeToken = bestYield.inputTokens?.[0] || bestYield.token + + // Icon resolution: prefer known asset icon, fallback to token logoURI + const knownAsset = representativeToken.assetId + ? (assets[representativeToken.assetId] as { icon?: string } | undefined) + : undefined + const icon = + knownAsset?.icon || representativeToken.logoURI || bestYield.metadata.logoURI || '' + + return { + symbol, + name: representativeToken.name || symbol, + icon, + assetId: representativeToken.assetId, + yields: groupYields, + count: groupYields.length, + maxApy: Math.max(0, ...groupYields.map(y => y.rewardRate.total)), + totalTvlUsd: groupYields + .reduce((acc, y) => acc.plus(bnOrZero(y.statistics?.tvlUsd)), bnOrZero(0)) + .toFixed(), + providerIds: [...new Set(groupYields.map(y => y.providerId))], + chainIds: [...new Set(groupYields.map(y => y.chainId).filter(Boolean))] as string[], + } + }, + ) + + return { + all: filtered, + byId, + ids, + byAssetSymbol, + byInputAssetId, + assetGroups, + meta: { + networks: globalNetworks, + providers: globalProviders, + }, + } + }, [allYields, assets, params?.network, params?.provider]) + + return { ...queryResult, data } +} diff --git a/src/state/slices/actionSlice/types.ts b/src/state/slices/actionSlice/types.ts index 0359f7e8a10..08fb078224b 100644 --- a/src/state/slices/actionSlice/types.ts +++ b/src/state/slices/actionSlice/types.ts @@ -93,6 +93,8 @@ export enum GenericTransactionDisplayType { SEND = 'Send', Approve = 'Approve', ThorchainLP = 'ThorchainLP', + Yield = 'Yield', + Claim = 'Claim', } export enum GenericTransactionQueryId { diff --git a/src/state/slices/preferencesSlice/preferencesSlice.ts b/src/state/slices/preferencesSlice/preferencesSlice.ts index 2378a085711..63c56c1aed2 100644 --- a/src/state/slices/preferencesSlice/preferencesSlice.ts +++ b/src/state/slices/preferencesSlice/preferencesSlice.ts @@ -110,6 +110,8 @@ export type FeatureFlags = { WebServices: boolean AddressBook: boolean AppRating: boolean + YieldXyz: boolean + YieldMultiAccount: boolean } export type Flag = keyof FeatureFlags @@ -254,6 +256,8 @@ const initialState: Preferences = { WebServices: getConfig().VITE_FEATURE_NOTIFICATIONS_WEBSERVICES, AddressBook: getConfig().VITE_FEATURE_ADDRESS_BOOK, AppRating: getConfig().VITE_FEATURE_APP_RATING, + YieldXyz: getConfig().VITE_FEATURE_YIELD_XYZ, + YieldMultiAccount: getConfig().VITE_FEATURE_YIELD_MULTI_ACCOUNT, }, selectedLocale: simpleLocale(), hasWalletSeenTcyClaimAlert: {}, diff --git a/src/test/mocks/store.ts b/src/test/mocks/store.ts index b1b0866ec80..1b8a947958a 100644 --- a/src/test/mocks/store.ts +++ b/src/test/mocks/store.ts @@ -183,6 +183,8 @@ export const mockStore: ReduxState = { WebServices: false, AddressBook: false, AppRating: false, + YieldXyz: false, + YieldMultiAccount: false, }, showTopAssetsCarousel: true, quickBuyAmounts: [10, 50, 100], diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 0177663ca3c..1ef558277dd 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -122,6 +122,10 @@ interface ImportMetaEnv { readonly VITE_TENDERLY_PROJECT_SLUG: string readonly VITE_TENDERLY_API_KEY: string readonly VITE_FEATURE_ADDRESS_BOOK: string + readonly VITE_FEATURE_YIELD_XYZ: string + readonly VITE_FEATURE_YIELD_MULTI_ACCOUNT: string + readonly VITE_YIELD_XYZ_API_KEY: string + readonly VITE_YIELD_XYZ_BASE_URL: string // Unchained URLs and node URLs - present in all envs (prod, development, private) // even though they're not present in base env