Merge branch 'development' of github.com:gnosis/safe-react into feature/#512-network-switching

This commit is contained in:
Mati Dastugue 2021-06-07 12:59:23 -03:00
commit 31180d974a
110 changed files with 1918 additions and 1680 deletions

View File

@ -1,6 +1,6 @@
{
"name": "safe-react",
"version": "3.6.7",
"version": "3.7.0",
"description": "Allowing crypto users manage funds in a safer way",
"website": "https://github.com/gnosis/safe-react#readme",
"bugs": {
@ -161,7 +161,7 @@
"@gnosis.pm/safe-apps-sdk": "3.1.0-alpha.0",
"@gnosis.pm/safe-apps-sdk-v1": "npm:@gnosis.pm/safe-apps-sdk@0.4.2",
"@gnosis.pm/safe-contracts": "1.1.1-dev.2",
"@gnosis.pm/safe-react-components": "https://github.com/gnosis/safe-react-components.git#4864ebb",
"@gnosis.pm/safe-react-components": "https://github.com/gnosis/safe-react-components.git#0e4fcd6",
"@gnosis.pm/util-contracts": "2.0.6",
"@ledgerhq/hw-transport-node-hid-singleton": "5.51.1",
"@material-ui/core": "^4.11.0",
@ -214,6 +214,7 @@
"react-ga": "3.3.0",
"react-hot-loader": "4.13.0",
"react-intersection-observer": "^8.31.0",
"react-papaparse": "^3.14.0",
"react-qr-reader": "^2.2.1",
"react-redux": "7.2.3",
"react-router-dom": "5.2.0",
@ -221,6 +222,7 @@
"react-window": "^1.8.6",
"redux": "4.0.5",
"redux-actions": "^2.6.5",
"redux-localstorage-simple": "^2.4.0",
"redux-thunk": "^2.3.0",
"reselect": "^4.0.0",
"semver": "^7.3.2",

View File

@ -20,15 +20,11 @@ import { getNetworkId } from 'src/config'
import { ETHEREUM_NETWORK } from 'src/config/networks/network.d'
import { networkSelector } from 'src/logic/wallets/store/selectors'
import { SAFELIST_ADDRESS, WELCOME_ADDRESS } from 'src/routes/routes'
import {
safeTotalFiatBalanceSelector,
safeNameSelector,
safeParamAddressFromStateSelector,
safeLoadedViaUrlSelector,
} from 'src/logic/safe/store/selectors'
import { safeTotalFiatBalanceSelector, safeParamAddressFromStateSelector } from 'src/logic/safe/store/selectors'
import { currentCurrencySelector } from 'src/logic/currencyValues/store/selectors'
import Modal from 'src/components/Modal'
import SendModal from 'src/routes/safe/components/Balances/SendModal'
import { useSafeName } from 'src/logic/addressBook/hooks/useSafeName'
import { useLoadSafe } from 'src/logic/safe/hooks/useLoadSafe'
import { useSafeScheduledUpdates } from 'src/logic/safe/hooks/useSafeScheduledUpdates'
import useSafeActions from 'src/logic/safe/hooks/useSafeActions'
@ -72,14 +68,13 @@ const App: React.FC = ({ children }) => {
const matchSafe = useRouteMatch({ path: `${SAFELIST_ADDRESS}`, strict: false })
const history = useHistory()
const safeAddress = useSelector(safeParamAddressFromStateSelector)
const safeName = useSelector(safeNameSelector) ?? ''
const safeName = useSafeName(safeAddress)
const { safeActionsState, onShow, onHide, showSendFunds, hideSendFunds } = useSafeActions()
const currentSafeBalance = useSelector(safeTotalFiatBalanceSelector)
const currentCurrency = useSelector(currentCurrencySelector)
const granted = useSelector(grantedSelector)
const sidebarItems = useSidebarItems()
const isSafeLoadedViaUrl = useSelector(safeLoadedViaUrlSelector)
const safeLoaded = useLoadSafe(safeAddress, isSafeLoadedViaUrl)
const safeLoaded = useLoadSafe(safeAddress)
useSafeScheduledUpdates(safeLoaded, safeAddress)
const sendFunds = safeActionsState.sendFunds

View File

@ -56,7 +56,7 @@ const useSidebarItems = (): ListItemType[] => {
href: `${matchSafeWithAddress?.url}/transactions`,
},
{
label: 'AddressBook',
label: 'ADDRESS BOOK',
icon: <ListIcon type="addressBook" />,
selected: matchSafeWithAction?.params.safeAction === 'address-book',
href: `${matchSafeWithAddress?.url}/address-book`,

View File

@ -9,7 +9,6 @@ import { COOKIES_KEY } from 'src/logic/cookies/model/cookie'
import { openCookieBanner } from 'src/logic/cookies/store/actions/openCookieBanner'
import { cookieBannerOpen } from 'src/logic/cookies/store/selectors'
import { loadFromCookie, saveCookie } from 'src/logic/cookies/utils'
import { useSafeAppUrl } from 'src/logic/hooks/useSafeAppUrl'
import { mainFontFamily, md, primary, screenSm } from 'src/theme/variables'
import { loadGoogleAnalytics, removeCookies } from 'src/utils/googleAnalytics'
import { closeIntercom, isIntercomLoaded, loadIntercom } from 'src/utils/intercom'
@ -98,7 +97,7 @@ interface CookiesBannerFormProps {
const CookiesBanner = (): ReactElement => {
const classes = useStyles()
const dispatch = useRef(useDispatch())
const { url: appUrl } = useSafeAppUrl()
const [showAnalytics, setShowAnalytics] = useState(false)
const [showIntercom, setShowIntercom] = useState(false)
const [localNecessary, setLocalNecessary] = useState(true)
@ -107,12 +106,6 @@ const CookiesBanner = (): ReactElement => {
const showBanner = useSelector(cookieBannerOpen)
useEffect(() => {
if (appUrl) {
setTimeout(closeIntercom, 50)
}
}, [appUrl])
useEffect(() => {
async function fetchCookiesFromStorage() {
const cookiesState = await loadFromCookie(COOKIES_KEY)
@ -178,7 +171,7 @@ const CookiesBanner = (): ReactElement => {
dispatch.current(openCookieBanner({ cookieBannerOpen: false }))
}
if (showIntercom && !appUrl) {
if (showIntercom) {
loadIntercom()
}

View File

@ -1,15 +1,17 @@
import React from 'react'
import styled from 'styled-components'
import { ButtonLink, EthHashInfo, Text } from '@gnosis.pm/safe-react-components'
import { makeStyles } from '@material-ui/core/styles'
import React, { ReactElement } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import styled from 'styled-components'
import { safeNameSelector } from 'src/logic/safe/store/selectors'
import { formatAmount } from 'src/logic/tokens/utils/formatAmount'
import { sameAddress } from 'src/logic/wallets/ethAddresses'
import DefaultBadge from './DefaultBadge'
import { SafeRecordProps } from 'src/logic/safe/store/models/safe'
import { DefaultSafe } from 'src/routes/safe/store/reducer/types/safe'
import setDefaultSafe from 'src/logic/safe/store/actions/setDefaultSafe'
import { makeStyles } from '@material-ui/core/styles'
import { getNetworkInfo } from 'src/config'
import { useDispatch } from 'react-redux'
const StyledButtonLink = styled(ButtonLink)`
visibility: hidden;
@ -51,10 +53,10 @@ type Props = {
const { nativeCoin } = getNetworkInfo()
export const AddressWrapper = (props: Props): React.ReactElement => {
export const AddressWrapper = ({ safe, defaultSafe }: Props): ReactElement => {
const classes = useStyles()
const { safe, defaultSafe } = props
const dispatch = useDispatch()
const safeName = useSelector((state) => safeNameSelector(state, safe.address))
const setDefaultSafeAction = (safeAddress: string) => {
dispatch(setDefaultSafe(safeAddress))
@ -62,7 +64,7 @@ export const AddressWrapper = (props: Props): React.ReactElement => {
return (
<div className={classes.wrapper}>
<EthHashInfo hash={safe.address} name={safe.name} showAvatar shortenHash={4} />
<EthHashInfo hash={safe.address} name={safeName} showAvatar shortenHash={4} />
<div className={classes.addressDetails}>
<Text size="xl">{`${formatAmount(safe.ethBalance)} ${nativeCoin.name}`}</Text>

View File

@ -39,7 +39,7 @@ type Props = {
export const SafeListSidebar = ({ children }: Props): ReactElement => {
const [isOpen, setIsOpen] = useState(false)
const [filter, setFilter] = useState('')
const safes = useSelector(sortedSafeListSelector).filter((safe) => !safe.loadedViaUrl)
const safes = useSelector(sortedSafeListSelector)
const defaultSafe = useSelector(defaultSafeSelector)
const currentSafe = useSelector(safeParamAddressFromStateSelector)

View File

@ -1,7 +1,10 @@
import { createSelector } from 'reselect'
import { safesListSelector } from 'src/logic/safe/store/selectors'
import { safesListWithAddressBookNameSelector } from 'src/logic/safe/store/selectors'
export const sortedSafeListSelector = createSelector(safesListSelector, (safes) =>
safes.sort((a, b) => (a.name > b.name ? 1 : -1)),
/**
* Sort safe list by the name in the address book
*/
export const sortedSafeListSelector = createSelector([safesListWithAddressBookNameSelector], (safes) =>
safes.sort((a, b) => (a.name.toLowerCase() > b.name.toLowerCase() ? 1 : -1)),
)

View File

@ -14,6 +14,7 @@ import {
addressIsNotCurrentSafe,
OWNER_ADDRESS_IS_SAFE_ADDRESS_ERROR,
mustBeHexData,
validAddressBookName,
} from 'src/components/forms/validator'
describe('Forms > Validators', () => {
@ -249,4 +250,26 @@ describe('Forms > Validators', () => {
expect(differentFrom('a')('a')).toEqual(getDifferentFromErrMsg('a'))
})
})
describe('validAddressBookName validator', () => {
it('Returns error for an empty string', () => {
expect(validAddressBookName('')).toBe('Should be 1 to 50 symbols')
})
it('Returns error for a name longer than 50 chars', () => {
expect(validAddressBookName('abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabc')).toBe(
'Should be 1 to 50 symbols',
)
})
it('Returns error for a blacklisted name', () => {
const blacklistedErrorMessage = 'Name should not include: UNKNOWN, OWNER #, MY WALLET'
expect(validAddressBookName('unknown')).toBe(blacklistedErrorMessage)
expect(validAddressBookName('unknown a')).toBe(blacklistedErrorMessage)
expect(validAddressBookName('owner #1')).toBe(blacklistedErrorMessage)
expect(validAddressBookName('My Wallet')).toBe(blacklistedErrorMessage)
})
it('Returns undefined for a non-blacklisted name', () => {
expect(validAddressBookName('A valid name')).toBeUndefined()
})
})
})

View File

@ -1,10 +1,11 @@
import { List } from 'immutable'
import memoize from 'lodash.memoize'
import { sameAddress } from 'src/logic/wallets/ethAddresses'
import { getWeb3 } from 'src/logic/wallets/getWeb3'
import { isFeatureEnabled } from 'src/config'
import { FEATURES } from 'src/config/networks/network.d'
import { isValidAddress } from 'src/utils/isValidAddress'
import { ADDRESS_BOOK_INVALID_NAMES, isValidAddressBookName } from 'src/logic/addressBook/utils'
type ValidatorReturnType = string | undefined
export type GenericValidatorType = (...args: unknown[]) => ValidatorReturnType
@ -74,9 +75,7 @@ export const mustBeHexData = (data: string): ValidatorReturnType => {
export const mustBeAddressHash = memoize(
(address: string): ValidatorReturnType => {
const errorMessage = 'Must be a valid address'
const startsWith0x = address?.startsWith('0x')
const isAddress = getWeb3().utils.isAddress(address)
return startsWith0x && isAddress ? undefined : errorMessage
return isValidAddress(address) ? undefined : errorMessage
},
)
@ -101,8 +100,12 @@ export const mustBeEthereumContractAddress = memoize(
},
)
export const minMaxLength = (minLen: number, maxLen: number) => (value: string): ValidatorReturnType =>
value.length >= +minLen && value.length <= +maxLen ? undefined : `Should be ${minLen} to ${maxLen} symbols`
export const minMaxLength = (minLen: number, maxLen: number) => (value: string): ValidatorReturnType => {
const testValue = value || ''
return testValue.length >= +minLen && testValue.length <= +maxLen
? undefined
: `Should be ${minLen} to ${maxLen} symbols`
}
export const minMaxDecimalsLength = (minLen: number, maxLen: number) => (value: string): ValidatorReturnType => {
const decimals = value.split('.')[1] || '0'
@ -113,7 +116,7 @@ export const minMaxDecimalsLength = (minLen: number, maxLen: number) => (value:
export const ADDRESS_REPEATED_ERROR = 'Address already introduced'
export const OWNER_ADDRESS_IS_SAFE_ADDRESS_ERROR = 'Cannot use Safe itself as owner.'
export const uniqueAddress = (addresses: string[] | List<string> = []) => (address?: string): string | undefined => {
export const uniqueAddress = (addresses: string[] = []) => (address?: string): string | undefined => {
const addressExists = addresses.some((addressFromList) => sameAddress(addressFromList, address))
return addressExists ? ADDRESS_REPEATED_ERROR : undefined
}
@ -136,3 +139,15 @@ export const differentFrom = (diffValue: number | string) => (value: string): Va
}
export const noErrorsOn = (name: string, errors: Record<string, unknown>): boolean => errors[name] === undefined
export const validAddressBookName = (name: string): string | undefined => {
const lengthError = minMaxLength(1, 50)(name)
if (lengthError === undefined) {
return isValidAddressBookName(name)
? undefined
: `Name should not include: ${ADDRESS_BOOK_INVALID_NAMES.join(', ')}`
}
return lengthError
}

View File

@ -34,6 +34,7 @@ type Token = {
}
export enum ETHEREUM_NETWORK {
UNKNOWN = 0,
MAINNET = 1,
MORDEN = 2,
ROPSTEN = 3,
@ -42,9 +43,8 @@ export enum ETHEREUM_NETWORK {
KOVAN = 42,
XDAI = 100,
ENERGY_WEB_CHAIN = 246,
VOLTA = 73799,
UNKNOWN = 0,
LOCAL = 4447,
VOLTA = 73799,
}
export type NetworkSettings = {

View File

@ -0,0 +1,10 @@
import { useSelector } from 'react-redux'
import { getNameFromAddressBook } from 'src/logic/addressBook/utils'
import { addressBookSelector } from 'src/logic/addressBook/store/selectors'
export const useSafeName = (safeAddress: string): string => {
const addressBook = useSelector(addressBookSelector)
return getNameFromAddressBook(addressBook, safeAddress) || ''
}

View File

@ -1,17 +1,28 @@
import { ETHEREUM_NETWORK } from 'src/config/networks/network.d'
import { getNetworkId } from 'src/config'
export const ADDRESS_BOOK_DEFAULT_NAME = 'UNKNOWN'
export type AddressBookEntry = {
address: string
name: string
address: string // the contact address
name: string // human-readable name
chainId: ETHEREUM_NETWORK // see https://chainid.network
}
const networkId = getNetworkId()
export const makeAddressBookEntry = ({
address = '',
name = '',
address,
name,
chainId = networkId,
}: {
address: string
name?: string
name: string
chainId?: number
}): AddressBookEntry => ({
address,
name,
chainId,
})
export type AddressBookState = AddressBookEntry[]

View File

@ -1,22 +0,0 @@
import { createAction } from 'redux-actions'
import { AddressBookEntry } from 'src/logic/addressBook/model/addressBook'
export const ADD_ENTRY = 'ADD_ENTRY'
type addAddressBookEntryOptions = {
notifyEntryUpdate: boolean
}
export const addAddressBookEntry = createAction(
ADD_ENTRY,
(entry: AddressBookEntry, options?: addAddressBookEntryOptions) => {
let notifyEntryUpdate = true
if (options) {
notifyEntryUpdate = options.notifyEntryUpdate
}
return {
entry,
shouldAvoidUpdatesNotifications: !notifyEntryUpdate,
}
},
)

View File

@ -1,8 +0,0 @@
import { createAction } from 'redux-actions'
import { AddressBookEntry } from 'src/logic/addressBook/model/addressBook'
export const ADD_OR_UPDATE_ENTRY = 'ADD_OR_UPDATE_ENTRY'
export const addOrUpdateAddressBookEntry = createAction(ADD_OR_UPDATE_ENTRY, (entry: AddressBookEntry) => ({
entry,
}))

View File

@ -0,0 +1,17 @@
import { createAction } from 'redux-actions'
import { AddressBookEntry, AddressBookState } from 'src/logic/addressBook/model/addressBook'
// following the suggested naming convention at
// https://redux.js.org/style-guide/style-guide#write-action-types-as-domaineventname
export enum ADDRESS_BOOK_ACTIONS {
ADD_OR_UPDATE = 'addressBook/addOrUpdate',
REMOVE = 'addressBook/remove',
IMPORT = 'addressBook/import',
SAFE_LOAD = 'addressBook/safeLoad',
}
export const addressBookAddOrUpdate = createAction<AddressBookEntry>(ADDRESS_BOOK_ACTIONS.ADD_OR_UPDATE)
export const addressBookRemove = createAction<AddressBookEntry>(ADDRESS_BOOK_ACTIONS.REMOVE)
export const addressBookSafeLoad = createAction<AddressBookState>(ADDRESS_BOOK_ACTIONS.SAFE_LOAD)
export const addressBookImport = createAction<AddressBookState>(ADDRESS_BOOK_ACTIONS.IMPORT)

View File

@ -1,8 +0,0 @@
import { createAction } from 'redux-actions'
import { AddressBookState } from 'src/logic/addressBook/model/addressBook'
export const LOAD_ADDRESS_BOOK = 'LOAD_ADDRESS_BOOK'
export const loadAddressBook = createAction(LOAD_ADDRESS_BOOK, (addressBook: AddressBookState) => ({
addressBook,
}))

View File

@ -1,22 +0,0 @@
import { loadAddressBook } from 'src/logic/addressBook/store/actions/loadAddressBook'
import { buildAddressBook } from 'src/logic/addressBook/store/reducer/addressBook'
import { getAddressBookFromStorage } from 'src/logic/addressBook/utils'
import { Dispatch } from 'redux'
const loadAddressBookFromStorage = () => async (dispatch: Dispatch): Promise<void> => {
try {
let storedAdBk = await getAddressBookFromStorage()
if (!storedAdBk) {
storedAdBk = []
}
const addressBook = buildAddressBook(storedAdBk)
dispatch(loadAddressBook(addressBook))
} catch (err) {
// eslint-disable-next-line
console.error('Error while loading active tokens from storage:', err)
}
}
export default loadAddressBookFromStorage

View File

@ -1,7 +0,0 @@
import { createAction } from 'redux-actions'
export const REMOVE_ENTRY = 'REMOVE_ENTRY'
export const removeAddressBookEntry = createAction(REMOVE_ENTRY, (entryAddress: string) => ({
entryAddress,
}))

View File

@ -1,8 +0,0 @@
import { createAction } from 'redux-actions'
import { AddressBookEntry } from 'src/logic/addressBook/model/addressBook'
export const UPDATE_ENTRY = 'UPDATE_ENTRY'
export const updateAddressBookEntry = createAction(UPDATE_ENTRY, (entry: AddressBookEntry) => ({
entry,
}))

View File

@ -1,61 +0,0 @@
import { ADD_ENTRY } from 'src/logic/addressBook/store/actions/addAddressBookEntry'
import { ADD_OR_UPDATE_ENTRY } from 'src/logic/addressBook/store/actions/addOrUpdateAddressBookEntry'
import { REMOVE_ENTRY } from 'src/logic/addressBook/store/actions/removeAddressBookEntry'
import { UPDATE_ENTRY } from 'src/logic/addressBook/store/actions/updateAddressBookEntry'
import { addressBookSelector } from 'src/logic/addressBook/store/selectors'
import { saveAddressBook } from 'src/logic/addressBook/utils'
import { enhanceSnackbarForAction, getNotificationsFromTxType } from 'src/logic/notifications'
import enqueueSnackbar from 'src/logic/notifications/store/actions/enqueueSnackbar'
import { TX_NOTIFICATION_TYPES } from 'src/logic/safe/transactions'
import { safesListSelector } from 'src/logic/safe/store/selectors'
import { sameAddress } from 'src/logic/wallets/ethAddresses'
import { updateSafe } from 'src/logic/safe/store/actions/updateSafe'
const watchedActions = [ADD_ENTRY, REMOVE_ENTRY, UPDATE_ENTRY, ADD_OR_UPDATE_ENTRY]
const addressBookMiddleware = (store) => (next) => async (action) => {
const handledAction = next(action)
if (watchedActions.includes(action.type)) {
const state = store.getState()
const { dispatch } = store
const addressBook = addressBookSelector(state)
const safes = safesListSelector(state)
if (addressBook.length) {
await saveAddressBook(addressBook)
}
switch (action.type) {
case ADD_ENTRY: {
const { shouldAvoidUpdatesNotifications } = action.payload
if (!shouldAvoidUpdatesNotifications) {
const notification = getNotificationsFromTxType(TX_NOTIFICATION_TYPES.ADDRESSBOOK_NEW_ENTRY)
dispatch(enqueueSnackbar(enhanceSnackbarForAction(notification.afterExecution.noMoreConfirmationsNeeded)))
}
break
}
case REMOVE_ENTRY: {
const notification = getNotificationsFromTxType(TX_NOTIFICATION_TYPES.ADDRESSBOOK_DELETE_ENTRY)
dispatch(enqueueSnackbar(enhanceSnackbarForAction(notification.afterExecution.noMoreConfirmationsNeeded)))
break
}
case UPDATE_ENTRY: {
const { entry } = action.payload
const notification = getNotificationsFromTxType(TX_NOTIFICATION_TYPES.ADDRESSBOOK_EDIT_ENTRY)
dispatch(enqueueSnackbar(enhanceSnackbarForAction(notification.afterExecution.noMoreConfirmationsNeeded)))
const safeFound = safes.find((safe) => sameAddress(safe.address, entry.address))
if (safeFound) {
dispatch(updateSafe({ address: safeFound.address, name: entry.name }))
}
break
}
default:
break
}
}
return handledAction
}
export default addressBookMiddleware

View File

@ -0,0 +1,37 @@
import { ADDRESS_BOOK_ACTIONS } from 'src/logic/addressBook/store/actions'
import { enhanceSnackbarForAction, getNotificationsFromTxType } from 'src/logic/notifications'
import enqueueSnackbar from 'src/logic/notifications/store/actions/enqueueSnackbar'
import { TX_NOTIFICATION_TYPES } from 'src/logic/safe/transactions'
const watchedActions = Object.values(ADDRESS_BOOK_ACTIONS)
export const addressBookMiddleware = (store) => (next) => async (action) => {
const handledAction = next(action)
if (watchedActions.includes(action.type)) {
const { dispatch } = store
switch (action.type) {
case ADDRESS_BOOK_ACTIONS.ADD_OR_UPDATE: {
const { shouldAvoidUpdatesNotifications } = action.payload
if (!shouldAvoidUpdatesNotifications) {
const notification = getNotificationsFromTxType(TX_NOTIFICATION_TYPES.ADDRESS_BOOK_NEW_ENTRY)
dispatch(enqueueSnackbar(enhanceSnackbarForAction(notification.afterExecution.noMoreConfirmationsNeeded)))
}
break
}
case ADDRESS_BOOK_ACTIONS.REMOVE: {
const notification = getNotificationsFromTxType(TX_NOTIFICATION_TYPES.ADDRESS_BOOK_DELETE_ENTRY)
dispatch(enqueueSnackbar(enhanceSnackbarForAction(notification.afterExecution.noMoreConfirmationsNeeded)))
break
}
case ADDRESS_BOOK_ACTIONS.IMPORT: {
const notification = getNotificationsFromTxType(TX_NOTIFICATION_TYPES.ADDRESS_BOOK_IMPORT_ENTRIES)
dispatch(enqueueSnackbar(enhanceSnackbarForAction(notification.afterExecution.noMoreConfirmationsNeeded)))
break
}
default:
break
}
}
return handledAction
}

View File

@ -1,78 +0,0 @@
import { Action, handleActions } from 'redux-actions'
import { AddressBookEntry, AddressBookState, makeAddressBookEntry } from 'src/logic/addressBook/model/addressBook'
import { ADD_ENTRY } from 'src/logic/addressBook/store/actions/addAddressBookEntry'
import { ADD_OR_UPDATE_ENTRY } from 'src/logic/addressBook/store/actions/addOrUpdateAddressBookEntry'
import { LOAD_ADDRESS_BOOK } from 'src/logic/addressBook/store/actions/loadAddressBook'
import { REMOVE_ENTRY } from 'src/logic/addressBook/store/actions/removeAddressBookEntry'
import { UPDATE_ENTRY } from 'src/logic/addressBook/store/actions/updateAddressBookEntry'
import { AppReduxState } from 'src/store'
import { checksumAddress } from 'src/utils/checksumAddress'
import { getValidAddressBookName } from 'src/logic/addressBook/utils'
export const ADDRESS_BOOK_REDUCER_ID = 'addressBook'
export const buildAddressBook = (storedAddressBook: AddressBookState): AddressBookState => {
return storedAddressBook.map((addressBookEntry) => {
const { address, name } = addressBookEntry
return makeAddressBookEntry({ address: checksumAddress(address), name })
})
}
type AddressBookPayload = { addressBook: AddressBookState }
type EntryPayload = { entry: AddressBookEntry }
type RemoveEntryPayload = { entryAddress: string }
type Payloads = AddressBookPayload | EntryPayload | RemoveEntryPayload
export default handleActions<AppReduxState['addressBook'], Payloads>(
{
[LOAD_ADDRESS_BOOK]: (state, action: Action<AddressBookPayload>) => {
const { addressBook } = action.payload
return addressBook
},
[ADD_ENTRY]: (state, action: Action<EntryPayload>) => {
const { entry } = action.payload
const entryFound = state.find((oldEntry) => oldEntry.address === entry.address)
if (!entryFound) {
state.push(entry)
}
return state
},
[UPDATE_ENTRY]: (state, action: Action<EntryPayload>) => {
const { entry } = action.payload
const entryIndex = state.findIndex((oldEntry) => oldEntry.address === entry.address)
if (entryIndex >= 0) {
state[entryIndex] = entry
}
return state
},
[REMOVE_ENTRY]: (state, action: Action<RemoveEntryPayload>) => {
const { entryAddress } = action.payload
const entryIndex = state.findIndex((oldEntry) => oldEntry.address === entryAddress)
state.splice(entryIndex, 1)
return state
},
[ADD_OR_UPDATE_ENTRY]: (state, action: Action<EntryPayload>) => {
const { entry } = action.payload
// Only updates entries with valid names
const validName = getValidAddressBookName(entry.name)
if (!validName) {
return state
}
const entryIndex = state.findIndex((oldEntry) => oldEntry.address === entry.address)
if (entryIndex >= 0) {
state[entryIndex] = entry
} else {
state.push(entry)
}
return state
},
},
[],
)

View File

@ -0,0 +1,66 @@
import { Action, handleActions } from 'redux-actions'
import { AddressBookEntry, AddressBookState } from 'src/logic/addressBook/model/addressBook'
import { ADDRESS_BOOK_ACTIONS } from 'src/logic/addressBook/store/actions'
import { getEntryIndex, isValidAddressBookName } from 'src/logic/addressBook/utils'
import { AppReduxState } from 'src/store'
export const ADDRESS_BOOK_REDUCER_ID = 'addressBook'
type Payloads = AddressBookEntry | AddressBookState
const batchLoadEntries = (state, action: Action<AddressBookState>): AddressBookState => {
const newState = [...state]
const addressBookEntries = action.payload
addressBookEntries
// exclude those entries with invalid name
.filter(({ name }) => isValidAddressBookName(name))
.forEach((addressBookEntry) => {
const entryIndex = getEntryIndex(newState, addressBookEntry)
if (entryIndex >= 0) {
// update
newState[entryIndex] = addressBookEntry
} else {
// add
newState.push(addressBookEntry)
}
})
return newState
}
export default handleActions<AppReduxState['addressBook'], Payloads>(
{
[ADDRESS_BOOK_ACTIONS.ADD_OR_UPDATE]: (state, action: Action<AddressBookEntry>) => {
const newState = [...state]
const addressBookEntry = action.payload
const entryIndex = getEntryIndex(newState, addressBookEntry)
// update
if (entryIndex >= 0) {
newState[entryIndex] = addressBookEntry
return newState
}
// add
return [...newState, addressBookEntry]
},
[ADDRESS_BOOK_ACTIONS.REMOVE]: (state, action: Action<AddressBookEntry>) => {
const newState = [...state]
const addressBookEntry = action.payload
const entryIndex = getEntryIndex(newState, addressBookEntry)
if (entryIndex >= 0) {
newState.splice(entryIndex, 1)
return newState
}
return newState
},
[ADDRESS_BOOK_ACTIONS.SAFE_LOAD]: batchLoadEntries,
[ADDRESS_BOOK_ACTIONS.IMPORT]: batchLoadEntries,
},
[],
)

View File

@ -1,27 +1,56 @@
import { AppReduxState } from 'src/store'
import { createSelector } from 'reselect'
import { ADDRESS_BOOK_REDUCER_ID } from 'src/logic/addressBook/store/reducer/addressBook'
import { getNetworkId } from 'src/config'
import { ADDRESS_BOOK_DEFAULT_NAME, AddressBookEntry } from 'src/logic/addressBook/model/addressBook'
import { AppReduxState } from 'src/store'
import { Overwrite } from 'src/types/helpers'
import { AddressBookState } from 'src/logic/addressBook/model/addressBook'
const networkId = getNetworkId()
export const addressBookSelector = (state: AppReduxState): AddressBookState => state[ADDRESS_BOOK_REDUCER_ID]
export const addressBookSelector = (state: AppReduxState): AppReduxState['addressBook'] => state['addressBook']
export const addressBookAddressesListSelector = (state: AppReduxState): string[] => {
const addressBook = addressBookSelector(state)
return addressBook.map((entry) => entry.address)
type AddressBookMap = {
[chainId: number]: {
[address: string]: AddressBookEntry
}
}
export const getNameFromAddressBookSelector = createSelector(
addressBookSelector,
(_, address) => address,
(addressBook, address) => {
const adbkEntry = addressBook.find((addressBookItem) => addressBookItem.address === address)
export const addressBookMapSelector = createSelector(
[addressBookSelector],
(addressBook): AddressBookMap => {
const addressBookMap = {}
if (adbkEntry) {
return adbkEntry.name
}
return 'UNKNOWN'
addressBook.forEach((entry) => {
const { address, chainId } = entry
if (!addressBookMap[chainId]) {
addressBookMap[chainId] = { [address]: entry }
} else {
addressBookMap[chainId][address] = entry
}
})
return addressBookMap
},
)
export const addressBookAddressesListSelector = createSelector([addressBookSelector], (addressBook): string[] =>
addressBook.map(({ address }) => address),
)
type GetNameParams = Overwrite<
AddressBookEntry,
{ chainId?: AddressBookEntry['chainId']; name?: AddressBookEntry['name'] }
>
type GetNameReturnObject = Overwrite<GetNameParams, { chainId: AddressBookEntry['chainId'] }>
export const getNameFromAddressBookSelector = createSelector(
[
addressBookMapSelector,
(_, { address, chainId = networkId }: GetNameParams): GetNameReturnObject => ({
address,
chainId,
}),
],
(addressBook, entry) => addressBook?.[entry.chainId]?.[entry.address]?.name ?? ADDRESS_BOOK_DEFAULT_NAME,
)

View File

@ -1,16 +1,8 @@
import { List } from 'immutable'
import {
checkIfEntryWasDeletedFromAddressBook,
getAddressBookFromStorage,
getNameFromAddressBook,
getOwnersWithNameFromAddressBook,
isValidAddressBookName,
migrateOldAddressBook,
OldAddressBookEntry,
OldAddressBookType,
saveAddressBook,
} from 'src/logic/addressBook/utils/index'
import { buildAddressBook } from 'src/logic/addressBook/store/reducer/addressBook'
} from 'src/logic/addressBook/utils'
import { AddressBookEntry, AddressBookState, makeAddressBookEntry } from 'src/logic/addressBook/model/addressBook'
const getMockAddressBookEntry = (address: string, name: string = 'test'): AddressBookEntry =>
@ -19,14 +11,6 @@ const getMockAddressBookEntry = (address: string, name: string = 'test'): Addres
name,
})
const getMockOldAddressBookEntry = ({ address = '', name = '', isOwner = false }): OldAddressBookEntry => {
return {
address,
name,
isOwner,
}
}
describe('getNameFromSafeAddressBook', () => {
const entry1 = getMockAddressBookEntry('123456', 'test1')
const entry2 = getMockAddressBookEntry('78910', 'test2')
@ -44,155 +28,6 @@ describe('getNameFromSafeAddressBook', () => {
})
})
describe('getOwnersWithNameFromAddressBook', () => {
const entry1 = getMockAddressBookEntry('123456', 'test1')
const entry2 = getMockAddressBookEntry('78910', 'test2')
const entry3 = getMockAddressBookEntry('4781321', 'test3')
it('It should returns the list of owners with their names given a safeAddressBook and a list of owners', () => {
// given
const safeAddressBook = [entry1, entry2, entry3]
const ownerList = List([
{ address: entry1.address, name: '' },
{ address: entry2.address, name: '' },
])
const expectedResult = List([
{ address: entry1.address, name: entry1.name },
{ address: entry2.address, name: entry2.name },
])
// when
const result = getOwnersWithNameFromAddressBook(safeAddressBook, ownerList)
// then
expect(result).toStrictEqual(expectedResult)
})
})
jest.mock('src/utils/storage/index')
describe('saveAddressBook', () => {
const mockAdd1 = '0x696fd93D725d84acfFf6c62a1fe8C94E1c9E934A'
const mockAdd2 = '0x2C7aC78b01Be0FC66AD29b684ffAb0C93B381D00'
const mockAdd3 = '0x537BD452c3505FC07bA242E437bD29D4E1DC9126'
const entry1 = getMockAddressBookEntry(mockAdd1, 'test1')
const entry2 = getMockAddressBookEntry(mockAdd2, 'test2')
const entry3 = getMockAddressBookEntry(mockAdd3, 'test3')
afterAll(() => {
jest.unmock('src/utils/storage/index')
})
it('It should save a given addressBook to the localStorage', async () => {
// given
const addressBook: AddressBookState = [entry1, entry2, entry3]
// when
await saveAddressBook(addressBook)
const storageUtils = require('src/utils/storage/index')
const spy = storageUtils.loadFromStorage.mockImplementationOnce(() => JSON.stringify(addressBook))
const storedAddressBook = await getAddressBookFromStorage()
// @ts-ignore
let result = buildAddressBook(storedAddressBook)
// then
expect(result).toStrictEqual(addressBook)
expect(spy).toHaveBeenCalled()
})
})
describe('migrateOldAddressBook', () => {
const safeAddress1 = '0x696fd93D725d84acfFf6c62a1fe8C94E1c9E934A'
const safeAddress2 = '0x2C7aC78b01Be0FC66AD29b684ffAb0C93B381D00'
const mockAdd1 = '0x9163c2F4452E3399CB60AAf737231Af87548DA91'
const mockAdd2 = '0xC4e446Da9C3D37385C86488294C6758c4e25dbD8'
it('It should receive an addressBook in old format and return the same addressBook in new format', () => {
// given
const entry1 = getMockOldAddressBookEntry({ name: 'test1', address: mockAdd1 })
const entry2 = getMockOldAddressBookEntry({ name: 'test2', address: mockAdd2 })
const oldAddressBook: OldAddressBookType = {
[safeAddress1]: [entry1],
[safeAddress2]: [entry2],
}
const expectedEntry1 = getMockAddressBookEntry(mockAdd1, 'test1')
const expectedEntry2 = getMockAddressBookEntry(mockAdd2, 'test2')
const expectedResult = [expectedEntry1, expectedEntry2]
// when
const result = migrateOldAddressBook(oldAddressBook)
// then
expect(result).toStrictEqual(expectedResult)
})
})
describe('getAddressBookFromStorage', () => {
const safeAddress1 = '0x696fd93D725d84acfFf6c62a1fe8C94E1c9E934A'
const safeAddress2 = '0x2C7aC78b01Be0FC66AD29b684ffAb0C93B381D00'
const mockAdd1 = '0x9163c2F4452E3399CB60AAf737231Af87548DA91'
const mockAdd2 = '0xC4e446Da9C3D37385C86488294C6758c4e25dbD8'
beforeAll(() => {
jest.mock('src/utils/storage/index')
})
afterAll(() => {
jest.unmock('src/utils/storage/index')
})
it('It should return null if no addressBook in storage', async () => {
// given
const expectedResult = null
const storageUtils = require('src/utils/storage/index')
const spy = storageUtils.loadFromStorage.mockImplementationOnce(() => null)
// when
const result = await getAddressBookFromStorage()
// then
expect(result).toStrictEqual(expectedResult)
expect(spy).toHaveBeenCalled()
})
it('It should return migrated addressBook if old addressBook in storage', async () => {
// given
const expectedEntry1 = getMockAddressBookEntry(mockAdd1, 'test1')
const expectedEntry2 = getMockAddressBookEntry(mockAdd2, 'test2')
const entry1 = getMockOldAddressBookEntry({ name: 'test1', address: mockAdd1 })
const entry2 = getMockOldAddressBookEntry({ name: 'test2', address: mockAdd2 })
const oldAddressBook: OldAddressBookType = {
[safeAddress1]: [entry1],
[safeAddress2]: [entry2],
}
const expectedResult = [expectedEntry1, expectedEntry2]
const storageUtils = require('src/utils/storage/index')
const spy = storageUtils.loadFromStorage.mockImplementationOnce(() => oldAddressBook)
// when
const result = await getAddressBookFromStorage()
// then
expect(result).toStrictEqual(expectedResult)
expect(spy).toHaveBeenCalled()
})
it('It should return addressBook if addressBook in storage', async () => {
// given
const expectedEntry1 = getMockAddressBookEntry(mockAdd1, 'test1')
const expectedEntry2 = getMockAddressBookEntry(mockAdd2, 'test2')
const expectedResult = [expectedEntry1, expectedEntry2]
const storageUtils = require('src/utils/storage/index')
const spy = storageUtils.loadFromStorage.mockImplementationOnce(() => JSON.stringify(expectedResult))
// when
const result = await getAddressBookFromStorage()
// then
expect(result).toStrictEqual(expectedResult)
expect(spy).toHaveBeenCalled()
})
})
describe('isValidAddressBookName', () => {
it('It should return false if given a blacklisted name like UNKNOWN', () => {
// given

View File

@ -1,11 +1,9 @@
import { List } from 'immutable'
import { mustBeEthereumContractAddress } from 'src/components/forms/validator'
import { AddressBookEntry, AddressBookState, makeAddressBookEntry } from 'src/logic/addressBook/model/addressBook'
import { SafeOwner } from 'src/logic/safe/store/models/safe'
import { ETHEREUM_NETWORK } from 'src/config/networks/network.d'
import { AddressBookEntry, AddressBookState } from 'src/logic/addressBook/model/addressBook'
import { sameAddress } from 'src/logic/wallets/ethAddresses'
import { loadFromStorage, saveToStorage } from 'src/utils/storage'
const ADDRESS_BOOK_STORAGE_KEY = 'ADDRESS_BOOK_STORAGE_KEY'
import { AppReduxState } from 'src/store'
import { Overwrite } from 'src/types/helpers'
export type OldAddressBookEntry = {
address: string
@ -17,44 +15,7 @@ export type OldAddressBookType = {
[safeAddress: string]: [OldAddressBookEntry]
}
const ADDRESSBOOK_INVALID_NAMES = ['UNKNOWN', 'OWNER #', 'MY WALLET']
export const migrateOldAddressBook = (oldAddressBook: OldAddressBookType): AddressBookState => {
const values: AddressBookState = []
const adbkValues = Object.values(oldAddressBook)
for (const safeIterator of adbkValues) {
for (const safeAddressBook of safeIterator) {
if (!values.find((entry) => sameAddress(entry.address, safeAddressBook.address))) {
values.push(makeAddressBookEntry({ address: safeAddressBook.address, name: safeAddressBook.name }))
}
}
}
return values
}
export const getAddressBookFromStorage = async (): Promise<AddressBookState | null> => {
const result: OldAddressBookType | string | undefined = await loadFromStorage(ADDRESS_BOOK_STORAGE_KEY)
if (!result) {
return null
}
if (typeof result === 'string') {
return JSON.parse(result)
}
return migrateOldAddressBook(result as OldAddressBookType)
}
export const saveAddressBook = async (addressBook: AddressBookState): Promise<void> => {
try {
await saveToStorage(ADDRESS_BOOK_STORAGE_KEY, JSON.stringify(addressBook))
} catch (err) {
console.error('Error storing addressBook in localstorage', err)
}
}
export const ADDRESS_BOOK_INVALID_NAMES = ['UNKNOWN', 'OWNER #', 'MY WALLET']
type GetNameFromAddressBookOptions = {
filterOnlyValidName: boolean
@ -73,32 +34,17 @@ export const getNameFromAddressBook = (
}
export const isValidAddressBookName = (addressBookName: string): boolean => {
const hasInvalidName = ADDRESSBOOK_INVALID_NAMES.find((invalidName) =>
addressBookName.toUpperCase().includes(invalidName),
const hasInvalidName = ADDRESS_BOOK_INVALID_NAMES.find((invalidName) =>
addressBookName?.toUpperCase().includes(invalidName),
)
return !hasInvalidName
}
// TODO: is this really required?
export const getValidAddressBookName = (addressBookName: string): string | null => {
return isValidAddressBookName(addressBookName) ? addressBookName : null
}
export const getOwnersWithNameFromAddressBook = (
addressBook: AddressBookState,
ownerList: List<SafeOwner>,
): List<SafeOwner> => {
if (!ownerList) {
return List([])
}
return ownerList.map((owner) => {
const ownerName = getNameFromAddressBook(addressBook, owner.address)
return {
address: owner.address,
name: ownerName || owner.name,
}
})
}
export const formatAddressListToAddressBookNames = (
addressBook: AddressBookState,
addresses: string[],
@ -111,12 +57,13 @@ export const formatAddressListToAddressBookNames = (
return {
address: address,
name: ownerName || '',
chainId: ETHEREUM_NETWORK.UNKNOWN,
}
})
}
/**
* If the safe is not loaded, the owner wasn't not deleted
* If the safe is not loaded, the owner wasn't deleted
* If the safe is already loaded and the owner has a valid name, will return true if the address is not already on the addressBook
* @param name
* @param address
@ -172,3 +119,11 @@ export const filterAddressEntries = (
return foundName || foundAddress
})
export const getEntryIndex = (
state: AppReduxState['addressBook'],
addressBookEntry: Overwrite<AddressBookEntry, { name?: string }>,
): number =>
state.findIndex(
({ address, chainId }) => chainId === addressBookEntry.chainId && sameAddress(address, addressBookEntry.address),
)

View File

@ -0,0 +1,133 @@
import { AddressBookEntry, AddressBookState, makeAddressBookEntry } from 'src/logic/addressBook/model/addressBook'
import { saveSafes, StoredSafes } from 'src/logic/safe/utils'
import { removeFromStorage } from 'src/utils/storage'
import { getNetworkName } from 'src/config'
import { getWeb3 } from 'src/logic/wallets/getWeb3'
import { Errors, logError } from 'src/logic/exceptions/CodedException'
import { getEntryIndex, isValidAddressBookName } from '.'
interface StorageConfig {
states: string[]
namespace: string
namespaceSeparator: string
}
/**
* Migrates the safes names from the Safe Object to the Address Book
*
* Works on the persistence layer, reads from the localStorage and stores back the mutated safe info through immortalDB.
*
* @note If the Safe name is an invalid AB name, it's renamed to "Migrated from: {safe.name}"
*/
const migrateSafeNames = ({ states, namespace, namespaceSeparator }: StorageConfig): void => {
const prefix = `v2_${getNetworkName()}`
const safesKey = `_immortal|${prefix}__SAFES`
const storedSafes = localStorage.getItem(safesKey)
if (!storedSafes) {
// nothing left to migrate
return
}
const parsedStoredSafes = JSON.parse(storedSafes) as Record<string, any>
if (Object.entries(parsedStoredSafes).every(([, { name }]) => name === undefined)) {
// no name key, safes already migrated
return
}
// make address book entries from the safe names & addresses
const safeAbEntries: AddressBookState = Object.values(parsedStoredSafes)
.filter(({ name }) => name && isValidAddressBookName(name))
.map(({ address, name }) => makeAddressBookEntry({ address, name }))
// remove names from the safes in place
Object.values(parsedStoredSafes).forEach((item) => {
item.owners = item.owners.map((owner: any) => owner.address)
delete item.name
})
const migratedSafes = parsedStoredSafes as StoredSafes
const [state] = states
const addressBookKey = `${namespace}${namespaceSeparator}${state}`
const storedAddressBook = localStorage.getItem(addressBookKey)
const addressBookToStore: AddressBookState = storedAddressBook ? JSON.parse(storedAddressBook) : []
// mutate `addressBookToStore` by adding safes' information
safeAbEntries.forEach((entry) => {
const safeIndex = getEntryIndex(addressBookToStore, entry)
if (safeIndex >= 0) {
// update AB entry with what was stored in the safe object
addressBookToStore[safeIndex] = entry
} else {
// add the safe entry to the AB
addressBookToStore.push(entry)
}
})
// store the mutated address book
localStorage.setItem(addressBookKey, JSON.stringify(addressBookToStore))
// update stored safe
localStorage.setItem(safesKey, JSON.stringify(migratedSafes))
saveSafes(migratedSafes).then(() => console.info('Safe objects migrated'))
}
/**
* Migrates the AddressBook from a per-network to a global storage under the key `ADDRESS_BOOK` in `localStorage`
*
* The migrated structure will be `{ address, name, chainId }`
*
* @note Also, adds `chainId` to every entry in the AddressBook list.
*/
const migrateAddressBook = ({ states, namespace, namespaceSeparator }: StorageConfig): void => {
const [state] = states
const prefix = `v2_${getNetworkName()}`
const newKey = `${namespace}${namespaceSeparator}${state}`
const oldKey = 'ADDRESS_BOOK_STORAGE_KEY'
const storageKey = `_immortal|${prefix}__${oldKey}`
if (localStorage.getItem(newKey)) {
// already migrated
return
}
const storedAddressBook = localStorage.getItem(storageKey)
if (!storedAddressBook) {
// nothing to migrate
return
}
const parsedAddressBook = JSON.parse(JSON.parse(storedAddressBook as string))
const migratedAddressBook = (parsedAddressBook as Omit<AddressBookEntry, 'chainId'>[])
// exclude those addresses with invalid names and addresses
.filter((item) => {
return isValidAddressBookName(item.name) && getWeb3().utils.isAddress(item.address)
})
.map(({ address, ...entry }) =>
makeAddressBookEntry({
address,
...entry,
}),
)
localStorage.setItem(newKey, JSON.stringify(migratedAddressBook))
// Remove the old Address Book storage
localStorage.removeItem(storageKey)
removeFromStorage(oldKey).then(() => console.info('Legacy Address Book removed'))
}
const migrate = (storageConfig: StorageConfig): void => {
try {
migrateAddressBook(storageConfig)
migrateSafeNames(storageConfig)
} catch (e) {
logError(Errors._200, e.message)
}
}
export default migrate

View File

@ -7,6 +7,7 @@
enum ErrorCodes {
___0 = '0: No such error code',
_100 = '100: Invalid input in the address field',
_200 = '200: Failed migrating to the address book v2',
_600 = '600: Error fetching token list',
_601 = '601: Error fetching balances',
_900 = '900: Error loading Safe App',

View File

@ -1,22 +0,0 @@
import { useLocation } from 'react-router-dom'
import { useEffect, useState } from 'react'
type AppUrlReturnType = {
url: string | null
}
export const useSafeAppUrl = (): AppUrlReturnType => {
const [url, setUrl] = useState<string | null>(null)
const { search } = useLocation()
useEffect(() => {
if (search !== url) {
const query = new URLSearchParams(search)
setUrl(query.get('appUrl'))
}
}, [search, url])
return {
url,
}
}

View File

@ -142,6 +142,17 @@ const addressBookEditEntry = {
afterExecutionError: null,
}
const addressBookImportEntries = {
beforeExecution: null,
afterRejection: null,
waitingConfirmation: null,
afterExecution: {
noMoreConfirmationsNeeded: NOTIFICATIONS.ADDRESS_BOOK_IMPORT_ENTRIES_SUCCESS,
moreConfirmationsNeeded: null,
},
afterExecutionError: null,
}
const addressBookDeleteEntry = {
beforeExecution: null,
afterRejection: null,
@ -153,6 +164,17 @@ const addressBookDeleteEntry = {
afterExecutionError: null,
}
const addressBookExportEntries = {
beforeExecution: null,
afterRejection: null,
waitingConfirmation: null,
afterExecution: {
noMoreConfirmationsNeeded: NOTIFICATIONS.ADDRESS_BOOK_EXPORT_ENTRIES_SUCCESS,
moreConfirmationsNeeded: null,
},
afterExecutionError: NOTIFICATIONS.ADDRESS_BOOK_EXPORT_ENTRIES_ERROR,
}
export const getNotificationsFromTxType: any = (txType, origin) => {
let notificationsQueue
@ -193,18 +215,26 @@ export const getNotificationsFromTxType: any = (txType, origin) => {
notificationsQueue = waitingTransactionNotificationsQueue
break
}
case TX_NOTIFICATION_TYPES.ADDRESSBOOK_NEW_ENTRY: {
case TX_NOTIFICATION_TYPES.ADDRESS_BOOK_NEW_ENTRY: {
notificationsQueue = addressBookNewEntry
break
}
case TX_NOTIFICATION_TYPES.ADDRESSBOOK_EDIT_ENTRY: {
case TX_NOTIFICATION_TYPES.ADDRESS_BOOK_EDIT_ENTRY: {
notificationsQueue = addressBookEditEntry
break
}
case TX_NOTIFICATION_TYPES.ADDRESSBOOK_DELETE_ENTRY: {
case TX_NOTIFICATION_TYPES.ADDRESS_BOOK_IMPORT_ENTRIES: {
notificationsQueue = addressBookImportEntries
break
}
case TX_NOTIFICATION_TYPES.ADDRESS_BOOK_DELETE_ENTRY: {
notificationsQueue = addressBookDeleteEntry
break
}
case TX_NOTIFICATION_TYPES.ADDRESS_BOOK_EXPORT_ENTRIES: {
notificationsQueue = addressBookExportEntries
break
}
default: {
notificationsQueue = defaultNotificationsQueue
break

View File

@ -52,7 +52,10 @@ const NOTIFICATION_IDS = {
WRONG_NETWORK_MSG: 'WRONG_NETWORK_MSG',
ADDRESS_BOOK_NEW_ENTRY_SUCCESS: 'ADDRESS_BOOK_NEW_ENTRY_SUCCESS',
ADDRESS_BOOK_EDIT_ENTRY_SUCCESS: 'ADDRESS_BOOK_EDIT_ENTRY_SUCCESS',
ADDRESS_BOOK_IMPORT_ENTRIES_SUCCESS: 'ADDRESS_BOOK_IMPORT_ENTRIES_SUCCESS',
ADDRESS_BOOK_DELETE_ENTRY_SUCCESS: 'ADDRESS_BOOK_DELETE_ENTRY_SUCCESS',
ADDRESS_BOOK_EXPORT_ENTRIES_SUCCESS: 'ADDRESS_BOOK_EXPORT_ENTRIES_SUCCESS',
ADDRESS_BOOK_EXPORT_ENTRIES_ERROR: 'ADDRESS_BOOK_EXPORT_ENTRIES_ERROR',
SAFE_NEW_VERSION_AVAILABLE: 'SAFE_NEW_VERSION_AVAILABLE',
}
@ -207,10 +210,22 @@ export const NOTIFICATIONS: Record<NotificationId, Notification> = {
message: 'Entry saved successfully',
options: { variant: SUCCESS, persist: false, preventDuplicate: false },
},
ADDRESS_BOOK_IMPORT_ENTRIES_SUCCESS: {
message: 'Entries imported successfully',
options: { variant: SUCCESS, persist: false, preventDuplicate: false },
},
ADDRESS_BOOK_DELETE_ENTRY_SUCCESS: {
message: 'Entry deleted successfully',
options: { variant: SUCCESS, persist: false, preventDuplicate: false },
},
ADDRESS_BOOK_EXPORT_ENTRIES_SUCCESS: {
message: 'Address book exported',
options: { variant: SUCCESS, persist: false, preventDuplicate: false },
},
ADDRESS_BOOK_EXPORT_ENTRIES_ERROR: {
message: 'An error occurred while generating the address book CSV.',
options: { variant: ERROR, persist: false, preventDuplicate: false },
},
// Safe Version
SAFE_NEW_VERSION_AVAILABLE: {

View File

@ -1,7 +1,6 @@
import { useEffect, useState } from 'react'
import { useDispatch } from 'react-redux'
import loadAddressBookFromStorage from 'src/logic/addressBook/store/actions/loadAddressBookFromStorage'
import addViewedSafe from 'src/logic/currentSession/store/actions/addViewedSafe'
import { fetchSafeTokens } from 'src/logic/tokens/store/actions/fetchSafeTokens'
import fetchLatestMasterContractVersion from 'src/logic/safe/store/actions/fetchLatestMasterContractVersion'
@ -10,7 +9,7 @@ import fetchTransactions from 'src/logic/safe/store/actions/transactions/fetchTr
import { Dispatch } from 'src/logic/safe/store/actions/types.d'
import { updateAvailableCurrencies } from 'src/logic/currencyValues/store/actions/updateAvailableCurrencies'
export const useLoadSafe = (safeAddress?: string, loadedViaUrl = true): boolean => {
export const useLoadSafe = (safeAddress?: string): boolean => {
const dispatch = useDispatch<Dispatch>()
const [isSafeLoaded, setIsSafeLoaded] = useState(false)
@ -23,15 +22,11 @@ export const useLoadSafe = (safeAddress?: string, loadedViaUrl = true): boolean
await dispatch(fetchSafeTokens(safeAddress))
await dispatch(updateAvailableCurrencies())
await dispatch(fetchTransactions(safeAddress))
if (!loadedViaUrl) {
dispatch(addViewedSafe(safeAddress))
}
dispatch(addViewedSafe(safeAddress))
}
}
dispatch(loadAddressBookFromStorage())
fetchData()
}, [dispatch, safeAddress, loadedViaUrl])
}, [dispatch, safeAddress])
return isSafeLoaded
}

View File

@ -1,14 +1,12 @@
// --no-ignore
import axios from 'axios'
import { List, Map } from 'immutable'
import { Map } from 'immutable'
import configureMockStore from 'redux-mock-store'
import thunk from 'redux-thunk'
import { buildSafe, fetchSafe } from 'src/logic/safe/store/actions/fetchSafe'
import * as storageUtils from 'src/utils/storage'
import { SafeRecordProps } from 'src/logic/safe/store/models/safe'
import { makeOwner } from 'src/logic/safe/store/models/owner'
import { Overwrite } from 'src/types/helpers'
import { UPDATE_SAFE } from 'src/logic/safe/store/actions/updateSafe'
import { DEFAULT_SAFE_INITIAL_STATE } from 'src/logic/safe/store/reducer/safe'
import { inMemoryPartialSafeInformation, localSafesInfo, remoteSafeInfoWithoutModules } from '../mocks/safeInformation'
@ -25,51 +23,46 @@ describe('buildSafe', () => {
jest.unmock('src/utils/storage/index')
})
it('should return a Partial SafeRecord with a mix of remote and local safe info', async () => {
// ToDo: use a property other than `name`
it.skip('should return a Partial SafeRecord with a mix of remote and local safe info', async () => {
mockedAxios.get.mockImplementationOnce(async () => ({ data: remoteSafeInfoWithoutModules }))
storageUtil.loadFromStorage.mockImplementationOnce(async () => localSafesInfo)
const finalValues: Overwrite<Partial<SafeRecordProps>, { name: string }> = {
name: 'My Safe Name that will last',
const finalValues: Partial<SafeRecordProps> = {
modules: undefined,
spendingLimits: undefined,
}
const builtSafe = await buildSafe(SAFE_ADDRESS, finalValues.name)
const builtSafe = await buildSafe(SAFE_ADDRESS)
expect(builtSafe).toStrictEqual({ ...inMemoryPartialSafeInformation, ...finalValues })
})
it('should return a Partial SafeRecord when `remoteSafeInfo` is not present', async () => {
it.skip('should return a Partial SafeRecord when `remoteSafeInfo` is not present', async () => {
jest.spyOn(global.console, 'error').mockImplementationOnce(() => {})
mockedAxios.get.mockImplementationOnce(async () => {
throw new Error('-- test -- no resource available')
})
const name = 'My Safe Name that will last'
storageUtil.loadFromStorage.mockImplementationOnce(async () => localSafesInfo)
const builtSafe = await buildSafe(SAFE_ADDRESS, name)
const builtSafe = await buildSafe(SAFE_ADDRESS)
expect(builtSafe).toStrictEqual({ ...inMemoryPartialSafeInformation, name })
expect(builtSafe).toStrictEqual({ ...inMemoryPartialSafeInformation })
})
it('should return a Partial SafeRecord when `localSafeInfo` is not present', async () => {
it.skip('should return a Partial SafeRecord when `localSafeInfo` is not present', async () => {
mockedAxios.get.mockImplementationOnce(async () => ({ data: remoteSafeInfoWithoutModules }))
storageUtil.loadFromStorage.mockImplementationOnce(async () => undefined)
const name = 'My Safe Name that WILL last'
const builtSafe = await buildSafe(SAFE_ADDRESS, name)
const builtSafe = await buildSafe(SAFE_ADDRESS)
expect(builtSafe).toStrictEqual({
name,
address: SAFE_ADDRESS,
threshold: 2,
owners: List(
[
{ address: '0xcCdd7e3af1c24c08D8B65A328351e7e23923d875' },
{ address: '0x04Aa5eC2065224aDB15aCE6fb1aAb988Ae55631F' },
{ address: '0x52Da808E9a83FEB147a2d0ca7d2f5bBBd3035C47' },
{ address: '0x4dcD12D11dE7382F9c26D59Db1aCE1A4737e58A2' },
{ address: '0x5e47249883F6a1d639b84e8228547fB289e222b6' },
].map(makeOwner),
),
owners: [
'0xcCdd7e3af1c24c08D8B65A328351e7e23923d875',
'0x04Aa5eC2065224aDB15aCE6fb1aAb988Ae55631F',
'0x52Da808E9a83FEB147a2d0ca7d2f5bBBd3035C47',
'0x4dcD12D11dE7382F9c26D59Db1aCE1A4737e58A2',
'0x5e47249883F6a1d639b84e8228547fB289e222b6',
],
modules: undefined,
spendingLimits: undefined,
nonce: 492,
@ -78,19 +71,18 @@ describe('buildSafe', () => {
featuresEnabled: ['ERC721', 'ERC1155', 'SAFE_APPS', 'CONTRACT_INTERACTION'],
})
})
it('should return a Partial SafeRecord with only `address` and `name` keys if it fails to recover info', async () => {
it.skip('should return a Partial SafeRecord with only `address` and `name` keys if it fails to recover info', async () => {
jest.spyOn(global.console, 'error').mockImplementationOnce(() => {})
mockedAxios.get.mockImplementationOnce(async () => {
throw new Error('-- test -- no resource available')
})
const finalValues: Overwrite<Partial<SafeRecordProps>, { name: string }> = {
name: 'My Safe Name that will last',
const finalValues: Partial<SafeRecordProps> = {
address: SAFE_ADDRESS,
owners: undefined,
}
storageUtil.loadFromStorage.mockImplementationOnce(async () => undefined)
const builtSafe = await buildSafe(SAFE_ADDRESS, finalValues.name)
const builtSafe = await buildSafe(SAFE_ADDRESS)
expect(builtSafe).toStrictEqual(finalValues)
})
@ -99,7 +91,6 @@ describe('buildSafe', () => {
describe('fetchSafe', () => {
const SAFE_ADDRESS = '0xe414604Ad49602C0b9c0b08D0781ECF96740786a'
const mockedAxios = axios as jest.Mocked<typeof axios>
const storageUtil = require('src/utils/storage/index') as jest.Mocked<typeof storageUtils>
const middlewares = [thunk]
const mockStore = configureMockStore(middlewares)
@ -115,15 +106,13 @@ describe('fetchSafe', () => {
payload: {
address: SAFE_ADDRESS,
threshold: 2,
owners: List(
[
{ address: '0xcCdd7e3af1c24c08D8B65A328351e7e23923d875' },
{ address: '0x04Aa5eC2065224aDB15aCE6fb1aAb988Ae55631F' },
{ address: '0x52Da808E9a83FEB147a2d0ca7d2f5bBBd3035C47' },
{ address: '0x4dcD12D11dE7382F9c26D59Db1aCE1A4737e58A2' },
{ address: '0x5e47249883F6a1d639b84e8228547fB289e222b6' },
].map(makeOwner),
),
owners: [
'0xcCdd7e3af1c24c08D8B65A328351e7e23923d875',
'0x04Aa5eC2065224aDB15aCE6fb1aAb988Ae55631F',
'0x52Da808E9a83FEB147a2d0ca7d2f5bBBd3035C47',
'0x4dcD12D11dE7382F9c26D59Db1aCE1A4737e58A2',
'0x5e47249883F6a1d639b84e8228547fB289e222b6',
],
modules: undefined,
spendingLimits: undefined,
nonce: 492,

View File

@ -1,5 +1,4 @@
import axios from 'axios'
import { List } from 'immutable'
import { FEATURES } from 'src/config/networks/network.d'
import {
@ -9,8 +8,7 @@ import {
getNewTxNonce,
shouldExecuteTransaction,
} from 'src/logic/safe/store/actions/utils'
import { makeOwner } from 'src/logic/safe/store/models/owner'
import { SafeOwner, SafeRecordProps } from 'src/logic/safe/store/models/safe'
import { SafeRecordProps } from 'src/logic/safe/store/models/safe'
import { buildTxServiceUrl } from 'src/logic/safe/transactions'
import { getMockedSafeInstance, getMockedTxServiceModel } from 'src/test/utils/safeHelper'
import {
@ -223,96 +221,49 @@ describe('buildSafeOwners', () => {
expect(buildSafeOwners()).toBeUndefined()
})
it('should return `localSafeOwners` if no `remoteSafeOwners` were provided', () => {
const expectedOwners = List(
[
{ address: '0xcCdd7e3af1c24c08D8B65A328351e7e23923d875' },
{ address: '0x04Aa5eC2065224aDB15aCE6fb1aAb988Ae55631F' },
{ address: '0x52Da808E9a83FEB147a2d0ca7d2f5bBBd3035C47' },
{ address: '0x4dcD12D11dE7382F9c26D59Db1aCE1A4737e58A2' },
{ address: '0x5e47249883F6a1d639b84e8228547fB289e222b6' },
].map(makeOwner),
)
const expectedOwners = [
'0xcCdd7e3af1c24c08D8B65A328351e7e23923d875',
'0x04Aa5eC2065224aDB15aCE6fb1aAb988Ae55631F',
'0x52Da808E9a83FEB147a2d0ca7d2f5bBBd3035C47',
'0x4dcD12D11dE7382F9c26D59Db1aCE1A4737e58A2',
'0x5e47249883F6a1d639b84e8228547fB289e222b6',
]
expect(buildSafeOwners(remoteSafeInfoWithModules.owners)).toStrictEqual(expectedOwners)
})
it('should discard those owners that are not present in `remoteSafeOwners`', () => {
const localOwners: List<SafeOwner> = List(localSafesInfo[SAFE_ADDRESS].owners.map(makeOwner))
const localOwners: SafeRecordProps['owners'] = localSafesInfo[SAFE_ADDRESS].owners
const [, ...remoteOwners] = remoteSafeInfoWithModules.owners
const expectedOwners = List(
[
{
name: 'UNKNOWN',
address: '0x04Aa5eC2065224aDB15aCE6fb1aAb988Ae55631F',
},
{
name: 'UNKNOWN',
address: '0x52Da808E9a83FEB147a2d0ca7d2f5bBBd3035C47',
},
{
name: 'Owner B',
address: '0x4dcD12D11dE7382F9c26D59Db1aCE1A4737e58A2',
},
{
name: 'Owner A',
address: '0x5e47249883F6a1d639b84e8228547fB289e222b6',
},
].map(makeOwner),
)
const expectedOwners = [
'0x04Aa5eC2065224aDB15aCE6fb1aAb988Ae55631F',
'0x52Da808E9a83FEB147a2d0ca7d2f5bBBd3035C47',
'0x4dcD12D11dE7382F9c26D59Db1aCE1A4737e58A2',
'0x5e47249883F6a1d639b84e8228547fB289e222b6',
]
expect(buildSafeOwners(remoteOwners, localOwners)).toStrictEqual(expectedOwners)
})
it('should add those owners that are not present in `localSafeOwners`', () => {
const localOwners: List<SafeOwner> = List(localSafesInfo[SAFE_ADDRESS].owners.slice(0, 4).map(makeOwner))
const localOwners: SafeRecordProps['owners'] = localSafesInfo[SAFE_ADDRESS].owners.slice(0, 4)
const remoteOwners = remoteSafeInfoWithModules.owners
const expectedOwners = List(
[
{
name: 'UNKNOWN',
address: '0xcCdd7e3af1c24c08D8B65A328351e7e23923d875',
},
{
name: 'UNKNOWN',
address: '0x04Aa5eC2065224aDB15aCE6fb1aAb988Ae55631F',
},
{
name: 'UNKNOWN',
address: '0x52Da808E9a83FEB147a2d0ca7d2f5bBBd3035C47',
},
{
name: 'Owner B',
address: '0x4dcD12D11dE7382F9c26D59Db1aCE1A4737e58A2',
},
{
name: 'UNKNOWN',
address: '0x5e47249883F6a1d639b84e8228547fB289e222b6',
},
].map(makeOwner),
)
const expectedOwners = [
'0xcCdd7e3af1c24c08D8B65A328351e7e23923d875',
'0x04Aa5eC2065224aDB15aCE6fb1aAb988Ae55631F',
'0x52Da808E9a83FEB147a2d0ca7d2f5bBBd3035C47',
'0x4dcD12D11dE7382F9c26D59Db1aCE1A4737e58A2',
'0x5e47249883F6a1d639b84e8228547fB289e222b6',
]
expect(buildSafeOwners(remoteOwners, localOwners)).toStrictEqual(expectedOwners)
})
it('should preserve those owners that are present in `remoteSafeOwners` with data present in `localSafeOwners`', () => {
const localOwners: List<SafeOwner> = List(localSafesInfo[SAFE_ADDRESS].owners.slice(0, 4).map(makeOwner))
const localOwners: SafeRecordProps['owners'] = localSafesInfo[SAFE_ADDRESS].owners.slice(0, 4)
const [, ...remoteOwners] = remoteSafeInfoWithModules.owners
const expectedOwners = List(
[
{
name: 'UNKNOWN',
address: '0x04Aa5eC2065224aDB15aCE6fb1aAb988Ae55631F',
},
{
name: 'UNKNOWN',
address: '0x52Da808E9a83FEB147a2d0ca7d2f5bBBd3035C47',
},
{
name: 'Owner B',
address: '0x4dcD12D11dE7382F9c26D59Db1aCE1A4737e58A2',
},
{
name: 'UNKNOWN',
address: '0x5e47249883F6a1d639b84e8228547fB289e222b6',
},
].map(makeOwner),
)
const expectedOwners = [
'0x04Aa5eC2065224aDB15aCE6fb1aAb988Ae55631F',
'0x52Da808E9a83FEB147a2d0ca7d2f5bBBd3035C47',
'0x4dcD12D11dE7382F9c26D59Db1aCE1A4737e58A2',
'0x5e47249883F6a1d639b84e8228547fB289e222b6',
]
expect(buildSafeOwners(remoteOwners, localOwners)).toStrictEqual(expectedOwners)
})

View File

@ -1,17 +1,9 @@
import { createAction } from 'redux-actions'
import { SafeOwner, SafeRecordProps } from '../models/safe'
import { List } from 'immutable'
import { makeOwner } from '../models/owner'
import { SafeRecordProps } from 'src/logic/safe/store/models/safe'
export const ADD_OR_UPDATE_SAFE = 'ADD_OR_UPDATE_SAFE'
export const buildOwnersFrom = (names: string[], addresses: string[]): List<SafeOwner> => {
const owners = names.map((name, index) => makeOwner({ name, address: addresses[index] }))
return List(owners)
}
export const addOrUpdateSafe = createAction(ADD_OR_UPDATE_SAFE, (safe: SafeRecordProps) => ({
safe,
}))

View File

@ -1,5 +0,0 @@
import { createAction } from 'redux-actions'
export const ADD_SAFE_OWNER = 'ADD_SAFE_OWNER'
export const addSafeOwner = createAction(ADD_SAFE_OWNER)

View File

@ -1,7 +0,0 @@
import { createAction } from 'redux-actions'
export const EDIT_SAFE_OWNER = 'EDIT_SAFE_OWNER'
const editSafeOwner = createAction(EDIT_SAFE_OWNER)
export default editSafeOwner

View File

@ -1,16 +1,13 @@
import { List } from 'immutable'
import { Dispatch } from 'redux'
import { Action } from 'redux-actions'
import { addressBookSelector } from 'src/logic/addressBook/store/selectors'
import { updateSafe } from 'src/logic/safe/store/actions/updateSafe'
import { SafeRecordProps } from 'src/logic/safe/store/models/safe'
import { getLocalSafe } from 'src/logic/safe/utils'
import { allSettled } from 'src/logic/safe/utils/allSettled'
import { getSafeInfo, SafeInfo } from 'src/logic/safe/utils/safeInformation'
import { AppReduxState } from 'src/store'
import { checksumAddress } from 'src/utils/checksumAddress'
import { buildSafeOwners, extractRemoteSafeInfo } from './utils'
import { Action } from 'redux-actions'
/**
* Builds a Safe Record that will be added to the app's store
@ -19,15 +16,12 @@ import { Action } from 'redux-actions'
* @note It's being used by "Load Existing Safe" and "Create New Safe" flows
*
* @param {string} safeAddress
* @param {string} safeName
* @returns Promise<SafeRecordProps>
*/
export const buildSafe = async (safeAddress: string, safeName: string): Promise<SafeRecordProps> => {
export const buildSafe = async (safeAddress: string): Promise<SafeRecordProps> => {
const address = checksumAddress(safeAddress)
const safeInfo: Partial<SafeRecordProps> = {
address,
name: safeName,
}
// setting `loadedViaUrl` to false, as `buildSafe` is called on safe Load or Open flows
const safeInfo: Partial<SafeRecordProps> = { address, loadedViaUrl: false }
const [remote, localSafeInfo] = await allSettled<[SafeInfo | null, SafeRecordProps | undefined | null]>(
getSafeInfo(safeAddress),
@ -52,7 +46,6 @@ export const buildSafe = async (safeAddress: string, safeName: string): Promise<
*/
export const fetchSafe = (safeAddress: string) => async (
dispatch: Dispatch,
getState: () => AppReduxState,
): Promise<Action<Partial<SafeRecordProps>>> => {
const address = checksumAddress(safeAddress)
@ -61,13 +54,10 @@ export const fetchSafe = (safeAddress: string) => async (
// remote (client-gateway)
const safeInfo = remoteSafeInfo ? await extractRemoteSafeInfo(remoteSafeInfo) : {}
// TODO: REVIEW: having the owner's names duplicated with what's in the address book seems a bit odd
const state = getState()
const addressBook = addressBookSelector(state)
// update owner's information
const owners = remoteSafeInfo
? // if we have remote info, we can enrich it with local address book information
buildSafeOwners(remoteSafeInfo.owners, List(addressBook))
? // if we have remote info, we use it
buildSafeOwners(remoteSafeInfo.owners)
: // if there's no remote info, we keep what's in memory
undefined

View File

@ -1,24 +1,15 @@
import { Dispatch } from 'redux'
import { SAFES_KEY } from 'src/logic/safe/utils'
import { SafeRecordProps } from 'src/logic/safe/store/models/safe'
import { getLocalSafes } from 'src/logic/safe/utils'
import { buildSafe } from 'src/logic/safe/store/reducer/safe'
import { loadFromStorage } from 'src/utils/storage'
import { addOrUpdateSafe } from './addOrUpdateSafe'
const loadSafesFromStorage = () => async (dispatch: Dispatch): Promise<void> => {
try {
const safes = await loadFromStorage<Record<string, SafeRecordProps>>(SAFES_KEY)
const safes = await getLocalSafes()
if (safes) {
Object.values(safes).forEach((safeProps) => {
dispatch(addOrUpdateSafe(buildSafe(safeProps)))
})
}
} catch (err) {
// eslint-disable-next-line
console.error('Error while getting Safes from storage:', err)
if (safes) {
safes.forEach((safeProps) => {
dispatch(addOrUpdateSafe(buildSafe(safeProps)))
})
}
return Promise.resolve()

View File

@ -1,6 +1,3 @@
import { List } from 'immutable'
import { makeOwner } from '../../models/owner'
export const remoteSafeInfoWithModules = {
address: {
value: '0xe414604Ad49602C0b9c0b08D0781ECF96740786a',
@ -86,30 +83,13 @@ export const localSafesInfo = {
name: 'Safe A',
address: '0xe414604Ad49602C0b9c0b08D0781ECF96740786a',
threshold: 2,
owners: List(
[
{
name: 'UNKNOWN',
address: '0xcCdd7e3af1c24c08D8B65A328351e7e23923d875',
},
{
name: 'UNKNOWN',
address: '0x04Aa5eC2065224aDB15aCE6fb1aAb988Ae55631F',
},
{
name: 'UNKNOWN',
address: '0x52Da808E9a83FEB147a2d0ca7d2f5bBBd3035C47',
},
{
name: 'Owner B',
address: '0x4dcD12D11dE7382F9c26D59Db1aCE1A4737e58A2',
},
{
name: 'Owner A',
address: '0x5e47249883F6a1d639b84e8228547fB289e222b6',
},
].map(makeOwner),
),
owners: [
'0xcCdd7e3af1c24c08D8B65A328351e7e23923d875',
'0x04Aa5eC2065224aDB15aCE6fb1aAb988Ae55631F',
'0x52Da808E9a83FEB147a2d0ca7d2f5bBBd3035C47',
'0x4dcD12D11dE7382F9c26D59Db1aCE1A4737e58A2',
'0x5e47249883F6a1d639b84e8228547fB289e222b6',
],
modules: [['0x0000000000000000000000000000000000000001', '0xCFbFaC74C26F8647cBDb8c5caf80BB5b32E43134']],
spendingLimits: [
{
@ -177,30 +157,13 @@ export const inMemoryPartialSafeInformation = {
name: 'Safe A',
address: '0xe414604Ad49602C0b9c0b08D0781ECF96740786a',
threshold: 2,
owners: List(
[
{
name: 'UNKNOWN',
address: '0xcCdd7e3af1c24c08D8B65A328351e7e23923d875',
},
{
name: 'UNKNOWN',
address: '0x04Aa5eC2065224aDB15aCE6fb1aAb988Ae55631F',
},
{
name: 'UNKNOWN',
address: '0x52Da808E9a83FEB147a2d0ca7d2f5bBBd3035C47',
},
{
name: 'Owner B',
address: '0x4dcD12D11dE7382F9c26D59Db1aCE1A4737e58A2',
},
{
name: 'Owner A',
address: '0x5e47249883F6a1d639b84e8228547fB289e222b6',
},
].map(makeOwner),
),
owners: [
'0xcCdd7e3af1c24c08D8B65A328351e7e23923d875',
'0x04Aa5eC2065224aDB15aCE6fb1aAb988Ae55631F',
'0x52Da808E9a83FEB147a2d0ca7d2f5bBBd3035C47',
'0x4dcD12D11dE7382F9c26D59Db1aCE1A4737e58A2',
'0x5e47249883F6a1d639b84e8228547fB289e222b6',
],
modules: [['0x0000000000000000000000000000000000000001', '0xCFbFaC74C26F8647cBDb8c5caf80BB5b32E43134']],
spendingLimits: [
{

View File

@ -1,5 +0,0 @@
import { createAction } from 'redux-actions'
export const REMOVE_SAFE_OWNER = 'REMOVE_SAFE_OWNER'
export const removeSafeOwner = createAction(REMOVE_SAFE_OWNER)

View File

@ -1,5 +0,0 @@
import { createAction } from 'redux-actions'
export const REPLACE_SAFE_OWNER = 'REPLACE_SAFE_OWNER'
export const replaceSafeOwner = createAction(REPLACE_SAFE_OWNER)

View File

@ -8,10 +8,7 @@ import { SafeRecordProps } from 'src/logic/safe/store/models/safe'
import { getSpendingLimits } from 'src/logic/safe/utils/spendingLimits'
import { buildModulesLinkedList } from 'src/logic/safe/utils/modules'
import { enabledFeatures, safeNeedsUpdate } from 'src/logic/safe/utils/safeVersion'
import { sameAddress } from 'src/logic/wallets/ethAddresses'
import { makeOwner } from 'src/logic/safe/store/models/owner'
import { checksumAddress } from 'src/utils/checksumAddress'
import { List } from 'immutable'
export const getLastTx = async (safeAddress: string): Promise<TxServiceModel | null> => {
try {
@ -105,13 +102,9 @@ export const buildSafeOwners = (
localSafeOwners?: SafeRecordProps['owners'],
): SafeRecordProps['owners'] | undefined => {
if (remoteSafeOwners) {
const remoteOwners = remoteSafeOwners.map(({ value }) => {
const localOwner = localSafeOwners?.find(({ address }) => sameAddress(address, value))
const name = localOwner?.name
return makeOwner({ name, address: checksumAddress(value) })
})
return List(remoteOwners)
// ToDo: review if checksums addresses is necessary,
// as they must be provided already in the checksum form from the services
return remoteSafeOwners.map(({ value }) => checksumAddress(value))
}
// nothing to do without remote owners, so we return the stored list

View File

@ -1,30 +1,13 @@
import { Store } from 'redux'
import { saveDefaultSafe, saveSafes } from 'src/logic/safe/utils'
import { ADD_SAFE_OWNER } from 'src/logic/safe/store/actions/addSafeOwner'
import { EDIT_SAFE_OWNER } from 'src/logic/safe/store/actions/editSafeOwner'
import { REMOVE_SAFE } from 'src/logic/safe/store/actions/removeSafe'
import { REMOVE_SAFE_OWNER } from 'src/logic/safe/store/actions/removeSafeOwner'
import { REPLACE_SAFE_OWNER } from 'src/logic/safe/store/actions/replaceSafeOwner'
import { SET_DEFAULT_SAFE } from 'src/logic/safe/store/actions/setDefaultSafe'
import { UPDATE_SAFE } from 'src/logic/safe/store/actions/updateSafe'
import { safesMapSelector } from 'src/logic/safe/store/selectors'
import { ADD_OR_UPDATE_SAFE } from 'src/logic/safe/store/actions/addOrUpdateSafe'
import { makeAddressBookEntry } from 'src/logic/addressBook/model/addressBook'
import { checksumAddress } from 'src/utils/checksumAddress'
import { isValidAddressBookName } from 'src/logic/addressBook/utils'
import { addOrUpdateAddressBookEntry } from 'src/logic/addressBook/store/actions/addOrUpdateAddressBookEntry'
import { SafeRecord } from '../models/safe'
const watchedActions = [
UPDATE_SAFE,
REMOVE_SAFE,
ADD_OR_UPDATE_SAFE,
ADD_SAFE_OWNER,
REMOVE_SAFE_OWNER,
REPLACE_SAFE_OWNER,
EDIT_SAFE_OWNER,
SET_DEFAULT_SAFE,
]
const watchedActions = [REMOVE_SAFE, SET_DEFAULT_SAFE, UPDATE_SAFE]
type SafeProps = {
safe: SafeRecord
@ -40,34 +23,10 @@ export const safeStorageMiddleware = (store: Store) => (
if (watchedActions.includes(action.type)) {
const state = store.getState()
const { dispatch } = store
const safes = safesMapSelector(state)
await saveSafes(safes.filter((safe) => !safe.loadedViaUrl).toJSON())
switch (action.type) {
case ADD_OR_UPDATE_SAFE: {
const { safe } = action.payload as SafeProps
safe.owners.forEach((owner: { address: string; name: any }) => {
const checksumEntry = makeAddressBookEntry({ address: checksumAddress(owner.address), name: owner.name })
if (isValidAddressBookName(checksumEntry.name)) {
dispatch(addOrUpdateAddressBookEntry(checksumEntry))
}
})
// add the recently loaded safe as an entry in the address book
// if it exists already, it will be replaced with the recently added name
if (safe.name) {
dispatch(addOrUpdateAddressBookEntry(makeAddressBookEntry({ name: safe.name, address: safe.address })))
}
break
}
case UPDATE_SAFE: {
const { name, address } = action.payload as { name: string; address: string }
if (name) {
dispatch(addOrUpdateAddressBookEntry(makeAddressBookEntry({ name, address })))
}
break
}
case SET_DEFAULT_SAFE: {
if (action.payload) {
saveDefaultSafe(action.payload as string)

View File

@ -1,8 +0,0 @@
import { Record } from 'immutable'
export const makeOwner = Record({
name: 'UNKNOWN',
address: '',
})
// Usage const someRecord: Owner = makeOwner({ name: ... })

View File

@ -1,11 +1,9 @@
import { List, Record, RecordOf } from 'immutable'
import { Record, RecordOf } from 'immutable'
import { FEATURES } from 'src/config/networks/network.d'
import { BalanceRecord } from 'src/logic/tokens/store/actions/fetchSafeTokens'
export type SafeOwner = {
name: string
address: string
}
export type SafeOwner = string
export type ModulePair = [
// previous module
@ -25,39 +23,37 @@ export type SpendingLimit = {
}
export type SafeRecordProps = {
name: string
address: string
threshold: number
ethBalance: string
totalFiatBalance: string
owners: List<SafeOwner>
owners: SafeOwner[]
modules?: ModulePair[] | null
spendingLimits?: SpendingLimit[] | null
balances: BalanceRecord[]
nonce: number
recurringUser?: boolean
loadedViaUrl?: boolean
currentVersion: string
needsUpdate: boolean
featuresEnabled: Array<FEATURES>
loadedViaUrl: boolean
}
const makeSafe = Record<SafeRecordProps>({
name: '',
address: '',
threshold: 0,
ethBalance: '0',
totalFiatBalance: '0',
owners: List([]),
owners: [],
modules: [],
spendingLimits: [],
balances: [],
nonce: 0,
loadedViaUrl: false,
recurringUser: undefined,
currentVersion: '',
needsUpdate: false,
featuresEnabled: [],
loadedViaUrl: true,
})
export type SafeRecord = RecordOf<SafeRecordProps>

View File

@ -1,30 +1,22 @@
import { Map, List } from 'immutable'
import { Action, handleActions } from 'redux-actions'
import { ADD_SAFE_OWNER } from 'src/logic/safe/store/actions/addSafeOwner'
import { EDIT_SAFE_OWNER } from 'src/logic/safe/store/actions/editSafeOwner'
import { REMOVE_SAFE } from 'src/logic/safe/store/actions/removeSafe'
import { REMOVE_SAFE_OWNER } from 'src/logic/safe/store/actions/removeSafeOwner'
import { REPLACE_SAFE_OWNER } from 'src/logic/safe/store/actions/replaceSafeOwner'
import { SET_DEFAULT_SAFE } from 'src/logic/safe/store/actions/setDefaultSafe'
import { SET_LATEST_MASTER_CONTRACT_VERSION } from 'src/logic/safe/store/actions/setLatestMasterContractVersion'
import { UPDATE_SAFE } from 'src/logic/safe/store/actions/updateSafe'
import { makeOwner } from 'src/logic/safe/store/models/owner'
import makeSafe, { SafeRecord, SafeRecordProps } from 'src/logic/safe/store/models/safe'
import { AppReduxState } from 'src/store'
import { checksumAddress } from 'src/utils/checksumAddress'
import { ADD_OR_UPDATE_SAFE, buildOwnersFrom } from 'src/logic/safe/store/actions/addOrUpdateSafe'
import { ADD_OR_UPDATE_SAFE } from 'src/logic/safe/store/actions/addOrUpdateSafe'
import { sameAddress } from 'src/logic/wallets/ethAddresses'
import { shouldSafeStoreBeUpdated } from 'src/logic/safe/utils/shouldSafeStoreBeUpdated'
import { LOADED_SAFE_KEY } from 'src/utils/constants'
export const SAFE_REDUCER_ID = 'safes'
export const DEFAULT_SAFE_INITIAL_STATE = 'NOT_ASKED'
export const buildSafe = (storedSafe: SafeRecordProps): SafeRecordProps => {
const names = storedSafe.owners.map((owner) => owner.name)
const addresses = storedSafe.owners.map((owner) => checksumAddress(owner.address))
const owners = buildOwnersFrom(Array.from(names), Array.from(addresses))
const owners = storedSafe.owners.map(checksumAddress)
return {
...storedSafe,
@ -82,15 +74,8 @@ export default handleActions<AppReduxState['safes'], Payloads>(
const shouldUpdate = shouldSafeStoreBeUpdated(safe, state.getIn(['safes', safeAddress]))
return shouldUpdate
? state.updateIn(
['safes', safeAddress],
// This intermediate value is used as prevSafe if no previous state. Else is not used
makeSafe({
name: safe?.name || LOADED_SAFE_KEY,
address: safeAddress,
loadedViaUrl: !safe?.name || safe?.name === LOADED_SAFE_KEY,
}),
(prevSafe) => updateSafeProps(prevSafe, safe),
? state.updateIn(['safes', safeAddress], makeSafe({ address: safeAddress }), (prevSafe) =>
updateSafeProps(prevSafe, safe),
)
: state
},
@ -104,15 +89,8 @@ export default handleActions<AppReduxState['safes'], Payloads>(
const shouldUpdate = shouldSafeStoreBeUpdated(safe, state.getIn(['safes', safeAddress]))
return shouldUpdate
? state.updateIn(
['safes', safeAddress],
// This intermediate value is used as prevSafe if no previous state. Else is not used
makeSafe({
name: safe?.name || LOADED_SAFE_KEY,
address: safeAddress,
loadedViaUrl: !safe?.name || safe?.name === LOADED_SAFE_KEY,
}),
(prevSafe) => updateSafeProps(prevSafe, safe),
? state.updateIn(['safes', safeAddress], makeSafe({ address: safeAddress }), (prevSafe) =>
updateSafeProps(prevSafe, safe),
)
: state
},
@ -128,54 +106,6 @@ export default handleActions<AppReduxState['safes'], Payloads>(
return newState
},
[ADD_SAFE_OWNER]: (state, action: Action<FullOwnerPayload>) => {
const { ownerAddress, ownerName, safeAddress } = action.payload
const addressFound = state
.getIn(['safes', safeAddress])
.owners.find((owner) => sameAddress(owner.address, ownerAddress))
if (addressFound) {
return state
}
return state.updateIn(['safes', safeAddress], (prevSafe) =>
prevSafe.merge({
owners: prevSafe.owners.push(makeOwner({ address: ownerAddress, name: ownerName })),
}),
)
},
[REMOVE_SAFE_OWNER]: (state, action: Action<BaseOwnerPayload>) => {
const { ownerAddress, safeAddress } = action.payload
return state.updateIn(['safes', safeAddress], (prevSafe) =>
prevSafe.merge({
owners: prevSafe.owners.filter((o) => o.address.toLowerCase() !== ownerAddress.toLowerCase()),
}),
)
},
[REPLACE_SAFE_OWNER]: (state, action: Action<ReplaceOwnerPayload>) => {
const { oldOwnerAddress, ownerAddress, ownerName, safeAddress } = action.payload
return state.updateIn(['safes', safeAddress], (prevSafe) =>
prevSafe.merge({
owners: prevSafe.owners
.filter((o) => o.address.toLowerCase() !== oldOwnerAddress.toLowerCase())
.push(makeOwner({ address: ownerAddress, name: ownerName })),
}),
)
},
[EDIT_SAFE_OWNER]: (state, action: Action<FullOwnerPayload>) => {
const { ownerAddress, ownerName, safeAddress } = action.payload
return state.updateIn(['safes', safeAddress], (prevSafe) => {
const ownerToUpdateIndex = prevSafe.owners.findIndex(
(o) => o.address.toLowerCase() === ownerAddress.toLowerCase(),
)
const updatedOwners = prevSafe.owners.update(ownerToUpdateIndex, (owner) => owner.set('name', ownerName))
return prevSafe.merge({ owners: updatedOwners })
})
},
[SET_DEFAULT_SAFE]: (state, action: Action<SafeRecord>) => state.set('defaultSafe', action.payload),
[SET_LATEST_MASTER_CONTRACT_VERSION]: (state, action: Action<SafeRecord>) =>
state.set('latestMasterContractVersion', action.payload),

View File

@ -1,15 +1,20 @@
import { List } from 'immutable'
import { matchPath, RouteComponentProps } from 'react-router-dom'
import { createSelector } from 'reselect'
import { SAFELIST_ADDRESS, SAFE_PARAM_ADDRESS } from 'src/routes/routes'
import { getNetworkId } from 'src/config'
import { SAFE_REDUCER_ID } from 'src/logic/safe/store/reducer/safe'
import { AppReduxState } from 'src/store'
import { checksumAddress } from 'src/utils/checksumAddress'
import makeSafe, { SafeRecord, SafeRecordProps } from '../models/safe'
import { ETHEREUM_NETWORK } from 'src/config/networks/network'
import { makeAddressBookEntry } from 'src/logic/addressBook/model/addressBook'
import { addressBookMapSelector, addressBookSelector } from 'src/logic/addressBook/store/selectors'
import makeSafe, { SafeRecord, SafeRecordProps } from 'src/logic/safe/store/models/safe'
import { SAFELIST_ADDRESS, SAFE_PARAM_ADDRESS } from 'src/routes/routes'
import { SafesMap } from 'src/routes/safe/store/reducer/types/safe'
import { BalanceRecord } from 'src/logic/tokens/store/actions/fetchSafeTokens'
import { sameAddress } from 'src/logic/wallets/ethAddresses'
const safesStateSelector = (state: AppReduxState) => state[SAFE_REDUCER_ID]
@ -17,6 +22,32 @@ export const safesMapSelector = (state: AppReduxState): SafesMap => safesStateSe
export const safesListSelector = createSelector(safesMapSelector, (safes): List<SafeRecord> => safes.toList())
const chainId = getNetworkId()
type SafeRecordWithName = SafeRecordProps & { name: string }
export const safesListWithAddressBookNameSelector = createSelector(
[safesListSelector, addressBookMapSelector],
(safesList, addressBookMap): List<SafeRecordWithName> => {
const addressBook = addressBookMap?.[chainId]
return safesList
.filter((safeRecord) => !safeRecord.loadedViaUrl)
.map((safeRecord) => {
const safe = safeRecord.toObject()
const name = addressBook?.[safe.address]?.name ?? ''
return { ...safe, name }
})
},
)
export const safeNameSelector = createSelector(
[safesListWithAddressBookNameSelector, (_, safeName: string) => safeName],
(safes, safeAddress): string => {
return safes.find((safe) => sameAddress(safe.address, safeAddress))?.name ?? ''
},
)
export const safesCountSelector = createSelector(safesMapSelector, (safes) => safes.size)
export const defaultSafeSelector = createSelector(safesStateSelector, (safeState) => safeState.get('defaultSafe'))
@ -83,8 +114,6 @@ export const safeFieldSelector = <K extends keyof SafeRecordProps>(field: K) =>
safe: SafeRecord,
): SafeRecordProps[K] | undefined => (safe ? safe.get(field, baseSafe.get(field)) : undefined)
export const safeNameSelector = createSelector(safeSelector, safeFieldSelector('name'))
export const safeEthBalanceSelector = createSelector(safeSelector, safeFieldSelector('ethBalance'))
export const safeNeedsUpdateSelector = createSelector(safeSelector, safeFieldSelector('needsUpdate'))
@ -103,19 +132,24 @@ export const safeFeaturesEnabledSelector = createSelector(safeSelector, safeFiel
export const safeSpendingLimitsSelector = createSelector(safeSelector, safeFieldSelector('spendingLimits'))
export const safeLoadedViaUrlSelector = createSelector(safeSelector, safeFieldSelector('loadedViaUrl'))
export const safeOwnersAddressesListSelector = createSelector(
safeOwnersSelector,
(owners): List<string> => {
if (!owners) {
return List([])
}
return owners?.map(({ address }) => address)
},
)
export const safeTotalFiatBalanceSelector = createSelector(safeSelector, (currentSafe) => {
return currentSafe?.totalFiatBalance
})
export const safeOwnersWithAddressBookDataSelector = createSelector(
[safeOwnersSelector, addressBookSelector, (_, chainId: ETHEREUM_NETWORK) => chainId],
(owners, addressBook, chainId): AppReduxState['addressBook'] | undefined =>
owners?.map((ownerAddress) => {
const ownerInAddressBook = addressBook.find(
(addressBookEntry) =>
sameAddress(ownerAddress, addressBookEntry.address) && chainId === addressBookEntry.chainId,
)
if (ownerInAddressBook) {
return ownerInAddressBook
}
// if there's no owner's data in the AB, we create an in-memory AB-like structure
return makeAddressBookEntry({ address: ownerAddress, name: '' })
}),
)

View File

@ -8,7 +8,9 @@ export const TX_NOTIFICATION_TYPES = {
REMOVE_SPENDING_LIMIT_TX: 'REMOVE_SPENDING_LIMIT_TX',
SAFE_NAME_CHANGE_TX: 'SAFE_NAME_CHANGE_TX',
OWNER_NAME_CHANGE_TX: 'OWNER_NAME_CHANGE_TX',
ADDRESSBOOK_NEW_ENTRY: 'ADDRESSBOOK_NEW_ENTRY',
ADDRESSBOOK_EDIT_ENTRY: 'ADDRESSBOOK_EDIT_ENTRY',
ADDRESSBOOK_DELETE_ENTRY: 'ADDRESSBOOK_DELETE_ENTRY',
ADDRESS_BOOK_NEW_ENTRY: 'ADDRESS_BOOK_NEW_ENTRY',
ADDRESS_BOOK_EDIT_ENTRY: 'ADDRESS_BOOK_EDIT_ENTRY',
ADDRESS_BOOK_DELETE_ENTRY: 'ADDRESS_BOOK_DELETE_ENTRY',
ADDRESS_BOOK_EXPORT_ENTRIES: 'ADDRESS_BOOK_EXPORT_ENTRIES',
ADDRESS_BOOK_IMPORT_ENTRIES: 'ADDRESS_BOOK_IMPORT_ENTRIES',
}

View File

@ -12,28 +12,20 @@ const getMockedOldSafe = ({
currentVersion,
ethBalance,
threshold,
name,
nonce,
modules,
spendingLimits,
}: Partial<SafeRecordProps>): SafeRecordProps => {
const owner1 = {
name: 'MockedOwner1',
address: '0x3bE3c2dE077FBC409ae50AFFA66a94a9aE669A8d',
}
const owner2 = {
name: 'MockedOwner2',
address: '0xA2366b0c2607de70777d87aCdD1D22F0708fA6a3',
}
const owner1 = '0x3bE3c2dE077FBC409ae50AFFA66a94a9aE669A8d'
const owner2 = '0xA2366b0c2607de70777d87aCdD1D22F0708fA6a3'
const mockedActiveTokenAddress1 = '0x36591cd3DA96b21Ac9ca54cFaf80fe45107294F1'
const mockedActiveTokenAddress2 = '0x92aF97cbF10742dD2527ffaBA70e34C03CFFC2c1'
return {
name: name || 'MockedSafe',
address: address || '0xAE173F30ec9A293d37c44BA68d3fCD35F989Ce9F',
threshold: threshold || 2,
ethBalance: ethBalance || '10',
owners: owners || List([owner1, owner2]),
owners: owners || [owner1, owner2],
modules: modules || [],
spendingLimits: spendingLimits || [],
balances: balances || [
@ -46,6 +38,7 @@ const getMockedOldSafe = ({
needsUpdate: needsUpdate || false,
featuresEnabled: featuresEnabled || [],
totalFiatBalance: '110',
loadedViaUrl: false,
}
}
@ -75,21 +68,6 @@ describe('shouldSafeStoreBeUpdated', () => {
// Then
expect(expectedResult).toEqual(true)
})
it(`Given an old safe and a new name for the safe, should return true`, () => {
// given
const oldName = 'oldName'
const newName = 'newName'
const oldSafe = getMockedOldSafe({ name: oldName })
const newSafeProps: Partial<SafeRecordProps> = {
name: newName,
}
// When
const expectedResult = shouldSafeStoreBeUpdated(newSafeProps, oldSafe)
// Then
expect(expectedResult).toEqual(true)
})
it(`Given an old safe and a new threshold for the safe, should return true`, () => {
// given
const oldThreshold = 1
@ -122,18 +100,10 @@ describe('shouldSafeStoreBeUpdated', () => {
})
it(`Given an old owners list and a new owners list for the safe, should return true`, () => {
// given
const owner1 = {
name: 'MockedOwner1',
address: '0x3bE3c2dE077FBC409ae50AFFA66a94a9aE669A8d',
}
const owner2 = {
name: 'MockedOwner2',
address: '0xA2366b0c2607de70777d87aCdD1D22F0708fA6a3',
}
const oldSafe = getMockedOldSafe({ owners: List([owner1, owner2]) })
const newSafeProps: Partial<SafeRecordProps> = {
owners: List([owner1]),
}
const owner1 = '0x3bE3c2dE077FBC409ae50AFFA66a94a9aE669A8d'
const owner2 = '0xA2366b0c2607de70777d87aCdD1D22F0708fA6a3'
const oldSafe = getMockedOldSafe({ owners: [owner1, owner2] })
const newSafeProps: Partial<SafeRecordProps> = { owners: [owner1] }
// When
const expectedResult = shouldSafeStoreBeUpdated(newSafeProps, oldSafe)
@ -146,9 +116,7 @@ describe('shouldSafeStoreBeUpdated', () => {
const oldModulesList = []
const newModulesList = null
const oldSafe = getMockedOldSafe({ modules: oldModulesList })
const newSafeProps: Partial<SafeRecordProps> = {
modules: newModulesList,
}
const newSafeProps: Partial<SafeRecordProps> = { modules: newModulesList }
// When
const expectedResult = shouldSafeStoreBeUpdated(newSafeProps, oldSafe)
@ -161,9 +129,7 @@ describe('shouldSafeStoreBeUpdated', () => {
const oldSpendingLimitsList = []
const newSpendingLimitsList = null
const oldSafe = getMockedOldSafe({ spendingLimits: oldSpendingLimitsList })
const newSafeProps: Partial<SafeRecordProps> = {
modules: newSpendingLimitsList,
}
const newSafeProps: Partial<SafeRecordProps> = { modules: newSpendingLimitsList }
// When
const expectedResult = shouldSafeStoreBeUpdated(newSafeProps, oldSafe)

View File

@ -4,7 +4,7 @@ import { SafeRecordProps } from 'src/logic/safe/store/models/safe'
export const SAFES_KEY = 'SAFES'
export const DEFAULT_SAFE_KEY = 'DEFAULT_SAFE'
type StoredSafes = Record<string, SafeRecordProps>
export type StoredSafes = Record<string, SafeRecordProps>
export const loadStoredSafes = (): Promise<StoredSafes | undefined> => {
return loadFromStorage<StoredSafes>(SAFES_KEY)
@ -18,6 +18,11 @@ export const saveSafes = async (safes: StoredSafes): Promise<void> => {
}
}
export const getLocalSafes = async (): Promise<SafeRecordProps[] | undefined> => {
const storedSafes = await loadStoredSafes()
return storedSafes ? Object.values(storedSafes) : undefined
}
export const getLocalSafe = async (safeAddress: string): Promise<SafeRecordProps | undefined> => {
const storedSafes = await loadStoredSafes()
return storedSafes?.[safeAddress]

View File

@ -6,7 +6,7 @@ import { SafeRecordProps } from 'src/logic/safe/store/models/safe'
const isStateSubset = (superObj, subObj) => {
return Object.keys(subObj).every((key) => {
if (subObj[key] && typeof subObj[key] == 'object') {
if (typeof subObj[key] === 'object' || subObj[key].length >= 0) {
if (typeof subObj[key] === 'object' || subObj[key].size >= 0) {
// If type is Immutable Map, List or Object we use Immutable equals
return isEqual(superObj[key], subObj[key])
}

View File

@ -9,7 +9,6 @@ import {
shortVersionOf,
} from 'src/logic/wallets/ethAddresses'
import makeSafe from 'src/logic/safe/store/models/safe'
import { makeOwner } from 'src/logic/safe/store/models/owner'
describe('src/logic/wallets/ethAddresses', () => {
describe('Utility function: sameAddress', () => {
@ -113,7 +112,7 @@ describe('src/logic/wallets/ethAddresses', () => {
it("Should return false if there's no `userAccount`", () => {
// given
const userAddress = null
const owners = List([makeOwner({ address: userAddress })])
const owners = [userAddress]
const safeInstance = makeSafe({ owners })
const expectedResult = false
@ -139,7 +138,7 @@ describe('src/logic/wallets/ethAddresses', () => {
it("Should return true if `userAccount` is not in the list of Safe's owners", () => {
// given
const userAddress = 'address1'
const owners = List([makeOwner({ address: userAddress })])
const owners = [userAddress]
const safeInstance = makeSafe({ owners })
const expectedResult = true
@ -153,7 +152,7 @@ describe('src/logic/wallets/ethAddresses', () => {
// given
const userAddress = 'address1'
const userAddress2 = 'address2'
const owners = List([makeOwner({ address: userAddress })])
const owners = [userAddress]
const safeInstance = makeSafe({ owners })
const expectedResult = false
@ -170,8 +169,8 @@ describe('src/logic/wallets/ethAddresses', () => {
// given
const userAddress = 'address1'
const userAddress2 = 'address2'
const owners1 = List([makeOwner({ address: userAddress })])
const owners2 = List([makeOwner({ address: userAddress2 })])
const owners1 = [userAddress]
const owners2 = [userAddress2]
const safeInstance = makeSafe({ owners: owners1 })
const safeInstance2 = makeSafe({ owners: owners2 })
const safesList = List([safeInstance, safeInstance2])
@ -188,8 +187,8 @@ describe('src/logic/wallets/ethAddresses', () => {
const userAddress = 'address1'
const userAddress2 = 'address2'
const userAddress3 = 'address3'
const owners1 = List([makeOwner({ address: userAddress3 })])
const owners2 = List([makeOwner({ address: userAddress2 })])
const owners1 = [userAddress3]
const owners2 = [userAddress2]
const safeInstance = makeSafe({ owners: owners1 })
const safeInstance2 = makeSafe({ owners: owners2 })
const safesList = List([safeInstance, safeInstance2])

View File

@ -40,7 +40,7 @@ export const isUserAnOwner = (safe: SafeRecord, userAccount: string): boolean =>
return false
}
return owners.find((owner) => sameAddress(owner.address, userAccount)) !== undefined
return owners.find((address) => sameAddress(address, userAccount)) !== undefined
}
export const isUserAnOwnerOfAnySafe = (safes: List<SafeRecord> | SafeRecord[], userAccount: string): boolean =>

View File

@ -1,7 +1,7 @@
import InputAdornment from '@material-ui/core/InputAdornment'
import { makeStyles } from '@material-ui/core/styles'
import CheckCircle from '@material-ui/icons/CheckCircle'
import * as React from 'react'
import React, { ReactElement, ReactNode } from 'react'
import { FormApi } from 'final-form'
import { ScanQRWrapper } from 'src/components/ScanQRModal/ScanQRWrapper'
@ -15,7 +15,7 @@ import {
noErrorsOn,
required,
composeValidators,
minMaxLength,
validAddressBookName,
} from 'src/components/forms/validator'
import Block from 'src/components/layout/Block'
import Col from 'src/components/layout/Col'
@ -68,7 +68,7 @@ interface DetailsFormProps {
form: FormApi
}
const DetailsForm = ({ errors, form }: DetailsFormProps): React.ReactElement => {
const DetailsForm = ({ errors, form }: DetailsFormProps): ReactElement => {
const classes = useStyles()
const handleScan = (value: string, closeQrModal: () => void): void => {
@ -92,10 +92,10 @@ const DetailsForm = ({ errors, form }: DetailsFormProps): React.ReactElement =>
<Field
component={TextField}
name={FIELD_LOAD_NAME}
placeholder="Name of the Safe"
placeholder="Name of the Safe*"
text="Safe name"
type="text"
validate={composeValidators(required, minMaxLength(1, 50))}
validate={composeValidators(required, validAddressBookName)}
testId="load-safe-name-field"
/>
</Col>
@ -145,13 +145,11 @@ const DetailsForm = ({ errors, form }: DetailsFormProps): React.ReactElement =>
}
const DetailsPage = () =>
function LoadSafeDetails(controls: React.ReactNode, { errors, form }: StepperPageFormProps): React.ReactElement {
function LoadSafeDetails(controls: ReactNode, { errors, form }: StepperPageFormProps): ReactElement {
return (
<>
<OpenPaper controls={controls}>
<DetailsForm errors={errors} form={form} />
</OpenPaper>
</>
<OpenPaper controls={controls}>
<DetailsForm errors={errors} form={form} />
</OpenPaper>
)
}

View File

@ -1,6 +1,6 @@
import IconButton from '@material-ui/core/IconButton'
import ChevronLeft from '@material-ui/icons/ChevronLeft'
import * as React from 'react'
import React, { ReactElement } from 'react'
import Stepper, { StepperPage } from 'src/components/Stepper'
import Block from 'src/components/layout/Block'
@ -34,13 +34,12 @@ const formMutators = {
}
interface LayoutProps {
network: string
provider?: string
userAddress: string
onLoadSafeSubmit: (values: LoadFormValues) => void
}
const Layout = ({ network, onLoadSafeSubmit, provider, userAddress }: LayoutProps): React.ReactElement => (
const Layout = ({ onLoadSafeSubmit, provider, userAddress }: LayoutProps): ReactElement => (
<>
{provider ? (
<Block>
@ -58,8 +57,8 @@ const Layout = ({ network, onLoadSafeSubmit, provider, userAddress }: LayoutProp
testId="load-safe-form"
>
<StepperPage validate={safeFieldsValidation} component={DetailsForm} />
<StepperPage network={network} component={OwnerList} />
<StepperPage network={network} userAddress={userAddress} component={ReviewInformation} />
<StepperPage component={OwnerList} />
<StepperPage userAddress={userAddress} component={ReviewInformation} />
</Stepper>
</Block>
) : (

View File

@ -1,12 +1,13 @@
import { EthHashInfo } from '@gnosis.pm/safe-react-components'
import { makeStyles } from '@material-ui/core/styles'
import TableContainer from '@material-ui/core/TableContainer'
import React, { useEffect, useState } from 'react'
import React, { ReactElement, ReactNode, useEffect, useState } from 'react'
import { useSelector } from 'react-redux'
import { getExplorerInfo } from 'src/config'
import Field from 'src/components/forms/Field'
import TextField from 'src/components/forms/TextField'
import { composeValidators, minMaxLength, required } from 'src/components/forms/validator'
import { minMaxLength } from 'src/components/forms/validator'
import Block from 'src/components/layout/Block'
import Col from 'src/components/layout/Col'
import Hairline from 'src/components/layout/Hairline'
@ -21,8 +22,7 @@ import { getGnosisSafeInstanceAt } from 'src/logic/contracts/safeContracts'
import { FIELD_LOAD_ADDRESS, THRESHOLD } from 'src/routes/load/components/fields'
import { getOwnerAddressBy, getOwnerNameBy } from 'src/routes/open/components/fields'
import { styles } from './styles'
import { getExplorerInfo } from 'src/config'
import { EthHashInfo } from '@gnosis.pm/safe-react-components'
import { LoadFormValues } from 'src/routes/load/container/Load'
const calculateSafeValues = (owners, threshold, values) => {
const initialValues = { ...values }
@ -41,10 +41,14 @@ const useAddressBookForOwnersNames = (ownersList: string[]): AddressBookEntry[]
const useStyles = makeStyles(styles)
const OwnerListComponent = (props) => {
interface OwnerListComponentProps {
values: LoadFormValues
updateInitialProps: (initialValues) => void
}
const OwnerListComponent = ({ values, updateInitialProps }: OwnerListComponentProps): ReactElement => {
const [owners, setOwners] = useState<string[]>([])
const classes = useStyles()
const { updateInitialProps, values } = props
const ownersWithNames = useAddressBookForOwnersNames(owners)
@ -88,19 +92,18 @@ const OwnerListComponent = (props) => {
<Hairline />
<Block margin="md" padding="md">
{ownersWithNames.map(({ address, name }, index) => {
const ownerName = name || `Owner #${index + 1}`
return (
<Row className={classes.owner} key={address} data-testid="owner-row">
<Col className={classes.ownerName} xs={4}>
<Field
className={classes.name}
component={TextField}
initialValue={ownerName}
initialValue={name}
name={getOwnerNameBy(index)}
placeholder="Owner Name*"
placeholder="Owner Name"
text="Owner Name"
type="text"
validate={composeValidators(required, minMaxLength(1, 50))}
validate={minMaxLength(0, 50)}
testId={`load-safe-owner-name-${index}`}
/>
</Col>
@ -118,14 +121,12 @@ const OwnerListComponent = (props) => {
)
}
const OwnerList = ({ updateInitialProps }, network) =>
function LoadSafeOwnerList(controls, { values }): React.ReactElement {
const OwnerList = ({ updateInitialProps }) =>
function LoadSafeOwnerList(controls: ReactNode, { values }: { values: LoadFormValues }): ReactElement {
return (
<>
<OpenPaper controls={controls} padding={false}>
<OwnerListComponent network={network} updateInitialProps={updateInitialProps} values={values} />
</OpenPaper>
</>
<OpenPaper controls={controls} padding={false}>
<OwnerListComponent updateInitialProps={updateInitialProps} values={values} />
</OpenPaper>
)
}

View File

@ -1,6 +1,8 @@
import { EthHashInfo } from '@gnosis.pm/safe-react-components'
import TableContainer from '@material-ui/core/TableContainer'
import React from 'react'
import React, { ReactElement, ReactNode } from 'react'
import { getExplorerInfo } from 'src/config'
import Block from 'src/components/layout/Block'
import Col from 'src/components/layout/Col'
import Hairline from 'src/components/layout/Hairline'
@ -11,8 +13,6 @@ import { FIELD_LOAD_ADDRESS, FIELD_LOAD_NAME, THRESHOLD } from 'src/routes/load/
import { getNumOwnersFrom, getOwnerAddressBy, getOwnerNameBy } from 'src/routes/open/components/fields'
import { getAccountsFrom } from 'src/routes/open/utils/safeDataExtractor'
import { useStyles } from './styles'
import { getExplorerInfo } from 'src/config'
import { EthHashInfo } from '@gnosis.pm/safe-react-components'
import { LoadFormValues } from 'src/routes/load/container/Load'
const checkIfUserAddressIsAnOwner = (values: LoadFormValues, userAddress: string): boolean => {
@ -33,108 +33,104 @@ interface Props {
values: LoadFormValues
}
const ReviewComponent = ({ userAddress, values }: Props): React.ReactElement => {
const ReviewComponent = ({ userAddress, values }: Props): ReactElement => {
const classes = useStyles()
const isOwner = checkIfUserAddressIsAnOwner(values, userAddress)
const owners = getAccountsFrom(values)
const safeAddress = values[FIELD_LOAD_ADDRESS]
return (
<>
<Row className={classes.root}>
<Col className={classes.detailsColumn} layout="column" xs={4}>
<Block className={classes.details}>
<Block margin="lg">
<Paragraph color="primary" noMargin size="lg" data-testid="load-safe-step-three">
Review details
</Paragraph>
</Block>
<Block margin="lg">
<Paragraph color="disabled" noMargin size="sm">
Name of the Safe
</Paragraph>
<Paragraph
className={classes.name}
color="primary"
noMargin
size="lg"
weight="bolder"
data-testid="load-form-review-safe-name"
>
{values[FIELD_LOAD_NAME]}
</Paragraph>
</Block>
<Block margin="lg">
<Paragraph color="disabled" noMargin size="sm">
Safe address
</Paragraph>
<Row className={classes.container}>
<EthHashInfo
hash={safeAddress}
shortenHash={4}
showAvatar
showCopyBtn
explorerUrl={getExplorerInfo(safeAddress)}
/>
</Row>
</Block>
<Block margin="lg">
<Paragraph color="disabled" noMargin size="sm">
Connected wallet client is owner?
</Paragraph>
<Paragraph className={classes.name} color="primary" noMargin size="lg" weight="bolder">
{isOwner ? 'Yes' : 'No (read-only)'}
</Paragraph>
</Block>
<Block margin="lg">
<Paragraph color="disabled" noMargin size="sm">
Any transaction requires the confirmation of:
</Paragraph>
<Paragraph className={classes.name} color="primary" noMargin size="lg" weight="bolder">
{`${values[THRESHOLD]} out of ${getNumOwnersFrom(values)} owners`}
</Paragraph>
</Block>
<Row className={classes.root}>
<Col className={classes.detailsColumn} layout="column" xs={4}>
<Block className={classes.details}>
<Block margin="lg">
<Paragraph color="primary" noMargin size="lg" data-testid="load-safe-step-three">
Review details
</Paragraph>
</Block>
</Col>
<Col className={classes.ownersColumn} layout="column" xs={8}>
<TableContainer>
<Block className={classes.owners}>
<Paragraph color="primary" noMargin size="lg">
{`${getNumOwnersFrom(values)} Safe owners`}
</Paragraph>
</Block>
<Hairline />
{owners.map((address, index) => (
<>
<Row className={classes.owner} testId={'load-safe-review-owner-name-' + index}>
<Col align="center" xs={12}>
<EthHashInfo
hash={address}
name={values[getOwnerNameBy(index)]}
showAvatar
showCopyBtn
explorerUrl={getExplorerInfo(address)}
/>
</Col>
</Row>
{index !== owners.length - 1 && <Hairline />}
</>
))}
</TableContainer>
</Col>
</Row>
</>
<Block margin="lg">
<Paragraph color="disabled" noMargin size="sm">
Name of the Safe
</Paragraph>
<Paragraph
className={classes.name}
color="primary"
noMargin
size="lg"
weight="bolder"
data-testid="load-form-review-safe-name"
>
{values[FIELD_LOAD_NAME]}
</Paragraph>
</Block>
<Block margin="lg">
<Paragraph color="disabled" noMargin size="sm">
Safe address
</Paragraph>
<Row className={classes.container}>
<EthHashInfo
hash={safeAddress}
shortenHash={4}
showAvatar
showCopyBtn
explorerUrl={getExplorerInfo(safeAddress)}
/>
</Row>
</Block>
<Block margin="lg">
<Paragraph color="disabled" noMargin size="sm">
Connected wallet client is owner?
</Paragraph>
<Paragraph className={classes.name} color="primary" noMargin size="lg" weight="bolder">
{isOwner ? 'Yes' : 'No (read-only)'}
</Paragraph>
</Block>
<Block margin="lg">
<Paragraph color="disabled" noMargin size="sm">
Any transaction requires the confirmation of:
</Paragraph>
<Paragraph className={classes.name} color="primary" noMargin size="lg" weight="bolder">
{`${values[THRESHOLD]} out of ${getNumOwnersFrom(values)} owners`}
</Paragraph>
</Block>
</Block>
</Col>
<Col className={classes.ownersColumn} layout="column" xs={8}>
<TableContainer>
<Block className={classes.owners}>
<Paragraph color="primary" noMargin size="lg">
{`${getNumOwnersFrom(values)} Safe owners`}
</Paragraph>
</Block>
<Hairline />
{owners.map((address, index) => (
<>
<Row className={classes.owner} testId={'load-safe-review-owner-name-' + index}>
<Col align="center" xs={12}>
<EthHashInfo
hash={address}
name={values[getOwnerNameBy(index)]}
showAvatar
showCopyBtn
explorerUrl={getExplorerInfo(address)}
/>
</Col>
</Row>
{index !== owners.length - 1 && <Hairline />}
</>
))}
</TableContainer>
</Col>
</Row>
)
}
const Review = ({ userAddress }: { userAddress: string }) =>
function ReviewPage(controls: React.ReactNode, { values }: { values: LoadFormValues }): React.ReactElement {
function ReviewPage(controls: ReactNode, { values }: { values: LoadFormValues }): ReactElement {
return (
<>
<OpenPaper controls={controls} padding={false}>
<ReviewComponent userAddress={userAddress} values={values} />
</OpenPaper>
</>
<OpenPaper controls={controls} padding={false}>
<ReviewComponent userAddress={userAddress} values={values} />
</OpenPaper>
)
}

View File

@ -1,34 +1,25 @@
import { List } from 'immutable'
import * as React from 'react'
import React, { ReactElement } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import { ETHEREUM_NETWORK } from 'src/config/networks/network.d'
import Layout from 'src/routes/load/components/Layout'
import { FIELD_LOAD_ADDRESS, FIELD_LOAD_NAME } from '../components/fields'
import { AddressBookEntry, makeAddressBookEntry } from 'src/logic/addressBook/model/addressBook'
import { addressBookSafeLoad } from 'src/logic/addressBook/store/actions'
import { FIELD_LOAD_ADDRESS } from 'src/routes/load/components/fields'
import Page from 'src/components/layout/Page'
import { getGnosisSafeInstanceAt } from 'src/logic/contracts/safeContracts'
import { saveSafes, loadStoredSafes } from 'src/logic/safe/utils'
import { getNamesFrom, getOwnersFrom } from 'src/routes/open/utils/safeDataExtractor'
import { getAccountsFrom, getNamesFrom } from 'src/routes/open/utils/safeDataExtractor'
import { SAFELIST_ADDRESS } from 'src/routes/routes'
import { buildSafe } from 'src/logic/safe/store/actions/fetchSafe'
import { history } from 'src/store'
import { SafeOwner, SafeRecordProps } from 'src/logic/safe/store/models/safe'
import { SafeRecordProps } from 'src/logic/safe/store/models/safe'
import { checksumAddress } from 'src/utils/checksumAddress'
import { networkSelector, providerNameSelector, userAccountSelector } from 'src/logic/wallets/store/selectors'
import { isValidAddress } from 'src/utils/isValidAddress'
import { providerNameSelector, userAccountSelector } from 'src/logic/wallets/store/selectors'
import { addOrUpdateSafe } from 'src/logic/safe/store/actions/addOrUpdateSafe'
export const loadSafe = async (
safeName: string,
safeAddress: string,
owners: List<SafeOwner>,
addSafe: (safe: SafeRecordProps) => void,
): Promise<void> => {
const safeProps = await buildSafe(safeAddress, safeName)
safeProps.owners = owners
// We are manually adding the safe. We enforce this state in case the safe was previously
// accessed by URL
safeProps.loadedViaUrl = false
export const loadSafe = async (safeAddress: string, addSafe: (safe: SafeRecordProps) => void): Promise<void> => {
const safeProps = await buildSafe(safeAddress)
const storedSafes = (await loadStoredSafes()) || {}
@ -56,10 +47,9 @@ interface LoadForm {
export type LoadFormValues = ReviewSafeCreationValues | LoadForm
const Load = (): React.ReactElement => {
const Load = (): ReactElement => {
const dispatch = useDispatch()
const provider = useSelector(providerNameSelector)
const network = useSelector(networkSelector)
const userAddress = useSelector(userAccountSelector)
const addSafeHandler = async (safe: SafeRecordProps) => {
@ -67,22 +57,32 @@ const Load = (): React.ReactElement => {
}
const onLoadSafeSubmit = async (values: LoadFormValues) => {
let safeAddress = values[FIELD_LOAD_ADDRESS]
// TODO: review this check. It doesn't seems to be necessary at this point
if (!safeAddress) {
if (!isValidAddress(safeAddress)) {
console.error('failed to add Safe address', JSON.stringify(values))
return
}
const ownersNames = getNamesFrom(values)
const ownersAddresses = getAccountsFrom(values)
const owners = ownersAddresses.reduce((acc, address, index) => {
if (ownersNames[index]) {
// Do not add owners to addressbook if names are empty
const newAddressBookEntry = makeAddressBookEntry({
address,
name: ownersNames[index],
})
acc.push(newAddressBookEntry)
}
return acc
}, [] as AddressBookEntry[])
const safe = makeAddressBookEntry({ address: safeAddress, name: values.name })
await dispatch(addressBookSafeLoad([...owners, safe]))
try {
const safeName = values[FIELD_LOAD_NAME]
safeAddress = checksumAddress(safeAddress)
const ownerNames = getNamesFrom(values)
const gnosisSafe = getGnosisSafeInstanceAt(safeAddress)
const ownerAddresses = await gnosisSafe.methods.getOwners().call()
const owners = getOwnersFrom(ownerNames, ownerAddresses.slice().sort())
await loadSafe(safeName, safeAddress, owners, addSafeHandler)
await loadSafe(safeAddress, addSafeHandler)
const url = `${SAFELIST_ADDRESS}/${safeAddress}/balances`
history.push(url)
@ -93,12 +93,7 @@ const Load = (): React.ReactElement => {
return (
<Page>
<Layout
onLoadSafeSubmit={onLoadSafeSubmit}
network={ETHEREUM_NETWORK[network]}
userAddress={userAddress}
provider={provider}
/>
<Layout onLoadSafeSubmit={onLoadSafeSubmit} userAddress={userAddress} provider={provider} />
</Page>
)
}

View File

@ -5,7 +5,7 @@ import styled from 'styled-components'
import OpenPaper from 'src/components/Stepper/OpenPaper'
import Field from 'src/components/forms/Field'
import TextField from 'src/components/forms/TextField'
import { composeValidators, minMaxLength, required } from 'src/components/forms/validator'
import { composeValidators, required, validAddressBookName } from 'src/components/forms/validator'
import Block from 'src/components/layout/Block'
import Paragraph from 'src/components/layout/Paragraph'
import { FIELD_NAME } from 'src/routes/open/components/fields'
@ -56,7 +56,7 @@ const SafeNameForm = ({ safeName }: { safeName: string }): React.ReactElement =>
placeholder="Name of the new Safe"
text="Safe name"
type="text"
validate={composeValidators(required, minMaxLength(1, 50))}
validate={composeValidators(required, validAddressBookName)}
testId="create-safe-name-field"
/>
</Block>

View File

@ -1,7 +1,7 @@
import { Loader } from '@gnosis.pm/safe-react-components'
import { backOff } from 'exponential-backoff'
import queryString from 'query-string'
import React, { useEffect, useState } from 'react'
import React, { useEffect, useState, ReactElement } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import { useLocation } from 'react-router-dom'
import { PromiEvent, TransactionReceipt } from 'web3-core'
@ -16,7 +16,6 @@ import {
CreateSafeValues,
getAccountsFrom,
getNamesFrom,
getOwnersFrom,
getSafeCreationSaltFrom,
getSafeNameFrom,
getThresholdFrom,
@ -25,8 +24,9 @@ import { SAFELIST_ADDRESS, WELCOME_ADDRESS } from 'src/routes/routes'
import { buildSafe } from 'src/logic/safe/store/actions/fetchSafe'
import { history } from 'src/store'
import { loadFromStorage, removeFromStorage, saveToStorage } from 'src/utils/storage'
import { makeAddressBookEntry } from 'src/logic/addressBook/model/addressBook'
import { addressBookSafeLoad } from 'src/logic/addressBook/store/actions'
import { userAccountSelector } from 'src/logic/wallets/store/selectors'
import { SafeRecordProps } from 'src/logic/safe/store/models/safe'
import { addOrUpdateSafe } from 'src/logic/safe/store/actions/addOrUpdateSafe'
import { useAnalytics } from 'src/utils/googleAnalytics'
import { sleep } from 'src/utils/timer'
@ -78,18 +78,6 @@ const getSafePropsValuesFromQueryParams = (queryParams: SafeCreationQueryParams)
}
}
export const getSafeProps = async (
safeAddress: string,
safeName: string,
ownersNames: string[],
ownerAddresses: string[],
): Promise<SafeRecordProps> => {
const safeProps = await buildSafe(safeAddress, safeName)
safeProps.owners = getOwnersFrom(ownersNames, ownerAddresses)
return safeProps
}
export const createSafe = (values: CreateSafeValues, userAccount: string): PromiEvent<TransactionReceipt> => {
const confirmations = getThresholdFrom(values)
const ownerAddresses = getAccountsFrom(values)
@ -118,7 +106,7 @@ export const createSafe = (values: CreateSafeValues, userAccount: string): Promi
return promiEvent
}
const Open = (): React.ReactElement => {
const Open = (): ReactElement => {
const [loading, setLoading] = useState(false)
const [showProgress, setShowProgress] = useState(false)
const [creationTxPromise, setCreationTxPromise] = useState<PromiEvent<TransactionReceipt>>()
@ -176,14 +164,24 @@ const Open = (): React.ReactElement => {
setShowProgress(true)
}
const onSafeCreated = async (safeAddress): Promise<void> => {
const onSafeCreated = async (safeAddress: string): Promise<void> => {
const pendingCreation = await loadFromStorage<LoadedSafeType>(SAFE_PENDING_CREATION_STORAGE_KEY)
const name = pendingCreation ? getSafeNameFrom(pendingCreation) : ''
const ownersNames = getNamesFrom(pendingCreation as CreateSafeValues)
const ownerAddresses = pendingCreation ? getAccountsFrom(pendingCreation) : []
const safeProps = await getSafeProps(safeAddress, name, ownersNames, ownerAddresses)
let name = ''
let ownersNames: string[] = []
let ownersAddresses: string[] = []
if (pendingCreation) {
name = getSafeNameFrom(pendingCreation)
ownersNames = getNamesFrom(pendingCreation as CreateSafeValues)
ownersAddresses = getAccountsFrom(pendingCreation)
}
const owners = ownersAddresses.map((address, index) => makeAddressBookEntry({ address, name: ownersNames[index] }))
const safe = makeAddressBookEntry({ address: safeAddress, name })
await dispatch(addressBookSafeLoad([...owners, safe]))
const safeProps = await buildSafe(safeAddress)
await dispatch(addOrUpdateSafe(safeProps))
trackEvent({

View File

@ -1,7 +1,3 @@
import { List } from 'immutable'
import { makeOwner } from 'src/logic/safe/store/models/owner'
import { SafeOwner } from 'src/logic/safe/store/models/safe'
import { LoadFormValues } from 'src/routes/load/container/Load'
import { getNumOwnersFrom } from 'src/routes/open/components/fields'
@ -15,29 +11,18 @@ export type CreateSafeValues = {
owners?: number | string
}
export const getAccountsFrom = (values: CreateSafeValues | LoadFormValues): string[] => {
const getByRegexFrom = (regex: RegExp) => (values: CreateSafeValues | LoadFormValues): string[] => {
const accounts = Object.keys(values)
.sort()
.filter((key) => /^owner\d+Address$/.test(key))
.filter((key) => regex.test(key))
const numOwners = getNumOwnersFrom(values)
return accounts.map((account) => values[account]).slice(0, numOwners)
}
export const getNamesFrom = (values: CreateSafeValues | LoadFormValues): string[] => {
const accounts = Object.keys(values)
.sort()
.filter((key) => /^owner\d+Name$/.test(key))
export const getAccountsFrom = getByRegexFrom(/^owner\d+Address$/)
const numOwners = getNumOwnersFrom(values)
return accounts.map((account) => values[account]).slice(0, numOwners)
}
export const getOwnersFrom = (names: string[], addresses: string[]): List<SafeOwner> => {
const owners = names.map((name, index) => makeOwner({ name, address: addresses[index] }))
return List(owners)
}
export const getNamesFrom = getByRegexFrom(/^owner\d+Name$/)
export const getThresholdFrom = (values: CreateSafeValues): number => Number(values.confirmations)

View File

@ -1,21 +1,17 @@
import IconButton from '@material-ui/core/IconButton'
import Close from '@material-ui/icons/Close'
import React, { ReactElement } from 'react'
import { useSelector } from 'react-redux'
import { useStyles } from './style'
import Modal, { Modal as GenericModal } from 'src/components/Modal'
import { Modal } from 'src/components/Modal'
import { ScanQRWrapper } from 'src/components/ScanQRModal/ScanQRWrapper'
import AddressInput from 'src/components/forms/AddressInput'
import Field from 'src/components/forms/Field'
import GnoForm from 'src/components/forms/GnoForm'
import TextField from 'src/components/forms/TextField'
import { composeValidators, minMaxLength, required, uniqueAddress } from 'src/components/forms/validator'
import { composeValidators, required, uniqueAddress, validAddressBookName } from 'src/components/forms/validator'
import Block from 'src/components/layout/Block'
import Col from 'src/components/layout/Col'
import Hairline from 'src/components/layout/Hairline'
import Paragraph from 'src/components/layout/Paragraph'
import Row from 'src/components/layout/Row'
import { addressBookAddressesListSelector } from 'src/logic/addressBook/store/selectors'
import { AddressBookEntry } from 'src/logic/addressBook/model/addressBook'
@ -66,81 +62,76 @@ export const CreateEditEntryModal = ({
description={isNew ? 'Create new addressBook entry' : 'Edit addressBook entry'}
handleClose={onClose}
open={isOpen}
paperClassName="smaller-modal-window"
title={isNew ? 'Create new entry' : 'Edit entry'}
>
<Row align="center" className={classes.heading} grow>
<Paragraph className={classes.manage} noMargin weight="bolder">
{isNew ? 'Create entry' : 'Edit entry'}
</Paragraph>
<IconButton disableRipple onClick={onClose}>
<Close className={classes.close} />
</IconButton>
</Row>
<Hairline />
<GnoForm formMutators={formMutators} onSubmit={onFormSubmitted} initialValues={initialValues}>
{(...args) => {
const formState = args[2]
const mutators = args[3]
const handleScan = (value, closeQrModal) => {
let scannedAddress = value
<Modal.Header onClose={onClose}>
<Modal.Header.Title>{isNew ? 'Create entry' : 'Edit entry'}</Modal.Header.Title>
</Modal.Header>
<Modal.Body withoutPadding>
<GnoForm formMutators={formMutators} onSubmit={onFormSubmitted} initialValues={initialValues}>
{(...args) => {
const formState = args[2]
const mutators = args[3]
const handleScan = (value, closeQrModal) => {
let scannedAddress = value
if (scannedAddress.startsWith('ethereum:')) {
scannedAddress = scannedAddress.replace('ethereum:', '')
if (scannedAddress.startsWith('ethereum:')) {
scannedAddress = scannedAddress.replace('ethereum:', '')
}
mutators.setOwnerAddress(scannedAddress)
closeQrModal()
}
mutators.setOwnerAddress(scannedAddress)
closeQrModal()
}
return (
<>
<Block className={classes.container}>
<Row margin="md">
<Col xs={11}>
<Field
component={TextField}
name="name"
placeholder="Name"
testId={CREATE_ENTRY_INPUT_NAME_ID}
text="Name"
type="text"
validate={composeValidators(required, minMaxLength(1, 50))}
/>
</Col>
</Row>
<Row margin="md">
<Col xs={11}>
<AddressInput
disabled={!isNew}
fieldMutator={mutators.setOwnerAddress}
name="address"
placeholder="Address*"
testId={CREATE_ENTRY_INPUT_ADDRESS_ID}
text="Address*"
validators={[(value?: string) => (isNew ? isUniqueAddress(value) : undefined)]}
/>
</Col>
{isNew ? (
<Col center="xs" className={classes} middle="xs" xs={1}>
<ScanQRWrapper handleScan={handleScan} />
return (
<>
<Block className={classes.container}>
<Row margin="md">
<Col xs={11}>
<Field
component={TextField}
name="name"
placeholder="Name*"
testId={CREATE_ENTRY_INPUT_NAME_ID}
text="Name*"
type="text"
validate={composeValidators(required, validAddressBookName)}
/>
</Col>
) : null}
</Row>
</Block>
<GenericModal.Footer>
<GenericModal.Footer.Buttons
cancelButtonProps={{ onClick: onClose }}
confirmButtonProps={{
disabled: !formState.valid,
testId: SAVE_NEW_ENTRY_BTN_ID,
text: isNew ? 'Create' : 'Save',
}}
/>
</GenericModal.Footer>
</>
)
}}
</GnoForm>
</Row>
<Row margin="md">
<Col xs={11}>
<AddressInput
disabled={!isNew}
fieldMutator={mutators.setOwnerAddress}
name="address"
placeholder="Address*"
testId={CREATE_ENTRY_INPUT_ADDRESS_ID}
text="Address*"
validators={[(value?: string) => (isNew ? isUniqueAddress(value) : undefined)]}
/>
</Col>
{isNew ? (
<Col center="xs" className={classes} middle="xs" xs={1}>
<ScanQRWrapper handleScan={handleScan} />
</Col>
) : null}
</Row>
</Block>
<Modal.Footer>
<Modal.Footer.Buttons
cancelButtonProps={{ onClick: onClose }}
confirmButtonProps={{
disabled: !formState.valid,
testId: SAVE_NEW_ENTRY_BTN_ID,
text: isNew ? 'Create' : 'Save',
}}
/>
</Modal.Footer>
</>
)
}}
</GnoForm>
</Modal.Body>
</Modal>
)
}

View File

@ -7,6 +7,9 @@ import { push } from 'connected-react-router'
import React from 'react'
import { useDispatch, useSelector } from 'react-redux'
import { sameString } from 'src/utils/strings'
import { ADDRESS_BOOK_DEFAULT_NAME } from 'src/logic/addressBook/model/addressBook'
import { getNameFromAddressBookSelector } from 'src/logic/addressBook/store/selectors'
import { SAFELIST_ADDRESS } from 'src/routes/routes'
import { safeParamAddressFromStateSelector } from 'src/logic/safe/store/selectors'
import { xs } from 'src/theme/variables'
@ -35,13 +38,11 @@ const useStyles = makeStyles(
type EllipsisTransactionDetailsProps = {
address: string
knownAddress?: boolean
sendModalOpenHandler?: () => void
}
export const EllipsisTransactionDetails = ({
address,
knownAddress,
sendModalOpenHandler,
}: EllipsisTransactionDetailsProps): React.ReactElement => {
const classes = useStyles()
@ -51,6 +52,10 @@ export const EllipsisTransactionDetails = ({
const currentSafeAddress = useSelector(safeParamAddressFromStateSelector)
const isOwnerConnected = useSelector(grantedSelector)
const recipientName = useSelector((state) => getNameFromAddressBookSelector(state, { address }))
// We have to check that the name returned is not UNKNOWN
const isStoredInAddressBook = !sameString(recipientName, ADDRESS_BOOK_DEFAULT_NAME)
const handleClick = (event) => setAnchorEl(event.currentTarget)
const closeMenuHandler = () => setAnchorEl(null)
@ -73,7 +78,7 @@ export const EllipsisTransactionDetails = ({
<Divider key="divider" />,
]
: null}
{knownAddress ? (
{isStoredInAddressBook ? (
<MenuItem onClick={addOrEditEntryHandler}>Edit Address book Entry</MenuItem>
) : (
<MenuItem onClick={addOrEditEntryHandler}>Add to address book</MenuItem>

View File

@ -0,0 +1,9 @@
<svg width="108" height="96" viewBox="0 0 108 96" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M54 4C79.405 4 100 24.595 100 50C100 75.405 79.405 96 54 96C28.595 96 8 75.405 8 50C8 24.595 28.595 4 54 4Z" fill="#F7F5F5"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M37.3327 60.8333L37.3327 66.6663H70.6663V60.8333C70.6663 60.6037 70.6663 58.3333 72.7496 58.3333C74.833 58.3333 74.833 60.6037 74.833 60.8333V68.7503C74.8325 68.7503 74.8321 68.7503 74.8317 68.7503C74.8313 69.8959 73.8961 70.833 72.7484 70.833H35.2484C34.1025 70.833 33.165 69.8955 33.165 68.7496C33.165 68.7284 33.1654 68.7073 33.166 68.6862L33.166 60.8333C33.166 60.6037 33.166 58.3333 35.2493 58.3333C37.3327 58.3333 37.3327 60.6037 37.3327 60.8333Z" fill="#B2B5B2"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M51.9156 36.2792V60.4105C51.9156 61.5626 52.849 62.4938 53.999 62.4938C55.151 62.4938 56.0823 61.5626 56.0823 60.4105V36.2792L64.6698 44.8647C65.4844 45.6793 66.8031 45.6793 67.6156 44.8647C68.4302 44.0501 68.4302 42.7334 67.6156 41.9188L55.8302 30.1334C55.7698 30.073 55.7052 30.0167 55.6406 29.9667C55.2573 29.4772 54.6656 29.1667 53.999 29.1667C53.3344 29.1667 52.7406 29.4772 52.3594 29.9667C52.2948 30.0167 52.2281 30.073 52.1698 30.1334L40.3844 41.9188C39.5698 42.7334 39.5698 44.0501 40.3844 44.8647C41.1969 45.6793 42.5156 45.6793 43.3302 44.8647L51.9156 36.2792Z" fill="#B2B5B2"/>
<circle cx="80" cy="74" r="17" fill="#F7F5F5"/>
<rect x="78.25" y="63.5" width="3.5" height="14" rx="1" fill="#F02525"/>
<path d="M80 80.5625C81.2081 80.5625 82.1875 81.5419 82.1875 82.75C82.1875 83.9581 81.2081 84.9375 80 84.9375C78.7919 84.9375 77.8125 83.9581 77.8125 82.75C77.8125 81.5419 78.7919 80.5625 80 80.5625Z" fill="#F02525"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M97.5 74C97.5 83.665 89.665 91.5 80 91.5C70.335 91.5 62.5 83.665 62.5 74C62.5 64.335 70.335 56.5 80 56.5C89.665 56.5 97.5 64.335 97.5 74ZM66 74C66 81.732 72.268 88 80 88C87.732 88 94 81.732 94 74C94 66.268 87.732 60 80 60C72.268 60 66 66.268 66 74Z" fill="#F02525"/>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@ -0,0 +1,8 @@
<svg width="108" height="96" viewBox="0 0 108 96" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M54 4C79.405 4 100 24.595 100 50C100 75.405 79.405 96 54 96C28.595 96 8 75.405 8 50C8 24.595 28.595 4 54 4Z" fill="#F7F5F5"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M37.3329 60.8333L37.3329 66.6663H70.6665V60.8333C70.6665 60.6037 70.6665 58.3333 72.7499 58.3333C74.8332 58.3333 74.8332 60.6037 74.8332 60.8333V68.7503C74.8328 68.7503 74.8324 68.7503 74.8319 68.7503C74.8316 69.8959 73.8963 70.833 72.7486 70.833H35.2486C34.1028 70.833 33.1653 69.8955 33.1653 68.7496C33.1653 68.7284 33.1656 68.7073 33.1662 68.6862L33.1662 60.8333C33.1662 60.6037 33.1662 58.3333 35.2496 58.3333C37.3329 58.3333 37.3329 60.6037 37.3329 60.8333Z" fill="#B2B5B2"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M51.9161 36.2791V60.4104C51.9161 61.5625 52.8494 62.4937 53.9994 62.4937C55.1515 62.4937 56.0828 61.5625 56.0828 60.4104V36.2791L64.6703 44.8645C65.4849 45.6791 66.8036 45.6791 67.6161 44.8645C68.4307 44.05 68.4307 42.7333 67.6161 41.9187L55.8307 30.1333C55.7703 30.0729 55.7057 30.0166 55.6411 29.9666C55.2578 29.477 54.6661 29.1666 53.9994 29.1666C53.3349 29.1666 52.7411 29.477 52.3599 29.9666C52.2953 30.0166 52.2286 30.0729 52.1703 30.1333L40.3849 41.9187C39.5703 42.7333 39.5703 44.05 40.3849 44.8645C41.1974 45.6791 42.5161 45.6791 43.3307 44.8645L51.9161 36.2791Z" fill="#B2B5B2"/>
<circle cx="80" cy="74" r="17" fill="#F7F5F5"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M97.5 74C97.5 83.665 89.665 91.5 80 91.5C70.335 91.5 62.5 83.665 62.5 74C62.5 64.335 70.335 56.5 80 56.5C89.665 56.5 97.5 64.335 97.5 74ZM66 74C66 81.732 72.268 88 80 88C87.732 88 94 81.732 94 74C94 66.268 87.732 60 80 60C72.268 60 66 66.268 66 74Z" fill="#008C73"/>
<path d="M72.443 70.9826C71.7373 70.3222 70.6299 70.3588 69.9694 71.0644C69.309 71.77 69.3456 72.8775 70.0512 73.5379L77.2979 80.3209C77.9934 80.9719 79.0818 80.947 79.7468 80.265L89.1041 70.668C89.7788 69.976 89.7648 68.868 89.0728 68.1933C88.3808 67.5186 87.2728 67.5326 86.5981 68.2246L78.4379 76.5939L72.443 70.9826Z" fill="#008C73"/>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@ -0,0 +1,8 @@
<svg width="108" height="96" viewBox="0 0 108 96" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M54 4C79.405 4 100 24.595 100 50C100 75.405 79.405 96 54 96C28.595 96 8 75.405 8 50C8 24.595 28.595 4 54 4Z" fill="#F7F5F5"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M37.3327 60.8333L37.3327 66.6663H70.6663V60.8333C70.6663 60.6037 70.6663 58.3333 72.7496 58.3333C74.833 58.3333 74.833 60.6037 74.833 60.8333V68.7503C74.8325 68.7503 74.8321 68.7503 74.8317 68.7503C74.8313 69.8959 73.8961 70.833 72.7484 70.833H35.2484C34.1025 70.833 33.165 69.8955 33.165 68.7496C33.165 68.7284 33.1654 68.7073 33.166 68.6862L33.166 60.8333C33.166 60.6037 33.166 58.3333 35.2493 58.3333C37.3327 58.3333 37.3327 60.6037 37.3327 60.8333Z" fill="#B2B5B2"/>
<circle cx="80" cy="74" r="17" fill="#F7F5F5"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M73.871 80C73.243 80 72.63 79.7078 72.2767 79.1771C71.7366 78.3629 72.0114 77.2989 72.8898 76.7984L78.2621 73.7429V66.7287C78.2621 65.7736 79.0994 65 80.131 65C81.1636 65 82 65.7736 82 66.7287V74.7093C82 75.3091 81.6636 75.8675 81.1104 76.1821L74.8484 79.7442C74.5429 79.917 74.2046 80 73.871 80Z" fill="#5D6D74"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M97.5 74C97.5 83.665 89.665 91.5 80 91.5C70.335 91.5 62.5 83.665 62.5 74C62.5 64.335 70.335 56.5 80 56.5C89.665 56.5 97.5 64.335 97.5 74ZM66 74C66 81.732 72.268 88 80 88C87.732 88 94 81.732 94 74C94 66.268 87.732 60 80 60C72.268 60 66 66.268 66 74Z" fill="#5D6D74"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M51.9156 36.2791V60.4104C51.9156 61.5625 52.849 62.4937 53.999 62.4937C55.151 62.4937 56.0823 61.5625 56.0823 60.4104V36.2791L64.6698 44.8645C65.4844 45.6791 66.8031 45.6791 67.6156 44.8645C68.4302 44.05 68.4302 42.7333 67.6156 41.9187L55.8302 30.1333C55.7698 30.0729 55.7052 30.0166 55.6406 29.9666C55.2573 29.477 54.6656 29.1666 53.999 29.1666C53.3344 29.1666 52.7406 29.477 52.3594 29.9666C52.2948 30.0166 52.2281 30.0729 52.1698 30.1333L40.3844 41.9187C39.5698 42.7333 39.5698 44.05 40.3844 44.8645C41.1969 45.6791 42.5156 45.6791 43.3302 44.8645L51.9156 36.2791Z" fill="#B2B5B2"/>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@ -0,0 +1,164 @@
import React, { ReactElement, useEffect, useState } from 'react'
import { format } from 'date-fns'
import { useSelector, useDispatch } from 'react-redux'
import { CSVDownloader, jsonToCSV } from 'react-papaparse'
import { Button, Loader, Text } from '@gnosis.pm/safe-react-components'
import styled from 'styled-components'
import { enhanceSnackbarForAction, getNotificationsFromTxType } from 'src/logic/notifications'
import enqueueSnackbar from 'src/logic/notifications/store/actions/enqueueSnackbar'
import { TX_NOTIFICATION_TYPES } from 'src/logic/safe/transactions'
import { addressBookSelector } from 'src/logic/addressBook/store/selectors'
import { AddressBookState } from 'src/logic/addressBook/model/addressBook'
import { lg, md, background } from 'src/theme/variables'
import { Modal } from 'src/components/Modal'
import Img from 'src/components/layout/Img'
import Row from 'src/components/layout/Row'
import HelpInfo from 'src/routes/safe/components/AddressBook/HelpInfo'
import SuccessSvg from './assets/success.svg'
import ErrorSvg from './assets/error.svg'
import LoadingSvg from './assets/wait.svg'
type ExportEntriesModalProps = {
isOpen: boolean
onClose: () => void
}
const ImageContainer = styled(Row)`
padding: ${md} ${lg};
justify-content: center;
`
const StyledButton = styled(Button)`
&.MuiButtonBase-root.MuiButton-root {
padding: 0;
.MuiButton-label {
height: 100%;
}
}
`
const InfoContainer = styled(Row)`
background-color: ${background};
flex-direction: column;
justify-content: center;
padding: ${lg};
text-align: center;
`
const BodyImage = styled.div`
grid-row: 1;
`
const StyledLoader = styled(Loader)`
margin-right: 5px;
`
const StyledCSVLink = styled(CSVDownloader)`
height: 100%;
display: flex;
flex: 1;
justify-content: center;
align-items: center;
`
export const ExportEntriesModal = ({ isOpen, onClose }: ExportEntriesModalProps): ReactElement => {
const dispatch = useDispatch()
const addressBook: AddressBookState = useSelector(addressBookSelector)
const [loading, setLoading] = useState<boolean>(true)
const [error, setError] = useState<string | undefined>('')
const [csvData, setCsvData] = useState<string>('')
const [doRetry, setDoRetry] = useState<boolean>(false)
const date = format(new Date(), 'yyyy-MM-dd')
const handleClose = () => {
//This timeout prevents modal to be closed abruptly
setLoading(true)
setTimeout(() => {
if (!loading) {
const notification = getNotificationsFromTxType(TX_NOTIFICATION_TYPES.ADDRESS_BOOK_EXPORT_ENTRIES)
const action = error
? notification.afterExecution.afterExecutionError
: notification.afterExecution.noMoreConfirmationsNeeded
dispatch(enqueueSnackbar(enhanceSnackbarForAction(action)))
}
onClose()
}, 600)
}
useEffect(() => {
const handleCsvData = () => {
if (!isOpen && !doRetry) return
setLoading(true)
setError('')
try {
setCsvData(jsonToCSV(addressBook))
} catch (e) {
setLoading(false)
setError(e.message)
return
}
setLoading(false)
setDoRetry(false)
}
handleCsvData()
}, [addressBook, isOpen, doRetry, csvData])
return (
<Modal description="Export address book" handleClose={onClose} open={isOpen} title="Export address book">
<Modal.Header onClose={onClose}>
<Modal.Header.Title withoutMargin>Export address book</Modal.Header.Title>
</Modal.Header>
<Modal.Body withoutPadding>
<ImageContainer>
<BodyImage>
<Img alt="Export" height={92} src={error ? ErrorSvg : loading ? LoadingSvg : SuccessSvg} />
</BodyImage>
</ImageContainer>
<InfoContainer>
<Text color="primary" as="p" size="xl">
{!error ? (
<Text size="xl" as="span">
You&apos;re about to export a CSV file with{' '}
<Text size="xl" strong as="span">
{addressBook.length} address book entries. <br />
<HelpInfo />
</Text>
.
</Text>
) : (
<Text size="xl" as="span">
An error occurred while generating the address book CSV.
</Text>
)}
</Text>
</InfoContainer>
</Modal.Body>
<Modal.Footer withoutBorder>
<Row>
<Button size="md" variant="outlined" onClick={onClose}>
Cancel
</Button>
<StyledButton
color="primary"
size="md"
disabled={loading}
onClick={error ? () => setDoRetry(true) : handleClose}
>
{!error ? (
<StyledCSVLink data={csvData} bom={true} filename={`gnosis-safe-address-book-${date}`} type="link">
{loading && <StyledLoader color="secondaryLight" size="xs" />}
Download
</StyledCSVLink>
) : (
'Retry'
)}
</StyledButton>
</Row>
</Modal.Footer>
</Modal>
)
}

View File

@ -0,0 +1,27 @@
import React, { ReactElement } from 'react'
import styled from 'styled-components'
import { Text, Link, Icon } from '@gnosis.pm/safe-react-components'
const StyledIcon = styled(Icon)`
svg {
position: relative;
top: 4px;
left: 4px;
}
`
const HelpInfo = (): ReactElement => (
<Link
href="https://help.gnosis-safe.io/en/articles/5299068-address-book-export-and-import"
target="_blank"
rel="noreferrer"
title="Export & import info"
>
<Text size="xl" as="span" color="primary">
Learn about the address book import and export
</Text>
<StyledIcon size="sm" type="externalLink" color="primary" />
</Link>
)
export default HelpInfo

View File

@ -0,0 +1,219 @@
import React, { ReactElement, useState } from 'react'
import styled from 'styled-components'
import { Text } from '@gnosis.pm/safe-react-components'
import { Modal } from 'src/components/Modal'
import { CSVReader } from 'react-papaparse'
import { AddressBookEntry } from 'src/logic/addressBook/model/addressBook'
import { isValidAddress } from 'src/utils/isValidAddress'
import { checksumAddress } from 'src/utils/checksumAddress'
import HelpInfo from 'src/routes/safe/components/AddressBook/HelpInfo'
const ImportContainer = styled.div`
flex-direction: column;
justify-content: center;
margin: 24px;
align-items: center;
/* width: 200px;*/
min-height: 100px;
display: flex;
`
const InfoContainer = styled.div`
background-color: ${({ theme }) => theme.colors.background};
flex-direction: column;
justify-content: center;
padding: 24px;
text-align: center;
margin-top: 16px;
`
const WRONG_FILE_EXTENSION_ERROR = 'Only CSV files are allowed'
const FILE_SIZE_TOO_BIG = 'The size of the file is over 1 MB'
const FILE_BYTES_LIMIT = 1000000
const IMPORT_SUPPORTED_FORMATS = [
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'text/csv',
]
type ImportEntriesModalProps = {
importEntryModalHandler: (addressList: AddressBookEntry[]) => void
isOpen: boolean
onClose: () => void
}
const ImportEntriesModal = ({ importEntryModalHandler, isOpen, onClose }: ImportEntriesModalProps): ReactElement => {
const [csvLoaded, setCsvLoaded] = useState(false)
const [importError, setImportError] = useState('')
const [entryList, setEntryList] = useState<AddressBookEntry[]>([])
const handleImportEntrySubmit = () => {
setCsvLoaded(false)
importEntryModalHandler(entryList)
}
const handleOnDrop = (data, file) => {
const slicedData = data.slice(1)
const fileError = validateFile(file)
if (fileError) {
setImportError(fileError)
return
}
const dataError = validateCsvData(slicedData)
if (dataError) {
setImportError(dataError)
return
}
const formatedList = slicedData.map((entry) => {
return { address: checksumAddress(entry.data[0]), name: entry.data[1], chainId: parseInt(entry.data[2]) }
})
setEntryList(formatedList)
setImportError('')
setCsvLoaded(true)
}
const validateFile = (file) => {
if (!IMPORT_SUPPORTED_FORMATS.includes(file.type)) {
return WRONG_FILE_EXTENSION_ERROR
}
if (file.size >= FILE_BYTES_LIMIT) {
return FILE_SIZE_TOO_BIG
}
return
}
const validateCsvData = (data) => {
for (let index = 0; index < data.length; index++) {
const entry = data[index]
if (!entry.data[0] || !entry.data[1] || !entry.data[2]) {
return `Invalid amount of columns on row ${index + 1}`
}
// Verify address properties
const address = entry.data[0].toLowerCase()
if (!isValidAddress(address)) {
return `Invalid address on row ${index + 1}`
}
if (isNaN(entry.data[2])) {
return `Invalid chain id on row ${index + 1}`
}
}
return
}
const handleOnError = (error) => {
setImportError(error.message)
}
const handleOnRemoveFile = () => {
setCsvLoaded(false)
setImportError('')
}
const handleClose = () => {
setCsvLoaded(false)
setEntryList([])
setImportError('')
onClose()
}
return (
<Modal description="Import address book" handleClose={handleClose} open={isOpen} title="Import address book">
<Modal.Header onClose={handleClose}>
<Modal.Header.Title>Import address book</Modal.Header.Title>
</Modal.Header>
<Modal.Body withoutPadding>
<ImportContainer>
<CSVReader
onDrop={handleOnDrop}
onError={handleOnError}
addRemoveButton
onRemoveFile={handleOnRemoveFile}
style={{
dropArea: {
borderColor: '#B2B5B2',
borderRadius: 8,
},
dropAreaActive: {
borderColor: '#008C73',
/* borderColor: '${({ theme }) => theme.colors.primary}', */
},
dropFile: {
width: 200,
height: 100,
background: '#fff',
boxShadow: 'rgb(40 54 61 / 18%) 1px 2px 10px 0px',
borderRadius: 8,
},
fileSizeInfo: {
color: '#001428',
lineHeight: 1,
position: 'absolute',
left: '10px',
top: '12px',
},
fileNameInfo: {
color: importError === '' ? '#008C73' : '#DB3A3D',
backgroundColor: '#fff',
fontSize: 14,
lineHeight: 1.4,
padding: '0 0.4em',
margin: '1.2em 0 0.5em 0',
maxHeight: '59px',
overflow: 'hidden',
},
progressBar: {
backgroundColor: '#008C73',
},
removeButton: {
color: '#DB3A3D',
},
}}
>
<Text size="xl">
Drop your CSV file here <br />
or click to upload.
</Text>
</CSVReader>
</ImportContainer>
<InfoContainer>
{importError !== '' && (
<Text size="xl" color="error">
{importError}
</Text>
)}
{!csvLoaded && importError === '' && (
<Text color="text" as="p" size="xl">
Only CSV files exported from Gnosis Safe are allowed. <br />
<HelpInfo />
</Text>
)}
{csvLoaded && importError === '' && (
<>
<Text size="xl" as="span">{`You're about to import`}</Text>
<Text size="xl" strong as="span">{` ${entryList.length} entries to your address book`}</Text>
</>
)}
</InfoContainer>
</Modal.Body>
<Modal.Footer>
<Modal.Footer.Buttons
cancelButtonProps={{ onClick: () => handleClose() }}
confirmButtonProps={{
color: 'primary',
disabled: !csvLoaded || importError !== '',
onClick: handleImportEntrySubmit,
text: 'Import',
}}
/>
</Modal.Footer>
</Modal>
)
}
export default ImportEntriesModal

View File

@ -1,4 +1,4 @@
import { Button, EthHashInfo, FixedIcon, Text } from '@gnosis.pm/safe-react-components'
import { Button, EthHashInfo, FixedIcon, Text, ButtonLink, Icon } from '@gnosis.pm/safe-react-components'
import TableCell from '@material-ui/core/TableCell'
import TableContainer from '@material-ui/core/TableContainer'
import TableRow from '@material-ui/core/TableRow'
@ -10,37 +10,32 @@ import { useDispatch, useSelector } from 'react-redux'
import { styles } from './style'
import { getExplorerInfo } from 'src/config'
import { getExplorerInfo, getNetworkId } from 'src/config'
import Table from 'src/components/Table'
import { cellWidth } from 'src/components/Table/TableHead'
import Block from 'src/components/layout/Block'
import ButtonLink from 'src/components/layout/ButtonLink'
import Col from 'src/components/layout/Col'
import Img from 'src/components/layout/Img'
import Row from 'src/components/layout/Row'
import { AddressBookEntry, makeAddressBookEntry } from 'src/logic/addressBook/model/addressBook'
import { addAddressBookEntry } from 'src/logic/addressBook/store/actions/addAddressBookEntry'
import { removeAddressBookEntry } from 'src/logic/addressBook/store/actions/removeAddressBookEntry'
import { updateAddressBookEntry } from 'src/logic/addressBook/store/actions/updateAddressBookEntry'
import { addressBookAddOrUpdate, addressBookImport, addressBookRemove } from 'src/logic/addressBook/store/actions'
import { addressBookSelector } from 'src/logic/addressBook/store/selectors'
import { isUserAnOwnerOfAnySafe, sameAddress } from 'src/logic/wallets/ethAddresses'
import { CreateEditEntryModal } from 'src/routes/safe/components/AddressBook/CreateEditEntryModal'
import { ExportEntriesModal } from 'src/routes/safe/components/AddressBook/ExportEntriesModal'
import { DeleteEntryModal } from 'src/routes/safe/components/AddressBook/DeleteEntryModal'
import {
AB_NAME_ID,
AB_ADDRESS_ID,
ADDRESS_BOOK_ROW_ID,
EDIT_ENTRY_BUTTON,
REMOVE_ENTRY_BUTTON,
SEND_ENTRY_BUTTON,
generateColumns,
} from 'src/routes/safe/components/AddressBook/columns'
import SendModal from 'src/routes/safe/components/Balances/SendModal'
import RenameOwnerIcon from 'src/routes/safe/components/Settings/ManageOwners/assets/icons/rename-owner.svg'
import RemoveOwnerIcon from 'src/routes/safe/components/Settings/assets/icons/bin.svg'
import { addressBookQueryParamsSelector, safesListSelector } from 'src/logic/safe/store/selectors'
import { checksumAddress } from 'src/utils/checksumAddress'
import { grantedSelector } from 'src/routes/safe/container/selector'
import { useAnalytics, SAFE_NAVIGATION_EVENT } from 'src/utils/googleAnalytics'
import ImportEntriesModal from './ImportEntriesModal'
const StyledButton = styled(Button)`
&&.MuiButton-root {
@ -48,10 +43,22 @@ const StyledButton = styled(Button)`
padding: 0 12px;
min-width: auto;
}
svg {
margin: 0 6px 0 0;
}
`
const UnStyledButton = styled.button`
background: none;
color: inherit;
border: none;
padding: 0;
font: inherit;
cursor: pointer;
outline-color: ${({ theme }) => theme.colors.icon};
display: flex;
align-items: center;
`
const useStyles = makeStyles(styles)
interface AddressBookSelectedEntry extends AddressBookEntry {
@ -64,7 +71,8 @@ export type Entry = {
isOwnerAddress?: boolean
}
const initialEntryState: Entry = { entry: { address: '', name: '', isNew: true } }
const chainId = getNetworkId()
const initialEntryState: Entry = { entry: { address: '', name: '', chainId, isNew: true } }
const AddressBookTable = (): ReactElement => {
const classes = useStyles()
@ -77,7 +85,9 @@ const AddressBookTable = (): ReactElement => {
const granted = useSelector(grantedSelector)
const [selectedEntry, setSelectedEntry] = useState<Entry>(initialEntryState)
const [editCreateEntryModalOpen, setEditCreateEntryModalOpen] = useState(false)
const [importEntryModalOpen, setImportEntryModalOpen] = useState(false)
const [deleteEntryModalOpen, setDeleteEntryModalOpen] = useState(false)
const [exportEntriesModalOpen, setExportEntriesModalOpen] = useState(false)
const [sendFundsModalOpen, setSendFundsModalOpen] = useState(false)
const { trackEvent } = useAnalytics()
@ -105,6 +115,7 @@ const AddressBookTable = (): ReactElement => {
entry: {
name: '',
address,
chainId,
isNew: true,
},
})
@ -113,44 +124,73 @@ const AddressBookTable = (): ReactElement => {
}, [addressBook, entryAddressToEditOrCreateNew])
const newEntryModalHandler = (entry: AddressBookEntry) => {
// close the modal
setEditCreateEntryModalOpen(false)
const checksumEntries = {
...entry,
address: checksumAddress(entry.address),
}
dispatch(addAddressBookEntry(makeAddressBookEntry(checksumEntries)))
// update the store
dispatch(addressBookAddOrUpdate(makeAddressBookEntry({ ...entry, address: checksumAddress(entry.address) })))
}
const editEntryModalHandler = (entry: AddressBookEntry) => {
// reset the form
setSelectedEntry(initialEntryState)
// close the modal
setEditCreateEntryModalOpen(false)
const checksumEntries = {
...entry,
address: checksumAddress(entry.address),
}
dispatch(updateAddressBookEntry(makeAddressBookEntry(checksumEntries)))
// update the store
dispatch(addressBookAddOrUpdate(makeAddressBookEntry({ ...entry, address: checksumAddress(entry.address) })))
}
const deleteEntryModalHandler = () => {
const entryAddress = selectedEntry?.entry ? checksumAddress(selectedEntry.entry.address) : ''
// reset the form
setSelectedEntry(initialEntryState)
// close the modal
setDeleteEntryModalOpen(false)
dispatch(removeAddressBookEntry(entryAddress))
// update the store
selectedEntry?.entry && dispatch(addressBookRemove(selectedEntry.entry))
}
const importEntryModalHandler = (addressList: AddressBookEntry[]) => {
dispatch(addressBookImport(addressList))
setImportEntryModalOpen(false)
}
return (
<>
<Row align="center" className={classes.message}>
<Col end="sm" xs={12}>
<ButtonLink
onClick={() => {
setSelectedEntry(initialEntryState)
setExportEntriesModalOpen(true)
}}
color="primary"
iconType="exportImg"
iconSize="sm"
textSize="xl"
>
Export
</ButtonLink>
<ButtonLink
onClick={() => {
setImportEntryModalOpen(true)
}}
color="primary"
iconType="importImg"
iconSize="sm"
textSize="xl"
>
Import
</ButtonLink>
<ButtonLink
onClick={() => {
setSelectedEntry(initialEntryState)
setEditCreateEntryModalOpen(true)
}}
size="lg"
testId="manage-tokens-btn"
color="primary"
iconType="add"
iconSize="sm"
textSize="xl"
>
+ Create entry
Create entry
</ButtonLink>
</Col>
</Row>
@ -160,6 +200,7 @@ const AddressBookTable = (): ReactElement => {
columns={columns}
data={addressBook}
defaultFixed
defaultOrderBy={AB_NAME_ID}
defaultRowsPerPage={25}
disableLoadingOnEmptyTable
label="Owners"
@ -196,9 +237,7 @@ const AddressBookTable = (): ReactElement => {
})}
<TableCell component="td">
<Row align="end" className={classes.actions}>
<Img
alt="Edit entry"
className={granted ? classes.editEntryButton : classes.editEntryButtonNonOwner}
<UnStyledButton
onClick={() => {
setSelectedEntry({
entry: row,
@ -206,19 +245,28 @@ const AddressBookTable = (): ReactElement => {
})
setEditCreateEntryModalOpen(true)
}}
src={RenameOwnerIcon}
testId={EDIT_ENTRY_BUTTON}
/>
<Img
alt="Remove entry"
className={granted ? classes.removeEntryButton : classes.removeEntryButtonNonOwner}
>
<Icon
size="sm"
type="edit"
tooltip="Edit entry"
className={granted ? classes.editEntryButton : classes.editEntryButtonNonOwner}
/>
</UnStyledButton>
<UnStyledButton
onClick={() => {
setSelectedEntry({ entry: row })
setDeleteEntryModalOpen(true)
}}
src={RemoveOwnerIcon}
testId={REMOVE_ENTRY_BUTTON}
/>
>
<Icon
size="sm"
type="delete"
color="error"
tooltip="Delete entry"
className={granted ? classes.removeEntryButton : classes.removeEntryButtonNonOwner}
/>
</UnStyledButton>
{granted ? (
<StyledButton
color="primary"
@ -258,6 +306,12 @@ const AddressBookTable = (): ReactElement => {
isOpen={deleteEntryModalOpen}
onClose={() => setDeleteEntryModalOpen(false)}
/>
<ExportEntriesModal isOpen={exportEntriesModalOpen} onClose={() => setExportEntriesModalOpen(false)} />
<ImportEntriesModal
importEntryModalHandler={importEntryModalHandler}
isOpen={importEntryModalOpen}
onClose={() => setImportEntryModalOpen(false)}
/>
<SendModal
activeScreenType="chooseTxType"
isOpen={sendFundsModalOpen}

View File

@ -6,11 +6,8 @@ import { useHistory } from 'react-router-dom'
import { useSelector } from 'react-redux'
import { INTERFACE_MESSAGES, Transaction, RequestId, LowercaseNetworks } from '@gnosis.pm/safe-apps-sdk-v1'
import {
safeEthBalanceSelector,
safeParamAddressFromStateSelector,
safeNameSelector,
} from 'src/logic/safe/store/selectors'
import { safeEthBalanceSelector, safeParamAddressFromStateSelector } from 'src/logic/safe/store/selectors'
import { useSafeName } from 'src/logic/addressBook/hooks/useSafeName'
import { grantedSelector } from 'src/routes/safe/container/selector'
import { getNetworkId, getNetworkName, getTxServiceUrl } from 'src/config'
import { SAFELIST_ADDRESS } from 'src/routes/routes'
@ -89,7 +86,7 @@ const AppFrame = ({ appUrl }: Props): ReactElement => {
const granted = useSelector(grantedSelector)
const safeAddress = useSelector(safeParamAddressFromStateSelector)
const ethBalance = useSelector(safeEthBalanceSelector)
const safeName = useSelector(safeNameSelector)
const safeName = useSafeName(safeAddress)
const { trackEvent } = useAnalytics()
const history = useHistory()
const { consentReceived, onConsentReceipt } = useLegalConsent()

View File

@ -12,12 +12,10 @@ import {
} from '@gnosis.pm/safe-apps-sdk-v1'
import { useDispatch, useSelector } from 'react-redux'
import { useEffect, useCallback, MutableRefObject } from 'react'
import { getNetworkName, getTxServiceUrl } from 'src/config/'
import {
safeEthBalanceSelector,
safeNameSelector,
safeParamAddressFromStateSelector,
} from 'src/logic/safe/store/selectors'
import { useSafeName } from 'src/logic/addressBook/hooks/useSafeName'
import { safeEthBalanceSelector, safeParamAddressFromStateSelector } from 'src/logic/safe/store/selectors'
import { TransactionParams } from '../components/AppFrame'
import { SafeApp } from 'src/routes/safe/components/Apps/types'
@ -39,8 +37,8 @@ const useIframeMessageHandler = (
iframeRef: MutableRefObject<HTMLIFrameElement | null>,
): ReturnType => {
const { enqueueSnackbar, closeSnackbar } = useSnackbar()
const safeName = useSelector(safeNameSelector)
const safeAddress = useSelector(safeParamAddressFromStateSelector)
const safeName = useSafeName(safeAddress)
const ethBalance = useSelector(safeEthBalanceSelector)
const dispatch = useDispatch()

View File

@ -1,14 +1,20 @@
import React from 'react'
import { useSafeAppUrl } from 'src/logic/hooks/useSafeAppUrl'
import { useLocation } from 'react-router-dom'
import AppFrame from './components/AppFrame'
import AppsList from './components/AppsList'
const Apps = (): React.ReactElement => {
const { url } = useSafeAppUrl()
const useQuery = () => {
return new URLSearchParams(useLocation().search)
}
if (url) {
return <AppFrame appUrl={url} />
const Apps = (): React.ReactElement => {
const query = useQuery()
const appUrl = query.get('appUrl')
if (appUrl) {
return <AppFrame appUrl={appUrl} />
} else {
return <AppsList />
}

View File

@ -4,11 +4,12 @@ import { EthHashInfo } from '@gnosis.pm/safe-react-components'
import styled from 'styled-components'
import { getExplorerInfo, getNetworkInfo } from 'src/config'
import { safeSelector } from 'src/logic/safe/store/selectors'
import { safeNameSelector, safeSelector } from 'src/logic/safe/store/selectors'
import Paragraph from 'src/components/layout/Paragraph'
import Bold from 'src/components/layout/Bold'
import { border, xs } from 'src/theme/variables'
import Block from 'src/components/layout/Block'
const { nativeCoin } = getNetworkInfo()
const StyledBlock = styled(Block)`
@ -24,7 +25,8 @@ const StyledBlock = styled(Block)`
`
const SafeInfo = (): React.ReactElement => {
const { address: safeAddress = '', ethBalance, name: safeName } = useSelector(safeSelector) || {}
const { address: safeAddress = '', ethBalance } = useSelector(safeSelector) || {}
const safeName = useSelector((state) => safeNameSelector(state, safeAddress))
return (
<>

View File

@ -5,7 +5,7 @@ import React, { Dispatch, ReactElement, SetStateAction, useEffect, useState } fr
import { useSelector } from 'react-redux'
import { mustBeEthereumAddress, mustBeEthereumContractAddress } from 'src/components/forms/validator'
import { isFeatureEnabled } from 'src/config'
import { getNetworkId, isFeatureEnabled } from 'src/config'
import { FEATURES } from 'src/config/networks/network.d'
import { AddressBookEntry } from 'src/logic/addressBook/model/addressBook'
import { addressBookSelector } from 'src/logic/addressBook/store/selectors'
@ -18,6 +18,8 @@ import {
} from 'src/routes/safe/components/Balances/SendModal/screens/AddressBookInput/style'
import { trimSpaces } from 'src/utils/strings'
const chainId = getNetworkId()
export interface AddressBookProps {
fieldMutator: (address: string) => void
label?: string
@ -65,8 +67,8 @@ const BaseAddressBookInput = ({
const onChange: AutocompleteProps<AddressBookEntry, false, false, true>['onChange'] = (_, value, reason) => {
switch (reason) {
case 'select-option': {
const { address, name } = value as AddressBookEntry
updateAddressInfo({ address, name })
const { address, name, chainId } = value as AddressBookEntry
updateAddressInfo({ address, name, chainId })
break
}
}
@ -99,7 +101,14 @@ const BaseAddressBookInput = ({
break
}
const newEntry = typeof validatedAddress === 'string' ? { address, name: normalizedValue } : validatedAddress
const newEntry =
typeof validatedAddress === 'string'
? {
address,
name: normalizedValue,
chainId,
}
: validatedAddress
updateAddressInfo(newEntry)
break
@ -114,7 +123,13 @@ const BaseAddressBookInput = ({
}
const newEntry =
typeof validatedAddress === 'string' ? { address: validatedAddress, name: '' } : validatedAddress
typeof validatedAddress === 'string'
? {
address: validatedAddress,
name: '',
chainId,
}
: validatedAddress
updateAddressInfo(newEntry)

View File

@ -27,6 +27,7 @@ import {
getValueFromTxInputs,
} from 'src/routes/safe/components/Balances/SendModal/screens/ContractInteraction/utils'
import { useEstimateTransactionGas, EstimationStatus } from 'src/logic/hooks/useEstimateTransactionGas'
import { getNameFromAddressBookSelector } from 'src/logic/addressBook/store/selectors'
import { useEstimationStatus } from 'src/logic/hooks/useEstimationStatus'
import { ButtonStatus, Modal } from 'src/components/Modal'
import { TransactionFees } from 'src/components/TransactionsFees'
@ -60,6 +61,9 @@ const ContractInteractionReview = ({ onClose, onPrev, tx }: Props): React.ReactE
const [manualSafeTxGas, setManualSafeTxGas] = useState(0)
const [manualGasPrice, setManualGasPrice] = useState<string | undefined>()
const [manualGasLimit, setManualGasLimit] = useState<string | undefined>()
const addressName = useSelector((state) =>
getNameFromAddressBookSelector(state, { address: tx.contractAddress as string }),
)
const [txInfo, setTxInfo] = useState<{
txRecipient: string
@ -154,7 +158,13 @@ const ContractInteractionReview = ({ onClose, onPrev, tx }: Props): React.ReactE
</Paragraph>
</Row>
<Row align="center" margin="md">
<EthHashInfo hash={tx.contractAddress as string} showAvatar showCopyBtn explorerUrl={explorerUrl} />
<EthHashInfo
hash={tx.contractAddress as string}
name={addressName}
showAvatar
showCopyBtn
explorerUrl={explorerUrl}
/>
</Row>
<Row margin="xs">
<Paragraph color="disabled" noMargin size="md" style={{ letterSpacing: '-0.5px' }}>

View File

@ -35,6 +35,7 @@ const useStyles = makeStyles(styles)
export type CollectibleTx = {
recipientAddress: string
recipientName?: string
assetAddress: string
assetName: string
nftTokenId: string
@ -177,6 +178,7 @@ const ReviewCollectible = ({ onClose, onPrev, tx }: Props): React.ReactElement =
<Col xs={12}>
<EthHashInfo
hash={tx.recipientAddress}
name={tx.recipientName}
showAvatar
showCopyBtn
explorerUrl={getExplorerInfo(tx.recipientAddress)}

View File

@ -57,6 +57,7 @@ export type SendCollectibleTxInfo = {
assetName: string
nftTokenId: string
recipientAddress?: string
recipientName?: string
}
const SendCollectible = ({
@ -106,7 +107,7 @@ const SendCollectible = ({
if (!values.recipientAddress) {
values.recipientAddress = selectedEntry?.address
}
values.recipientName = selectedEntry?.name
values.assetName = nftAssets[values.assetAddress].name
onNext(values)

View File

@ -1,5 +1,5 @@
import { makeStyles } from '@material-ui/core/styles'
import React, { useEffect, useState } from 'react'
import React, { ReactElement, useEffect, useState } from 'react'
import { useSelector } from 'react-redux'
import ReceiveModal from 'src/components/App/ReceiveModal'
@ -13,11 +13,8 @@ import Row from 'src/components/layout/Row'
import { SAFELIST_ADDRESS } from 'src/routes/routes'
import SendModal from 'src/routes/safe/components/Balances/SendModal'
import { CurrencyDropdown } from 'src/routes/safe/components/CurrencyDropdown'
import {
safeFeaturesEnabledSelector,
safeNameSelector,
safeParamAddressFromStateSelector,
} from 'src/logic/safe/store/selectors'
import { useSafeName } from 'src/logic/addressBook/hooks/useSafeName'
import { safeFeaturesEnabledSelector, safeParamAddressFromStateSelector } from 'src/logic/safe/store/selectors'
import { wrapInSuspense } from 'src/utils/wrapInSuspense'
import { useFetchTokens } from 'src/logic/safe/hooks/useFetchTokens'
@ -45,13 +42,13 @@ export const COLLECTIBLES_LOCATION_REGEX = /\/balances\/collectibles$/
const useStyles = makeStyles(styles)
const Balances = (): React.ReactElement => {
const Balances = (): ReactElement => {
const classes = useStyles()
const [state, setState] = useState(INITIAL_STATE)
const address = useSelector(safeParamAddressFromStateSelector)
const featuresEnabled = useSelector(safeFeaturesEnabledSelector)
const safeName = useSelector(safeNameSelector) ?? ''
const safeName = useSafeName(address)
useFetchTokens(address)

View File

@ -2,10 +2,9 @@ import React, { useEffect, useState } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import Modal from 'src/components/Modal'
import { addOrUpdateAddressBookEntry } from 'src/logic/addressBook/store/actions/addOrUpdateAddressBookEntry'
import { addressBookAddOrUpdate } from 'src/logic/addressBook/store/actions'
import { getGnosisSafeInstanceAt } from 'src/logic/contracts/safeContracts'
import { TX_NOTIFICATION_TYPES } from 'src/logic/safe/transactions'
import { addSafeOwner } from 'src/logic/safe/store/actions/addSafeOwner'
import { createTransaction } from 'src/logic/safe/store/actions/createTransaction'
import { safeParamAddressFromStateSelector } from 'src/logic/safe/store/selectors'
import { checksumAddress } from 'src/utils/checksumAddress'
@ -46,7 +45,7 @@ export const sendAddOwner = async (
)
if (txHash) {
dispatch(addSafeOwner({ safeAddress, ownerName: values.ownerName, ownerAddress: values.ownerAddress }))
dispatch(addressBookAddOrUpdate(makeAddressBookEntry({ address: values.ownerAddress, name: values.ownerName })))
}
}
@ -99,9 +98,7 @@ export const AddOwnerModal = ({ isOpen, onClose }: Props): React.ReactElement =>
try {
await sendAddOwner(values, safeAddress, txParameters, dispatch)
dispatch(
addOrUpdateAddressBookEntry(makeAddressBookEntry({ name: values.ownerName, address: values.ownerAddress })),
)
dispatch(addressBookAddOrUpdate(makeAddressBookEntry({ name: values.ownerName, address: values.ownerAddress })))
} catch (error) {
console.error('Error while removing an owner', error)
}

View File

@ -1,11 +1,14 @@
import IconButton from '@material-ui/core/IconButton'
import { makeStyles } from '@material-ui/core/styles'
import Close from '@material-ui/icons/Close'
import { Mutator } from 'final-form'
import React from 'react'
import { useSelector } from 'react-redux'
import { OnChange } from 'react-final-form-listeners'
import { styles } from './style'
import { getNetworkId } from 'src/config'
import { ScanQRWrapper } from 'src/components/ScanQRModal/ScanQRWrapper'
import AddressInput from 'src/components/forms/AddressInput'
import Field from 'src/components/forms/Field'
@ -14,16 +17,18 @@ import TextField from 'src/components/forms/TextField'
import {
addressIsNotCurrentSafe,
composeValidators,
minMaxLength,
required,
uniqueAddress,
validAddressBookName,
} from 'src/components/forms/validator'
import Block from 'src/components/layout/Block'
import Col from 'src/components/layout/Col'
import Hairline from 'src/components/layout/Hairline'
import Paragraph from 'src/components/layout/Paragraph'
import Row from 'src/components/layout/Row'
import { safeOwnersAddressesListSelector, safeParamAddressFromStateSelector } from 'src/logic/safe/store/selectors'
import { addressBookMapSelector } from 'src/logic/addressBook/store/selectors'
import { safeOwnersSelector, safeParamAddressFromStateSelector } from 'src/logic/safe/store/selectors'
import { web3ReadOnly } from 'src/logic/wallets/getWeb3'
import { OwnerValues } from '../..'
import { Modal } from 'src/components/Modal'
@ -32,14 +37,22 @@ export const ADD_OWNER_NAME_INPUT_TEST_ID = 'add-owner-name-input'
export const ADD_OWNER_ADDRESS_INPUT_TEST_ID = 'add-owner-address-testid'
export const ADD_OWNER_NEXT_BTN_TEST_ID = 'add-owner-next-btn'
const formMutators = {
const formMutators: Record<
string,
Mutator<{ setOwnerAddress: { address: string }; setOwnerName: { name: string } }>
> = {
setOwnerAddress: (args, state, utils) => {
utils.changeValue(state, 'ownerAddress', () => args[0])
},
setOwnerName: (args, state, utils) => {
utils.changeValue(state, 'ownerName', () => args[0])
},
}
const useStyles = makeStyles(styles)
const chainId = getNetworkId()
type OwnerFormProps = {
onClose: () => void
onSubmit: (values) => void
@ -51,7 +64,8 @@ export const OwnerForm = ({ onClose, onSubmit, initialValues }: OwnerFormProps):
const handleSubmit = (values) => {
onSubmit(values)
}
const owners = useSelector(safeOwnersAddressesListSelector)
const addressBookMap = useSelector(addressBookMapSelector)
const owners = useSelector(safeOwnersSelector)
const safeAddress = useSelector(safeParamAddressFromStateSelector)
const ownerDoesntExist = uniqueAddress(owners)
const ownerAddressIsNotSafeAddress = addressIsNotCurrentSafe(safeAddress)
@ -104,8 +118,18 @@ export const OwnerForm = ({ onClose, onSubmit, initialValues }: OwnerFormProps):
testId={ADD_OWNER_NAME_INPUT_TEST_ID}
text="Owner name*"
type="text"
validate={composeValidators(required, minMaxLength(1, 50))}
validate={composeValidators(required, validAddressBookName)}
/>
<OnChange name="ownerAddress">
{async (address: string) => {
if (web3ReadOnly.utils.isAddress(address)) {
const { name: ownerName } = addressBookMap[chainId][address]
if (ownerName) {
mutators.setOwnerName(ownerName)
}
}
}}
</OnChange>
</Col>
</Row>
<Row margin="md">

View File

@ -1,11 +1,11 @@
import IconButton from '@material-ui/core/IconButton'
import { makeStyles } from '@material-ui/core/styles'
import Close from '@material-ui/icons/Close'
import React, { useEffect, useState } from 'react'
import React, { ReactElement, useEffect, useState } from 'react'
import { useSelector } from 'react-redux'
import { EthHashInfo } from '@gnosis.pm/safe-react-components'
import { getExplorerInfo } from 'src/config'
import { getExplorerInfo, getNetworkId } from 'src/config'
import Block from 'src/components/layout/Block'
import Col from 'src/components/layout/Col'
import Hairline from 'src/components/layout/Hairline'
@ -13,7 +13,11 @@ import Paragraph from 'src/components/layout/Paragraph'
import Row from 'src/components/layout/Row'
import { getGnosisSafeInstanceAt } from 'src/logic/contracts/safeContracts'
import { useEstimationStatus } from 'src/logic/hooks/useEstimationStatus'
import { safeNameSelector, safeOwnersSelector, safeParamAddressFromStateSelector } from 'src/logic/safe/store/selectors'
import { useSafeName } from 'src/logic/addressBook/hooks/useSafeName'
import {
safeOwnersWithAddressBookDataSelector,
safeParamAddressFromStateSelector,
} from 'src/logic/safe/store/selectors'
import { TxParametersDetail } from 'src/routes/safe/components/Transactions/helpers/TxParametersDetail'
import { TxParameters } from 'src/routes/safe/container/hooks/useTransactionParameters'
import { EstimationStatus, useEstimateTransactionGas } from 'src/logic/hooks/useEstimateTransactionGas'
@ -28,6 +32,8 @@ export const ADD_OWNER_SUBMIT_BTN_TEST_ID = 'add-owner-submit-btn'
const useStyles = makeStyles(styles)
const chainId = getNetworkId()
type ReviewAddOwnerProps = {
onClickBack: () => void
onClose: () => void
@ -35,12 +41,12 @@ type ReviewAddOwnerProps = {
values: OwnerValues
}
export const ReviewAddOwner = ({ onClickBack, onClose, onSubmit, values }: ReviewAddOwnerProps): React.ReactElement => {
export const ReviewAddOwner = ({ onClickBack, onClose, onSubmit, values }: ReviewAddOwnerProps): ReactElement => {
const classes = useStyles()
const [data, setData] = useState('')
const safeAddress = useSelector(safeParamAddressFromStateSelector) as string
const safeName = useSelector(safeNameSelector)
const owners = useSelector(safeOwnersSelector)
const safeName = useSafeName(safeAddress)
const owners = useSelector((state) => safeOwnersWithAddressBookDataSelector(state, chainId))
const [manualSafeTxGas, setManualSafeTxGas] = useState(0)
const [manualGasPrice, setManualGasPrice] = useState<string | undefined>()
const [manualGasLimit, setManualGasLimit] = useState<string | undefined>()
@ -148,7 +154,7 @@ export const ReviewAddOwner = ({ onClickBack, onClose, onSubmit, values }: Revie
Any transaction requires the confirmation of:
</Paragraph>
<Paragraph className={classes.name} color="primary" noMargin size="lg" weight="bolder">
{`${values.threshold} out of ${(owners?.size || 0) + 1} owner(s)`}
{`${values.threshold} out of ${(owners?.length || 0) + 1} owner(s)`}
</Paragraph>
</Block>
</Block>
@ -156,7 +162,7 @@ export const ReviewAddOwner = ({ onClickBack, onClose, onSubmit, values }: Revie
<Col className={classes.owners} layout="column" xs={8}>
<Row className={classes.ownersTitle}>
<Paragraph color="primary" noMargin size="lg">
{`${(owners?.size || 0) + 1} Safe owner(s)`}
{`${(owners?.length || 0) + 1} Safe owner(s)`}
</Paragraph>
</Row>
<Hairline />

View File

@ -42,6 +42,7 @@ export const ThresholdForm = ({ onClickBack, onClose, onSubmit, initialValues }:
const classes = useStyles()
const threshold = useSelector(safeThresholdSelector) as number
const owners = useSelector(safeOwnersSelector)
const numOptions = owners ? owners.length + 1 : 0
const handleSubmit = (values: SubmitProps) => {
onSubmit(values)
@ -79,7 +80,7 @@ export const ThresholdForm = ({ onClickBack, onClose, onSubmit, initialValues }:
render={(props) => (
<>
<SelectField {...props} disableError>
{[...Array(Number(owners ? owners.size + 1 : 0))].map((x, index) => (
{[...Array(Number(numOptions))].map((x, index) => (
<MenuItem key={index} value={`${index + 1}`}>
{index + 1}
</MenuItem>
@ -92,17 +93,12 @@ export const ThresholdForm = ({ onClickBack, onClose, onSubmit, initialValues }:
)}
</>
)}
validate={composeValidators(
required,
mustBeInteger,
minValue(1),
maxValue(owners ? owners.size + 1 : 0),
)}
validate={composeValidators(required, mustBeInteger, minValue(1), maxValue(numOptions))}
/>
</Col>
<Col xs={10}>
<Paragraph className={classes.ownersText} color="primary" noMargin size="lg">
out of {owners ? owners.size + 1 : 0} owner(s)
out of {numOptions} owner(s)
</Paragraph>
</Col>
</Row>

View File

@ -1,23 +1,22 @@
import IconButton from '@material-ui/core/IconButton'
import Close from '@material-ui/icons/Close'
import React from 'react'
import { useDispatch, useSelector } from 'react-redux'
import { useDispatch } from 'react-redux'
import Field from 'src/components/forms/Field'
import GnoForm from 'src/components/forms/GnoForm'
import TextField from 'src/components/forms/TextField'
import { composeValidators, minMaxLength, required } from 'src/components/forms/validator'
import { composeValidators, required, validAddressBookName } from 'src/components/forms/validator'
import Block from 'src/components/layout/Block'
import Hairline from 'src/components/layout/Hairline'
import Paragraph from 'src/components/layout/Paragraph'
import Row from 'src/components/layout/Row'
import Modal, { Modal as GenericModal } from 'src/components/Modal'
import { makeAddressBookEntry } from 'src/logic/addressBook/model/addressBook'
import { addOrUpdateAddressBookEntry } from 'src/logic/addressBook/store/actions/addOrUpdateAddressBookEntry'
import { addressBookAddOrUpdate } from 'src/logic/addressBook/store/actions'
import { NOTIFICATIONS } from 'src/logic/notifications'
import enqueueSnackbar from 'src/logic/notifications/store/actions/enqueueSnackbar'
import editSafeOwner from 'src/logic/safe/store/actions/editSafeOwner'
import { safeParamAddressFromStateSelector } from 'src/logic/safe/store/selectors'
import { OwnerData } from 'src/routes/safe/components/Settings/ManageOwners/dataFetcher'
import { useStyles } from './style'
import { getExplorerInfo } from 'src/config'
@ -29,20 +28,17 @@ export const SAVE_OWNER_CHANGES_BTN_TEST_ID = 'save-owner-changes-btn'
type OwnProps = {
isOpen: boolean
onClose: () => void
ownerAddress: string
selectedOwnerName: string
owner: OwnerData
}
export const EditOwnerModal = ({ isOpen, onClose, ownerAddress, selectedOwnerName }: OwnProps): React.ReactElement => {
export const EditOwnerModal = ({ isOpen, onClose, owner }: OwnProps): React.ReactElement => {
const classes = useStyles()
const dispatch = useDispatch()
const safeAddress = useSelector(safeParamAddressFromStateSelector)
const handleSubmit = ({ ownerName }: { ownerName: string }): void => {
// Update the value only if the ownerName really changed
if (ownerName !== selectedOwnerName) {
dispatch(editSafeOwner({ safeAddress, ownerAddress, ownerName }))
dispatch(addOrUpdateAddressBookEntry(makeAddressBookEntry({ address: ownerAddress, name: ownerName })))
if (ownerName !== owner.name) {
dispatch(addressBookAddOrUpdate(makeAddressBookEntry({ address: owner.address, name: ownerName })))
dispatch(enqueueSnackbar(NOTIFICATIONS.OWNER_NAME_CHANGE_EXECUTED_MSG))
}
onClose()
@ -74,22 +70,22 @@ export const EditOwnerModal = ({ isOpen, onClose, ownerAddress, selectedOwnerNam
<Row margin="md">
<Field
component={TextField}
initialValue={selectedOwnerName}
initialValue={owner.name}
name="ownerName"
placeholder="Owner name*"
testId={RENAME_OWNER_INPUT_TEST_ID}
text="Owner name*"
type="text"
validate={composeValidators(required, minMaxLength(1, 50))}
validate={composeValidators(required, validAddressBookName)}
/>
</Row>
<Row>
<Block justify="center">
<EthHashInfo
hash={ownerAddress}
hash={owner.address}
showCopyBtn
showAvatar
explorerUrl={getExplorerInfo(ownerAddress)}
explorerUrl={getExplorerInfo(owner.address)}
/>
</Block>
</Row>

View File

@ -1,5 +1,6 @@
import React, { useEffect, useState } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import { OwnerData } from 'src/routes/safe/components/Settings/ManageOwners/dataFetcher'
import { CheckOwner } from './screens/CheckOwner'
import { ReviewRemoveOwnerModal } from './screens/Review'
@ -9,14 +10,11 @@ import Modal from 'src/components/Modal'
import { SENTINEL_ADDRESS, getGnosisSafeInstanceAt } from 'src/logic/contracts/safeContracts'
import { TX_NOTIFICATION_TYPES } from 'src/logic/safe/transactions'
import { createTransaction } from 'src/logic/safe/store/actions/createTransaction'
import { removeSafeOwner } from 'src/logic/safe/store/actions/removeSafeOwner'
import { safeParamAddressFromStateSelector, safeThresholdSelector } from 'src/logic/safe/store/selectors'
import { safeParamAddressFromStateSelector } from 'src/logic/safe/store/selectors'
import { Dispatch } from 'src/logic/safe/store/actions/types.d'
import { TxParameters } from 'src/routes/safe/container/hooks/useTransactionParameters'
type OwnerValues = {
ownerAddress: string
ownerName: string
type OwnerValues = OwnerData & {
threshold: string
}
@ -27,7 +25,6 @@ export const sendRemoveOwner = async (
ownerNameToRemove: string,
dispatch: Dispatch,
txParameters: TxParameters,
threshold?: number,
): Promise<void> => {
const gnosisSafe = getGnosisSafeInstanceAt(safeAddress)
const safeOwners = await gnosisSafe.methods.getOwners().call()
@ -37,7 +34,7 @@ export const sendRemoveOwner = async (
const prevAddress = index === 0 ? SENTINEL_ADDRESS : safeOwners[index - 1]
const txData = gnosisSafe.methods.removeOwner(prevAddress, ownerAddressToRemove, values.threshold).encodeABI()
const txHash = await dispatch(
dispatch(
createTransaction({
safeAddress,
to: safeAddress,
@ -49,30 +46,19 @@ export const sendRemoveOwner = async (
notifiedTransaction: TX_NOTIFICATION_TYPES.SETTINGS_CHANGE_TX,
}),
)
if (txHash && threshold === 1) {
dispatch(removeSafeOwner({ safeAddress, ownerAddress: ownerAddressToRemove }))
}
}
type RemoveOwnerProps = {
isOpen: boolean
onClose: () => void
ownerAddress: string
ownerName: string
owner: OwnerData
}
export const RemoveOwnerModal = ({
isOpen,
onClose,
ownerAddress,
ownerName,
}: RemoveOwnerProps): React.ReactElement => {
export const RemoveOwnerModal = ({ isOpen, onClose, owner }: RemoveOwnerProps): React.ReactElement => {
const [activeScreen, setActiveScreen] = useState('checkOwner')
const [values, setValues] = useState<OwnerValues>({ ownerAddress, ownerName, threshold: '' })
const [values, setValues] = useState<OwnerValues>({ ...owner, threshold: '' })
const dispatch = useDispatch()
const safeAddress = useSelector(safeParamAddressFromStateSelector)
const threshold = useSelector(safeThresholdSelector) || 1
useEffect(
() => () => {
@ -101,7 +87,7 @@ export const RemoveOwnerModal = ({
const onRemoveOwner = (txParameters: TxParameters) => {
onClose()
sendRemoveOwner(values, safeAddress, ownerAddress, ownerName, dispatch, txParameters, threshold)
sendRemoveOwner(values, safeAddress, owner.address, owner.name, dispatch, txParameters)
}
return (
@ -113,9 +99,7 @@ export const RemoveOwnerModal = ({
title="Remove owner from Safe"
>
<>
{activeScreen === 'checkOwner' && (
<CheckOwner onClose={onClose} onSubmit={ownerSubmitted} ownerAddress={ownerAddress} ownerName={ownerName} />
)}
{activeScreen === 'checkOwner' && <CheckOwner onClose={onClose} onSubmit={ownerSubmitted} owner={owner} />}
{activeScreen === 'selectThreshold' && (
<ThresholdForm
onClickBack={onClickBack}
@ -129,8 +113,7 @@ export const RemoveOwnerModal = ({
onClickBack={onClickBack}
onClose={onClose}
onSubmit={onRemoveOwner}
ownerAddress={ownerAddress}
ownerName={ownerName}
owner={owner}
threshold={Number(values.threshold)}
/>
)}

View File

@ -7,6 +7,7 @@ import Col from 'src/components/layout/Col'
import Hairline from 'src/components/layout/Hairline'
import Paragraph from 'src/components/layout/Paragraph'
import Row from 'src/components/layout/Row'
import { OwnerData } from 'src/routes/safe/components/Settings/ManageOwners/dataFetcher'
import { useStyles } from './style'
import { EthHashInfo } from '@gnosis.pm/safe-react-components'
@ -18,11 +19,10 @@ export const REMOVE_OWNER_MODAL_NEXT_BTN_TEST_ID = 'remove-owner-next-btn'
interface CheckOwnerProps {
onClose: () => void
onSubmit: () => void
ownerAddress: string
ownerName: string
owner: OwnerData
}
export const CheckOwner = ({ onClose, onSubmit, ownerAddress, ownerName }: CheckOwnerProps): ReactElement => {
export const CheckOwner = ({ onClose, onSubmit, owner }: CheckOwnerProps): ReactElement => {
const classes = useStyles()
return (
@ -44,11 +44,11 @@ export const CheckOwner = ({ onClose, onSubmit, ownerAddress, ownerName }: Check
<Row>
<Col align="center" xs={12}>
<EthHashInfo
hash={ownerAddress}
name={ownerName}
hash={owner.address}
name={owner.name}
showCopyBtn
showAvatar
explorerUrl={getExplorerInfo(ownerAddress)}
explorerUrl={getExplorerInfo(owner.address)}
/>
</Col>
</Row>

View File

@ -3,21 +3,23 @@ import Close from '@material-ui/icons/Close'
import React, { useEffect, useState } from 'react'
import { useSelector } from 'react-redux'
import { EthHashInfo } from '@gnosis.pm/safe-react-components'
import { List } from 'immutable'
import { getExplorerInfo } from 'src/config'
import { getExplorerInfo, getNetworkId } from 'src/config'
import Block from 'src/components/layout/Block'
import Col from 'src/components/layout/Col'
import Hairline from 'src/components/layout/Hairline'
import Paragraph from 'src/components/layout/Paragraph'
import Row from 'src/components/layout/Row'
import { getGnosisSafeInstanceAt, SENTINEL_ADDRESS } from 'src/logic/contracts/safeContracts'
import { safeNameSelector, safeOwnersSelector, safeParamAddressFromStateSelector } from 'src/logic/safe/store/selectors'
import { getOwnersWithNameFromAddressBook } from 'src/logic/addressBook/utils'
import { addressBookSelector } from 'src/logic/addressBook/store/selectors'
import {
safeOwnersWithAddressBookDataSelector,
safeParamAddressFromStateSelector,
} from 'src/logic/safe/store/selectors'
import { useSafeName } from 'src/logic/addressBook/hooks/useSafeName'
import { TxParametersDetail } from 'src/routes/safe/components/Transactions/helpers/TxParametersDetail'
import { EstimationStatus, useEstimateTransactionGas } from 'src/logic/hooks/useEstimateTransactionGas'
import { TxParameters } from 'src/routes/safe/container/hooks/useTransactionParameters'
import { OwnerData } from 'src/routes/safe/components/Settings/ManageOwners/dataFetcher'
import { useStyles } from './style'
import { Modal } from 'src/components/Modal'
@ -28,12 +30,13 @@ import { sameAddress } from 'src/logic/wallets/ethAddresses'
export const REMOVE_OWNER_REVIEW_BTN_TEST_ID = 'remove-owner-review-btn'
const chainId = getNetworkId()
type ReviewRemoveOwnerProps = {
onClickBack: () => void
onClose: () => void
onSubmit: (txParameters: TxParameters) => void
ownerAddress: string
ownerName: string
owner: OwnerData
threshold?: number
}
@ -41,17 +44,15 @@ export const ReviewRemoveOwnerModal = ({
onClickBack,
onClose,
onSubmit,
ownerAddress,
ownerName,
owner,
threshold = 1,
}: ReviewRemoveOwnerProps): React.ReactElement => {
const classes = useStyles()
const [data, setData] = useState('')
const safeAddress = useSelector(safeParamAddressFromStateSelector)
const safeName = useSelector(safeNameSelector)
const owners = useSelector(safeOwnersSelector)
const addressBook = useSelector(addressBookSelector)
const ownersWithAddressBookName = owners ? getOwnersWithNameFromAddressBook(addressBook, owners) : List([])
const safeName = useSafeName(safeAddress)
const owners = useSelector((state) => safeOwnersWithAddressBookDataSelector(state, chainId))
const numOptions = owners ? owners.length - 1 : 0
const [manualSafeTxGas, setManualSafeTxGas] = useState(0)
const [manualGasPrice, setManualGasPrice] = useState<string | undefined>()
const [manualGasLimit, setManualGasLimit] = useState<string | undefined>()
@ -85,11 +86,13 @@ export const ReviewRemoveOwnerModal = ({
const calculateRemoveOwnerData = async () => {
try {
// FixMe: if the order returned by the service is the same as in the contracts
// the data lookup can be removed from here
const gnosisSafe = getGnosisSafeInstanceAt(safeAddress)
const safeOwners = await gnosisSafe.methods.getOwners().call()
const index = safeOwners.findIndex((owner) => sameAddress(owner, ownerAddress))
const index = safeOwners.findIndex((ownerAddress) => sameAddress(ownerAddress, owner.address))
const prevAddress = index === 0 ? SENTINEL_ADDRESS : safeOwners[index - 1]
const txData = gnosisSafe.methods.removeOwner(prevAddress, ownerAddress, threshold).encodeABI()
const txData = gnosisSafe.methods.removeOwner(prevAddress, owner.address, threshold).encodeABI()
if (isCurrent) {
setData(txData)
@ -103,7 +106,7 @@ export const ReviewRemoveOwnerModal = ({
return () => {
isCurrent = false
}
}, [safeAddress, ownerAddress, threshold])
}, [safeAddress, owner.address, threshold])
const closeEditModalCallback = (txParameters: TxParameters) => {
const oldGasPrice = Number(gasPriceFormatted)
@ -168,7 +171,7 @@ export const ReviewRemoveOwnerModal = ({
Any transaction requires the confirmation of:
</Paragraph>
<Paragraph className={classes.name} color="primary" noMargin size="lg" weight="bolder">
{`${threshold} out of ${owners ? owners.size - 1 : 0} owner(s)`}
{`${threshold} out of ${numOptions} owner(s)`}
</Paragraph>
</Block>
</Block>
@ -177,22 +180,22 @@ export const ReviewRemoveOwnerModal = ({
<Col className={classes.owners} layout="column" xs={8}>
<Row className={classes.ownersTitle}>
<Paragraph color="primary" noMargin size="lg">
{`${owners ? owners.size - 1 : 0} Safe owner(s)`}
{`${numOptions} Safe owner(s)`}
</Paragraph>
</Row>
<Hairline />
{ownersWithAddressBookName?.map(
(owner) =>
owner.address !== ownerAddress && (
<React.Fragment key={owner.address}>
{owners?.map(
(safeOwner) =>
!sameAddress(safeOwner.address, owner.address) && (
<React.Fragment key={safeOwner.address}>
<Row className={classes.owner}>
<Col align="center" xs={12}>
<EthHashInfo
hash={owner.address}
name={owner.name}
hash={safeOwner.address}
name={safeOwner.name}
showCopyBtn
showAvatar
explorerUrl={getExplorerInfo(owner.address)}
explorerUrl={getExplorerInfo(safeOwner.address)}
/>
</Col>
</Row>
@ -209,11 +212,11 @@ export const ReviewRemoveOwnerModal = ({
<Row className={classes.selectedOwner}>
<Col align="center" xs={12}>
<EthHashInfo
hash={ownerAddress}
name={ownerName}
hash={owner.address}
name={owner.name}
showCopyBtn
showAvatar
explorerUrl={getExplorerInfo(ownerAddress)}
explorerUrl={getExplorerInfo(owner.address)}
/>
</Col>
</Row>

View File

@ -35,6 +35,7 @@ type Props = {
export const ThresholdForm = ({ onClickBack, onClose, onSubmit, initialValues }: Props): ReactElement => {
const classes = useStyles()
const owners = useSelector(safeOwnersSelector)
const ownersCount = owners?.length ?? 0
const threshold = useSelector(safeThresholdSelector) as number
const handleSubmit = (values) => {
onSubmit(values)
@ -58,7 +59,7 @@ export const ThresholdForm = ({ onClickBack, onClose, onSubmit, initialValues }:
onSubmit={handleSubmit}
>
{() => {
const numOptions = owners && owners.size > 1 ? owners.size - 1 : 1
const numOptions = ownersCount > 1 ? ownersCount - 1 : 1
return (
<>
@ -97,7 +98,7 @@ export const ThresholdForm = ({ onClickBack, onClose, onSubmit, initialValues }:
</Col>
<Col xs={10}>
<Paragraph className={classes.ownersText} color="primary" noMargin size="lg">
out of {owners ? owners.size - 1 : 0} owner(s)
out of {ownersCount ? ownersCount - 1 : 0} owner(s)
</Paragraph>
</Col>
</Row>

View File

@ -2,12 +2,11 @@ import React, { useEffect, useState } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import Modal from 'src/components/Modal'
import { addOrUpdateAddressBookEntry } from 'src/logic/addressBook/store/actions/addOrUpdateAddressBookEntry'
import { addressBookAddOrUpdate } from 'src/logic/addressBook/store/actions'
import { SENTINEL_ADDRESS, getGnosisSafeInstanceAt } from 'src/logic/contracts/safeContracts'
import { TX_NOTIFICATION_TYPES } from 'src/logic/safe/transactions'
import { createTransaction } from 'src/logic/safe/store/actions/createTransaction'
import { replaceSafeOwner } from 'src/logic/safe/store/actions/replaceSafeOwner'
import { safeParamAddressFromStateSelector, safeThresholdSelector } from 'src/logic/safe/store/selectors'
import { safeParamAddressFromStateSelector } from 'src/logic/safe/store/selectors'
import { checksumAddress } from 'src/utils/checksumAddress'
import { makeAddressBookEntry } from 'src/logic/addressBook/model/addressBook'
import { sameAddress } from 'src/logic/wallets/ethAddresses'
@ -16,25 +15,26 @@ import { Dispatch } from 'src/logic/safe/store/actions/types.d'
import { OwnerForm } from 'src/routes/safe/components/Settings/ManageOwners/ReplaceOwnerModal/screens/OwnerForm'
import { ReviewReplaceOwnerModal } from 'src/routes/safe/components/Settings/ManageOwners/ReplaceOwnerModal/screens/Review'
import { TxParameters } from 'src/routes/safe/container/hooks/useTransactionParameters'
import { isValidAddress } from 'src/utils/isValidAddress'
import { OwnerData } from 'src/routes/safe/components/Settings/ManageOwners/dataFetcher'
export type OwnerValues = {
newOwnerAddress: string
newOwnerName: string
address: string
name: string
}
export const sendReplaceOwner = async (
values: OwnerValues,
newOwner: OwnerValues,
safeAddress: string,
ownerAddressToRemove: string,
dispatch: Dispatch,
txParameters: TxParameters,
threshold?: number,
): Promise<void> => {
const gnosisSafe = getGnosisSafeInstanceAt(safeAddress)
const safeOwners = await gnosisSafe.methods.getOwners().call()
const index = safeOwners.findIndex((ownerAddress) => sameAddress(ownerAddress, ownerAddressToRemove))
const prevAddress = index === 0 ? SENTINEL_ADDRESS : safeOwners[index - 1]
const txData = gnosisSafe.methods.swapOwner(prevAddress, ownerAddressToRemove, values.newOwnerAddress).encodeABI()
const txData = gnosisSafe.methods.swapOwner(prevAddress, ownerAddressToRemove, newOwner.address).encodeABI()
const txHash = await dispatch(
createTransaction({
@ -49,47 +49,28 @@ export const sendReplaceOwner = async (
}),
)
if (txHash && threshold === 1) {
dispatch(
replaceSafeOwner({
safeAddress,
oldOwnerAddress: ownerAddressToRemove,
ownerAddress: values.newOwnerAddress,
ownerName: values.newOwnerName,
}),
)
if (txHash) {
// update the AB
dispatch(addressBookAddOrUpdate(makeAddressBookEntry(newOwner)))
}
}
type ReplaceOwnerProps = {
isOpen: boolean
onClose: () => void
ownerAddress: string
ownerName: string
owner: OwnerData
}
export const ReplaceOwnerModal = ({
isOpen,
onClose,
ownerAddress,
ownerName,
}: ReplaceOwnerProps): React.ReactElement => {
export const ReplaceOwnerModal = ({ isOpen, onClose, owner }: ReplaceOwnerProps): React.ReactElement => {
const [activeScreen, setActiveScreen] = useState('checkOwner')
const [values, setValues] = useState({
newOwnerAddress: '',
newOwnerName: '',
})
const [newOwner, setNewOwner] = useState({ address: '', name: '' })
const dispatch = useDispatch()
const safeAddress = useSelector(safeParamAddressFromStateSelector)
const threshold = useSelector(safeThresholdSelector) || 1
useEffect(
() => () => {
setActiveScreen('checkOwner')
setValues({
newOwnerAddress: '',
newOwnerName: '',
})
setNewOwner({ address: '', name: '' })
},
[isOpen],
)
@ -98,24 +79,19 @@ export const ReplaceOwnerModal = ({
const ownerSubmitted = (newValues) => {
const { ownerAddress, ownerName } = newValues
const checksumAddr = checksumAddress(ownerAddress)
setValues({
newOwnerAddress: checksumAddr,
newOwnerName: ownerName,
})
setActiveScreen('reviewReplaceOwner')
if (isValidAddress(ownerAddress)) {
const checksumAddr = checksumAddress(ownerAddress)
setNewOwner({ address: checksumAddr, name: ownerName })
setActiveScreen('reviewReplaceOwner')
}
}
const onReplaceOwner = async (txParameters: TxParameters) => {
onClose()
try {
await sendReplaceOwner(values, safeAddress, ownerAddress, dispatch, txParameters, threshold)
dispatch(
addOrUpdateAddressBookEntry(
makeAddressBookEntry({ address: values.newOwnerAddress, name: values.newOwnerName }),
),
)
await sendReplaceOwner(newOwner, safeAddress, owner.address, dispatch, txParameters)
dispatch(addressBookAddOrUpdate(makeAddressBookEntry(newOwner)))
} catch (error) {
console.error('Error while removing an owner', error)
}
@ -131,22 +107,15 @@ export const ReplaceOwnerModal = ({
>
<>
{activeScreen === 'checkOwner' && (
<OwnerForm
onClose={onClose}
onSubmit={ownerSubmitted}
initialValues={values}
ownerAddress={ownerAddress}
ownerName={ownerName}
/>
<OwnerForm onClose={onClose} onSubmit={ownerSubmitted} initialValues={newOwner} owner={owner} />
)}
{activeScreen === 'reviewReplaceOwner' && (
<ReviewReplaceOwnerModal
onClickBack={onClickBack}
onClose={onClose}
onSubmit={onReplaceOwner}
ownerAddress={ownerAddress}
ownerName={ownerName}
values={values}
owner={owner}
newOwner={newOwner}
/>
)}
</>

View File

@ -1,7 +1,9 @@
import IconButton from '@material-ui/core/IconButton'
import Close from '@material-ui/icons/Close'
import { Mutator } from 'final-form'
import React, { ReactElement } from 'react'
import { useSelector } from 'react-redux'
import { OnChange } from 'react-final-form-listeners'
import AddressInput from 'src/components/forms/AddressInput'
import Field from 'src/components/forms/Field'
@ -10,9 +12,9 @@ import TextField from 'src/components/forms/TextField'
import {
addressIsNotCurrentSafe,
composeValidators,
minMaxLength,
required,
uniqueAddress,
validAddressBookName,
} from 'src/components/forms/validator'
import Block from 'src/components/layout/Block'
import Col from 'src/components/layout/Col'
@ -21,10 +23,13 @@ import Paragraph from 'src/components/layout/Paragraph'
import Row from 'src/components/layout/Row'
import { ScanQRWrapper } from 'src/components/ScanQRModal/ScanQRWrapper'
import { Modal } from 'src/components/Modal'
import { safeOwnersAddressesListSelector, safeParamAddressFromStateSelector } from 'src/logic/safe/store/selectors'
import { safeOwnersSelector, safeParamAddressFromStateSelector } from 'src/logic/safe/store/selectors'
import { addressBookMapSelector } from 'src/logic/addressBook/store/selectors'
import { web3ReadOnly } from 'src/logic/wallets/getWeb3'
import { OwnerData } from 'src/routes/safe/components/Settings/ManageOwners/dataFetcher'
import { useStyles } from './style'
import { getExplorerInfo } from 'src/config'
import { getExplorerInfo, getNetworkId } from 'src/config'
import { EthHashInfo } from '@gnosis.pm/safe-react-components'
export const REPLACE_OWNER_NAME_INPUT_TEST_ID = 'replace-owner-name-input'
@ -33,12 +38,20 @@ export const REPLACE_OWNER_NEXT_BTN_TEST_ID = 'replace-owner-next-btn'
import { OwnerValues } from '../..'
const formMutators = {
const formMutators: Record<
string,
Mutator<{ setOwnerAddress: { address: string }; setOwnerName: { name: string } }>
> = {
setOwnerAddress: (args, state, utils) => {
utils.changeValue(state, 'ownerAddress', () => args[0])
},
setOwnerName: (args, state, utils) => {
utils.changeValue(state, 'ownerName', () => args[0])
},
}
const chainId = getNetworkId()
type NewOwnerProps = {
ownerAddress: string
ownerName: string
@ -47,26 +60,21 @@ type NewOwnerProps = {
type OwnerFormProps = {
onClose: () => void
onSubmit: (values: NewOwnerProps) => void
ownerAddress: string
ownerName: string
owner: OwnerData
initialValues?: OwnerValues
}
export const OwnerForm = ({
onClose,
onSubmit,
ownerAddress,
ownerName,
initialValues,
}: OwnerFormProps): ReactElement => {
export const OwnerForm = ({ onClose, onSubmit, owner, initialValues }: OwnerFormProps): ReactElement => {
const classes = useStyles()
const handleSubmit = (values: NewOwnerProps) => {
onSubmit(values)
}
const owners = useSelector(safeOwnersAddressesListSelector)
const safeAddress = useSelector(safeParamAddressFromStateSelector)
const addressBookMap = useSelector(addressBookMapSelector)
const owners = useSelector(safeOwnersSelector)
const ownerDoesntExist = uniqueAddress(owners)
const safeAddress = useSelector(safeParamAddressFromStateSelector)
const ownerAddressIsNotSafeAddress = addressIsNotCurrentSafe(safeAddress)
return (
@ -85,8 +93,8 @@ export const OwnerForm = ({
formMutators={formMutators}
onSubmit={handleSubmit}
initialValues={{
ownerName: initialValues?.newOwnerName,
ownerAddress: initialValues?.newOwnerAddress,
ownerName: initialValues?.name,
ownerAddress: initialValues?.address,
}}
>
{(...args) => {
@ -118,11 +126,11 @@ export const OwnerForm = ({
<Row className={classes.owner}>
<Col align="center" xs={12}>
<EthHashInfo
hash={ownerAddress}
name={ownerName}
hash={owner.address}
name={owner.name}
showCopyBtn
showAvatar
explorerUrl={getExplorerInfo(ownerAddress)}
explorerUrl={getExplorerInfo(owner.address)}
/>
</Col>
</Row>
@ -138,8 +146,18 @@ export const OwnerForm = ({
testId={REPLACE_OWNER_NAME_INPUT_TEST_ID}
text="Owner name*"
type="text"
validate={composeValidators(required, minMaxLength(1, 50))}
validate={composeValidators(required, validAddressBookName)}
/>
<OnChange name="ownerAddress">
{async (address: string) => {
if (web3ReadOnly.utils.isAddress(address)) {
const ownerName = addressBookMap?.[chainId]?.[address]?.name
if (ownerName) {
mutators.setOwnerName(ownerName)
}
}
}}
</OnChange>
</Col>
</Row>
<Row margin="md">

View File

@ -2,10 +2,9 @@ import IconButton from '@material-ui/core/IconButton'
import Close from '@material-ui/icons/Close'
import React, { useEffect, useState } from 'react'
import { useSelector } from 'react-redux'
import { List } from 'immutable'
import { EthHashInfo } from '@gnosis.pm/safe-react-components'
import { getExplorerInfo } from 'src/config'
import { getExplorerInfo, getNetworkId } from 'src/config'
import Block from 'src/components/layout/Block'
import Col from 'src/components/layout/Col'
import Hairline from 'src/components/layout/Hairline'
@ -13,34 +12,35 @@ import Paragraph from 'src/components/layout/Paragraph'
import Row from 'src/components/layout/Row'
import { getGnosisSafeInstanceAt, SENTINEL_ADDRESS } from 'src/logic/contracts/safeContracts'
import {
safeNameSelector,
safeOwnersSelector,
safeOwnersWithAddressBookDataSelector,
safeParamAddressFromStateSelector,
safeThresholdSelector,
} from 'src/logic/safe/store/selectors'
import { getOwnersWithNameFromAddressBook } from 'src/logic/addressBook/utils'
import { addressBookSelector } from 'src/logic/addressBook/store/selectors'
import { useEstimationStatus } from 'src/logic/hooks/useEstimationStatus'
import { useSafeName } from 'src/logic/addressBook/hooks/useSafeName'
import { TxParametersDetail } from 'src/routes/safe/components/Transactions/helpers/TxParametersDetail'
import { EstimationStatus, useEstimateTransactionGas } from 'src/logic/hooks/useEstimateTransactionGas'
import { TxParameters } from 'src/routes/safe/container/hooks/useTransactionParameters'
import { Modal } from 'src/components/Modal'
import { TransactionFees } from 'src/components/TransactionsFees'
import { EditableTxParameters } from 'src/routes/safe/components/Transactions/helpers/EditableTxParameters'
import { sameAddress } from 'src/logic/wallets/ethAddresses'
import { OwnerData } from 'src/routes/safe/components/Settings/ManageOwners/dataFetcher'
import { useStyles } from './style'
export const REPLACE_OWNER_SUBMIT_BTN_TEST_ID = 'replace-owner-submit-btn'
const chainId = getNetworkId()
type ReplaceOwnerProps = {
onClose: () => void
onClickBack: () => void
onSubmit: (txParameters: TxParameters) => void
ownerAddress: string
ownerName: string
values: {
newOwnerAddress: string
newOwnerName: string
owner: OwnerData
newOwner: {
address: string
name: string
}
}
@ -48,18 +48,15 @@ export const ReviewReplaceOwnerModal = ({
onClickBack,
onClose,
onSubmit,
ownerAddress,
ownerName,
values,
owner,
newOwner,
}: ReplaceOwnerProps): React.ReactElement => {
const classes = useStyles()
const [data, setData] = useState('')
const safeAddress = useSelector(safeParamAddressFromStateSelector)
const safeName = useSelector(safeNameSelector)
const owners = useSelector(safeOwnersSelector)
const safeName = useSafeName(safeAddress)
const owners = useSelector((state) => safeOwnersWithAddressBookDataSelector(state, chainId))
const threshold = useSelector(safeThresholdSelector) || 1
const addressBook = useSelector(addressBookSelector)
const ownersWithAddressBookName = owners ? getOwnersWithNameFromAddressBook(addressBook, owners) : List([])
const [manualSafeTxGas, setManualSafeTxGas] = useState(0)
const [manualGasPrice, setManualGasPrice] = useState<string | undefined>()
const [manualGasLimit, setManualGasLimit] = useState<string | undefined>()
@ -88,9 +85,9 @@ export const ReviewReplaceOwnerModal = ({
const calculateReplaceOwnerData = async () => {
const gnosisSafe = getGnosisSafeInstanceAt(safeAddress)
const safeOwners = await gnosisSafe.methods.getOwners().call()
const index = safeOwners.findIndex((owner) => owner.toLowerCase() === ownerAddress.toLowerCase())
const index = safeOwners.findIndex((ownerAddress) => sameAddress(ownerAddress, owner.address))
const prevAddress = index === 0 ? SENTINEL_ADDRESS : safeOwners[index - 1]
const txData = gnosisSafe.methods.swapOwner(prevAddress, ownerAddress, values.newOwnerAddress).encodeABI()
const txData = gnosisSafe.methods.swapOwner(prevAddress, owner.address, newOwner.address).encodeABI()
if (isCurrent) {
setData(txData)
}
@ -100,7 +97,7 @@ export const ReviewReplaceOwnerModal = ({
return () => {
isCurrent = false
}
}, [ownerAddress, safeAddress, values.newOwnerAddress])
}, [owner.address, safeAddress, newOwner.address])
const closeEditModalCallback = (txParameters: TxParameters) => {
const oldGasPrice = Number(gasPriceFormatted)
@ -164,7 +161,7 @@ export const ReviewReplaceOwnerModal = ({
Any transaction requires the confirmation of:
</Paragraph>
<Paragraph className={classes.name} color="primary" noMargin size="lg" weight="bolder">
{`${threshold} out of ${owners?.size || 0} owner(s)`}
{`${threshold} out of ${owners?.length || 0} owner(s)`}
</Paragraph>
</Block>
</Block>
@ -172,22 +169,22 @@ export const ReviewReplaceOwnerModal = ({
<Col className={classes.owners} layout="column" xs={8}>
<Row className={classes.ownersTitle}>
<Paragraph color="primary" noMargin size="lg">
{`${owners?.size || 0} Safe owner(s)`}
{`${owners?.length || 0} Safe owner(s)`}
</Paragraph>
</Row>
<Hairline />
{ownersWithAddressBookName?.map(
(owner) =>
owner.address !== ownerAddress && (
<React.Fragment key={owner.address}>
{owners?.map(
(safeOwner) =>
!sameAddress(safeOwner.address, owner.address) && (
<React.Fragment key={safeOwner.address}>
<Row className={classes.owner}>
<Col align="center" xs={12}>
<EthHashInfo
hash={owner.address}
name={owner.name}
hash={safeOwner.address}
name={safeOwner.name}
showCopyBtn
showAvatar
explorerUrl={getExplorerInfo(owner.address)}
explorerUrl={getExplorerInfo(safeOwner.address)}
/>
</Col>
</Row>
@ -204,11 +201,11 @@ export const ReviewReplaceOwnerModal = ({
<Row className={classes.selectedOwnerRemoved}>
<Col align="center" xs={12}>
<EthHashInfo
hash={ownerAddress}
name={ownerName}
hash={owner.address}
name={owner.name}
showCopyBtn
showAvatar
explorerUrl={getExplorerInfo(ownerAddress)}
explorerUrl={getExplorerInfo(owner.address)}
/>
</Col>
</Row>
@ -221,11 +218,11 @@ export const ReviewReplaceOwnerModal = ({
<Row className={classes.selectedOwnerAdded}>
<Col align="center" xs={12}>
<EthHashInfo
hash={values.newOwnerAddress}
name={values.newOwnerName}
hash={newOwner.address}
name={newOwner.name}
showCopyBtn
showAvatar
explorerUrl={getExplorerInfo(values.newOwnerAddress)}
explorerUrl={getExplorerInfo(newOwner.address)}
/>
</Col>
</Row>

View File

@ -1,12 +1,15 @@
import { List } from 'immutable'
import { TableColumn } from 'src/components/Table/types.d'
import { SafeOwner } from 'src/logic/safe/store/models/safe'
import { AddressBookState } from 'src/logic/addressBook/model/addressBook'
export const OWNERS_TABLE_NAME_ID = 'name'
export const OWNERS_TABLE_ADDRESS_ID = 'address'
export const OWNERS_TABLE_ACTIONS_ID = 'actions'
export const getOwnerData = (owners: List<SafeOwner>): List<{ address: string; name: string }> => {
export type OwnerData = { address: string; name: string }
export const getOwnerData = (owners: AddressBookState): OwnerData[] => {
return owners.map((owner) => ({
[OWNERS_TABLE_NAME_ID]: owner.name,
[OWNERS_TABLE_ADDRESS_ID]: owner.address,

View File

@ -1,10 +1,9 @@
import React, { useState, useEffect } from 'react'
import React, { useState, useEffect, ReactElement } from 'react'
import { EthHashInfo } from '@gnosis.pm/safe-react-components'
import TableCell from '@material-ui/core/TableCell'
import TableContainer from '@material-ui/core/TableContainer'
import TableRow from '@material-ui/core/TableRow'
import cn from 'classnames'
import { List } from 'immutable'
import RemoveOwnerIcon from '../assets/icons/bin.svg'
@ -14,7 +13,7 @@ import { RemoveOwnerModal } from './RemoveOwnerModal'
import { ReplaceOwnerModal } from './ReplaceOwnerModal'
import RenameOwnerIcon from './assets/icons/rename-owner.svg'
import ReplaceOwnerIcon from './assets/icons/replace-owner.svg'
import { OWNERS_TABLE_ADDRESS_ID, OWNERS_TABLE_NAME_ID, generateColumns, getOwnerData } from './dataFetcher'
import { OWNERS_TABLE_ADDRESS_ID, generateColumns, getOwnerData, OwnerData } from './dataFetcher'
import { useStyles } from './style'
import { getExplorerInfo } from 'src/config'
@ -28,10 +27,8 @@ import Heading from 'src/components/layout/Heading'
import Img from 'src/components/layout/Img'
import Paragraph from 'src/components/layout/Paragraph/index'
import Row from 'src/components/layout/Row'
import { getOwnersWithNameFromAddressBook } from 'src/logic/addressBook/utils'
import { useAnalytics, SAFE_NAVIGATION_EVENT } from 'src/utils/googleAnalytics'
import { AddressBookState } from 'src/logic/addressBook/model/addressBook'
import { SafeOwner } from 'src/logic/safe/store/models/safe'
export const RENAME_OWNER_BTN_TEST_ID = 'rename-owner-btn'
export const REMOVE_OWNER_BTN_TEST_ID = 'remove-owner-btn'
@ -40,17 +37,15 @@ export const REPLACE_OWNER_BTN_TEST_ID = 'replace-owner-btn'
export const OWNERS_ROW_TEST_ID = 'owners-row'
type Props = {
addressBook: AddressBookState
granted: boolean
owners: List<SafeOwner>
owners: AddressBookState
}
const ManageOwners = ({ addressBook, granted, owners }: Props): React.ReactElement => {
const ManageOwners = ({ granted, owners }: Props): ReactElement => {
const { trackEvent } = useAnalytics()
const classes = useStyles()
const [selectedOwnerAddress, setSelectedOwnerAddress] = useState('')
const [selectedOwnerName, setSelectedOwnerName] = useState('')
const [selectedOwner, setSelectedOwner] = useState<OwnerData | undefined>()
const [modalsStatus, setModalStatus] = useState({
showAddOwner: false,
showRemoveOwner: false,
@ -58,13 +53,14 @@ const ManageOwners = ({ addressBook, granted, owners }: Props): React.ReactEleme
showEditOwner: false,
})
const onShow = (action, row?: any) => () => {
const onShow = (action, row?: OwnerData) => () => {
setModalStatus((prevState) => ({
...prevState,
[`show${action}`]: !prevState[`show${action}`],
}))
setSelectedOwnerAddress(row && row.address)
setSelectedOwnerName(row && row.name)
if (row) {
setSelectedOwner(row)
}
}
const onHide = (action) => () => {
@ -72,8 +68,7 @@ const ManageOwners = ({ addressBook, granted, owners }: Props): React.ReactEleme
...prevState,
[`show${action}`]: !Boolean(prevState[`show${action}`]),
}))
setSelectedOwnerAddress('')
setSelectedOwnerName('')
setSelectedOwner(undefined)
}
useEffect(() => {
@ -82,8 +77,7 @@ const ManageOwners = ({ addressBook, granted, owners }: Props): React.ReactEleme
const columns = generateColumns()
const autoColumns = columns.filter((c) => !c.custom)
const ownersWithAddressBookName = getOwnersWithNameFromAddressBook(addressBook, owners)
const ownerData = getOwnerData(ownersWithAddressBookName)
const ownerData = getOwnerData(owners)
return (
<>
@ -100,11 +94,11 @@ const ManageOwners = ({ addressBook, granted, owners }: Props): React.ReactEleme
columns={columns}
data={ownerData}
defaultFixed
defaultOrderBy={OWNERS_TABLE_NAME_ID}
defaultOrderBy={OWNERS_TABLE_ADDRESS_ID}
disablePagination
label="Owners"
noBorder
size={ownerData.size}
size={ownerData.length}
>
{(sortedData) =>
sortedData.map((row, index) => (
@ -147,7 +141,7 @@ const ManageOwners = ({ addressBook, granted, owners }: Props): React.ReactEleme
src={ReplaceOwnerIcon}
testId={REPLACE_OWNER_BTN_TEST_ID}
/>
{ownerData.size > 1 && (
{ownerData.length > 1 && (
<Img
alt="Remove owner"
className={classes.removeOwnerIcon}
@ -185,24 +179,21 @@ const ManageOwners = ({ addressBook, granted, owners }: Props): React.ReactEleme
</>
)}
<AddOwnerModal isOpen={modalsStatus.showAddOwner} onClose={onHide('AddOwner')} />
<RemoveOwnerModal
isOpen={modalsStatus.showRemoveOwner}
onClose={onHide('RemoveOwner')}
ownerAddress={selectedOwnerAddress}
ownerName={selectedOwnerName}
/>
<ReplaceOwnerModal
isOpen={modalsStatus.showReplaceOwner}
onClose={onHide('ReplaceOwner')}
ownerAddress={selectedOwnerAddress}
ownerName={selectedOwnerName}
/>
<EditOwnerModal
isOpen={modalsStatus.showEditOwner}
onClose={onHide('EditOwner')}
ownerAddress={selectedOwnerAddress}
selectedOwnerName={selectedOwnerName}
/>
{selectedOwner && (
<>
<RemoveOwnerModal
isOpen={modalsStatus.showRemoveOwner}
onClose={onHide('RemoveOwner')}
owner={selectedOwner}
/>
<ReplaceOwnerModal
isOpen={modalsStatus.showReplaceOwner}
onClose={onHide('ReplaceOwner')}
owner={selectedOwner}
/>
<EditOwnerModal isOpen={modalsStatus.showEditOwner} onClose={onHide('EditOwner')} owner={selectedOwner} />
</>
)}
</>
)
}

View File

@ -1,3 +1,4 @@
import { EthHashInfo } from '@gnosis.pm/safe-react-components'
import IconButton from '@material-ui/core/IconButton'
import Close from '@material-ui/icons/Close'
import React from 'react'
@ -5,25 +6,23 @@ import { useDispatch, useSelector } from 'react-redux'
import { useStyles } from './style'
import { EthHashInfo } from '@gnosis.pm/safe-react-components'
import Modal, { Modal as GenericModal } from 'src/components/Modal'
import Block from 'src/components/layout/Block'
import Hairline from 'src/components/layout/Hairline'
import Paragraph from 'src/components/layout/Paragraph'
import Row from 'src/components/layout/Row'
import {
defaultSafeSelector,
safeNameSelector,
safeParamAddressFromStateSelector,
} from 'src/logic/safe/store/selectors'
import { addressBookMapSelector } from 'src/logic/addressBook/store/selectors'
import { defaultSafeSelector, safeParamAddressFromStateSelector } from 'src/logic/safe/store/selectors'
import { WELCOME_ADDRESS } from 'src/routes/routes'
import { removeLocalSafe } from 'src/logic/safe/store/actions/removeLocalSafe'
import { sameAddress } from 'src/logic/wallets/ethAddresses'
import { saveDefaultSafe } from 'src/logic/safe/utils'
import { getExplorerInfo } from 'src/config'
import { getExplorerInfo, getNetworkId } from 'src/config'
import Col from 'src/components/layout/Col'
const chainId = getNetworkId()
type RemoveSafeModalProps = {
isOpen: boolean
onClose: () => void
@ -32,12 +31,16 @@ type RemoveSafeModalProps = {
export const RemoveSafeModal = ({ isOpen, onClose }: RemoveSafeModalProps): React.ReactElement => {
const classes = useStyles()
const safeAddress = useSelector(safeParamAddressFromStateSelector)
const safeName = useSelector(safeNameSelector)
const addressBookMap = useSelector(addressBookMapSelector)
const safeAddressBookEntry = addressBookMap[chainId]?.[safeAddress]
const safeName = safeAddressBookEntry?.name
const defaultSafe = useSelector(defaultSafeSelector)
const dispatch = useDispatch()
const onRemoveSafeHandler = async () => {
// ToDo: review if this is necessary or we should directly use the `removeSafe` action.
await dispatch(removeLocalSafe(safeAddress))
if (sameAddress(safeAddress, defaultSafe)) {
await saveDefaultSafe('')
}

View File

@ -1,6 +1,6 @@
import { Icon, Link, Text } from '@gnosis.pm/safe-react-components'
import { makeStyles } from '@material-ui/core/styles'
import React, { useEffect, useState } from 'react'
import React, { ReactElement, useEffect, useState } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import styled from 'styled-components'
@ -10,13 +10,15 @@ import Modal from 'src/components/Modal'
import Field from 'src/components/forms/Field'
import GnoForm from 'src/components/forms/GnoForm'
import TextField from 'src/components/forms/TextField'
import { composeValidators, minMaxLength, required } from 'src/components/forms/validator'
import { composeValidators, required, validAddressBookName } from 'src/components/forms/validator'
import Block from 'src/components/layout/Block'
import Button from 'src/components/layout/Button'
import Col from 'src/components/layout/Col'
import Heading from 'src/components/layout/Heading'
import Paragraph from 'src/components/layout/Paragraph'
import Row from 'src/components/layout/Row'
import { makeAddressBookEntry } from 'src/logic/addressBook/model/addressBook'
import { addressBookAddOrUpdate } from 'src/logic/addressBook/store/actions'
import enqueueSnackbar from 'src/logic/notifications/store/actions/enqueueSnackbar'
import { getNotificationsFromTxType, enhanceSnackbarForAction } from 'src/logic/notifications'
import { sameAddress } from 'src/logic/wallets/ethAddresses'
@ -25,17 +27,16 @@ import { UpdateSafeModal } from 'src/routes/safe/components/Settings/UpdateSafeM
import { grantedSelector } from 'src/routes/safe/container/selector'
import { updateSafe } from 'src/logic/safe/store/actions/updateSafe'
import { useSafeName } from 'src/logic/addressBook/hooks/useSafeName'
import {
latestMasterContractVersionSelector,
safeCurrentVersionSelector,
safeNameSelector,
safeNeedsUpdateSelector,
safeParamAddressFromStateSelector,
} from 'src/logic/safe/store/selectors'
import { useAnalytics, SAFE_NAVIGATION_EVENT } from 'src/utils/googleAnalytics'
import { fetchMasterCopies, MasterCopy, MasterCopyDeployer } from 'src/logic/contracts/api/masterCopies'
import { getMasterCopyAddressFromProxyAddress } from 'src/logic/contracts/safeContracts'
import { LOADED_SAFE_KEY } from 'src/utils/constants'
export const SAFE_NAME_INPUT_TEST_ID = 'safe-name-input'
export const SAFE_NAME_SUBMIT_BTN_TEST_ID = 'change-safe-name-btn'
@ -52,18 +53,18 @@ const StyledIcon = styled(Icon)`
left: 6px;
`
const SafeDetails = (): React.ReactElement => {
const SafeDetails = (): ReactElement => {
const classes = useStyles()
const isUserOwner = useSelector(grantedSelector)
const latestMasterContractVersion = useSelector(latestMasterContractVersionSelector)
const dispatch = useDispatch()
const safeName = useSelector(safeNameSelector)
const safeAddress = useSelector(safeParamAddressFromStateSelector)
const safeName = useSafeName(safeAddress)
const safeNeedsUpdate = useSelector(safeNeedsUpdateSelector)
const safeCurrentVersion = useSelector(safeCurrentVersionSelector)
const { trackEvent } = useAnalytics()
const [isModalOpen, setModalOpen] = React.useState(false)
const safeAddress = useSelector(safeParamAddressFromStateSelector)
const [isModalOpen, setModalOpen] = useState(false)
const [safeInfo, setSafeInfo] = useState<MasterCopy | undefined>()
const toggleModal = () => {
@ -71,10 +72,9 @@ const SafeDetails = (): React.ReactElement => {
}
const handleSubmit = (values) => {
// In case they set a name we assume the safe want to be stored even if it was opened via URL
dispatch(
updateSafe({ address: safeAddress, name: values.safeName, loadedViaUrl: values.safeName === LOADED_SAFE_KEY }),
)
dispatch(addressBookAddOrUpdate(makeAddressBookEntry({ address: safeAddress, name: values.safeName })))
// setting `loadedViaUrl` to `false` as setting a safe's name is considered to intentionally add the safe
dispatch(updateSafe({ address: safeAddress, loadedViaUrl: false }))
const notification = getNotificationsFromTxType(TX_NOTIFICATION_TYPES.SAFE_NAME_CHANGE_TX)
dispatch(enqueueSnackbar(enhanceSnackbarForAction(notification.afterExecution.noMoreConfirmationsNeeded)))
@ -166,7 +166,7 @@ const SafeDetails = (): React.ReactElement => {
testId={SAFE_NAME_INPUT_TEST_ID}
text="Safe name*"
type="text"
validate={composeValidators(required, minMaxLength(1, 50))}
validate={composeValidators(required, validAddressBookName)}
/>
</Block>
</Block>

View File

@ -4,6 +4,7 @@ import { useSelector } from 'react-redux'
import { getExplorerInfo } from 'src/config'
import { getNameFromAddressBookSelector } from 'src/logic/addressBook/store/selectors'
import { ADDRESS_BOOK_DEFAULT_NAME } from 'src/logic/addressBook/model/addressBook'
import { sameString } from 'src/utils/strings'
import DataDisplay from './DataDisplay'
@ -14,14 +15,14 @@ interface AddressInfoProps {
}
const AddressInfo = ({ address, title }: AddressInfoProps): ReactElement => {
const name = useSelector((state) => getNameFromAddressBookSelector(state, address))
const name = useSelector((state) => getNameFromAddressBookSelector(state, { address }))
const explorerUrl = getExplorerInfo(address)
return (
<DataDisplay title={title}>
<EthHashInfo
hash={address}
name={sameString(name, 'UNKNOWN') ? undefined : name}
name={sameString(name, ADDRESS_BOOK_DEFAULT_NAME) ? undefined : name}
showCopyBtn
showAvatar
textSize="lg"

View File

@ -3,7 +3,6 @@ import MenuItem from '@material-ui/core/MenuItem'
import Close from '@material-ui/icons/Close'
import React, { ReactElement, useEffect, useState } from 'react'
import { useDispatch } from 'react-redux'
import { List } from 'immutable'
import Field from 'src/components/forms/Field'
import GnoForm from 'src/components/forms/GnoForm'
@ -16,7 +15,6 @@ import Paragraph from 'src/components/layout/Paragraph'
import Row from 'src/components/layout/Row'
import { getGnosisSafeInstanceAt } from 'src/logic/contracts/safeContracts'
import { useEstimationStatus } from 'src/logic/hooks/useEstimationStatus'
import { SafeOwner } from 'src/logic/safe/store/models/safe'
import { EstimationStatus, useEstimateTransactionGas } from 'src/logic/hooks/useEstimateTransactionGas'
import { ButtonStatus, Modal } from 'src/components/Modal'
import { TransactionFees } from 'src/components/TransactionsFees'
@ -32,14 +30,14 @@ const THRESHOLD_FIELD_NAME = 'threshold'
type ChangeThresholdModalProps = {
onClose: () => void
owners?: List<SafeOwner>
ownersCount?: number
safeAddress: string
threshold?: number
}
export const ChangeThresholdModal = ({
onClose,
owners,
ownersCount = 0,
safeAddress,
threshold = 1,
}: ChangeThresholdModalProps): ReactElement => {
@ -164,7 +162,7 @@ export const ChangeThresholdModal = ({
render={(props) => (
<>
<SelectField {...props} disableError>
{[...Array(Number(owners?.size))].map((x, index) => (
{[...Array(Number(ownersCount))].map((x, index) => (
<MenuItem key={index} value={`${index + 1}`}>
{index + 1}
</MenuItem>
@ -177,7 +175,7 @@ export const ChangeThresholdModal = ({
</Col>
<Col xs={10}>
<Paragraph className={classes.ownersText} color="primary" noMargin size="lg">
{`out of ${owners?.size} owner(s)`}
{`out of ${ownersCount} owner(s)`}
</Paragraph>
</Col>
</Row>

View File

@ -46,9 +46,9 @@ const ThresholdSettings = (): React.ReactElement => {
<Heading tag="h2">Required confirmations</Heading>
<Paragraph>Any transaction requires the confirmation of:</Paragraph>
<Paragraph className={classes.ownersText} size="lg">
<Bold>{threshold}</Bold> out of <Bold>{owners?.size || 0}</Bold> owners
<Bold>{threshold}</Bold> out of <Bold>{owners?.length || 0}</Bold> owners
</Paragraph>
{owners && owners.size > 1 && granted && (
{owners && owners.length > 1 && granted && (
<Row className={classes.buttonRow}>
<Button
className={classes.modifyBtn}
@ -68,7 +68,12 @@ const ThresholdSettings = (): React.ReactElement => {
open={isModalOpen}
title="Change Required Confirmations"
>
<ChangeThresholdModal onClose={toggleModal} owners={owners} safeAddress={safeAddress} threshold={threshold} />
<ChangeThresholdModal
onClose={toggleModal}
ownersCount={owners?.length}
safeAddress={safeAddress}
threshold={threshold}
/>
</Modal>
</>
)

View File

@ -16,6 +16,7 @@ import ThresholdSettings from './ThresholdSettings'
import RemoveSafeIcon from './assets/icons/bin.svg'
import { styles } from './style'
import { getNetworkId } from 'src/config'
import Block from 'src/components/layout/Block'
import ButtonLink from 'src/components/layout/ButtonLink'
import Col from 'src/components/layout/Col'
@ -24,12 +25,17 @@ import Img from 'src/components/layout/Img'
import Paragraph from 'src/components/layout/Paragraph'
import Row from 'src/components/layout/Row'
import Span from 'src/components/layout/Span'
import { addressBookSelector } from 'src/logic/addressBook/store/selectors'
import {
safeNeedsUpdateSelector,
safeOwnersWithAddressBookDataSelector,
safeSelector,
} from 'src/logic/safe/store/selectors'
import { grantedSelector } from 'src/routes/safe/container/selector'
import { safeLoadedViaUrlSelector, safeNeedsUpdateSelector, safeOwnersSelector } from 'src/logic/safe/store/selectors'
export const OWNERS_SETTINGS_TAB_TEST_ID = 'owner-settings-tab'
const chainId = getNetworkId()
const INITIAL_STATE = {
showRemoveSafe: false,
menuOptionIndex: 1,
@ -40,11 +46,10 @@ const useStyles = makeStyles(styles)
const Settings: React.FC = () => {
const classes = useStyles()
const [state, setState] = useState(INITIAL_STATE)
const owners = useSelector(safeOwnersSelector)
const isSafeLoadedViaUrl = useSelector(safeLoadedViaUrlSelector)
const owners = useSelector((state) => safeOwnersWithAddressBookDataSelector(state, chainId))
const needsUpdate = useSelector(safeNeedsUpdateSelector)
const granted = useSelector(grantedSelector)
const addressBook = useSelector(addressBookSelector)
const safe = useSelector(safeSelector)
const handleChange = (menuOptionIndex) => () => {
setState((prevState) => ({ ...prevState, menuOptionIndex }))
@ -67,13 +72,15 @@ const Settings: React.FC = () => {
) : (
<>
<Row className={classes.message}>
{!isSafeLoadedViaUrl && (
<ButtonLink className={classes.removeSafeBtn} color="error" onClick={onShow('RemoveSafe')} size="lg">
<Span className={classes.links}>Remove Safe</Span>
<Img alt="Trash Icon" className={classes.removeSafeIcon} src={RemoveSafeIcon} />
</ButtonLink>
{!safe?.loadedViaUrl && (
<>
<ButtonLink className={classes.removeSafeBtn} color="error" onClick={onShow('RemoveSafe')} size="lg">
<Span className={classes.links}>Remove Safe</Span>
<Img alt="Trash Icon" className={classes.removeSafeIcon} src={RemoveSafeIcon} />
</ButtonLink>
<RemoveSafeModal isOpen={showRemoveSafe} onClose={onHide('RemoveSafe')} />
</>
)}
<RemoveSafeModal isOpen={showRemoveSafe} onClose={onHide('RemoveSafe')} />
</Row>
<Block className={classes.root}>
<Col className={classes.menuWrapper} layout="column">
@ -109,7 +116,7 @@ const Settings: React.FC = () => {
color={menuOptionIndex === 2 ? 'primary' : 'secondary'}
/>
<Paragraph className={classes.counter} size="xs">
{owners.size}
{owners.length}
</Paragraph>
</Row>
<Hairline className={classes.hairline} />
@ -148,7 +155,7 @@ const Settings: React.FC = () => {
<Col className={classes.contents} layout="column">
<Block className={classes.container}>
{menuOptionIndex === 1 && <SafeDetails />}
{menuOptionIndex === 2 && <ManageOwners addressBook={addressBook} granted={granted} owners={owners} />}
{menuOptionIndex === 2 && <ManageOwners granted={granted} owners={owners} />}
{menuOptionIndex === 3 && <ThresholdSettings />}
{menuOptionIndex === 4 && <SpendingLimitSettings />}
{menuOptionIndex === 5 && <Advanced />}

Some files were not shown because too many files have changed in this diff Show More