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',
+}