Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat: stake dashboard widget #4192

Closed
wants to merge 7 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion next-env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
/// <reference types="next/image-types/global" />

// 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.
2 changes: 1 addition & 1 deletion public/images/common/stake.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
10 changes: 9 additions & 1 deletion src/components/balances/AssetsTable/index.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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: {
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -130,6 +134,10 @@ const AssetsTable = ({

<Typography>{item.tokenInfo.name}</Typography>

{isStakingFeatureEnabled && item.tokenInfo.type === TokenType.NATIVE_TOKEN && (
<StakeButton tokenInfo={item.tokenInfo} trackingLabel={STAKE_LABELS.asset} />
)}

{!isNative && <TokenExplorerLink address={item.tokenInfo.address} />}
</div>
),
Expand Down
5 changes: 5 additions & 0 deletions src/components/dashboard/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'))

Expand Down Expand Up @@ -68,6 +69,10 @@ const Dashboard = (): ReactElement => {
<PendingTxsList />
</Grid>

<Grid item xs={12}>
<StakingDashboardWidget />
</Grid>

{showSafeApps && (
<Grid item xs={12} lg={showRecoveryWidget ? 12 : 6}>
<FeaturedApps stackedLayout={!showRecoveryWidget} />
Expand Down
2 changes: 1 addition & 1 deletion src/components/tx/FieldsGrid/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<Grid container alignItems="center" gap={1} sx={wrap}>
<Grid item minWidth={minWidth}>
Expand Down
58 changes: 58 additions & 0 deletions src/features/stake/components/StakeButton/index.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<CheckWallet allowSpendingLimit={!!spendingLimit}>
{(isOk) => (
<Track {...STAKE_EVENTS.OPEN_STAKE} label={trackingLabel}>
<Button
data-testid="stake-btn"
aria-label="Stake"
variant="text"
color="info"
size="small"
startIcon={<StakeIcon />}
onClick={() => {
router.push({
pathname: AppRoutes.stake,
query: {
...router.query,
asset: `${chain?.shortName}_${
tokenInfo.type === TokenType.NATIVE_TOKEN ? 'NATIVE_TOKEN' : tokenInfo.address
}`,
},
})
}}
disabled={!isOk}
>
Stake
</Button>
</Track>
)}
</CheckWallet>
)
}

export default StakeButton
56 changes: 56 additions & 0 deletions src/features/stake/components/StakeDashboardWidget/index.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<div className={css.title}>
<Typography component="h2" variant="subtitle1" fontWeight={700} mb={2}>
Stake
</Typography>

<ViewAllLink url={stakeUrl} text="Go to staking" />
</div>

<Typography component="p" variant="body1" fontWeight={400} mb={2}>
You have enough ETH to stake. Stake your ETH to earn rewards.
</Typography>
</>
)
}

return null
}

export default StakingDashboardWidget
5 changes: 4 additions & 1 deletion src/features/stake/components/StakePage/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 ? (
<StakingWidget />
<StakingWidget asset={String(asset ? asset : '')} />
) : (
<Stack direction="column" alignItems="center" justifyContent="center" flex={1}>
<Disclaimer
Expand Down
12 changes: 8 additions & 4 deletions src/features/stake/components/StakingConfirmationTx/Deposit.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { Typography, Stack, Box } from '@mui/material'
import { Box, Stack, Typography } from '@mui/material'
import FieldsGrid from '@/components/tx/FieldsGrid'
import type { StakingTxDepositInfo } from '@safe-global/safe-gateway-typescript-sdk'
import {
ConfirmationViewTypes,
type NativeStakingDepositConfirmationView,
NativeStakingStatus,
} from '@safe-global/safe-gateway-typescript-sdk'
import ConfirmationOrderHeader from '@/components/tx/ConfirmationOrder/ConfirmationOrderHeader'
import { formatVisualAmount, formatDurationFromSeconds } from '@/utils/formatters'
import { formatDurationFromSeconds, formatVisualAmount } from '@/utils/formatters'
import { formatCurrency } from '@/utils/formatNumber'
import StakingStatus from '@/features/stake/components/StakingStatus'

Expand Down Expand Up @@ -68,8 +69,11 @@ const StakingConfirmationTxDeposit = ({ order }: StakingOrderConfirmationViewPro
<FieldsGrid title="Validators">{order.numValidators}</FieldsGrid>
)}

<FieldsGrid title="Active in">{formatDurationFromSeconds(order.estimatedEntryTime)}</FieldsGrid>
<FieldsGrid title="Rewards">Approx. every 5 days after 4 days from activation</FieldsGrid>
{!isOrder && order.status === NativeStakingStatus.VALIDATION_STARTED ? null : (
<FieldsGrid title="Active in">{formatDurationFromSeconds(order.estimatedEntryTime)}</FieldsGrid>
)}

<FieldsGrid title="Rewards">Approx. every 5 days after activation</FieldsGrid>

{!isOrder && (
<FieldsGrid title="Status">
Expand Down
39 changes: 37 additions & 2 deletions src/features/stake/components/StakingConfirmationTx/Exit.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -31,7 +32,41 @@ const StakingConfirmationTxExit = ({ order }: StakingOrderConfirmationViewProps)
]}
/>

<FieldsGrid title="Withdraw in">Up to {withdrawIn}</FieldsGrid>
<FieldsGrid
title={
<>
Withdraw in
<Tooltip
title={
<>
Withdrawal time is the sum of:
<ul>
<li>Time until your validator is successfully exited after the withdraw request</li>
<li>Time for a stake to receive Consensus rewards on the execution layer</li>
</ul>
</>
}
arrow
placement="top"
>
<span>
<SvgIcon
component={InfoIcon}
inheritViewBox
color="border"
fontSize="small"
sx={{
verticalAlign: 'middle',
ml: 0.5,
}}
/>
</span>
</Tooltip>
</>
}
>
Up to {withdrawIn}
</FieldsGrid>

<Typography variant="body2" color="text.secondary" mt={2}>
The selected amount and any rewards will be withdrawn from Dedicated Staking for ETH after the validator exit.
Expand Down
5 changes: 4 additions & 1 deletion src/features/stake/components/StakingTxExitDetails/index.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -22,7 +23,9 @@ const StakingTxExitDetails = ({ info }: { info: StakingTxExitInfo; txData?: Tran
{info.numValidators} Validator{info.numValidators > 1 ? 's' : ''}
</FieldsGrid>

<FieldsGrid title="Est. exit time">Up to {withdrawIn}</FieldsGrid>
{info.status !== NativeStakingExitStatus.READY_TO_WITHDRAW && (
<FieldsGrid title="Est. exit time">Up to {withdrawIn}</FieldsGrid>
)}

<FieldsGrid title="Exit">
<StakingStatus status={info.status} />
Expand Down
31 changes: 6 additions & 25 deletions src/features/stake/components/StakingWidget/index.tsx
Original file line number Diff line number Diff line change
@@ -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 (
Expand Down
10 changes: 10 additions & 0 deletions src/features/stake/constants.ts
Original file line number Diff line number Diff line change
@@ -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'],
}
Loading