Skip to content

Commit

Permalink
feat: [Counterfactual] Add backup option (safe-global#3202)
Browse files Browse the repository at this point in the history
* feat: Create counterfactual 1/1 safes

* fix: Add feature flag

* fix: Lint issues

* fix: Use incremental saltNonce for all safe creations

* fix: Replace useCounterfactualBalance hook with get function and write tests

* refactor: Move creation logic out of Review component

* fix: useLoadBalance check for undefined value

* fix: Extract saltNonce, safeAddress calculation into a hook

* refactor: Rename redux slice

* fix: Show error message in case saltNonce can't be retrieved

* feat: Add backup option for counterfactual safes

* fix: Adjust wording

* fix: Remove feature flag check for recovery

* refactor: Extract file upload logic from safe loading component

* fix: Add recover option to welcome page

* fix: Fallback to readonly provider to fetch balances and cache response

* fix: Navigate to dashboard after recovery

* fix: Remove restore feature

* fix: link prevent default
  • Loading branch information
usame-algan authored Feb 9, 2024
1 parent 629dedf commit 8df393c
Show file tree
Hide file tree
Showing 3 changed files with 110 additions and 26 deletions.
50 changes: 48 additions & 2 deletions src/components/dashboard/CreationDialog/index.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { selectUndeployedSafe } from '@/features/counterfactual/store/undeployedSafesSlice'
import useChainId from '@/hooks/useChainId'
import useSafeInfo from '@/hooks/useSafeInfo'
import { useAppSelector } from '@/store'
import type { PredictedSafeProps } from '@safe-global/protocol-kit'
import React, { type ElementType } from 'react'
import { Box, Button, Dialog, DialogContent, Grid, SvgIcon, Typography } from '@mui/material'
import { Alert, Box, Button, Dialog, DialogContent, Grid, Link, SvgIcon, Typography } from '@mui/material'
import { useRouter } from 'next/router'

import HomeIcon from '@/public/images/sidebar/home.svg'
Expand Down Expand Up @@ -27,11 +32,34 @@ const HintItem = ({ Icon, title, description }: { Icon: ElementType; title: stri
)
}

const getExportFileName = () => {
const today = new Date().toISOString().slice(0, 10)
return `safe-backup-${today}.json`
}

const backupSafe = (chainId: string, safeAddress: string, undeployedSafe: PredictedSafeProps) => {
const data = JSON.stringify({ chainId, safeAddress, safeProps: undeployedSafe }, null, 2)

const blob = new Blob([data], { type: 'text/json' })
const link = document.createElement('a')

link.download = getExportFileName()
link.href = window.URL.createObjectURL(blob)
link.dataset.downloadurl = ['text/json', link.download, link.href].join(':')
link.dispatchEvent(new MouseEvent('click'))

// TODO: Track this as an event
// trackEvent(COUNTERFACTUAL_EVENTS.EXPORT_SAFE)
}

const CreationDialog = () => {
const router = useRouter()
const [open, setOpen] = React.useState(true)
const [remoteSafeApps = []] = useRemoteSafeApps()
const chain = useCurrentChain()
const chainId = useChainId()
const { safeAddress } = useSafeInfo()
const undeployedSafe = useAppSelector((state) => selectUndeployedSafe(state, chainId, safeAddress))

const onClose = () => {
const { [CREATION_MODAL_QUERY_PARM]: _, ...query } = router.query
Expand All @@ -49,7 +77,8 @@ const CreationDialog = () => {
<Typography variant="body2">
Congratulations on your first step to truly unlock ownership. Enjoy the experience and discover our app.
</Typography>
<Grid container mt={4} mb={6} spacing={3}>

<Grid container mt={2} mb={4} spacing={3}>
<HintItem Icon={HomeIcon} title="Home" description="Get a status overview of your Safe Account here." />
<HintItem
Icon={TransactionIcon}
Expand All @@ -73,6 +102,23 @@ const CreationDialog = () => {
description="Have any questions? Check out our collection of articles."
/>
</Grid>

{undeployedSafe && (
<Alert severity="info" sx={{ mb: 2 }}>
We recommend{' '}
<Link
href="#"
onClick={(e) => {
e.preventDefault()
backupSafe(chainId, safeAddress, undeployedSafe)
}}
>
backing up your Safe Account
</Link>{' '}
in case you lose access to this device.
</Alert>
)}

<Box display="flex" justifyContent="center">
<Button onClick={onClose} variant="contained" size="stretched">
Got it
Expand Down
52 changes: 36 additions & 16 deletions src/features/counterfactual/__tests__/utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { getCounterfactualBalance, getUndeployedSafeInfo } from '@/features/counterfactual/utils'
import * as web3 from '@/hooks/wallets/web3'
import { chainBuilder } from '@/tests/builders/chains'
import { faker } from '@faker-js/faker'
import type { PredictedSafeProps } from '@safe-global/protocol-kit'
import { ZERO_ADDRESS } from '@safe-global/protocol-kit/dist/src/utils/constants'
import { TokenType } from '@safe-global/safe-gateway-typescript-sdk'
import { BrowserProvider, type Eip1193Provider } from 'ethers'
import { type BrowserProvider, type JsonRpcProvider } from 'ethers'

describe('Counterfactual utils', () => {
describe('getUndeployedSafeInfo', () => {
Expand Down Expand Up @@ -35,35 +36,54 @@ describe('Counterfactual utils', () => {
jest.clearAllMocks()
})

it('should return undefined if there is no provider', () => {
it('should fall back to readonly provider if there is no provider', async () => {
const mockBalance = 123n
const mockReadOnlyProvider = {
getBalance: jest.fn(() => Promise.resolve(mockBalance)),
} as unknown as JsonRpcProvider
jest.spyOn(web3, 'getWeb3ReadOnly').mockImplementation(() => mockReadOnlyProvider)

const mockSafeAddress = faker.finance.ethereumAddress()
const mockChain = chainBuilder().build()
const result = getCounterfactualBalance(mockSafeAddress, undefined, mockChain)
const result = await getCounterfactualBalance(mockSafeAddress, undefined, mockChain)

expect(result).resolves.toBeUndefined()
expect(mockReadOnlyProvider.getBalance).toHaveBeenCalled()
expect(result).toEqual({
fiatTotal: '0',
items: [
{
tokenInfo: {
type: TokenType.NATIVE_TOKEN,
address: ZERO_ADDRESS,
...mockChain.nativeCurrency,
},
balance: mockBalance.toString(),
fiatBalance: '0',
fiatConversion: '0',
},
],
})
})

it('should return undefined if there is no chain info', () => {
it('should return undefined if there is no chain info', async () => {
const mockSafeAddress = faker.finance.ethereumAddress()
const mockProvider = new BrowserProvider(jest.fn() as unknown as Eip1193Provider)
mockProvider.getBalance = jest.fn(() => Promise.resolve(1n))
const mockProvider = { getBalance: jest.fn(() => Promise.resolve(1n)) } as unknown as BrowserProvider

const result = getCounterfactualBalance(mockSafeAddress, mockProvider, undefined)
const result = await getCounterfactualBalance(mockSafeAddress, mockProvider, undefined)

expect(result).resolves.toBeUndefined()
expect(result).toBeUndefined()
})

it('should return the native balance', () => {
it('should return the native balance', async () => {
const mockSafeAddress = faker.finance.ethereumAddress()
const mockProvider = new BrowserProvider(jest.fn() as unknown as Eip1193Provider)
const mockChain = chainBuilder().build()
const mockBalance = 1000000n
const mockProvider = { getBalance: jest.fn(() => Promise.resolve(mockBalance)) } as unknown as BrowserProvider
const mockChain = chainBuilder().build()

mockProvider.getBalance = jest.fn(() => Promise.resolve(mockBalance))

const result = getCounterfactualBalance(mockSafeAddress, mockProvider, mockChain)
const result = await getCounterfactualBalance(mockSafeAddress, mockProvider, mockChain)

expect(result).resolves.toEqual({
expect(mockProvider.getBalance).toHaveBeenCalled()
expect(result).toEqual({
fiatTotal: '0',
items: [
{
Expand Down
34 changes: 26 additions & 8 deletions src/features/counterfactual/utils.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import { LATEST_SAFE_VERSION } from '@/config/constants'
import type { NewSafeFormData } from '@/components/new-safe/create'
import { CREATION_MODAL_QUERY_PARM } from '@/components/new-safe/create/logic'
import { LATEST_SAFE_VERSION } from '@/config/constants'
import { AppRoutes } from '@/config/routes'
import { addUndeployedSafe } from '@/features/counterfactual/store/undeployedSafesSlice'
import { getWeb3ReadOnly } from '@/hooks/wallets/web3'
import ExternalStore from '@/services/ExternalStore'
import { type ConnectedWallet } from '@/hooks/wallets/useOnboard'
import { asError } from '@/services/exceptions/utils'
import { assertWalletChain, getUncheckedSafeSDK } from '@/services/tx/tx-sender/sdk'
import { AppRoutes } from '@/config/routes'
import { addUndeployedSafe } from '@/features/counterfactual/store/undeployedSafesSlice'
import { txDispatch, TxEvent } from '@/services/tx/txEvents'
import type { AppDispatch } from '@/store'
import { addOrUpdateSafe } from '@/store/addedSafesSlice'
Expand Down Expand Up @@ -117,10 +120,22 @@ export const deploySafeAndExecuteTx = async (
return dispatchTxExecutionAndDeploySafe(safeTx, txOptions, onboard, chainId, onSuccess)
}

export const getCounterfactualBalance = async (safeAddress: string, provider?: BrowserProvider, chain?: ChainInfo) => {
const balance = await provider?.getBalance(safeAddress)
const { getStore: getNativeBalance, setStore: setNativeBalance } = new ExternalStore<bigint | undefined>()

if (balance === undefined || !chain) return
export const getCounterfactualBalance = async (safeAddress: string, provider?: BrowserProvider, chain?: ChainInfo) => {
let balance: bigint | undefined

if (!chain) return undefined

// Fetch balance via the connected wallet.
// If there is no wallet connected we fetch and cache the balance instead
if (provider) {
balance = await provider.getBalance(safeAddress)
} else {
const cachedBalance = getNativeBalance()
balance = cachedBalance !== undefined ? cachedBalance : await getWeb3ReadOnly()?.getBalance(safeAddress)
setNativeBalance(balance)
}

return <SafeBalanceResponse>{
fiatTotal: '0',
Expand All @@ -131,7 +146,7 @@ export const getCounterfactualBalance = async (safeAddress: string, provider?: B
address: ZERO_ADDRESS,
...chain?.nativeCurrency,
},
balance: balance.toString(),
balance: balance?.toString(),
fiatBalance: '0',
fiatConversion: '0',
},
Expand Down Expand Up @@ -176,5 +191,8 @@ export const createCounterfactualSafe = (
},
}),
)
router.push({ pathname: AppRoutes.home, query: { safe: `${chain.shortName}:${safeAddress}` } })
router.push({
pathname: AppRoutes.home,
query: { safe: `${chain.shortName}:${safeAddress}`, [CREATION_MODAL_QUERY_PARM]: true },
})
}

0 comments on commit 8df393c

Please sign in to comment.