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(safenet): Multi-recipient token transfers #4788

Merged
merged 21 commits into from
Jan 22, 2025
Merged
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
15 changes: 15 additions & 0 deletions apps/web/public/images/apps/csv-airdrop-app-logo.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
49 changes: 15 additions & 34 deletions apps/web/public/images/safenet-bright.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
20 changes: 10 additions & 10 deletions apps/web/src/components/common/BuyCryptoButton/index.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,21 @@
import { useTheme } from '@mui/material/styles'
import { usePathname, useSearchParams } from 'next/navigation'
import Link, { type LinkProps } from 'next/link'
import { Alert, Box, Button, ButtonBase, Typography, useMediaQuery } from '@mui/material'
import AddIcon from '@mui/icons-material/Add'
import { SafeAppsTag } from '@/config/constants'
import { AppRoutes } from '@/config/routes'
import { useRemoteSafeApps } from '@/hooks/safe-apps/useRemoteSafeApps'
import RampLogo from '@/public/images/common/ramp_logo.svg'
import { OVERVIEW_EVENTS } from '@/services/analytics'
import madProps from '@/utils/mad-props'
import { type ReactNode, useMemo } from 'react'
import AddIcon from '@mui/icons-material/Add'
import ChevronRightRoundedIcon from '@mui/icons-material/ChevronRightRounded'
import { Alert, Box, Button, ButtonBase, Typography, useMediaQuery } from '@mui/material'
import { useTheme } from '@mui/material/styles'
import Link, { type LinkProps } from 'next/link'
import { usePathname, useSearchParams } from 'next/navigation'
import { useMemo, type ReactNode } from 'react'
import Track from '../Track'
import { OVERVIEW_EVENTS } from '@/services/analytics'
import RampLogo from '@/public/images/common/ramp_logo.svg'
import css from './styles.module.css'
import ChevronRightRoundedIcon from '@mui/icons-material/ChevronRightRounded'

const useOnrampAppUrl = (): string | undefined => {
const [onrampApps] = useRemoteSafeApps(SafeAppsTag.ONRAMP)
const [onrampApps] = useRemoteSafeApps({ tag: SafeAppsTag.ONRAMP })
return onrampApps?.[0]?.url
}

Expand Down
14 changes: 12 additions & 2 deletions apps/web/src/components/common/ProgressBar/index.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,18 @@
import { LinearProgress } from '@mui/material'
import useIsSafenetEnabled from '@/hooks/useIsSafenetEnabled'
import type { LinearProgressProps } from '@mui/material'
import { LinearProgress } from '@mui/material'

import css from './styles.module.css'

