diff --git a/next-env.d.ts b/next-env.d.ts index 4f11a03dc6..a4a7b3f5cf 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -2,4 +2,4 @@ /// // NOTE: This file should not be edited -// see https://nextjs.org/docs/basic-features/typescript for more information. +// see https://nextjs.org/docs/pages/building-your-application/configuring/typescript for more information. diff --git a/public/images/common/stake.svg b/public/images/common/stake.svg index b0a4b399df..41469d1e83 100644 --- a/public/images/common/stake.svg +++ b/public/images/common/stake.svg @@ -1,4 +1,4 @@ - + diff --git a/src/components/balances/AssetsTable/index.tsx b/src/components/balances/AssetsTable/index.tsx index c04d795040..077bfed573 100644 --- a/src/components/balances/AssetsTable/index.tsx +++ b/src/components/balances/AssetsTable/index.tsx @@ -1,6 +1,6 @@ import CheckBalance from '@/features/counterfactual/CheckBalance' import { type ReactElement } from 'react' -import { Tooltip, Typography, SvgIcon, IconButton, Box, Checkbox, Skeleton } from '@mui/material' +import { Box, IconButton, Checkbox, Skeleton, SvgIcon, Tooltip, Typography } from '@mui/material' import type { TokenInfo } from '@safe-global/safe-gateway-typescript-sdk' import { TokenType } from '@safe-global/safe-gateway-typescript-sdk' import css from './styles.module.css' @@ -21,6 +21,9 @@ import SwapButton from '@/features/swap/components/SwapButton' import { SWAP_LABELS } from '@/services/analytics/events/swaps' import SendButton from './SendButton' import useIsSwapFeatureEnabled from '@/features/swap/hooks/useIsSwapFeatureEnabled' +import useIsStakingFeatureEnabled from '@/features/stake/hooks/useIsSwapFeatureEnabled' +import { STAKE_LABELS } from '@/services/analytics/events/stake' +import StakeButton from '@/features/stake/components/StakeButton' const skeletonCells: EnhancedTableProps['rows'][0]['cells'] = { asset: { @@ -97,6 +100,7 @@ const AssetsTable = ({ }): ReactElement => { const { balances, loading } = useBalances() const isSwapFeatureEnabled = useIsSwapFeatureEnabled() + const isStakingFeatureEnabled = useIsStakingFeatureEnabled() const { isAssetSelected, toggleAsset, hidingAsset, hideAsset, cancel, deselectAll, saveChanges } = useHideAssets(() => setShowHiddenAssets(false), @@ -130,6 +134,10 @@ const AssetsTable = ({ {item.tokenInfo.name} + {isStakingFeatureEnabled && item.tokenInfo.type === TokenType.NATIVE_TOKEN && ( + + )} + {!isNative && } ), diff --git a/src/components/dashboard/index.tsx b/src/components/dashboard/index.tsx index 4235cacf4a..79f8a956e4 100644 --- a/src/components/dashboard/index.tsx +++ b/src/components/dashboard/index.tsx @@ -18,6 +18,7 @@ import css from './styles.module.css' import SwapWidget from '@/features/swap/components/SwapWidget' import useIsSwapFeatureEnabled from '@/features/swap/hooks/useIsSwapFeatureEnabled' import { useSafeTokenEnabled } from '@/hooks/useSafeTokenEnabled' +import StakingDashboardWidget from '@/features/stake/components/StakeDashboardWidget' const RecoveryHeader = dynamic(() => import('@/features/recovery/components/RecoveryHeader')) @@ -68,6 +69,10 @@ const Dashboard = (): ReactElement => { + + + + {showSafeApps && ( diff --git a/src/components/tx/FieldsGrid/index.tsx b/src/components/tx/FieldsGrid/index.tsx index 36c1e4f475..ba0a4785db 100644 --- a/src/components/tx/FieldsGrid/index.tsx +++ b/src/components/tx/FieldsGrid/index.tsx @@ -4,7 +4,7 @@ import { Grid, Typography } from '@mui/material' const minWidth = { xl: '25%', lg: '100px' } const wrap = { flexWrap: { xl: 'nowrap' } } -const FieldsGrid = ({ title, children }: { title: string; children: ReactNode }) => { +const FieldsGrid = ({ title, children }: { title: string | ReactNode; children: ReactNode }) => { return ( diff --git a/src/features/stake/components/StakeButton/index.tsx b/src/features/stake/components/StakeButton/index.tsx new file mode 100644 index 0000000000..efe0b38228 --- /dev/null +++ b/src/features/stake/components/StakeButton/index.tsx @@ -0,0 +1,58 @@ +import CheckWallet from '@/components/common/CheckWallet' +import Track from '@/components/common/Track' +import { AppRoutes } from '@/config/routes' +import useSpendingLimit from '@/hooks/useSpendingLimit' +import { Button } from '@mui/material' +import type { TokenInfo } from '@safe-global/safe-gateway-typescript-sdk' +import { TokenType } from '@safe-global/safe-gateway-typescript-sdk' +import { useRouter } from 'next/router' +import type { ReactElement } from 'react' +import StakeIcon from '@/public/images/common/stake.svg' +import type { STAKE_LABELS } from '@/services/analytics/events/stake' +import { STAKE_EVENTS } from '@/services/analytics/events/stake' +import { useCurrentChain } from '@/hooks/useChains' + +const StakeButton = ({ + tokenInfo, + trackingLabel, +}: { + tokenInfo: TokenInfo + trackingLabel: STAKE_LABELS +}): ReactElement => { + const spendingLimit = useSpendingLimit(tokenInfo) + const chain = useCurrentChain() + const router = useRouter() + + return ( + + {(isOk) => ( + + + + )} + + ) +} + +export default StakeButton diff --git a/src/features/stake/components/StakeDashboardWidget/index.tsx b/src/features/stake/components/StakeDashboardWidget/index.tsx new file mode 100644 index 0000000000..773a1ce19a --- /dev/null +++ b/src/features/stake/components/StakeDashboardWidget/index.tsx @@ -0,0 +1,56 @@ +import { useMemo } from 'react' +import css from '@/components/dashboard/PendingTxs/styles.module.css' +import { Typography } from '@mui/material' +import { ViewAllLink } from '@/components/dashboard/styled' +import { AppRoutes } from '@/config/routes' +import { useRouter } from 'next/router' +import useBalances from '@/hooks/useBalances' +import { TokenType } from '@safe-global/safe-gateway-typescript-sdk' + +const useNativeTokenBalance = () => { + const balance = useBalances() + + return useMemo(() => { + if (!balance) { + return undefined + } + + return balance.balances.items.find((item) => item.tokenInfo.type === TokenType.NATIVE_TOKEN) + }, [balance]) +} + +const StakingDashboardWidget = () => { + const router = useRouter() + + const nativeTokenBalance = useNativeTokenBalance() + + const stakeUrl = useMemo( + () => ({ + pathname: AppRoutes.stake, + query: { safe: router.query.safe }, + }), + [router.query.safe], + ) + + if (nativeTokenBalance?.balance && BigInt(nativeTokenBalance.balance) >= 32n * 10n ** 18n) { + return ( + <> +
+ + Stake + + + +
+ + + You have enough ETH to stake. Stake your ETH to earn rewards. + + + ) + } + + return null +} + +export default StakingDashboardWidget diff --git a/src/features/stake/components/StakePage/index.tsx b/src/features/stake/components/StakePage/index.tsx index 7ab3382e1e..346ce12e7d 100644 --- a/src/features/stake/components/StakePage/index.tsx +++ b/src/features/stake/components/StakePage/index.tsx @@ -3,14 +3,17 @@ import Disclaimer from '@/components/common/Disclaimer' import WidgetDisclaimer from '@/components/common/WidgetDisclaimer' import useStakeConsent from '@/features/stake/useStakeConsent' import StakingWidget from '../StakingWidget' +import { useRouter } from 'next/router' const StakePage = () => { const { isConsentAccepted, onAccept } = useStakeConsent() + const router = useRouter() + const { asset } = router.query return ( <> {isConsentAccepted === undefined ? null : isConsentAccepted ? ( - + ) : ( {order.numValidators} )} - {formatDurationFromSeconds(order.estimatedEntryTime)} - Approx. every 5 days after 4 days from activation + {!isOrder && order.status === NativeStakingStatus.VALIDATION_STARTED ? null : ( + {formatDurationFromSeconds(order.estimatedEntryTime)} + )} + + Approx. every 5 days after activation {!isOrder && ( diff --git a/src/features/stake/components/StakingConfirmationTx/Exit.tsx b/src/features/stake/components/StakingConfirmationTx/Exit.tsx index 4c68d07073..eec8c93314 100644 --- a/src/features/stake/components/StakingConfirmationTx/Exit.tsx +++ b/src/features/stake/components/StakingConfirmationTx/Exit.tsx @@ -1,9 +1,10 @@ -import { Typography, Stack, Alert } from '@mui/material' +import { Typography, Stack, Alert, Tooltip, SvgIcon } from '@mui/material' import FieldsGrid from '@/components/tx/FieldsGrid' import type { StakingTxExitInfo } from '@safe-global/safe-gateway-typescript-sdk' import { formatDurationFromSeconds } from '@/utils/formatters' import { type NativeStakingValidatorsExitConfirmationView } from '@safe-global/safe-gateway-typescript-sdk/dist/types/decoded-data' import ConfirmationOrderHeader from '@/components/tx/ConfirmationOrder/ConfirmationOrderHeader' +import InfoIcon from '@/public/images/notifications/info.svg' type StakingOrderConfirmationViewProps = { order: NativeStakingValidatorsExitConfirmationView | StakingTxExitInfo @@ -31,7 +32,41 @@ const StakingConfirmationTxExit = ({ order }: StakingOrderConfirmationViewProps) ]} /> - Up to {withdrawIn} + + Withdraw in + + Withdrawal time is the sum of: +
    +
  • Time until your validator is successfully exited after the withdraw request
  • +
  • Time for a stake to receive Consensus rewards on the execution layer
  • +
+ + } + arrow + placement="top" + > + + + +
+ + } + > + Up to {withdrawIn} +
The selected amount and any rewards will be withdrawn from Dedicated Staking for ETH after the validator exit. diff --git a/src/features/stake/components/StakingTxExitDetails/index.tsx b/src/features/stake/components/StakingTxExitDetails/index.tsx index 31e4839cd4..16d3f6563e 100644 --- a/src/features/stake/components/StakingTxExitDetails/index.tsx +++ b/src/features/stake/components/StakingTxExitDetails/index.tsx @@ -1,5 +1,6 @@ import { Box } from '@mui/material' import type { StakingTxExitInfo, TransactionData } from '@safe-global/safe-gateway-typescript-sdk' +import { NativeStakingExitStatus } from '@safe-global/safe-gateway-typescript-sdk' import FieldsGrid from '@/components/tx/FieldsGrid' import TokenAmount from '@/components/common/TokenAmount' import StakingStatus from '@/features/stake/components/StakingStatus' @@ -22,7 +23,9 @@ const StakingTxExitDetails = ({ info }: { info: StakingTxExitInfo; txData?: Tran {info.numValidators} Validator{info.numValidators > 1 ? 's' : ''}
- Up to {withdrawIn} + {info.status !== NativeStakingExitStatus.READY_TO_WITHDRAW && ( + Up to {withdrawIn} + )} diff --git a/src/features/stake/components/StakingWidget/index.tsx b/src/features/stake/components/StakingWidget/index.tsx index 3330f86b3c..e06c911b03 100644 --- a/src/features/stake/components/StakingWidget/index.tsx +++ b/src/features/stake/components/StakingWidget/index.tsx @@ -1,38 +1,19 @@ import { useMemo } from 'react' -import { useDarkMode } from '@/hooks/useDarkMode' import AppFrame from '@/components/safe-apps/AppFrame' import { getEmptySafeApp } from '@/components/safe-apps/utils' -import useChainId from '@/hooks/useChainId' -import useChains from '@/hooks/useChains' +import { useGetStakeWidgetUrl } from '@/features/stake/hooks/useGetStakeWidgetUrl' +import { widgetAppData } from '@/features/stake/constants' -const WIDGET_PRODUCTION_URL = 'https://safe.widget.kiln.fi/earn' -const WIDGET_TESTNET_URL = 'https://safe.widget.testnet.kiln.fi/earn' -const widgetAppData = { - url: WIDGET_PRODUCTION_URL, - name: 'Stake', - iconUrl: '/images/common/stake.svg', - chainIds: ['17000', '11155111', '1', '42161', '137', '56', '8453', '10'], -} - -const StakingWidget = () => { - const isDarkMode = useDarkMode() - let url = widgetAppData.url - const currentChainId = useChainId() - const { configs } = useChains() - const testChains = useMemo(() => configs.filter((chain) => chain.isTestnet), [configs]) - - // if currentChainId is in testChains, then set the url to the testnet version - if (testChains.some((chain) => chain.chainId === currentChainId)) { - url = WIDGET_TESTNET_URL - } +const StakingWidget = ({ asset }: { asset?: string }) => { + const url = useGetStakeWidgetUrl(asset) const appData = useMemo( () => ({ ...getEmptySafeApp(), ...widgetAppData, - url: url + `?theme=${isDarkMode ? 'dark' : 'light'}`, + url, }), - [isDarkMode, url], + [url], ) return ( diff --git a/src/features/stake/constants.ts b/src/features/stake/constants.ts index 59b8a35621..840234dd3c 100644 --- a/src/features/stake/constants.ts +++ b/src/features/stake/constants.ts @@ -1 +1,11 @@ export const STAKE_TITLE = 'Stake' + +export const WIDGET_PRODUCTION_URL = 'https://safe.widget.kiln.fi' +export const WIDGET_TESTNET_URL = 'https://safe.widget.testnet.kiln.fi' + +export const widgetAppData = { + url: WIDGET_PRODUCTION_URL, + name: STAKE_TITLE, + iconUrl: '/images/common/stake.svg', + chainIds: ['17000', '11155111', '1', '42161', '137', '56', '8453', '10'], +} diff --git a/src/features/stake/hooks/useGetStakeWidgetUrl.ts b/src/features/stake/hooks/useGetStakeWidgetUrl.ts new file mode 100644 index 0000000000..0c5dc16d58 --- /dev/null +++ b/src/features/stake/hooks/useGetStakeWidgetUrl.ts @@ -0,0 +1,27 @@ +import { useDarkMode } from '@/hooks/useDarkMode' +import useChainId from '@/hooks/useChainId' +import useChains from '@/hooks/useChains' +import { useMemo } from 'react' +import { WIDGET_PRODUCTION_URL, WIDGET_TESTNET_URL } from '@/features/stake/constants' + +export const useGetStakeWidgetUrl = (asset?: string, tab = 'earn') => { + let url = WIDGET_PRODUCTION_URL + const isDarkMode = useDarkMode() + const currentChainId = useChainId() + const { configs } = useChains() + const testChains = useMemo(() => configs.filter((chain) => chain.isTestnet), [configs]) + if (testChains.some((chain) => chain.chainId === currentChainId)) { + url = WIDGET_TESTNET_URL + } + + url = `${url}/${tab}` + + const params = new URLSearchParams() + params.append('theme', isDarkMode ? 'dark' : 'light') + + if (asset) { + params.append('asset', asset) + } + + return url + '?' + params.toString() +} diff --git a/src/features/stake/hooks/useIsSwapFeatureEnabled.ts b/src/features/stake/hooks/useIsSwapFeatureEnabled.ts new file mode 100644 index 0000000000..964072036a --- /dev/null +++ b/src/features/stake/hooks/useIsSwapFeatureEnabled.ts @@ -0,0 +1,11 @@ +import { GeoblockingContext } from '@/components/common/GeoblockingProvider' +import { useHasFeature } from '@/hooks/useChains' +import { FEATURES } from '@/utils/chains' +import { useContext } from 'react' + +const useIsStakingFeatureEnabled = () => { + const isBlockedCountry = useContext(GeoblockingContext) + return useHasFeature(FEATURES.STAKING) && !isBlockedCountry +} + +export default useIsStakingFeatureEnabled diff --git a/src/services/analytics/events/stake.ts b/src/services/analytics/events/stake.ts new file mode 100644 index 0000000000..6754939484 --- /dev/null +++ b/src/services/analytics/events/stake.ts @@ -0,0 +1,14 @@ +const STAKE_CATEGORY = 'stake' + +export const STAKE_EVENTS = { + OPEN_STAKE: { + action: 'Open stake', + category: STAKE_CATEGORY, + }, +} + +export enum STAKE_LABELS { + dashboard = 'dashboard', + sidebar = 'sidebar', + asset = 'asset', +}