diff --git a/extension/package.json b/extension/package.json index a34f1c4a..192cf9d5 100644 --- a/extension/package.json +++ b/extension/package.json @@ -24,9 +24,11 @@ "fix:lint": "eslint src --ext .ts --fix" }, "devDependencies": { - "@gnosis.pm/safe-ethers-lib": "^1.6.0", - "@gnosis.pm/safe-service-client": "^1.3.0", "@gnosis.pm/zodiac": "^2.0.1", + "@safe-global/safe-core-sdk": "^3.2.0", + "@safe-global/safe-core-sdk-types": "^1.7.0", + "@safe-global/safe-ethers-lib": "^1.7.0", + "@safe-global/safe-service-client": "^1.4.0", "@shazow/whatsabi": "^0.2.1", "@testing-library/jest-dom": "^5.16.1", "@typechain/ethers-v5": "^10.0.0", @@ -53,7 +55,7 @@ "eslint-plugin-react": "^7.26.1", "eslint-plugin-react-hooks": "^4.2.0", "ethereum-blockies-base64": "^1.0.2", - "ethers": "^5.5.1", + "ethers": "^5.7.2", "ethers-proxies": "^1.0.0", "events": "^3.3.0", "ganache": "^7.0.0-beta.2", @@ -66,7 +68,7 @@ "react-dom": "^18.2.0", "react-icons": "^4.3.1", "react-modal": "^3.15.1", - "react-multisend": "^1.1.0", + "react-multisend": "^1.2.0", "react-select": "^5.2.1", "react-toastify": "^9.0.8", "rimraf": "^3.0.2", diff --git a/extension/src/app.tsx b/extension/src/app.tsx index 466fa809..8a7068c9 100644 --- a/extension/src/app.tsx +++ b/extension/src/app.tsx @@ -12,29 +12,45 @@ import ZodiacToastContainer from './components/Toast' import { pushLocation } from './location' import { ProvideMetaMask } from './providers' import { useMatchSettingsRoute, usePushSettingsRoute } from './routing' -import Settings, { ProvideConnections } from './settings' -import { useConnection } from './settings' +import Settings, { ProvideConnections, useConnection } from './settings' +import { validateAddress } from './utils' const Routes: React.FC = () => { const settingsRouteMatch = useMatchSettingsRoute() - const { connection, connected } = useConnection() const pushSettingsRoute = usePushSettingsRoute() + const { connection, connected } = useConnection() + const isSettingsRoute = !!settingsRouteMatch const settingsRequired = - !connection || - !connection.avatarAddress || - !connection.moduleAddress || - !connected + !validateAddress(connection.avatarAddress) || + !validateAddress(connection.pilotAddress) + + const waitForWallet = !isSettingsRoute && !settingsRequired && !connected // redirect to settings page if more settings are required useEffect(() => { - if (!settingsRouteMatch && settingsRequired) { + if (!isSettingsRoute && settingsRequired) { pushSettingsRoute() } - }, [pushSettingsRoute, settingsRouteMatch, settingsRequired]) - if (!settingsRouteMatch && settingsRequired) return null + }, [isSettingsRoute, pushSettingsRoute, settingsRequired]) + + // redirect to settings page if wallet is not connected, but only after a small delay to give the wallet time to connect when initially loading the page + useEffect(() => { + let timeout: number + if (waitForWallet) { + timeout = window.setTimeout(() => { + pushSettingsRoute() + }, 100) + } + return () => { + window.clearTimeout(timeout) + } + }, [waitForWallet, connected, pushSettingsRoute]) + + if (!isSettingsRoute && settingsRequired) return null + if (!isSettingsRoute && waitForWallet) return null - if (settingsRouteMatch) { + if (isSettingsRoute) { return ( = ({ value }) => { onChange: () => { /*nothing here*/ }, - network: chainId ? (chainId.toString() as NetworkId) : '1', + network: chainId.toString() as NetworkId, blockExplorerApiKey: process.env.ETHERSCAN_API_KEY, }) diff --git a/extension/src/browser/Drawer/ContractAddress/index.tsx b/extension/src/browser/Drawer/ContractAddress/index.tsx index 05192ca0..f6a211d0 100644 --- a/extension/src/browser/Drawer/ContractAddress/index.tsx +++ b/extension/src/browser/Drawer/ContractAddress/index.tsx @@ -1,5 +1,6 @@ import copy from 'copy-to-clipboard' import makeBlockie from 'ethereum-blockies-base64' +import { getAddress } from 'ethers/lib/utils' import React, { useEffect, useMemo, useState } from 'react' import { RiExternalLinkLine, RiFileCopyLine } from 'react-icons/ri' @@ -37,8 +38,9 @@ const ContractAddress: React.FC = ({ const blockie = useMemo(() => address && makeBlockie(address), [address]) - const start = address.substring(0, VISIBLE_START + 2) - const end = address.substring(42 - VISIBLE_END, 42) + const checksumAddress = address && getAddress(address) + const start = checksumAddress.substring(0, VISIBLE_START + 2) + const end = checksumAddress.substring(42 - VISIBLE_END, 42) const displayAddress = `${start}...${end}` useEffect(() => { @@ -64,7 +66,7 @@ const ContractAddress: React.FC = ({ diff --git a/extension/src/browser/Drawer/RolePermissionCheck.tsx b/extension/src/browser/Drawer/RolePermissionCheck.tsx index 57ddc392..6da19c10 100644 --- a/extension/src/browser/Drawer/RolePermissionCheck.tsx +++ b/extension/src/browser/Drawer/RolePermissionCheck.tsx @@ -32,7 +32,7 @@ const RolePermissionCheck: React.FC<{ if (!canceled) setError(false) }) .catch((e: JsonRpcError) => { - const decodedError = decodeRolesError(e.data.message || e.message) + const decodedError = decodeRolesError(e) if (!canceled) { setError(isPermissionsError(decodedError) ? decodedError : false) } diff --git a/extension/src/browser/Drawer/Submit.tsx b/extension/src/browser/Drawer/Submit.tsx index 30546f15..40ff9158 100644 --- a/extension/src/browser/Drawer/Submit.tsx +++ b/extension/src/browser/Drawer/Submit.tsx @@ -6,7 +6,7 @@ import { toast } from 'react-toastify' import { Button, IconButton } from '../../components' import toastClasses from '../../components/Toast/Toast.module.css' -import { EXPLORER_URL, NETWORK_PREFIX } from '../../networks' +import { ChainId, EXPLORER_URL, NETWORK_PREFIX } from '../../networks' import { waitForMultisigExecution } from '../../providers' import { useConnection } from '../../settings' import { JsonRpcError, ProviderType } from '../../types' @@ -19,7 +19,13 @@ import classes from './style.module.css' const Submit: React.FC = () => { const { provider, - connection: { chainId, pilotAddress, providerType }, + connection: { + chainId, + avatarAddress, + pilotAddress, + moduleAddress, + providerType, + }, } = useConnection() const dispatch = useDispatch() @@ -34,13 +40,14 @@ const Submit: React.FC = () => { try { batchTransactionHash = await submitTransactions() } catch (e) { + console.warn(e) setSignaturePending(false) const err = e as JsonRpcError toast.error( <>

Submitting the transaction batch failed:


- {decodeRolesError(err.data.message || err.message)} + {decodeRolesError(err)} , { className: toastClasses.toastError } ) @@ -93,39 +100,23 @@ const Submit: React.FC = () => { > Submit - {signaturePending && ( - - { - setSignaturePending(false) - }} - > - - -

Awaiting your signature ...

- {providerType === ProviderType.WalletConnect && ( - <> -
-

- - - WalletConnect Safe app - -

- - )} -
+ onClose={() => setSignaturePending(false)} + usesWalletConnectApp={providerType === ProviderType.WalletConnect} + chainId={chainId} + pilotAddress={pilotAddress} + /> + )} + + {signaturePending && !moduleAddress && ( + setSignaturePending(false)} + chainId={chainId} + avatarAddress={avatarAddress} + /> )} ) @@ -133,6 +124,72 @@ const Submit: React.FC = () => { export default Submit +const AwaitingSignatureModal: React.FC<{ + isOpen: boolean + onClose(): void + usesWalletConnectApp: boolean + chainId: ChainId + pilotAddress: string +}> = ({ isOpen, onClose, usesWalletConnectApp, chainId, pilotAddress }) => ( + + + + +

Awaiting your signature ...

+ {usesWalletConnectApp && ( + <> +
+

+ + + WalletConnect Safe app + +

+ + )} +
+) + +const AwaitingMultisigExecutionModal: React.FC<{ + isOpen: boolean + onClose(): void + chainId: ChainId + avatarAddress: string +}> = ({ isOpen, onClose, chainId, avatarAddress }) => ( + + + + +

Awaiting execution of Safe transaction ...

+ +
+

+ + + Collect signatures and trigger execution + +

+
+) + Modal.setAppElement('#root') const modalStyle: Styles = { diff --git a/extension/src/browser/Drawer/Transaction.tsx b/extension/src/browser/Drawer/Transaction.tsx index ecdb3f6f..99e75b66 100644 --- a/extension/src/browser/Drawer/Transaction.tsx +++ b/extension/src/browser/Drawer/Transaction.tsx @@ -177,7 +177,11 @@ export const Transaction: React.FC = ({ justifyContent="space-between" className={classes.transactionSubtitle} > - +
@@ -199,6 +203,9 @@ export const TransactionBadge: React.FC = ({ input, scrollIntoView, }) => { + const { connection } = useConnection() + const showRoles = connection.moduleType === KnownContracts.ROLES + const elementRef = useScrollIntoView(scrollIntoView) return ( @@ -214,7 +221,7 @@ export const TransactionBadge: React.FC = ({ )} - + {showRoles && } ) } diff --git a/extension/src/browser/Drawer/style.module.css b/extension/src/browser/Drawer/style.module.css index d57178c1..c679ce41 100644 --- a/extension/src/browser/Drawer/style.module.css +++ b/extension/src/browser/Drawer/style.module.css @@ -97,6 +97,12 @@ word-wrap: break-word; } +.contractName { + /** Important to not use auto width, because long contract names would make overflow the container */ + width: 1px; + flex-grow: 1; +} + .inputType { padding-left: var(--spacing-1); opacity: 0.7; diff --git a/extension/src/browser/ProvideProvider.tsx b/extension/src/browser/ProvideProvider.tsx index 6db74321..1560a340 100644 --- a/extension/src/browser/ProvideProvider.tsx +++ b/extension/src/browser/ProvideProvider.tsx @@ -8,7 +8,7 @@ import React, { } from 'react' import { decodeSingle, encodeMulti, encodeSingle } from 'react-multisend' -import { ChainId } from '../networks' +import { ChainId, EXPLORER_API_KEY } from '../networks' import { ForkProvider, useTenderlyProvider, @@ -96,7 +96,8 @@ const ProvideProvider: React.FC = ({ simulate, children }) => { chainId as ChainId, address, data, - new Web3Provider(provider) + new Web3Provider(provider), + EXPLORER_API_KEY[chainId as ChainId] || undefined ), txId ) diff --git a/extension/src/browser/fetchAbi.ts b/extension/src/browser/fetchAbi.ts index 5de62026..9ed5b943 100644 --- a/extension/src/browser/fetchAbi.ts +++ b/extension/src/browser/fetchAbi.ts @@ -50,7 +50,7 @@ const abiForAddress = async ( blockExplorerApiKey = '' ): Promise => { const abiLoader = new loaders.MultiABILoader([ - new loaders.SourcifyABILoader(), + // new loaders.SourcifyABILoader(), // doesn't work in the current version (v0.2.1) new loaders.EtherscanABILoader({ apiKey: blockExplorerApiKey, baseURL: EXPLORER_API_URL[network], diff --git a/extension/src/components/Address/index.tsx b/extension/src/components/Address/index.tsx index b053c091..f2ffeb40 100644 --- a/extension/src/components/Address/index.tsx +++ b/extension/src/components/Address/index.tsx @@ -4,6 +4,7 @@ import React from 'react' import { RiExternalLinkLine, RiFileCopyLine } from 'react-icons/ri' import { useConnection } from '../../settings' +import { validateAddress } from '../../utils' import Blockie from '../Blockie' import Box from '../Box' import IconButton from '../IconButton' @@ -32,8 +33,9 @@ const EXPLORER_URLS: Record = { } export const shortenAddress = (address: string): string => { - const start = address.substring(0, VISIBLE_START + 2) - const end = address.substring(42 - VISIBLE_END, 42) + const checksumAddress = validateAddress(address) + const start = checksumAddress.substring(0, VISIBLE_START + 2) + const end = checksumAddress.substring(42 - VISIBLE_END, 42) return `${start}...${end}` } @@ -47,19 +49,19 @@ const Address: React.FC = ({ connection: { chainId }, } = useConnection() const explorerUrl = chainId && EXPLORER_URLS[chainId] - - const displayAddress = shortenAddress(address) + const checksumAddress = validateAddress(address) + const displayAddress = shortenAddress(checksumAddress) return (
{displayAddress}
- + {address && } {copyToClipboard && ( { - copy(address) + copy(checksumAddress) }} > diff --git a/extension/src/components/Address/style.module.css b/extension/src/components/Address/style.module.css index 6757545b..6987019e 100644 --- a/extension/src/components/Address/style.module.css +++ b/extension/src/components/Address/style.module.css @@ -4,13 +4,12 @@ gap: 8px; background-color: rgba(217, 212, 173, 0.2); padding-left: 32px; - padding-right: var(--spacing-1); - height: 30px; + padding-right: calc(var(--spacing-1) - 1px); + height: 40px; } .blockies { - height: 20px; - width: 20px; + height: 100%; } .address { @@ -21,3 +20,9 @@ .link { text-decoration: none; } + +.blockieContainer { + height: 100%; + width: auto; + aspect-ratio: 1; +} diff --git a/extension/src/components/ConnectionBubble/index.tsx b/extension/src/components/ConnectionBubble/index.tsx index 768c3037..37439c9f 100644 --- a/extension/src/components/ConnectionBubble/index.tsx +++ b/extension/src/components/ConnectionBubble/index.tsx @@ -24,12 +24,14 @@ const ConnectionBubble: React.FC = () => { className={classes.blockie} /> - - - + {connection.moduleAddress && ( + + + + )} = ({ addressBoxClass, }) => { const { avatarAddress, moduleAddress, pilotAddress, moduleType } = connection - const redundant = avatarAddress === moduleAddress return (
= ({ )} - {!redundant && ( + {moduleAddress && ( = ({ className={cn([classes.address, addressBoxClass])} >
- {moduleAddress && ( -
-

{MODULE_NAMES[moduleType] || 'Zodiac'} Mod

-
- )} +
+

{(moduleType && MODULE_NAMES[moduleType]) || 'Zodiac'} Mod

+
)} = ({
{avatarAddress && (
-

Impersonated Safe

+

Piloted Safe

)} diff --git a/extension/src/components/Select/ModSelect.tsx b/extension/src/components/Select/ModSelect.tsx deleted file mode 100644 index 03178c89..00000000 --- a/extension/src/components/Select/ModSelect.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import React from 'react' -import { Props } from 'react-select' - -import { Select } from '..' -import Blockie from '../Blockie' -import Box from '../Box' - -import classes from './style.module.css' - -interface LabelProps { - value: string - label: string -} - -// react-select can't infer the type of Option here, so it expects unknown, -// hence the weird typing method below -const ModuleOptionLabel = (data: unknown) => { - const props = data as LabelProps - return ( -
- - - -
-

{props.label}

- {props.value} -
-
- ) -} - -const ModSelect: React.FC = (props) => { - return ( - + return { + const sanitized = ev.target.value.trim().replace(/^[a-z]{3}:/g, '') + setPendingValue(sanitized) + if (validateAddress(sanitized)) { + onChange(sanitized.toLowerCase()) + } + }} + /> + )} + + ) +} + +const SafeOptionLabel: React.FC = (opt) => { + const option = opt as Option + + const checksumAddress = getAddress(option.value) + return ( +
+ + + +
+ {checksumAddress} +
+
+ ) +} + +export default AvatarInput diff --git a/extension/src/settings/Connection/AvatarInput/style.module.css b/extension/src/settings/Connection/AvatarInput/style.module.css new file mode 100644 index 00000000..70bfcb4c --- /dev/null +++ b/extension/src/settings/Connection/AvatarInput/style.module.css @@ -0,0 +1,57 @@ +.avatarContainer { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 10px; + border: 1px solid rgba(217, 212, 173, 0.8); +} + +.avatar { + display: flex; + gap: 16px; + align-items: center; +} + +.avatarAddress { + font-size: 14px; +} + +.avatarBlockie { + width: 40px; + height: 40px; +} + +button.removeButton { + background: none; + width: fit-content; + padding: 4px 8px; + font-size: 14px; +} + +.safeOption { + display: flex; + align-items: center; + gap: 16px; + padding: 12px 0; +} + +.safeOption .safeBlockie { + width: 40px; + height: 40px; +} + +.safeLabel { + display: flex; + flex-direction: column; + gap: 4px; +} + +.safeLabel .type { + font-family: 'Spectra'; + font-size: 1rem; + padding-left: 4px; +} + +.safeLabel .address { + font-size: 14px; +} diff --git a/extension/src/settings/Connection/ConnectButton/index.tsx b/extension/src/settings/Connection/ConnectButton/index.tsx index 6d2918ad..d61ba0e0 100644 --- a/extension/src/settings/Connection/ConnectButton/index.tsx +++ b/extension/src/settings/Connection/ConnectButton/index.tsx @@ -8,6 +8,7 @@ import { ChainId } from '../../../networks' import { useMetaMask, useWalletConnect } from '../../../providers' import PUBLIC_PATH from '../../../publicPath' import { ProviderType } from '../../../types' +import { validateAddress } from '../../../utils' import { useConnection, useConnections } from '../../connectionHooks' import metamaskLogoUrl from './metamask-logo.svg' @@ -44,7 +45,7 @@ const ConnectButton: React.FC<{ id: string }> = ({ id }) => { ...connection, providerType, chainId, - pilotAddress: account, + pilotAddress: account.toLowerCase(), } : c ) @@ -74,7 +75,7 @@ const ConnectButton: React.FC<{ id: string }> = ({ id }) => { : metamaskLogo}
- {connection.pilotAddress} + {validateAddress(connection.pilotAddress)} - - ) : ( - { - const avatarAddress = ev.target.value.replace( - /^[a-z]{3}:/g, - '' - ) - updateConnection({ - avatarAddress, - moduleAddress: '', - moduleType: undefined, - }) - }} - /> - )} + + + updateConnection({ + avatarAddress: address, + moduleAddress: '', + moduleType: undefined, + }) + } + /> ({ - value: mod.moduleAddress, - label: MODULE_NAMES[mod.type], - }))} + options={[ + ...(pilotIsOwner || pilotIsDelegate ? [NO_MODULE_OPTION] : []), + ...modules.map((mod) => ({ + value: mod.moduleAddress, + label: `${MODULE_NAMES[mod.type]} Mod`, + })), + ]} onChange={(selected) => { const mod = modules.find( - (mod) => - mod.moduleAddress === - (selected as { value: string; label: string }).value + (mod) => mod.moduleAddress === (selected as Option).value ) updateConnection({ moduleAddress: mod?.moduleAddress, @@ -148,10 +135,11 @@ const EditConnection: React.FC = ({ id }) => { value: selectedModule.moduleAddress, label: MODULE_NAMES[selectedModule.type], } - : '' + : defaultModOption } - isDisabled={loading || !isValidSafe} - placeholder={loading || !isValidSafe ? '' : 'Select a module'} + isDisabled={loadingMods || !isValidSafe} + placeholder={loadingMods || !isValidSafe ? '' : 'Select a module'} + avatarAddress={avatarAddress} /> {selectedModule?.type === KnownContracts.ROLES && ( diff --git a/extension/src/settings/Connection/ModSelect/index.tsx b/extension/src/settings/Connection/ModSelect/index.tsx new file mode 100644 index 00000000..8da0e437 --- /dev/null +++ b/extension/src/settings/Connection/ModSelect/index.tsx @@ -0,0 +1,67 @@ +import { getAddress } from 'ethers/lib/utils' +import React from 'react' +import { Props as SelectProps } from 'react-select' + +import { Select } from '../../../components' +import Blockie from '../../../components/Blockie' +import Box from '../../../components/Box' + +import classes from './style.module.css' + +export const NO_MODULE_OPTION = { value: '', label: '' } +export interface Option { + value: string + label: string +} + +interface Props extends SelectProps { + avatarAddress: string +} + +const ModSelect: React.FC = (props) => { + const ModuleOptionLabel: React.FC = (props) => { + const option = props as Option + if (!option.value) return + + const checksumAddress = getAddress(option.value) + return ( +
+ + + +
+

{option.label}

+ {checksumAddress} +
+
+ ) + } + + const NoModuleOptionLabel = () => { + return ( +
+ + + +
+

No Mod — Direct execution

+ + Transactions submitted directly to the Safe + +
+
+ ) + } + return ( +