export const ProgressBar = (props: LinearProgressProps) => {
return <LinearProgress className={css.progressBar} variant="determinate" color="secondary" {...props} />
const isSafenetEnabled = useIsSafenetEnabled()

return (
<LinearProgress
className={isSafenetEnabled ? css.safenetProgressBar : css.progressBar}
variant="determinate"
color="secondary"
{...props}
/>
)
}
19 changes: 16 additions & 3 deletions apps/web/src/components/common/ProgressBar/styles.module.css
Original file line number Diff line number Diff line change
@@ -1,16 +1,29 @@
.progressBar {
.progressBar,
.safenetProgressBar {
height: 6px;
border-radius: 6px;
}

.progressBar,
.safenetProgressBar {
background-color: var(--color-border-light);
}

.progressBar :global .MuiLinearProgress-bar,
.safenetProgressBar :global .MuiLinearProgress-bar {
border-radius: 6px;
}

.progressBar :global .MuiLinearProgress-bar {
background: var(--color-primary-main);
border-radius: 6px;
}
.safenetProgressBar :global .MuiLinearProgress-bar {
background: linear-gradient(90deg, #32f970 0%, #eed509 100%);
}

@media (max-width: 599.95px) {
.progressBar {
.progressBar,
.safenetProgressBar {
border-radius: 0;
}
}
42 changes: 27 additions & 15 deletions apps/web/src/components/common/TokenAmountInput/index.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import NumberField from '@/components/common/NumberField'
import { AutocompleteItem } from '@/components/tx-flow/flows/TokenTransfer/CreateTokenTransfer'
import { safeFormatUnits } from '@/utils/formatters'
import { validateDecimalLength, validateLimitedAmount } from '@/utils/validation'
import { Button, Divider, FormControl, InputLabel, MenuItem, TextField } from '@mui/material'
import { type SafeBalanceResponse } from '@safe-global/safe-gateway-typescript-sdk'
import css from './styles.module.css'
import NumberField from '@/components/common/NumberField'
import { validateDecimalLength, validateLimitedAmount } from '@/utils/validation'
import { AutocompleteItem } from '@/components/tx-flow/flows/TokenTransfer/CreateTokenTransfer'
import { useFormContext } from 'react-hook-form'
import classNames from 'classnames'
import { useCallback } from 'react'
import { useCallback, useMemo } from 'react'
import { get, useFormContext } from 'react-hook-form'
import css from './styles.module.css'

export enum TokenAmountFields {
tokenAddress = 'tokenAddress',
Expand All @@ -19,22 +19,32 @@ const TokenAmountInput = ({
selectedToken,
maxAmount,
validate,
groupName,
}: {
balances: SafeBalanceResponse['items']
selectedToken: SafeBalanceResponse['items'][number] | undefined
maxAmount?: bigint
validate?: (value: string) => string | undefined
groupName?: string
}) => {
const fields = useMemo(
() => ({
tokenAddress: groupName ? `${groupName}.${TokenAmountFields.tokenAddress}` : TokenAmountFields.tokenAddress,
amount: groupName ? `${groupName}.${TokenAmountFields.amount}` : TokenAmountFields.amount,
}),
[groupName],
)

const {
formState: { errors },
register,
resetField,
watch,
setValue,
} = useFormContext<{ [TokenAmountFields.tokenAddress]: string; [TokenAmountFields.amount]: string }>()
} = useFormContext()

const tokenAddress = watch(TokenAmountFields.tokenAddress)
const isAmountError = !!errors[TokenAmountFields.tokenAddress] || !!errors[TokenAmountFields.amount]
const tokenAddress = watch(fields.tokenAddress)
const isAmountError = !!get(errors, fields.tokenAddress) || !!get(errors, fields.amount)

const validateAmount = useCallback(
(value: string) => {
Expand All @@ -47,10 +57,10 @@ const TokenAmountInput = ({
const onMaxAmountClick = useCallback(() => {
if (!selectedToken || maxAmount === undefined) return

setValue(TokenAmountFields.amount, safeFormatUnits(maxAmount.toString(), selectedToken.tokenInfo.decimals), {
setValue(fields.amount, safeFormatUnits(maxAmount.toString(), selectedToken.tokenInfo.decimals), {
shouldValidate: true,
})
}, [maxAmount, selectedToken, setValue])
}, [maxAmount, selectedToken, setValue, fields])

return (
<FormControl
Expand All @@ -59,7 +69,9 @@ const TokenAmountInput = ({
fullWidth
>
<InputLabel shrink required className={css.label}>
{errors[TokenAmountFields.tokenAddress]?.message || errors[TokenAmountFields.amount]?.message || 'Amount'}
{get(errors, fields.tokenAddress)?.message?.toString() ||
get(errors, fields.amount)?.message?.toString() ||
'Amount'}
</InputLabel>
<div className={css.inputs}>
<NumberField
Expand All @@ -76,7 +88,7 @@ const TokenAmountInput = ({
className={css.amount}
required
placeholder="0"
{...register(TokenAmountFields.amount, {
{...register(fields.amount, {
required: true,
validate: validate ?? validateAmount,
})}
Expand All @@ -90,10 +102,10 @@ const TokenAmountInput = ({
disableUnderline: true,
}}
className={css.select}
{...register(TokenAmountFields.tokenAddress, {
{...register(fields.tokenAddress, {
required: true,
onChange: () => {
resetField(TokenAmountFields.amount, { defaultValue: '' })
resetField(fields.amount, { defaultValue: '' })
},
})}
value={tokenAddress}
Expand Down
6 changes: 3 additions & 3 deletions apps/web/src/components/common/TokenIcon/index.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import Image from 'next/image'
import { useMemo, type ReactElement } from 'react'
import ImageFallback from '../ImageFallback'
import css from './styles.module.css'
import Image from 'next/image'

const FALLBACK_ICON = '/images/common/token-placeholder.svg'
const COINGECKO_THUMB = '/thumb/'
Expand All @@ -25,7 +25,7 @@ const TokenIcon = ({
}, [logoUri])

return (
<div className={css.container}>
<div className={`${css.container} ${safenet && css.additionalMargin}`}>
<ImageFallback
src={src}
alt={tokenSymbol}
Expand All @@ -37,7 +37,7 @@ const TokenIcon = ({
/>
{safenet && (
<div className={css.safenetContainer}>
<Image src="/images/safenet-bright.svg" alt="Safenet Logo" width={12} height={12} />
<Image src="/images/safenet-bright.svg" alt="Safenet Logo" width={16} height={16} />
</div>
)}
</div>
Expand Down
10 changes: 7 additions & 3 deletions apps/web/src/components/common/TokenIcon/styles.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@
position: relative;
}

.additionalMargin {
margin-right: 6px;
}

.image {
display: block;
width: auto;
Expand All @@ -15,9 +19,9 @@

.safenetContainer {
position: absolute;
top: -40%;
right: -40%;
background-color: #1c1c1c;
top: -10px;
right: -10px;
background-color: var(--color-background-paper);
border-radius: 50%;
padding: 4px;
display: flex;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,26 +1,26 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { Typography, Card, Box, Link, SvgIcon } from '@mui/material'
import { WidgetBody } from '@/components/dashboard/styled'
import css from './styles.module.css'
import { useBrowserPermissions } from '@/hooks/safe-apps/permissions'
import { useRemoteSafeApps } from '@/hooks/safe-apps/useRemoteSafeApps'
import { DISCORD_URL, SafeAppsTag } from '@/config/constants'
import { useDarkMode } from '@/hooks/useDarkMode'
import { OpenInNew } from '@mui/icons-material'
import NetworkError from '@/public/images/common/network-error.svg'
import useChainId from '@/hooks/useChainId'
import InfiniteScroll from '@/components/common/InfiniteScroll'
import { getSafeTokenAddress } from '@/components/common/SafeTokenWidget'
import { WidgetBody } from '@/components/dashboard/styled'
import SafeAppIframe from '@/components/safe-apps/AppFrame/SafeAppIframe'
import type { UseAppCommunicatorHandlers } from '@/components/safe-apps/AppFrame/useAppCommunicator'
import useAppCommunicator from '@/components/safe-apps/AppFrame/useAppCommunicator'
import { useCurrentChain } from '@/hooks/useChains'
import useGetSafeInfo from '@/components/safe-apps/AppFrame/useGetSafeInfo'
import type { SafeAppData } from '@safe-global/safe-gateway-typescript-sdk'
import { getOrigin } from '@/components/safe-apps/utils'
import { DISCORD_URL, SafeAppsTag } from '@/config/constants'
import { useBrowserPermissions } from '@/hooks/safe-apps/permissions'
import { useRemoteSafeApps } from '@/hooks/safe-apps/useRemoteSafeApps'
import useAsync from '@/hooks/useAsync'
import useChainId from '@/hooks/useChainId'
import { useCurrentChain } from '@/hooks/useChains'
import { useDarkMode } from '@/hooks/useDarkMode'
import useSafeInfo from '@/hooks/useSafeInfo'
import NetworkError from '@/public/images/common/network-error.svg'
import { fetchSafeAppFromManifest } from '@/services/safe-apps/manifest'
import useAsync from '@/hooks/useAsync'
import { getOrigin } from '@/components/safe-apps/utils'
import InfiniteScroll from '@/components/common/InfiniteScroll'
import { OpenInNew } from '@mui/icons-material'
import { Box, Card, Link, SvgIcon, Typography } from '@mui/material'
import type { SafeAppData } from '@safe-global/safe-gateway-typescript-sdk'
import { useCallback, useEffect, useRef, useState } from 'react'
import css from './styles.module.css'

// A fallback component when the Safe App fails to load
const WidgetLoadErrorFallback = () => (
Expand Down Expand Up @@ -100,7 +100,7 @@ const MiniAppFrame = ({ app, title }: { app: SafeAppData; title: string }) => {

// Entire section for the governance widgets
const GovernanceSection = () => {
const [matchingApps, errorFetchingGovernanceSafeApp] = useRemoteSafeApps(SafeAppsTag.SAFE_GOVERNANCE_APP)
const [matchingApps, errorFetchingGovernanceSafeApp] = useRemoteSafeApps({ tag: SafeAppsTag.SAFE_GOVERNANCE_APP })
const governanceApp = matchingApps?.[0]
const fetchingSafeGovernanceApp = !governanceApp && !errorFetchingGovernanceSafeApp
const { safeLoading } = useSafeInfo()
Expand Down
Loading
Loading