From eec68b8c39115d468975bb841fd027b89d755691 Mon Sep 17 00:00:00 2001 From: James Mealy Date: Mon, 15 Apr 2024 09:42:22 +0200 Subject: [PATCH 1/5] Feat: update voting power calculation to include locking contract tokens (#3543) * update voting power calculation to include tokens in locking contract * fix unit tests * keep balance as bigint --- .../common/SafeTokenWidget/index.tsx | 6 +++- src/config/constants.ts | 5 +++ .../__tests__/useSafeTokenAllocation.test.ts | 31 ++++++++++------ src/hooks/useSafeTokenAllocation.ts | 35 +++++++++++++++---- 4 files changed, 60 insertions(+), 17 deletions(-) diff --git a/src/components/common/SafeTokenWidget/index.tsx b/src/components/common/SafeTokenWidget/index.tsx index 234f1d3b84..4cffcb7a88 100644 --- a/src/components/common/SafeTokenWidget/index.tsx +++ b/src/components/common/SafeTokenWidget/index.tsx @@ -1,4 +1,4 @@ -import { IS_PRODUCTION, SAFE_TOKEN_ADDRESSES } from '@/config/constants' +import { IS_PRODUCTION, SAFE_TOKEN_ADDRESSES, SAFE_LOCKING_ADDRESS } from '@/config/constants' import { AppRoutes } from '@/config/routes' import useChainId from '@/hooks/useChainId' import useSafeTokenAllocation, { useSafeVotingPower, type Vesting } from '@/hooks/useSafeTokenAllocation' @@ -24,6 +24,10 @@ export const getSafeTokenAddress = (chainId: string): string | undefined => { return SAFE_TOKEN_ADDRESSES[chainId] } +export const getSafeLockingAddress = (chainId: string): string | undefined => { + return SAFE_LOCKING_ADDRESS[chainId] +} + const canRedeemSep5Airdrop = (allocation?: Vesting[]): boolean => { const sep5Allocation = allocation?.find(({ tag }) => tag === 'user_v2') diff --git a/src/config/constants.ts b/src/config/constants.ts index 208ba6ce61..600625e354 100644 --- a/src/config/constants.ts +++ b/src/config/constants.ts @@ -32,6 +32,11 @@ export const SAFE_TOKEN_ADDRESSES: { [chainId: string]: string } = { [chains.sep]: '0xd16d9C09d13E9Cf77615771eADC5d51a1Ae92a26', } +export const SAFE_LOCKING_ADDRESS: { [chainId: string]: string } = { + [chains.eth]: '0x0a7CB434f96f65972D46A5c1A64a9654dC9959b2', + [chains.sep]: '0xb161ccb96b9b817F9bDf0048F212725128779DE9', +} + // Safe Apps export const SAFE_APPS_INFURA_TOKEN = process.env.NEXT_PUBLIC_SAFE_APPS_INFURA_TOKEN || INFURA_TOKEN export const SAFE_APPS_THIRD_PARTY_COOKIES_CHECK_URL = 'https://third-party-cookies-check.gnosis-safe.com' diff --git a/src/hooks/__tests__/useSafeTokenAllocation.test.ts b/src/hooks/__tests__/useSafeTokenAllocation.test.ts index 5fbd4f5f8b..e029f067ad 100644 --- a/src/hooks/__tests__/useSafeTokenAllocation.test.ts +++ b/src/hooks/__tests__/useSafeTokenAllocation.test.ts @@ -279,15 +279,19 @@ describe('Allocations', () => { }) }) - it('should return balance if no allocation exists', async () => { + it('should return total balance of tokens held and tokens in locking contract if no allocation exists', async () => { jest.spyOn(web3, 'getWeb3ReadOnly').mockImplementation( () => ({ call: (transaction: any) => { - const sigHash = keccak256(toUtf8Bytes('balanceOf(address)')).slice(0, 10) + const balanceOfSigHash = keccak256(toUtf8Bytes('balanceOf(address)')).slice(0, 10) + const lockingBalanceSigHash = keccak256(toUtf8Bytes('getUserTokenBalance(address)')).slice(0, 10) - if (transaction.data?.startsWith(sigHash)) { - return Promise.resolve(parseEther('100').toString(16)) + if (transaction.data?.startsWith(balanceOfSigHash)) { + return Promise.resolve('0x' + parseEther('100').toString(16)) + } + if (transaction.data?.startsWith(lockingBalanceSigHash)) { + return Promise.resolve('0x' + parseEther('100').toString(16)) } return Promise.resolve('0x') }, @@ -295,23 +299,26 @@ describe('Allocations', () => { ) const { result } = renderHook(() => useSafeVotingPower()) - await waitFor(() => { - expect(result.current[0] === parseEther('100')).toBeTruthy() + expect(result.current[0] === parseEther('200')).toBeTruthy() expect(result.current[1]).toBeFalsy() }) }) - test('formula: allocation - claimed + balance', async () => { + test('formula: allocation - claimed + token balance + locking balance', async () => { jest.spyOn(web3, 'getWeb3ReadOnly').mockImplementation( () => ({ call: (transaction: any) => { const balanceOfSigHash = keccak256(toUtf8Bytes('balanceOf(address)')).slice(0, 10) + const lockingBalanceSigHash = keccak256(toUtf8Bytes('getUserTokenBalance(address)')).slice(0, 10) if (transaction.data?.startsWith(balanceOfSigHash)) { return Promise.resolve('0x' + BigInt('400').toString(16)) } + if (transaction.data?.startsWith(lockingBalanceSigHash)) { + return Promise.resolve('0x' + BigInt('200').toString(16)) + } return Promise.resolve('0x') }, } as any), @@ -338,20 +345,24 @@ describe('Allocations', () => { const { result } = renderHook(() => useSafeVotingPower(mockAllocation)) await waitFor(() => { - expect(Number(result.current[0])).toEqual(2000 - 1000 + 400) + expect(Number(result.current[0])).toEqual(2000 - 1000 + 400 + 200) expect(result.current[1]).toBeFalsy() }) }) - test('formula: allocation - claimed + balance, everything claimed and no balance', async () => { + test('formula: allocation - claimed + token balance + locking balance, everything claimed and no balance', async () => { jest.spyOn(web3, 'getWeb3ReadOnly').mockImplementation( () => ({ call: (transaction: any) => { const balanceOfSigHash = keccak256(toUtf8Bytes('balanceOf(address)')).slice(0, 10) + const lockingBalanceSigHash = keccak256(toUtf8Bytes('getUserTokenBalance(address)')).slice(0, 10) if (transaction.data?.startsWith(balanceOfSigHash)) { - return Promise.resolve(BigInt('0').toString(16)) + return Promise.resolve('0x' + BigInt('0').toString(16)) + } + if (transaction.data?.startsWith(lockingBalanceSigHash)) { + return Promise.resolve('0x' + BigInt('0').toString(16)) } return Promise.resolve('0x') }, diff --git a/src/hooks/useSafeTokenAllocation.ts b/src/hooks/useSafeTokenAllocation.ts index a3753d05aa..a8cdf4d931 100644 --- a/src/hooks/useSafeTokenAllocation.ts +++ b/src/hooks/useSafeTokenAllocation.ts @@ -1,4 +1,4 @@ -import { getSafeTokenAddress } from '@/components/common/SafeTokenWidget' +import { getSafeTokenAddress, getSafeLockingAddress } from '@/components/common/SafeTokenWidget' import { cgwDebugStorage } from '@/components/sidebar/DebugToggle' import { IS_PRODUCTION } from '@/config/constants' import { ZERO_ADDRESS } from '@safe-global/protocol-kit/dist/src/utils/constants' @@ -40,6 +40,9 @@ const airdropInterface = new Interface([ 'function vestings(bytes32) public returns (address account, uint8 curveType,bool managed, uint16 durationWeeks, uint64 startDate, uint128 amount, uint128 amountClaimed, uint64 pausingDate,bool cancelled)', ]) const tokenInterface = new Interface(['function balanceOf(address _owner) public view returns (uint256 balance)']) +const safeLockingInterface = new Interface([ + 'function getUserTokenBalance(address holder) external view returns (uint96 amount)', +]) export const _getRedeemDeadline = memoize( async (allocation: VestingData, web3ReadOnly: JsonRpcProvider): Promise => { @@ -136,6 +139,20 @@ const fetchTokenBalance = async (chainId: string, safeAddress: string): Promise< throw Error(`Error fetching Safe Token balance: ${err}`) } } +const fetchLockingContractBalance = async (chainId: string, safeAddress: string): Promise => { + try { + const web3ReadOnly = getWeb3ReadOnly() + const safeLockingAddress = getSafeLockingAddress(chainId) + if (!safeLockingAddress || !web3ReadOnly) return '0' + + return await web3ReadOnly.call({ + to: safeLockingAddress, + data: safeLockingInterface.encodeFunctionData('getUserTokenBalance', [safeAddress]), + }) + } catch (err) { + throw Error(`Error fetching Safe Token balance in locking contract: ${err}`) + } +} /** * The Safe token allocation is equal to the voting power. @@ -145,21 +162,27 @@ export const useSafeVotingPower = (allocationData?: Vesting[]): AsyncResult(() => { + const [balance, balanceError, balanceLoading] = useAsync(() => { if (!safeAddress) return - return fetchTokenBalance(chainId, safeAddress) + const tokenBalancePromise = fetchTokenBalance(chainId, safeAddress) + const lockingContractBalancePromise = fetchLockingContractBalance(chainId, safeAddress) + return Promise.all([tokenBalancePromise, lockingContractBalancePromise]).then( + ([tokenBalance, lockingContractBalance]) => { + return BigInt(tokenBalance) + BigInt(lockingContractBalance) + }, + ) // If the history tag changes we could have claimed / redeemed tokens // eslint-disable-next-line react-hooks/exhaustive-deps }, [chainId, safeAddress, safe.txHistoryTag]) const allocation = useMemo(() => { - if (!balance) { + if (balance === undefined) { return } // Return current balance if no allocation exists if (!allocationData) { - return BigInt(balance.startsWith('0x') ? balance : '0x' + balance) + return balance } const tokensInVesting = allocationData.reduce( @@ -168,7 +191,7 @@ export const useSafeVotingPower = (allocationData?: Vesting[]): AsyncResult Date: Thu, 18 Apr 2024 14:27:58 +0200 Subject: [PATCH 2/5] Feat: Add activity app banner on dashboard (#3480) --------- Co-authored-by: schmanu --- public/images/common/asterix.svg | 9 ++ public/images/common/safe-pass-logo.svg | 13 +++ .../ActivityRewardsSection/index.tsx | 110 ++++++++++++++++++ .../ActivityRewardsSection/styles.module.css | 84 +++++++++++++ src/components/dashboard/index.tsx | 7 +- .../safe-apps/AppFrame/useFromAppAnalytics.ts | 3 + src/services/analytics/events/overview.ts | 5 + src/utils/chains.ts | 1 + 8 files changed, 230 insertions(+), 2 deletions(-) create mode 100644 public/images/common/asterix.svg create mode 100644 public/images/common/safe-pass-logo.svg create mode 100644 src/components/dashboard/ActivityRewardsSection/index.tsx create mode 100644 src/components/dashboard/ActivityRewardsSection/styles.module.css diff --git a/public/images/common/asterix.svg b/public/images/common/asterix.svg new file mode 100644 index 0000000000..ce7d4b3fda --- /dev/null +++ b/public/images/common/asterix.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/public/images/common/safe-pass-logo.svg b/public/images/common/safe-pass-logo.svg new file mode 100644 index 0000000000..bafd12841e --- /dev/null +++ b/public/images/common/safe-pass-logo.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/src/components/dashboard/ActivityRewardsSection/index.tsx b/src/components/dashboard/ActivityRewardsSection/index.tsx new file mode 100644 index 0000000000..f0760566de --- /dev/null +++ b/src/components/dashboard/ActivityRewardsSection/index.tsx @@ -0,0 +1,110 @@ +import type { ReactNode } from 'react' +import { Typography, Card, SvgIcon, Grid, Button, Box } from '@mui/material' +import css from './styles.module.css' +import { useRemoteSafeApps } from '@/hooks/safe-apps/useRemoteSafeApps' +import { SafeAppsTag } from '@/config/constants' +import SafePass from '@/public/images/common/safe-pass-logo.svg' +import Asterix from '@/public/images/common/asterix.svg' + +import classNames from 'classnames' +import { useDarkMode } from '@/hooks/useDarkMode' +import { useRouter } from 'next/router' +import { getSafeAppUrl } from '@/components/safe-apps/SafeAppCard' +import NextLink from 'next/link' +import { OVERVIEW_EVENTS, trackEvent } from '@/services/analytics' +import { useHasFeature } from '@/hooks/useChains' +import { FEATURES } from '@/utils/chains' + +const Step = ({ active, title }: { active: boolean; title: ReactNode }) => { + return ( + + {title} + {!active && ( + + Coming soon + + )} + + ) +} + +const ActivityRewardsSection = () => { + const [matchingApps] = useRemoteSafeApps(SafeAppsTag.SAFE_GOVERNANCE_APP) + const isDarkMode = useDarkMode() + const router = useRouter() + + const isSAPBannerEnabled = useHasFeature(FEATURES.SAP_BANNER) + const governanceApp = matchingApps?.[0] + + if (!governanceApp || !governanceApp?.url || !isSAPBannerEnabled) return null + + const appUrl = getSafeAppUrl(router, governanceApp?.url) + + const onClick = () => { + trackEvent(OVERVIEW_EVENTS.OPEN_ACTIVITY_APP) + } + + return ( + + + + + + + + Interact with Safe and get rewards + + + + + + + + + + + + + How it works + +
+ + + +
+
+
+
+
+ ) +} + +export default ActivityRewardsSection diff --git a/src/components/dashboard/ActivityRewardsSection/styles.module.css b/src/components/dashboard/ActivityRewardsSection/styles.module.css new file mode 100644 index 0000000000..5d046345ef --- /dev/null +++ b/src/components/dashboard/ActivityRewardsSection/styles.module.css @@ -0,0 +1,84 @@ +.widgetWrapper { + position: relative; + border: none; + margin: 0; + padding: 40px; +} + +.loadErrorCard { + display: flex; + justify-content: center; + align-items: center; + padding: var(--space-2); + text-align: center; + flex-grow: 1; +} + +.milesIcon { + height: 21px; + width: 118px; +} + +.milesIconLight path { + fill: var(--color-text-primary); +} + +.gradientText { + background: linear-gradient(225deg, #5fddff 12.5%, #12ff80 88.07%); + background-clip: text; + color: transparent; +} + +.header { + padding-right: var(--space-8); +} + +.steps { + z-index: 1; + margin-top: 20px; + margin-bottom: 20px; +} + +.step { + margin-bottom: 18px; + counter-increment: item; +} + +.step:before { + content: counter(item); + width: 24px; + height: 24px; + background: var(--color-border-main); + border-radius: 50%; + color: #121312; + text-align: center; +} + +.activeStep:before { + background-color: #12ff80; +} + +.comingSoon { + background-color: var(--color-border-light); + border-radius: 4px; + color: var(--color-text-primary); + padding: 4px 8px; +} + +.links { + display: flex; + flex-wrap: wrap; + align-items: center; + margin-top: var(--space-3); + text-wrap: nowrap; +} + +@media (max-width: 899.99px) { + .header { + padding: 0; + } + + .widgetWrapper { + padding: 32px; + } +} diff --git a/src/components/dashboard/index.tsx b/src/components/dashboard/index.tsx index 4aafd3d4b3..e0a56f277a 100644 --- a/src/components/dashboard/index.tsx +++ b/src/components/dashboard/index.tsx @@ -13,6 +13,7 @@ import { useRouter } from 'next/router' import { CREATION_MODAL_QUERY_PARM } from '../new-safe/create/logic' import useRecovery from '@/features/recovery/hooks/useRecovery' import { useIsRecoverySupported } from '@/features/recovery/hooks/useIsRecoverySupported' +import ActivityRewardsSection from '@/components/dashboard/ActivityRewardsSection' const RecoveryHeader = dynamic(() => import('@/features/recovery/components/RecoveryHeader')) const RecoveryWidget = dynamic(() => import('@/features/recovery/components/RecoveryWidget')) @@ -40,6 +41,8 @@ const Dashboard = (): ReactElement => { {safe.deployed && ( <> + + @@ -55,11 +58,11 @@ const Dashboard = (): ReactElement => { - + - + )} diff --git a/src/components/safe-apps/AppFrame/useFromAppAnalytics.ts b/src/components/safe-apps/AppFrame/useFromAppAnalytics.ts index c1d452d734..042c4ee99f 100644 --- a/src/components/safe-apps/AppFrame/useFromAppAnalytics.ts +++ b/src/components/safe-apps/AppFrame/useFromAppAnalytics.ts @@ -11,6 +11,9 @@ const ALLOWED_DOMAINS: RegExp[] = [ /^https:\/\/safe-apps\.dev\.5afe\.dev$/, /^https:\/\/apps\.gnosis-safe\.io$/, /^https:\/\/apps-portal\.safe\.global$/, + /^https:\/\/community\.safe\.global$/, + /^https:\/\/safe-dao-governance\.staging\.5afe\.dev$/, + /^https:\/\/safe-dao-governance\.dev\.5afe\.dev$/, ] const useAnalyticsFromSafeApp = (iframeRef: MutableRefObject): void => { diff --git a/src/services/analytics/events/overview.ts b/src/services/analytics/events/overview.ts index e6b7af96f5..094d9ef7e6 100644 --- a/src/services/analytics/events/overview.ts +++ b/src/services/analytics/events/overview.ts @@ -138,6 +138,11 @@ export const OVERVIEW_EVENTS = { action: 'Proceed with transaction', category: OVERVIEW_CATEGORY, }, + OPEN_ACTIVITY_APP: { + event: EventType.CLICK, + action: 'Open activity app from widget', + category: OVERVIEW_CATEGORY, + }, } export enum OPEN_SAFE_LABELS { diff --git a/src/utils/chains.ts b/src/utils/chains.ts index 1278fcab6c..fda7465ada 100644 --- a/src/utils/chains.ts +++ b/src/utils/chains.ts @@ -21,6 +21,7 @@ export enum FEATURES { COUNTERFACTUAL = 'COUNTERFACTUAL', DELETE_TX = 'DELETE_TX', SPEED_UP_TX = 'SPEED_UP_TX', + SAP_BANNER = 'SAP_BANNER', } export const hasFeature = (chain: ChainInfo, feature: FEATURES): boolean => { From 56e4c35444eff4c6643b4b6324490941f6b5eae3 Mon Sep 17 00:00:00 2001 From: katspaugh <381895+katspaugh@users.noreply.github.com> Date: Wed, 17 Apr 2024 10:25:04 +0200 Subject: [PATCH 3/5] Feat: add a warning about Google login (#3564) * Feat: add a warning about Google login * Lint * Adjust CSS --- src/components/common/SocialSigner/index.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/components/common/SocialSigner/index.tsx b/src/components/common/SocialSigner/index.tsx index cbf8200ce7..2f21e51168 100644 --- a/src/components/common/SocialSigner/index.tsx +++ b/src/components/common/SocialSigner/index.tsx @@ -1,6 +1,6 @@ import useSocialWallet from '@/hooks/wallets/mpc/useSocialWallet' import { type ISocialWalletService } from '@/services/mpc/interfaces' -import { Box, Button, LinearProgress, SvgIcon, Typography } from '@mui/material' +import { Alert, Box, Button, LinearProgress, SvgIcon, Typography } from '@mui/material' import { COREKIT_STATUS } from '@web3auth/mpc-core-kit' import { useState } from 'react' import GoogleLogo from '@/public/images/welcome/logo-google.svg' @@ -132,6 +132,10 @@ export const SocialSigner = ({ socialWalletService, wallet, onLogin, onRequirePa )} {loginError && {loginError}} + + + From 01.05.2024 we will no longer support account creation and login with Google. + ) } From f6c0bd5f50fbb424914d41c85492bbd758244404 Mon Sep 17 00:00:00 2001 From: katspaugh Date: Thu, 18 Apr 2024 17:27:43 +0200 Subject: [PATCH 4/5] 1.34.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 60d2ac84c1..a52e7d138f 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "safe-wallet-web", "homepage": "https://github.com/safe-global/safe-wallet-web", "license": "GPL-3.0", - "version": "1.33.0", + "version": "1.34.0", "type": "module", "scripts": { "dev": "next dev", From 9903344e2a5c1412e05552c37a4341f4f4681543 Mon Sep 17 00:00:00 2001 From: Manuel Gellfart Date: Thu, 18 Apr 2024 20:46:23 +0200 Subject: [PATCH 5/5] fix: open info page in new tab (#3582) --- src/components/dashboard/ActivityRewardsSection/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/dashboard/ActivityRewardsSection/index.tsx b/src/components/dashboard/ActivityRewardsSection/index.tsx index f0760566de..9cfa0f0b90 100644 --- a/src/components/dashboard/ActivityRewardsSection/index.tsx +++ b/src/components/dashboard/ActivityRewardsSection/index.tsx @@ -86,7 +86,7 @@ const ActivityRewardsSection = () => { - +