diff --git a/src/components/common/BlockedAddress/index.tsx b/src/components/common/BlockedAddress/index.tsx index 3d271d4228..33e9c90d40 100644 --- a/src/components/common/BlockedAddress/index.tsx +++ b/src/components/common/BlockedAddress/index.tsx @@ -5,7 +5,15 @@ import { useRouter } from 'next/router' import Disclaimer from '@/components/common/Disclaimer' import { AppRoutes } from '@/config/routes' -export const BlockedAddress = ({ address, featureTitle }: { address: string; featureTitle: string }): ReactElement => { +export const BlockedAddress = ({ + address, + featureTitle, + onClose, +}: { + address: string + featureTitle: string + onClose?: () => void +}): ReactElement => { const theme = useTheme() const isMobile = useMediaQuery(theme.breakpoints.down('sm')) const displayAddress = address && isMobile ? shortenAddress(address) : address @@ -20,7 +28,7 @@ export const BlockedAddress = ({ address, featureTitle }: { address: string; fea title="Blocked address" subtitle={displayAddress} content={`The above address is part of the OFAC SDN list and the ${featureTitle} is unavailable for sanctioned addresses.`} - onAccept={handleAccept} + onAccept={onClose ?? handleAccept} /> ) } diff --git a/src/components/common/SafeTokenWidget/index.tsx b/src/components/common/SafeTokenWidget/index.tsx index ee8a9869d1..7082745ca8 100644 --- a/src/components/common/SafeTokenWidget/index.tsx +++ b/src/components/common/SafeTokenWidget/index.tsx @@ -11,6 +11,7 @@ import Track from '../Track' import SafeTokenIcon from '@/public/images/common/safe-token.svg' import SafePassStar from '@/public/images/common/safe-pass-star.svg' import css from './styles.module.css' +import { useSanctionedAddress } from '@/hooks/useSanctionedAddress' import useSafeAddress from '@/hooks/useSafeAddress' import { skipToken } from '@reduxjs/toolkit/query/react' import { useDarkMode } from '@/hooks/useDarkMode' @@ -43,13 +44,14 @@ const SafeTokenWidget = () => { const [allocationData, , allocationDataLoading] = useSafeTokenAllocation() const [allocation, , allocationLoading] = useSafeVotingPower(allocationData) + const sanctionedAddress = useSanctionedAddress() const { data: ownGlobalRank, isLoading: ownGlobalRankLoading } = useGetOwnGlobalCampaignRankQuery( chainId !== '1' && chainId !== '11155111' ? skipToken : { chainId, safeAddress }, { refetchOnFocus: false }, ) const tokenAddress = getSafeTokenAddress(chainId) - if (!tokenAddress) { + if (!tokenAddress || Boolean(sanctionedAddress)) { return null } diff --git a/src/components/dashboard/ActivityRewardsSection/index.tsx b/src/components/dashboard/ActivityRewardsSection/index.tsx index 0dd0035ed7..4c73fb8f2c 100644 --- a/src/components/dashboard/ActivityRewardsSection/index.tsx +++ b/src/components/dashboard/ActivityRewardsSection/index.tsx @@ -16,6 +16,7 @@ import { useHasFeature } from '@/hooks/useChains' import { FEATURES } from '@/utils/chains' import useLocalStorage from '@/services/local-storage/useLocalStorage' import ExternalLink from '@/components/common/ExternalLink' +import { useSanctionedAddress } from '@/hooks/useSanctionedAddress' const Step = ({ active, title }: { active: boolean; title: ReactNode }) => { return ( @@ -48,7 +49,10 @@ const ActivityRewardsSection = () => { const isSAPBannerEnabled = useHasFeature(FEATURES.SAP_BANNER) const governanceApp = matchingApps?.[0] - if (!governanceApp || !governanceApp?.url || !isSAPBannerEnabled || widgetHidden) return null + const sanctionedAddress = useSanctionedAddress(isSAPBannerEnabled && !widgetHidden && !!governanceApp) + + if (!governanceApp || !governanceApp?.url || !isSAPBannerEnabled || widgetHidden || Boolean(sanctionedAddress)) + return null const appUrl = getSafeAppUrl(router, governanceApp?.url) diff --git a/src/components/safe-apps/AppFrame/index.tsx b/src/components/safe-apps/AppFrame/index.tsx index de48f57e03..1d429f5776 100644 --- a/src/components/safe-apps/AppFrame/index.tsx +++ b/src/components/safe-apps/AppFrame/index.tsx @@ -4,7 +4,7 @@ import { type AddressBookItem, Methods } from '@safe-global/safe-apps-sdk' import type { ReactElement } from 'react' import { useMemo } from 'react' import { useCallback, useEffect } from 'react' -import { CircularProgress, Typography } from '@mui/material' +import { Box, CircularProgress, Typography } from '@mui/material' import { useRouter } from 'next/router' import Head from 'next/head' import type { RequestId } from '@safe-global/safe-apps-sdk' @@ -28,6 +28,11 @@ import { PermissionStatus, type SafeAppDataWithPermissions } from '@/components/ import css from './styles.module.css' import SafeAppIframe from './SafeAppIframe' import { useCustomAppCommunicator } from '@/hooks/safe-apps/useCustomAppCommunicator' +import { useSanctionedAddress } from '@/hooks/useSanctionedAddress' +import BlockedAddress from '@/components/common/BlockedAddress' +import { isSafePassApp } from '@/features/walletconnect/services/utils' + +const UNKNOWN_APP_NAME = 'Unknown Safe App' type AppFrameProps = { appUrl: string @@ -42,6 +47,8 @@ const AppFrame = ({ appUrl, allowedFeaturesList, safeAppFromManifest, isNativeEm const chainId = useChainId() const chain = useCurrentChain() const router = useRouter() + const isSafePass = isSafePassApp(appUrl) + const sanctionedAddress = useSanctionedAddress(isSafePass) const { expanded: queueBarExpanded, dismissedByUser: queueBarDismissed, @@ -118,6 +125,19 @@ const AppFrame = ({ appUrl, allowedFeaturesList, safeAppFromManifest, isNativeEm return
} + if (sanctionedAddress && isSafePass) { + return ( + <> + + {`Safe Apps - Viewer - ${remoteApp ? remoteApp.name : UNKNOWN_APP_NAME}`} + + + + + + ) + } + return ( <> {!isNativeEmbed && ( diff --git a/src/config/constants.ts b/src/config/constants.ts index 24b49de0b7..bfcdc8b63f 100644 --- a/src/config/constants.ts +++ b/src/config/constants.ts @@ -107,5 +107,6 @@ export const REDEFINE_ARTICLE = 'https://safe.mirror.xyz/rInLWZwD_sf7enjoFerj6FI export const CHAINALYSIS_OFAC_CONTRACT = '0x40c57923924b5c5c5455c48d93317139addac8fb' +export const SAFE_PASS_URL = 'community.safe.global' export const ECOSYSTEM_ID_ADDRESS = process.env.NEXT_PUBLIC_ECOSYSTEM_ID_ADDRESS || '0x0000000000000000000000000000000000000000' diff --git a/src/features/walletconnect/components/WcProposalForm/index.tsx b/src/features/walletconnect/components/WcProposalForm/index.tsx index f1189d9dff..21ac308a3e 100644 --- a/src/features/walletconnect/components/WcProposalForm/index.tsx +++ b/src/features/walletconnect/components/WcProposalForm/index.tsx @@ -4,6 +4,7 @@ import { getPeerName, getSupportedChainIds, isBlockedBridge, + isSafePassApp, isWarnedBridge, } from '@/features/walletconnect/services/utils' import { WalletConnectContext } from '@/features/walletconnect/WalletConnectContext' @@ -20,6 +21,8 @@ import { type Dispatch, type SetStateAction, useCallback, useContext, useEffect, import { CompatibilityWarning } from './CompatibilityWarning' import ProposalVerification from './ProposalVerification' import css from './styles.module.css' +import { useSanctionedAddress } from '@/hooks/useSanctionedAddress' +import BlockedAddress from '@/components/common/BlockedAddress' type ProposalFormProps = { proposal: Web3WalletTypes.SessionProposal @@ -38,13 +41,22 @@ const WcProposalForm = ({ proposal, setProposal, onApprove }: ProposalFormProps) const { isScam, origin } = proposal.verifyContext.verified const url = proposer.metadata.url || origin + const isSafePass = isSafePassApp(origin) + const sanctionedAddress = useSanctionedAddress(isSafePass) + const chainIds = useMemo(() => getSupportedChainIds(configs, proposal.params), [configs, proposal.params]) const isUnsupportedChain = !chainIds.includes(chainId) const name = getPeerName(proposer) || 'Unknown dApp' const isHighRisk = proposal.verifyContext.verified.validation === 'INVALID' || isWarnedBridge(origin, name) const isBlocked = isScam || isBlockedBridge(origin) - const disabled = !safeLoaded || isUnsupportedChain || isBlocked || (isHighRisk && !understandsRisk) || !!isLoading + const disabled = + !safeLoaded || + isUnsupportedChain || + isBlocked || + (isHighRisk && !understandsRisk) || + !!isLoading || + (Boolean(sanctionedAddress) && isSafePass) // On session reject const onReject = useCallback(async () => { @@ -134,6 +146,10 @@ const WcProposalForm = ({ proposal, setProposal, onApprove }: ProposalFormProps) /> )} + {isSafePass && sanctionedAddress && ( + + )} +
diff --git a/src/features/walletconnect/services/utils.ts b/src/features/walletconnect/services/utils.ts index 0c5d30da1f..e416588c5b 100644 --- a/src/features/walletconnect/services/utils.ts +++ b/src/features/walletconnect/services/utils.ts @@ -50,6 +50,10 @@ export const isBlockedBridge = (origin: string) => { return BlockedBridges.some((bridge) => origin.includes(bridge)) } +export const isSafePassApp = (origin: string) => { + return origin.includes('community.safe.global') +} + // Bridge defaults to same address on destination chain but allows changing it export const isWarnedBridge = (origin: string, name: string) => { return WarnedBridges.some((bridge) => origin.includes(bridge)) || WarnedBridgeNames.includes(name) diff --git a/src/hooks/__tests__/useSanctionedAddress.test.ts b/src/hooks/__tests__/useSanctionedAddress.test.ts new file mode 100644 index 0000000000..4c79e822d8 --- /dev/null +++ b/src/hooks/__tests__/useSanctionedAddress.test.ts @@ -0,0 +1,92 @@ +import { renderHook } from '@/tests/test-utils' +import { useSanctionedAddress } from '../useSanctionedAddress' +import useSafeAddress from '../useSafeAddress' +import useWallet from '../wallets/useWallet' +import { faker } from '@faker-js/faker' +import { connectedWalletBuilder } from '@/tests/builders/wallet' +import * as ofac from '@/store/ofac' +import { skipToken } from '@reduxjs/toolkit/query' + +jest.mock('@/hooks/useSafeAddress') +jest.mock('@/hooks/wallets/useWallet') + +describe('useSanctionedAddress', () => { + const mockUseSafeAddress = useSafeAddress as jest.MockedFunction + const mockUseWallet = useWallet as jest.MockedFunction + + it('should return undefined without safeAddress and wallet', () => { + const { result } = renderHook(() => useSanctionedAddress()) + expect(result.current).toBeUndefined() + }) + + it('should return undefined if neither safeAddress nor wallet are sanctioned', () => { + mockUseSafeAddress.mockReturnValue(faker.finance.ethereumAddress()) + mockUseWallet.mockReturnValue(connectedWalletBuilder().build()) + + jest.spyOn(ofac, 'useGetIsSanctionedQuery').mockReturnValue({ data: false, refetch: jest.fn() }) + + const { result } = renderHook(() => useSanctionedAddress()) + expect(result.current).toBeUndefined() + }) + + it('should return safeAddress if it is sanctioned', () => { + const mockSafeAddress = faker.finance.ethereumAddress() + const mockWalletAddress = faker.finance.ethereumAddress() + mockUseSafeAddress.mockReturnValue(mockSafeAddress) + mockUseWallet.mockReturnValue(connectedWalletBuilder().with({ address: mockWalletAddress }).build()) + + jest + .spyOn(ofac, 'useGetIsSanctionedQuery') + .mockImplementation((address) => ({ data: address === mockSafeAddress, refetch: jest.fn() })) + + const { result } = renderHook(() => useSanctionedAddress()) + expect(result.current).toEqual(mockSafeAddress) + }) + + it('should return walletAddress if it is sanctioned', () => { + const mockSafeAddress = faker.finance.ethereumAddress() + const mockWalletAddress = faker.finance.ethereumAddress() + mockUseSafeAddress.mockReturnValue(mockSafeAddress) + mockUseWallet.mockReturnValue(connectedWalletBuilder().with({ address: mockWalletAddress }).build()) + + jest + .spyOn(ofac, 'useGetIsSanctionedQuery') + .mockImplementation((address) => ({ data: address === mockWalletAddress, refetch: jest.fn() })) + + const { result } = renderHook(() => useSanctionedAddress()) + expect(result.current).toEqual(mockWalletAddress) + }) + + it('should return safeAddress if both are sanctioned', () => { + const mockSafeAddress = faker.finance.ethereumAddress() + const mockWalletAddress = faker.finance.ethereumAddress() + mockUseSafeAddress.mockReturnValue(mockSafeAddress) + mockUseWallet.mockReturnValue(connectedWalletBuilder().with({ address: mockWalletAddress }).build()) + + jest.spyOn(ofac, 'useGetIsSanctionedQuery').mockImplementation((arg) => { + if (arg === skipToken) { + return { data: undefined, refetch: jest.fn() } + } + return { data: true, refetch: jest.fn() } + }) + const { result } = renderHook(() => useSanctionedAddress()) + expect(result.current).toEqual(mockSafeAddress) + }) + + it('should skip sanction check if isRestricted is false', () => { + const mockSafeAddress = faker.finance.ethereumAddress() + const mockWalletAddress = faker.finance.ethereumAddress() + mockUseSafeAddress.mockReturnValue(mockSafeAddress) + mockUseWallet.mockReturnValue(connectedWalletBuilder().with({ address: mockWalletAddress }).build()) + + jest.spyOn(ofac, 'useGetIsSanctionedQuery').mockImplementation((arg) => { + if (arg === skipToken) { + return { data: undefined, refetch: jest.fn() } + } + return { data: true, refetch: jest.fn() } + }) + + const { result } = renderHook(() => useSanctionedAddress(false)) + expect(result.current).toBeUndefined() + }) +}) diff --git a/src/hooks/useSanctionedAddress.ts b/src/hooks/useSanctionedAddress.ts new file mode 100644 index 0000000000..ee6257e57a --- /dev/null +++ b/src/hooks/useSanctionedAddress.ts @@ -0,0 +1,29 @@ +import { useGetIsSanctionedQuery } from '@/store/ofac' +import useSafeAddress from './useSafeAddress' +import useWallet from './wallets/useWallet' +import { skipToken } from '@reduxjs/toolkit/query/react' + +/** + * Checks if the opened Safe or the connected wallet are sanctioned and returns the sanctioned address. + * @param isRestricted the check is only performed if isRestricted is true. + * @returns address of sanctioned wallet or Safe + */ +export const useSanctionedAddress = (isRestricted = true) => { + const wallet = useWallet() + const safeAddress = useSafeAddress() + + const { data: isWalletSanctioned } = useGetIsSanctionedQuery(isRestricted && wallet ? wallet.address : skipToken) + + const { data: isSafeSanctioned } = useGetIsSanctionedQuery( + isRestricted && safeAddress !== '' ? safeAddress : skipToken, + ) + + if (isSafeSanctioned) { + return safeAddress + } + if (isWalletSanctioned) { + return wallet?.address + } + + return undefined +} diff --git a/src/store/ofac.ts b/src/store/ofac.ts index 0982ce5c9d..9e2cd9e32b 100644 --- a/src/store/ofac.ts +++ b/src/store/ofac.ts @@ -44,7 +44,7 @@ export const ofacApi = createApi({ reducerPath: 'ofacApi', baseQuery: noopBaseQuery, endpoints: (builder) => ({ - getIsSanctioned: builder.query({ + getIsSanctioned: builder.query({ async queryFn(address, { getState }) { const state = getState() const chain = selectChainById(state as RootState, chains.eth) @@ -59,7 +59,7 @@ export const ofacApi = createApi({ const isAddressBlocked: boolean = await contract['isSanctioned'](address) return { data: isAddressBlocked } } catch (error) { - return { error: { status: 'CUSTOM_ERROR', data: (error as Error).message } } + return { error } } }, keepUnusedDataFor: 24 * 60 * 60, // 24 hours