Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
66d4e4b
feat: added custom network button
PatrickAlphaC Apr 19, 2025
6d7843d
feat: custom networks page
PatrickAlphaC Apr 19, 2025
a68483d
feat: imports and exports for custom chains
PatrickAlphaC Apr 20, 2025
859785f
fix: removed package manager
PatrickAlphaC Apr 20, 2025
cb6a852
wip
PatrickAlphaC Apr 20, 2025
a4dfce4
wip
PatrickAlphaC Apr 20, 2025
89c995b
feat: it works!
PatrickAlphaC Apr 20, 2025
2bf385d
wip
PatrickAlphaC Apr 20, 2025
1c32a88
wip
PatrickAlphaC Apr 20, 2025
45ac5b0
wip
PatrickAlphaC Apr 22, 2025
d525fa8
feat: added customization of multi-send and multi-send-call-only so y…
PatrickAlphaC Apr 22, 2025
7271766
Update src/store/addedSafesSlice.ts
PatrickAlphaC Apr 22, 2025
a42bea2
Update src/components/new-safe/load/steps/SetAddressStep/index.tsx
PatrickAlphaC Apr 22, 2025
d3cc843
Update src/components/new-safe/load/steps/SetAddressStep/index.tsx
PatrickAlphaC Apr 22, 2025
3313b0c
Update src/components/new-safe/create/logic/index.ts
PatrickAlphaC Apr 22, 2025
00278db
Apply suggestions from code review
PatrickAlphaC Apr 22, 2025
a661e02
Apply suggestions from code review
PatrickAlphaC Apr 22, 2025
ec0c64c
Apply suggestions from code review
PatrickAlphaC Apr 22, 2025
b798c92
Update src/hooks/coreSDK/useInitSafeCoreSDK.ts
PatrickAlphaC Apr 22, 2025
90651f0
fix: lint
PatrickAlphaC Apr 22, 2025
0d9e25e
Merge pull request #1 from PatrickAlphaC/feat/custom-multi-send
PatrickAlphaC Apr 22, 2025
35113d1
fix: removed image
PatrickAlphaC Apr 22, 2025
b6aa742
feat: fixed weird timing issue
PatrickAlphaC Apr 26, 2025
8be4b44
Merge pull request #3 from PatrickAlphaC/patrick-custom-network
PatrickAlphaC Apr 26, 2025
dd3fa27
Feat/safe creation (#62)
oed May 21, 2025
d20df59
feat: added custom network button
PatrickAlphaC Apr 19, 2025
15948ce
feat: custom networks page
PatrickAlphaC Apr 19, 2025
a654984
feat: imports and exports for custom chains
PatrickAlphaC Apr 20, 2025
82dd0d4
wip
PatrickAlphaC Apr 22, 2025
669eae7
Apply suggestions from code review
PatrickAlphaC Apr 22, 2025
673cf85
Apply suggestions from code review
PatrickAlphaC Apr 22, 2025
11034e8
lint: fix
PatrickAlphaC May 28, 2025
1688950
fix: fixed merge conflict
PatrickAlphaC May 28, 2025
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
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@
Eternal Safe is a decentralized fork of [Safe{Wallet}](https://github.com/safe-global/safe-wallet-monorepo), forked at v1.26.2. Funded by the [Safe Grants Program](https://app.charmverse.io/safe-grants-program/page-005239065690887612).

- The latest released version is always accessible at [https://eternalsafe.eth](https://eternalsafe.eth). If your browser doesn't support ENS, you can use alternatives below with different privacy trade-offs:
- [https://eternalsafe.eth.limo](https://eternalsafe.eth.limo) - centralized ENS resolution.
- [https://eternalsafe.eth.limo](https://eternalsafe.eth.limo) - centralized ENS resolution.
- [https://eternalsafe-eth.ipns.inbrowser.link](https://eternalsafe-eth.ipns.inbrowser.link) - this [fetches and verifies client-side](https://inbrowser.link/) the IPFS content.
- [https://eternalsafe.earthfast.app](https://eternalsafe.earthfast.app) - [EarthFast (an IPFS alternative)](https://earthfast.com) hosts a mirror.
- [https://eternalsafe.earthfast.app](https://eternalsafe.earthfast.app) - [EarthFast (an IPFS alternative)](https://earthfast.com) hosts a mirror.
- For the IPFS CID or pinned ENS subdomain, please check the [latest release](https://github.com/eternalsafe/wallet/releases/latest).
- The latest commit on the `eternalsafe` branch is always accessible at [https://eternalsafe.vercel.app](https://eternalsafe.vercel.app).

Expand Down Expand Up @@ -68,7 +68,7 @@ Here's the list of all the environment variables:
| Env variable | Description |
| --------------------------- | ----------------------------------------------------------------------------- |
| `NEXT_PUBLIC_IS_PRODUCTION` | Set to `true` to build a minified production app |
| `NEXT_PUBLIC_SAFE_VERSION` | The latest version of the Safe contract, defaults to 1.3.0 |
| `NEXT_PUBLIC_SAFE_VERSION` | The latest version of the Safe contract, defaults to 1.4.1 |
| `NEXT_PUBLIC_WC_PROJECT_ID` | [WalletConnect v2](https://docs.walletconnect.com/2.0/cloud/relay) project ID |

If you don't provide some of the variables, the corresponding features will be disabled in the UI.
Expand Down
21 changes: 11 additions & 10 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
"static-serve": "yarn build && yarn serve"
},
"engines": {
"node": ">=16"
"node": ">=20.18.0"
},
"resolutions": {
"@solana/web3.js": "^1.87.7",
Expand Down Expand Up @@ -60,13 +60,13 @@
"@uniswap/token-lists": "1.0.0-beta.33",
"@walletconnect/utils": "^2.10.6",
"@walletconnect/web3wallet": "^1.9.5",
"@web3-onboard/coinbase": "^2.2.6",
"@web3-onboard/core": "^2.21.2",
"@web3-onboard/injected-wallets": "^2.10.7",
"@web3-onboard/keystone": "^2.3.7",
"@web3-onboard/ledger": "2.3.2",
"@web3-onboard/trezor": "^2.4.2",
"@web3-onboard/walletconnect": "^2.5.2",
"@web3-onboard/coinbase": "^2.2.7",
"@web3-onboard/core": "^2.21.6",
"@web3-onboard/injected-wallets": "^2.10.17",
"@web3-onboard/keystone": "^2.3.8",
"@web3-onboard/ledger": "2.7.1",
"@web3-onboard/trezor": "^2.4.4",
"@web3-onboard/walletconnect": "^2.5.5",
"blo": "^1.1.1",
"bn.js": "^5.2.1",
"classnames": "^2.3.1",
Expand Down Expand Up @@ -127,7 +127,7 @@
"prettier": "^2.7.0",
"ts-prune": "^0.10.3",
"typechain": "^8.0.0",
"typescript": "4.9.4",
"typescript": "^5.0.4",
"typescript-plugin-css-modules": "^4.2.2",
"webpack": "^5.88.2"
},
Expand All @@ -136,5 +136,6 @@
"budgetPercentIncreaseRed": 20,
"minimumChangeThreshold": 0,
"showDetails": true
}
},
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
}
104 changes: 92 additions & 12 deletions src/components/common/NetworkSelector/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,25 +2,41 @@ import ChainIndicator from '@/components/common/ChainIndicator'
import { type ChainInfo } from '@safe-global/safe-gateway-typescript-sdk'
import Link from 'next/link'
import type { SelectChangeEvent } from '@mui/material'
import { ListSubheader, MenuItem, Select, Skeleton } from '@mui/material'
import { Divider, ListSubheader, MenuItem, Select, Skeleton, IconButton, Tooltip } from '@mui/material'
import partition from 'lodash/partition'
import ExpandMoreIcon from '@mui/icons-material/ExpandMore'
import AddCircleOutlineIcon from '@mui/icons-material/AddCircleOutline'
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline'
import useChains from '@/hooks/useChains'
import { useRouter } from 'next/router'
import css from './styles.module.css'
import { useChainId } from '@/hooks/useChainId'
import { type ReactElement, useMemo } from 'react'
import { useCallback } from 'react'
import { type ReactElement, useMemo, useCallback } from 'react'
import { AppRoutes } from '@/config/routes'
import { useAppDispatch } from '@/store'
import { removeChain } from '@/store/customChainsSlice'
import { showNotification } from '@/store/notificationsSlice'

const keepPathRoutes = [AppRoutes.welcome.index, AppRoutes.newSafe.load]
const keepPathRoutes = [AppRoutes.welcome.index, AppRoutes.newSafe.load, AppRoutes.newSafe.create]

// Define the route for custom chain page in the pages directory
const CUSTOM_CHAIN_ROUTE = '/customChain'
const CUSTOM_CHAIN_VALUE = 'custom-chain'

const NetworkSelector = (props: { onChainSelect?: () => void }): ReactElement => {
const { configs } = useChains()
const chainId = useChainId()
const router = useRouter()
const dispatch = useAppDispatch()

// Separate custom chains from regular ones
const [customChains, regularChains] = useMemo(() => partition(configs, (config) => config.custom === true), [configs])

const [testNets, prodNets] = useMemo(() => partition(configs, (config) => config.isTestnet ?? false), [configs])
// Then separate regular chains into testnets and mainnets
const [testNets, prodNets] = useMemo(
() => partition(regularChains, (config) => config.isTestnet ?? false),
[regularChains],
)

const getNetworkLink = useCallback(
(shortName: string) => {
Expand All @@ -45,10 +61,44 @@ const NetworkSelector = (props: { onChainSelect?: () => void }): ReactElement =>
[router],
)

// Wrap handleDeleteChain in useCallback
const handleDeleteChain = useCallback(
(e: React.MouseEvent, chain: ChainInfo) => {
e.preventDefault()
e.stopPropagation()

// Remove the chain from the store
dispatch(removeChain(chain.chainId))

// Show notification
dispatch(
showNotification({
message: `${chain.chainName} network has been removed`,
groupKey: 'delete-network-success',
variant: 'success',
}),
)

// If we're currently on this chain, redirect to mainnet or another chain
if (chainId === chain.chainId) {
const defaultChain = configs.find((c) => !c.custom)
if (defaultChain) {
router.push(getNetworkLink(defaultChain.shortName))
}
}
},
[chainId, configs, dispatch, getNetworkLink, router],
)

const onChange = (event: SelectChangeEvent) => {
event.preventDefault() // Prevent the link click

const newChainId = event.target.value
// Handle the custom chain option
if (newChainId === CUSTOM_CHAIN_VALUE) {
router.push(CUSTOM_CHAIN_ROUTE)
return
}

const shortName = configs.find((item) => item.chainId === newChainId)?.shortName

if (shortName) {
Expand All @@ -57,16 +107,28 @@ const NetworkSelector = (props: { onChainSelect?: () => void }): ReactElement =>
}

const renderMenuItem = useCallback(
(value: string, chain: ChainInfo) => {
(chain: ChainInfo, isCustom = false) => {
return (
<MenuItem key={value} value={value} className={css.menuItem}>
<MenuItem key={chain.chainId} value={chain.chainId} className={css.menuItem}>
<Link href={getNetworkLink(chain.shortName)} onClick={props.onChainSelect} className={css.item}>
<ChainIndicator chainId={chain.chainId} inline />
</Link>
{isCustom && (
<Tooltip title="Delete network">
<IconButton
size="small"
onClick={(e) => handleDeleteChain(e, chain)}
sx={{ ml: 1, p: 0.5 }}
aria-label="Delete network"
>
<DeleteOutlineIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
</MenuItem>
)
},
[getNetworkLink, props.onChainSelect],
[getNetworkLink, props.onChainSelect, handleDeleteChain],
)

return configs.length ? (
Expand All @@ -92,12 +154,30 @@ const NetworkSelector = (props: { onChainSelect?: () => void }): ReactElement =>
},
}}
>
{prodNets.map((chain) => renderMenuItem(chain.chainId, chain))}
{/* Custom Networks section */}
{customChains.length > 0 && <ListSubheader className={css.listSubHeader}>Custom Networks</ListSubheader>}
{customChains.map((chain) => renderMenuItem(chain, true))}
{customChains.length > 0 && <Divider sx={{ my: 1 }} />}

{/* Production Networks */}
<ListSubheader className={css.listSubHeader}>Production Networks</ListSubheader>
{prodNets.map((chain) => renderMenuItem(chain))}

{/* Testnets */}
<ListSubheader className={css.listSubHeader}>Testnets</ListSubheader>
{testNets.map((chain) => renderMenuItem(chain))}

{testNets.map((chain) => renderMenuItem(chain.chainId, chain))}
</Select>
{/* Create Custom Chain Option */}
<Divider sx={{ my: 1 }} />
<MenuItem value={CUSTOM_CHAIN_VALUE} className={css.menuItem}>
<Link href={CUSTOM_CHAIN_ROUTE} onClick={props.onChainSelect} className={css.item}>
<div style={{ display: 'flex', alignItems: 'center' }}>
<AddCircleOutlineIcon sx={{ mr: 1, fontSize: '1rem' }} />
<span>Create Custom Chain</span>
</div>
</Link>
</MenuItem>
</Select >
) : (
<Skeleton width={94} height={31} sx={{ mx: 2 }} />
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import * as usePendingSafe from '../steps/StatusStep/usePendingSafe'
import * as useIsWrongChain from '@/hooks/useIsWrongChain'
import * as useRouter from 'next/router'
import { type NextRouter } from 'next/router'
import { AppRoutes } from '@/config/routes'

describe('useSyncSafeCreationStep', () => {
const mockPendingSafe = {
Expand All @@ -23,21 +22,6 @@ describe('useSyncSafeCreationStep', () => {
jest.clearAllMocks()
})

it('should go to the first step if no wallet is connected and there is no pending safe', async () => {
const mockPushRoute = jest.fn()
jest.spyOn(wallet, 'default').mockReturnValue(null)
jest.spyOn(usePendingSafe, 'usePendingSafe').mockReturnValue([undefined, setPendingSafeSpy])
jest.spyOn(useRouter, 'useRouter').mockReturnValue({
push: mockPushRoute,
} as unknown as NextRouter)
const mockSetStep = jest.fn()

renderHook(() => useSyncSafeCreationStep(mockSetStep))

expect(mockSetStep).not.toHaveBeenCalled()
expect(mockPushRoute).toHaveBeenCalledWith({ pathname: AppRoutes.welcome.index, query: undefined })
})

it('should go to the fourth step if there is a pending safe', async () => {
const mockPushRoute = jest.fn()
jest.spyOn(localStorage, 'default').mockReturnValue([{}, jest.fn()])
Expand Down
2 changes: 2 additions & 0 deletions src/components/new-safe/create/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ export type NewSafeFormData = {
owners: NamedAddress[]
saltNonce: number
safeAddress?: string
multisendAddress?: string
multisendCallOnlyAddress?: string
}

const staticHints: Record<
Expand Down
4 changes: 4 additions & 0 deletions src/components/new-safe/create/logic/address-book.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ export const updateAddressBook = (
name: string,
owners: NamedAddress[],
threshold: number,
multisendAddress?: string,
multisendCallOnlyAddress?: string,
): AppThunk => {
return (dispatch) => {
dispatch(
Expand Down Expand Up @@ -39,6 +41,8 @@ export const updateAddressBook = (
})),
chainId: chainId,
nonce: 0,
multisendAddress: multisendAddress ? { value: multisendAddress } : null,
multisendCallOnlyAddress: multisendCallOnlyAddress ? { value: multisendCallOnlyAddress } : null,
},
}),
)
Expand Down
35 changes: 21 additions & 14 deletions src/components/new-safe/create/logic/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,10 @@ import { backOff } from 'exponential-backoff'
import { LATEST_SAFE_VERSION } from '@/config/constants'
import { EMPTY_DATA, ZERO_ADDRESS } from '@safe-global/protocol-kit/dist/src/utils/constants'
import { formatError } from '@/utils/formatters'
import { getSafeSDKAndImplementation } from '@/hooks/coreSDK/useInitSafeCoreSDK'
import { getSafeSDKAndImplementation, getSafeAddressFromTxReceipt } from '@/hooks/coreSDK/useInitSafeCoreSDK'
import type { Provider } from '@ethersproject/providers'
import { getSafeInfo } from '@/hooks/loadables/useLoadSafeInfo'
import type { SafeVersion } from '@safe-global/safe-core-sdk-types'

export type SafeCreationProps = {
owners: string[]
Expand Down Expand Up @@ -63,20 +64,10 @@ export const getSafeDeployProps = async (
export const createNewSafe = async (ethersProvider: Web3Provider, props: DeploySafeProps): Promise<Safe> => {
const ethAdapter = createEthersAdapter(ethersProvider)

const safeFactory = await SafeFactory.create({ ethAdapter })
const safeFactory = await SafeFactory.create({ ethAdapter, safeVersion: LATEST_SAFE_VERSION as SafeVersion })
return safeFactory.deploySafe(props)
}

/**
* Compute the new counterfactual Safe address before it is actually created
*/
export const computeNewSafeAddress = async (ethersProvider: Web3Provider, props: DeploySafeProps): Promise<string> => {
const ethAdapter = createEthersAdapter(ethersProvider)

const safeFactory = await SafeFactory.create({ ethAdapter })
return safeFactory.predictSafeAddress(props.safeAccountConfig, props.saltNonce)
}

/**
* Encode a Safe creation transaction NOT using the Core SDK because it doesn't support that
* This is used for gas estimation.
Expand Down Expand Up @@ -151,11 +142,27 @@ export const estimateSafeCreationGas = async (
})
}

export const pollSafeInfo = async (web3: Provider, chainId: string, safeAddress: string): Promise<SafeInfo> => {
export const pollSafeInfo = async (
web3: Provider,
chainId: string,
txHash: string,
multiSendAddress?: string,
multisendCallOnlyAddress?: string,
): Promise<SafeInfo> => {
// exponential delay between attempts for around 4 min
return backOff(
async () => {
let [sdk, implementation] = await getSafeSDKAndImplementation(web3, safeAddress, chainId)
const safeAddress = await getSafeAddressFromTxReceipt(txHash, web3)
if (!safeAddress) {
throw new Error('Safe address not found in transaction receipt')
}
let [sdk, implementation] = await getSafeSDKAndImplementation(
web3,
safeAddress,
chainId,
multiSendAddress,
multisendCallOnlyAddress,
)
if (!sdk) {
throw new Error('Safe SDK not available')
}
Expand Down
19 changes: 0 additions & 19 deletions src/components/new-safe/create/steps/ReviewStep/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,6 @@ import type { StepRenderProps } from '@/components/new-safe/CardStepper/useCardS
import type { NewSafeFormData } from '@/components/new-safe/create'
import css from '@/components/new-safe/create/steps/ReviewStep/styles.module.css'
import layoutCss from '@/components/new-safe/create/styles.module.css'
import { getReadOnlyFallbackHandlerContract } from '@/services/contracts/safeContracts'
import { computeNewSafeAddress } from '@/components/new-safe/create/logic'
import useWallet from '@/hooks/wallets/useWallet'
import { useWeb3 } from '@/hooks/wallets/web3'
import useSyncSafeCreationStep from '@/components/new-safe/create/useSyncSafeCreationStep'
Expand All @@ -24,7 +22,6 @@ import useIsWrongChain from '@/hooks/useIsWrongChain'
import ReviewRow from '@/components/new-safe/ReviewRow'
import { BigNumber } from 'ethers'
import { usePendingSafe } from '../StatusStep/usePendingSafe'
import { LATEST_SAFE_VERSION } from '@/config/constants'
import { type ChainInfo } from '@safe-global/safe-gateway-typescript-sdk'

export const NetworkFee = ({ totalFee, chain }: { totalFee: string; chain: ChainInfo | undefined }) => {
Expand Down Expand Up @@ -92,25 +89,9 @@ const ReviewStep = ({ data, onSubmit, onBack, setStep }: StepRenderProps<NewSafe
const createSafe = async () => {
if (!wallet || !provider || !chain) return

const readOnlyFallbackHandlerContract = await getReadOnlyFallbackHandlerContract(chain.chainId, LATEST_SAFE_VERSION)

const props = {
safeAccountConfig: {
threshold: data.threshold,
owners: data.owners.map((owner) => owner.address),
fallbackHandler: readOnlyFallbackHandlerContract.getAddress(),
},
safeDeploymentConfig: {
saltNonce: saltNonce.toString(),
},
}

const safeAddress = await computeNewSafeAddress(provider, props)

const pendingSafe = {
...data,
saltNonce,
safeAddress,
}

setPendingSafe(pendingSafe)
Expand Down
Loading