diff --git a/.gitignore b/.gitignore index ba76c55d..7867fb70 100644 --- a/.gitignore +++ b/.gitignore @@ -1,12 +1,12 @@ -node_modules/ -./build +/node_modules +/build .DS_Store yarn-error.log .env* -.idea/ +/.idea dist electron-builder.yml -.yalc/ +/.yalc yalc.lock # testing -/coverage/ +/coverage diff --git a/package.json b/package.json index 636d763f..966949ad 100644 --- a/package.json +++ b/package.json @@ -160,7 +160,7 @@ }, "dependencies": { "@gnosis.pm/safe-contracts": "1.1.1-dev.2", - "@gnosis.pm/safe-react-components": "^0.1.3", + "@gnosis.pm/safe-react-components": "^0.2.0", "@gnosis.pm/util-contracts": "2.0.6", "@ledgerhq/hw-transport-node-hid": "5.19.0", "@material-ui/core": "4.11.0", @@ -227,6 +227,7 @@ "@types/node": "14.0.14", "@types/react": "^16.9.32", "@types/react-dom": "^16.9.6", + "@types/react-redux": "^7.1.9", "@types/styled-components": "^5.1.0", "@typescript-eslint/eslint-plugin": "3.5.0", "@typescript-eslint/parser": "3.5.0", diff --git a/src/components/AddressInfo/index.tsx b/src/components/AddressInfo/index.tsx index 3bb6991a..efd40e50 100644 --- a/src/components/AddressInfo/index.tsx +++ b/src/components/AddressInfo/index.tsx @@ -42,7 +42,7 @@ interface Props { ethBalance?: string } -const AddressInfo = ({ ethBalance, safeAddress, safeName }: Props) => { +const AddressInfo = ({ ethBalance, safeAddress, safeName }: Props): React.ReactElement => { return (
diff --git a/src/components/Header/components/SafeListHeader/index.tsx b/src/components/Header/components/SafeListHeader/index.tsx index d4c81e23..6e11d98f 100644 --- a/src/components/Header/components/SafeListHeader/index.tsx +++ b/src/components/Header/components/SafeListHeader/index.tsx @@ -10,6 +10,7 @@ import Col from 'src/components/layout/Col' import Paragraph from 'src/components/layout/Paragraph' import { safesCountSelector } from 'src/routes/safe/store/selectors' import { border, md, screenSm, sm, xs } from 'src/theme/variables' +import { AppReduxState } from 'src/store' export const TOGGLE_SIDEBAR_BTN_TESTID = 'TOGGLE_SIDEBAR_BTN' @@ -59,8 +60,4 @@ const SafeListHeader = ({ safesCount }) => { ) } -export default connect( - // $FlowFixMe - (state) => ({ safesCount: safesCountSelector(state) }), - null, -)(SafeListHeader) +export default connect((state: AppReduxState) => ({ safesCount: safesCountSelector(state) }), null)(SafeListHeader) diff --git a/src/components/Sidebar/index.tsx b/src/components/Sidebar/index.tsx index fd0da18a..ab695b96 100644 --- a/src/components/Sidebar/index.tsx +++ b/src/components/Sidebar/index.tsx @@ -18,6 +18,7 @@ import { WELCOME_ADDRESS } from 'src/routes/routes' import setDefaultSafe from 'src/routes/safe/store/actions/setDefaultSafe' import { defaultSafeSelector, safeParamAddressFromStateSelector } from 'src/routes/safe/store/selectors' +import { AppReduxState } from 'src/store' const { useEffect, useMemo, useState } = React @@ -123,8 +124,7 @@ const Sidebar = ({ children, currentSafe, defaultSafe, safes, setDefaultSafeActi } export default connect( - // $FlowFixMe - (state) => ({ + (state: AppReduxState) => ({ safes: sortedSafeListSelector(state), defaultSafe: defaultSafeSelector(state), currentSafe: safeParamAddressFromStateSelector(state), diff --git a/src/components/Table/TableHead.tsx b/src/components/Table/TableHead.tsx index 6b948ddf..6d341fa6 100644 --- a/src/components/Table/TableHead.tsx +++ b/src/components/Table/TableHead.tsx @@ -4,7 +4,11 @@ import TableRow from '@material-ui/core/TableRow' import TableSortLabel from '@material-ui/core/TableSortLabel' import * as React from 'react' -export const cellWidth = (width) => { +interface CellWidth { + maxWidth: string +} + +export const cellWidth = (width: string | number): CellWidth | undefined => { if (!width) { return undefined } diff --git a/src/components/Table/types.d.ts b/src/components/Table/types.d.ts new file mode 100644 index 00000000..133b0f14 --- /dev/null +++ b/src/components/Table/types.d.ts @@ -0,0 +1,11 @@ +export interface TableColumn { + align?: 'inherit' | 'left' | 'center' | 'right' | 'justify' + custom: boolean + disablePadding: boolean + id: string + label: string + order: boolean + static?: boolean + style?: any + width?: number +} diff --git a/src/components/forms/validator.ts b/src/components/forms/validator.ts index 8cf59811..9aa3b87c 100644 --- a/src/components/forms/validator.ts +++ b/src/components/forms/validator.ts @@ -1,3 +1,5 @@ +import { List } from 'immutable' + import { sameAddress } from 'src/logic/wallets/ethAddresses' import { getWeb3 } from 'src/logic/wallets/getWeb3' @@ -100,7 +102,7 @@ export const minMaxLength = (minLen, maxLen) => (value) => export const ADDRESS_REPEATED_ERROR = 'Address already introduced' -export const uniqueAddress = (addresses: string[]) => +export const uniqueAddress = (addresses: string[] | List) => simpleMemoize((value: string[]) => { const addressAlreadyExists = addresses.some((address) => sameAddress(value, address)) return addressAlreadyExists ? ADDRESS_REPEATED_ERROR : undefined diff --git a/src/components/layout/PageFrame/index.tsx b/src/components/layout/PageFrame/index.tsx index 6221ed70..d21af615 100644 --- a/src/components/layout/PageFrame/index.tsx +++ b/src/components/layout/PageFrame/index.tsx @@ -19,6 +19,7 @@ import Img from 'src/components/layout/Img' import { getNetwork } from 'src/config' import { ETHEREUM_NETWORK } from 'src/logic/wallets/getWeb3' import { networkSelector } from 'src/logic/wallets/store/selectors' +import { AppReduxState } from 'src/store' const notificationStyles = { success: { @@ -75,7 +76,7 @@ const PageFrame = ({ children, classes, currentNetwork }) => { export default withStyles(notificationStyles)( connect( - (state) => ({ + (state: AppReduxState) => ({ currentNetwork: networkSelector(state), }), null, diff --git a/src/logic/collectibles/sources/OpenSea.ts b/src/logic/collectibles/sources/OpenSea.ts index 270db9e5..d626e825 100644 --- a/src/logic/collectibles/sources/OpenSea.ts +++ b/src/logic/collectibles/sources/OpenSea.ts @@ -57,14 +57,14 @@ export interface Collectibles { } class OpenSea { - _rateLimit = async () => {} + _rateLimit = async (): Promise => {} _endpointsUrls = { [ETHEREUM_NETWORK.MAINNET]: 'https://api.opensea.io/api/v1', [ETHEREUM_NETWORK.RINKEBY]: 'https://rinkeby-api.opensea.io/api/v1', } - _fetch = async (url) => { + _fetch = async (url: string): Promise => { // eslint-disable-next-line no-underscore-dangle return fetch(url, { headers: { 'X-API-KEY': OPENSEA_API_KEY || '' }, @@ -76,7 +76,7 @@ class OpenSea { * @param {object} options * @param {number} options.rps - requests per second */ - constructor(options) { + constructor(options: { rps: number }) { // eslint-disable-next-line no-underscore-dangle this._rateLimit = RateLimit(options.rps, { timeUnit: 60 * 1000, uniformDistribution: true }) } diff --git a/src/logic/collectibles/store/selectors/index.ts b/src/logic/collectibles/store/selectors/index.ts index fb517812..dbe7a55e 100644 --- a/src/logic/collectibles/store/selectors/index.ts +++ b/src/logic/collectibles/store/selectors/index.ts @@ -1,6 +1,5 @@ -import { List } from 'immutable' import { createSelector } from 'reselect' -import { NFTAssets, NFTTokens } from 'src/logic/collectibles/sources/OpenSea' +import { NFTAsset, NFTAssets, NFTTokens } from 'src/logic/collectibles/sources/OpenSea' import { AppReduxState } from 'src/store' import { NFT_ASSETS_REDUCER_ID, NFT_TOKENS_REDUCER_ID } from 'src/logic/collectibles/store/reducer/collectibles' @@ -9,22 +8,24 @@ import { safeActiveAssetsSelector } from 'src/routes/safe/store/selectors' export const nftAssetsSelector = (state: AppReduxState): NFTAssets => state[NFT_ASSETS_REDUCER_ID] export const nftTokensSelector = (state: AppReduxState): NFTTokens => state[NFT_TOKENS_REDUCER_ID] -export const nftAssetsListSelector = createSelector(nftAssetsSelector, (assets) => { - return assets ? List(Object.entries(assets).map((item) => item[1])) : List([]) +export const nftAssetsListSelector = createSelector(nftAssetsSelector, (assets): NFTAsset[] => { + return assets ? Object.values(assets) : [] }) export const activeNftAssetsListSelector = createSelector( nftAssetsListSelector, safeActiveAssetsSelector, - (assets, activeAssetsList) => { - return assets.filter((asset: any) => activeAssetsList.has(asset.address)) + (assets, activeAssetsList): NFTAsset[] => { + return assets.filter(({ address }) => activeAssetsList.has(address)) }, ) -export const safeActiveSelectorMap = createSelector(activeNftAssetsListSelector, (activeAssets) => { - const assetsMap = {} - activeAssets.forEach((asset: any) => { - assetsMap[asset.address] = asset - }) - return assetsMap -}) +export const safeActiveSelectorMap = createSelector( + activeNftAssetsListSelector, + (activeAssets): NFTAssets => { + return activeAssets.reduce((acc, asset) => { + acc[asset.address] = asset + return acc + }, {}) + }, +) diff --git a/src/logic/contracts/methodIds.ts b/src/logic/contracts/methodIds.ts index 851c0273..bc5359bd 100644 --- a/src/logic/contracts/methodIds.ts +++ b/src/logic/contracts/methodIds.ts @@ -43,6 +43,8 @@ export const SAFE_METHODS_NAMES = { CHANGE_THRESHOLD: 'changeThreshold', REMOVE_OWNER: 'removeOwner', SWAP_OWNER: 'swapOwner', + ENABLE_MODULE: 'enableModule', + DISABLE_MODULE: 'disableModule', } const METHOD_TO_ID = { @@ -50,9 +52,11 @@ const METHOD_TO_ID = { '0x0d582f13': SAFE_METHODS_NAMES.ADD_OWNER_WITH_THRESHOLD, '0xf8dc5dd9': SAFE_METHODS_NAMES.REMOVE_OWNER, '0x694e80c3': SAFE_METHODS_NAMES.CHANGE_THRESHOLD, + '0x610b5925': SAFE_METHODS_NAMES.ENABLE_MODULE, + '0xe009cfde': SAFE_METHODS_NAMES.DISABLE_MODULE, } -type SafeMethods = typeof SAFE_METHODS_NAMES[keyof typeof SAFE_METHODS_NAMES] +export type SafeMethods = typeof SAFE_METHODS_NAMES[keyof typeof SAFE_METHODS_NAMES] type TokenMethods = 'transfer' | 'transferFrom' | 'safeTransferFrom' type DecodedValues = Array<{ @@ -127,6 +131,29 @@ export const decodeParamsFromSafeMethod = (data: string): DataDecoded | null => } } + // enableModule + case '0x610b5925': { + const decodedParameters = web3.eth.abi.decodeParameters(['address'], params) + return { + method: METHOD_TO_ID[methodId], + parameters: [ + { name: 'module', type: '', value: decodedParameters[0] }, + ], + } + } + + // disableModule + case '0xe009cfde': { + const decodedParameters = web3.eth.abi.decodeParameters(['address', 'address'], params) + return { + method: METHOD_TO_ID[methodId], + parameters: [ + { name: 'prevModule', type: '', value: decodedParameters[0] }, + { name: 'module', type: '', value: decodedParameters[1] }, + ], + } + } + default: return null } diff --git a/src/logic/contracts/safeContracts.ts b/src/logic/contracts/safeContracts.ts index b27e78d6..ded25a39 100644 --- a/src/logic/contracts/safeContracts.ts +++ b/src/logic/contracts/safeContracts.ts @@ -4,10 +4,11 @@ import GnosisSafeSol from '@gnosis.pm/safe-contracts/build/contracts/GnosisSafe. import SafeProxy from '@gnosis.pm/safe-contracts/build/contracts/GnosisSafeProxy.json' import { ensureOnce } from 'src/utils/singleton' import { simpleMemoize } from 'src/components/forms/validator' -import { getWeb3, getNetworkIdFrom } from 'src/logic/wallets/getWeb3' +import { getNetworkIdFrom, getWeb3 } from 'src/logic/wallets/getWeb3' import { calculateGasOf, calculateGasPrice } from 'src/logic/wallets/ethTransactions' import { ZERO_ADDRESS } from 'src/logic/wallets/ethAddresses' import { isProxyCode } from 'src/logic/contracts/historicProxyCode' +import Web3 from 'web3' export const SENTINEL_ADDRESS = '0x0000000000000000000000000000000000000001' export const MULTI_SEND_ADDRESS = '0xB522a9f781924eD250A11C54105E51840B138AdD' @@ -19,7 +20,7 @@ export const SAFE_MASTER_COPY_ADDRESS_V10 = '0xb6029EA3B2c51D09a50B53CA8012FeEB0 let proxyFactoryMaster let safeMaster -const createGnosisSafeContract = (web3) => { +const createGnosisSafeContract = (web3: Web3): any => { const gnosisSafe = contract(GnosisSafeSol) gnosisSafe.setProvider(web3.currentProvider) @@ -72,7 +73,7 @@ export const getSafeMasterContract = async () => { export const getSafeDeploymentTransaction = (safeAccounts, numConfirmations, userAccount) => { const gnosisSafeData = safeMaster.contract.methods .setup(safeAccounts, numConfirmations, ZERO_ADDRESS, '0x', DEFAULT_FALLBACK_HANDLER_ADDRESS, ZERO_ADDRESS, 0, ZERO_ADDRESS) - .encodeABI() + .encodeABI() return proxyFactoryMaster.methods.createProxy(safeMaster.address, gnosisSafeData) } @@ -94,11 +95,10 @@ export const estimateGasForDeployingSafe = async ( return gas * parseInt(gasPrice, 10) } -export const getGnosisSafeInstanceAt = simpleMemoize(async (safeAddress) => { +export const getGnosisSafeInstanceAt = simpleMemoize(async (safeAddress): Promise => { const web3 = getWeb3() const GnosisSafe = await getGnosisSafeContract(web3) - const gnosisSafe = await GnosisSafe.at(safeAddress) - return gnosisSafe + return GnosisSafe.at(safeAddress) }) const cleanByteCodeMetadata = (bytecode) => { diff --git a/src/logic/safe/utils/safeVersion.ts b/src/logic/safe/utils/safeVersion.ts index 03bf2fe5..53d60dae 100644 --- a/src/logic/safe/utils/safeVersion.ts +++ b/src/logic/safe/utils/safeVersion.ts @@ -10,7 +10,7 @@ export const FEATURES = [ { name: 'ERC1155', validVersion: '>=1.1.1' }, ] -export const safeNeedsUpdate = (currentVersion, latestVersion) => { +export const safeNeedsUpdate = (currentVersion: string, latestVersion: string): boolean => { if (!currentVersion || !latestVersion) { return false } @@ -21,9 +21,10 @@ export const safeNeedsUpdate = (currentVersion, latestVersion) => { return latest ? semverLessThan(current, latest) : false } -export const getCurrentSafeVersion = (gnosisSafeInstance) => gnosisSafeInstance.VERSION() +export const getCurrentSafeVersion = (gnosisSafeInstance: { VERSION: () => Promise }): Promise => + gnosisSafeInstance.VERSION() -export const enabledFeatures = (version) => +export const enabledFeatures = (version: string): Array => FEATURES.reduce((acc, feature) => { if (semverSatisfies(version, feature.validVersion)) { acc.push(feature.name) @@ -31,7 +32,16 @@ export const enabledFeatures = (version) => return acc }, []) -export const checkIfSafeNeedsUpdate = async (gnosisSafeInstance, lastSafeVersion) => { +interface SafeVersionInfo { + current: string + latest: string + needUpdate: boolean +} + +export const checkIfSafeNeedsUpdate = async ( + gnosisSafeInstance: { VERSION: () => Promise }, + lastSafeVersion: string, +): Promise => { if (!gnosisSafeInstance || !lastSafeVersion) { return null } @@ -43,7 +53,7 @@ export const checkIfSafeNeedsUpdate = async (gnosisSafeInstance, lastSafeVersion return { current, latest, needUpdate } } -export const getCurrentMasterContractLastVersion = async () => { +export const getCurrentMasterContractLastVersion = async (): Promise => { const safeMaster = await getSafeMasterContract() let safeMasterVersion try { @@ -56,7 +66,7 @@ export const getCurrentMasterContractLastVersion = async () => { return safeMasterVersion } -export const getSafeVersionInfo = async (safeAddress) => { +export const getSafeVersionInfo = async (safeAddress: string): Promise => { try { const safeMaster = await getGnosisSafeInstanceAt(safeAddress) const lastSafeVersion = await getCurrentMasterContractLastVersion() diff --git a/src/logic/safe/utils/upgradeSafe.ts b/src/logic/safe/utils/upgradeSafe.ts index c9224562..90b01ee7 100644 --- a/src/logic/safe/utils/upgradeSafe.ts +++ b/src/logic/safe/utils/upgradeSafe.ts @@ -15,7 +15,7 @@ export const upgradeSafeToLatestVersion = async (safeAddress, createTransaction) createTransaction({ safeAddress, to: MULTI_SEND_ADDRESS, - valueInWei: 0, + valueInWei: '0', txData: encodeMultiSendCallData, notifiedTransaction: 'STANDARD_TX', enqueueSnackbar: () => {}, diff --git a/src/logic/tokens/store/actions/fetchSafeTokens.ts b/src/logic/tokens/store/actions/fetchSafeTokens.ts index c4e08d09..ad4bfb05 100644 --- a/src/logic/tokens/store/actions/fetchSafeTokens.ts +++ b/src/logic/tokens/store/actions/fetchSafeTokens.ts @@ -25,7 +25,7 @@ const fetchSafeTokens = (safeAddress: string) => async ( ): Promise => { try { const state = getState() - const safe = state[SAFE_REDUCER_ID].getIn([SAFE_REDUCER_ID, safeAddress]) + const safe = state[SAFE_REDUCER_ID].getIn(['safes', safeAddress]) const currentTokens = state[TOKEN_REDUCER_ID] if (!safe) { diff --git a/src/logic/tokens/store/reducer/tokens.ts b/src/logic/tokens/store/reducer/tokens.ts index 50dc1def..39a8c4f1 100644 --- a/src/logic/tokens/store/reducer/tokens.ts +++ b/src/logic/tokens/store/reducer/tokens.ts @@ -3,24 +3,24 @@ import { handleActions } from 'redux-actions' import { ADD_TOKEN } from 'src/logic/tokens/store/actions/addToken' import { ADD_TOKENS } from 'src/logic/tokens/store/actions/saveTokens' -import { makeToken } from 'src/logic/tokens/store/model/token' +import { makeToken, Token } from 'src/logic/tokens/store/model/token' export const TOKEN_REDUCER_ID = 'tokens' +export type TokenState = Map + export default handleActions( { - [ADD_TOKENS]: (state, action) => { + [ADD_TOKENS]: (state: TokenState, action) => { const { tokens } = action.payload - const newState = state.withMutations((map) => { - tokens.forEach((token) => { + return state.withMutations((map) => { + tokens.forEach((token: Token) => { map.set(token.address, token) }) }) - - return newState }, - [ADD_TOKEN]: (state, action) => { + [ADD_TOKEN]: (state: TokenState, action) => { const { token } = action.payload const { address: tokenAddress } = token diff --git a/src/logic/tokens/store/selectors/index.ts b/src/logic/tokens/store/selectors/index.ts index 41d5edb1..2017bd88 100644 --- a/src/logic/tokens/store/selectors/index.ts +++ b/src/logic/tokens/store/selectors/index.ts @@ -1,8 +1,9 @@ import { createSelector } from 'reselect' -import { TOKEN_REDUCER_ID } from 'src/logic/tokens/store/reducer/tokens' +import { TOKEN_REDUCER_ID, TokenState } from 'src/logic/tokens/store/reducer/tokens' +import { AppReduxState } from 'src/store' -export const tokensSelector = (state) => state[TOKEN_REDUCER_ID] +export const tokensSelector = (state: AppReduxState): TokenState => state[TOKEN_REDUCER_ID] export const tokenListSelector = createSelector(tokensSelector, (tokens) => tokens.toList()) diff --git a/src/logic/tokens/utils/tokenHelpers.ts b/src/logic/tokens/utils/tokenHelpers.ts index 24ecffe4..b234cb32 100644 --- a/src/logic/tokens/utils/tokenHelpers.ts +++ b/src/logic/tokens/utils/tokenHelpers.ts @@ -15,7 +15,7 @@ import { Map } from 'immutable' export const ETH_ADDRESS = '0x000' export const SAFE_TRANSFER_FROM_WITHOUT_DATA_HASH = '42842e0e' -export const getEthAsToken = (balance: string): Token => { +export const getEthAsToken = (balance: string | number): Token => { return makeToken({ address: ETH_ADDRESS, name: 'Ether', diff --git a/src/logic/wallets/getWeb3.ts b/src/logic/wallets/getWeb3.ts index 9febbc74..56704214 100644 --- a/src/logic/wallets/getWeb3.ts +++ b/src/logic/wallets/getWeb3.ts @@ -9,15 +9,17 @@ import { provider as Provider } from 'web3-core' import { ProviderProps } from './store/model/provider' export const ETHEREUM_NETWORK = { - MAINNET: 'MAINNET', - MORDEN: 'MORDEN', - ROPSTEN: 'ROPSTEN', - RINKEBY: 'RINKEBY', - GOERLI: 'GOERLI', - KOVAN: 'KOVAN', - UNKNOWN: 'UNKNOWN', + MAINNET: 'MAINNET' as const, + MORDEN: 'MORDEN' as const, + ROPSTEN: 'ROPSTEN' as const, + RINKEBY: 'RINKEBY' as const, + GOERLI: 'GOERLI' as const, + KOVAN: 'KOVAN' as const, + UNKNOWN: 'UNKNOWN' as const, } +export type EthereumNetworks = typeof ETHEREUM_NETWORK[keyof typeof ETHEREUM_NETWORK] + export const WALLET_PROVIDER = { SAFE: 'SAFE', METAMASK: 'METAMASK', @@ -37,17 +39,11 @@ export const WALLET_PROVIDER = { } export const ETHEREUM_NETWORK_IDS = { - // $FlowFixMe 1: ETHEREUM_NETWORK.MAINNET, - // $FlowFixMe 2: ETHEREUM_NETWORK.MORDEN, - // $FlowFixMe 3: ETHEREUM_NETWORK.ROPSTEN, - // $FlowFixMe 4: ETHEREUM_NETWORK.RINKEBY, - // $FlowFixMe 5: ETHEREUM_NETWORK.GOERLI, - // $FlowFixMe 42: ETHEREUM_NETWORK.KOVAN, } diff --git a/src/logic/wallets/store/reducer/provider.ts b/src/logic/wallets/store/reducer/provider.ts index b1386419..0bba1d0f 100644 --- a/src/logic/wallets/store/reducer/provider.ts +++ b/src/logic/wallets/store/reducer/provider.ts @@ -2,14 +2,17 @@ import { handleActions } from 'redux-actions' import { ADD_PROVIDER } from 'src/logic/wallets/store/actions/addProvider' import { REMOVE_PROVIDER } from 'src/logic/wallets/store/actions/removeProvider' -import { makeProvider } from 'src/logic/wallets/store/model/provider' +import { makeProvider, ProviderRecord, ProviderProps } from 'src/logic/wallets/store/model/provider' export const PROVIDER_REDUCER_ID = 'providers' +export type ProviderState = ProviderRecord + export default handleActions( { - [ADD_PROVIDER]: (state, { payload }) => makeProvider(payload), - [REMOVE_PROVIDER]: () => makeProvider(), + [ADD_PROVIDER]: (state: ProviderState, { payload }: { payload: ProviderProps }): ProviderState => + makeProvider(payload), + [REMOVE_PROVIDER]: (): ProviderState => makeProvider(), }, makeProvider(), ) diff --git a/src/logic/wallets/store/selectors/index.ts b/src/logic/wallets/store/selectors/index.ts index 07f84076..ed147c38 100644 --- a/src/logic/wallets/store/selectors/index.ts +++ b/src/logic/wallets/store/selectors/index.ts @@ -1,28 +1,33 @@ import { createSelector } from 'reselect' -import { ETHEREUM_NETWORK, ETHEREUM_NETWORK_IDS } from 'src/logic/wallets/getWeb3' -import { PROVIDER_REDUCER_ID } from 'src/logic/wallets/store/reducer/provider' +import { ETHEREUM_NETWORK, ETHEREUM_NETWORK_IDS, EthereumNetworks } from 'src/logic/wallets/getWeb3' +import { PROVIDER_REDUCER_ID, ProviderState } from 'src/logic/wallets/store/reducer/provider' +import { AppReduxState } from 'src/store' -export const providerSelector = (state) => state[PROVIDER_REDUCER_ID] +export const providerSelector = (state: AppReduxState): ProviderState => state[PROVIDER_REDUCER_ID] -export const userAccountSelector = createSelector(providerSelector, (provider): string => { +export const userAccountSelector = createSelector(providerSelector, (provider: ProviderState): string => { const account = provider.get('account') - return account || '' }) -export const providerNameSelector = createSelector(providerSelector, (provider): string => { +export const providerNameSelector = createSelector(providerSelector, (provider: ProviderState): string | undefined => { const name = provider.get('name') - return name ? name.toLowerCase() : undefined }) -export const networkSelector = createSelector(providerSelector, (provider): string => { - const networkId = provider.get('network') +export const networkSelector = createSelector( + providerSelector, + (provider: ProviderState): EthereumNetworks => { + const networkId = provider.get('network') + return ETHEREUM_NETWORK_IDS[networkId] || ETHEREUM_NETWORK.UNKNOWN + }, +) - return ETHEREUM_NETWORK_IDS[networkId] || ETHEREUM_NETWORK.UNKNOWN -}) +export const loadedSelector = createSelector(providerSelector, (provider: ProviderState): boolean => + provider.get('loaded'), +) -export const loadedSelector = createSelector(providerSelector, (provider) => provider.get('loaded')) - -export const availableSelector = createSelector(providerSelector, (provider) => provider.get('available')) +export const availableSelector = createSelector(providerSelector, (provider: ProviderState): boolean => + provider.get('available'), +) diff --git a/src/logic/wallets/store/test/account.selector.ts b/src/logic/wallets/store/test/account.selector.ts index 65b5b966..e85f0fbb 100644 --- a/src/logic/wallets/store/test/account.selector.ts +++ b/src/logic/wallets/store/test/account.selector.ts @@ -1,13 +1,13 @@ -// import { PROVIDER_REDUCER_ID } from 'src/logic/wallets/store/reducer/provider' import { userAccountSelector } from '../selectors' import { ProviderFactory } from './builder/index.builder' +import { AppReduxState } from 'src/store' const providerReducerTests = () => { describe('Provider Name Selector[userAccountSelector]', () => { it('should return empty when no provider is loaded', () => { // GIVEN - const reduxStore = { [PROVIDER_REDUCER_ID]: ProviderFactory.noProvider } + const reduxStore = { [PROVIDER_REDUCER_ID]: ProviderFactory.noProvider } as AppReduxState // WHEN const providerName = userAccountSelector(reduxStore) @@ -18,7 +18,7 @@ const providerReducerTests = () => { it('should return empty when Metamask is loaded but not available', () => { // GIVEN - const reduxStore = { [PROVIDER_REDUCER_ID]: ProviderFactory.metamaskLoaded } + const reduxStore = { [PROVIDER_REDUCER_ID]: ProviderFactory.metamaskLoaded } as AppReduxState // WHEN const providerName = userAccountSelector(reduxStore) @@ -29,7 +29,7 @@ const providerReducerTests = () => { it('should return account when Metamask is loaded and available', () => { // GIVEN - const reduxStore = { [PROVIDER_REDUCER_ID]: ProviderFactory.metamaskAvailable } + const reduxStore = { [PROVIDER_REDUCER_ID]: ProviderFactory.metamaskAvailable } as AppReduxState // WHEN const providerName = userAccountSelector(reduxStore) diff --git a/src/logic/wallets/store/test/name.selector.ts b/src/logic/wallets/store/test/name.selector.ts index 26659f8d..2e812575 100644 --- a/src/logic/wallets/store/test/name.selector.ts +++ b/src/logic/wallets/store/test/name.selector.ts @@ -1,13 +1,13 @@ -// import { PROVIDER_REDUCER_ID } from 'src/logic/wallets/store/reducer/provider' import { providerNameSelector } from '../selectors' import { ProviderFactory } from './builder/index.builder' +import { AppReduxState } from 'src/store' const providerReducerTests = () => { describe('Provider Name Selector[providerNameSelector]', () => { it('should return undefined when no provider is loaded', () => { // GIVEN - const reduxStore = { [PROVIDER_REDUCER_ID]: ProviderFactory.noProvider } + const reduxStore = { [PROVIDER_REDUCER_ID]: ProviderFactory.noProvider } as AppReduxState // WHEN const providerName = providerNameSelector(reduxStore) @@ -18,7 +18,7 @@ const providerReducerTests = () => { it('should return metamask when Metamask is loaded but not available', () => { // GIVEN - const reduxStore = { [PROVIDER_REDUCER_ID]: ProviderFactory.metamaskLoaded } + const reduxStore = { [PROVIDER_REDUCER_ID]: ProviderFactory.metamaskLoaded } as AppReduxState // WHEN const providerName = providerNameSelector(reduxStore) @@ -29,7 +29,7 @@ const providerReducerTests = () => { it('should return METAMASK when Metamask is loaded and available', () => { // GIVEN - const reduxStore = { [PROVIDER_REDUCER_ID]: ProviderFactory.metamaskAvailable } + const reduxStore = { [PROVIDER_REDUCER_ID]: ProviderFactory.metamaskAvailable } as AppReduxState // WHEN const providerName = providerNameSelector(reduxStore) diff --git a/src/routes/safe/components/Apps/sendTransactions.ts b/src/routes/safe/components/Apps/sendTransactions.ts index 5bbe4781..f4bee5a5 100644 --- a/src/routes/safe/components/Apps/sendTransactions.ts +++ b/src/routes/safe/components/Apps/sendTransactions.ts @@ -37,7 +37,7 @@ const sendTransactions = (dispatch, safeAddress, txs, enqueueSnackbar, closeSnac createTransaction({ safeAddress, to: multiSendAddress, - valueInWei: 0, + valueInWei: '0', txData: encodeMultiSendCallData, notifiedTransaction: 'STANDARD_TX', enqueueSnackbar, diff --git a/src/routes/safe/components/Balances/Collectibles/index.tsx b/src/routes/safe/components/Balances/Collectibles/index.tsx index 40a3e27a..b3f94106 100644 --- a/src/routes/safe/components/Balances/Collectibles/index.tsx +++ b/src/routes/safe/components/Balances/Collectibles/index.tsx @@ -90,7 +90,7 @@ const Collectibles = () => { return (
- {activeAssetsList.size ? ( + {activeAssetsList.length ? ( activeAssetsList.map((nftAsset) => { return ( diff --git a/src/routes/safe/components/Balances/SendModal/screens/ContractInteraction/RenderInputParams/InputComponent/ArrayTypeInput.tsx b/src/routes/safe/components/Balances/SendModal/screens/ContractInteraction/RenderInputParams/InputComponent/ArrayTypeInput.tsx index a8e9a697..7180edcb 100644 --- a/src/routes/safe/components/Balances/SendModal/screens/ContractInteraction/RenderInputParams/InputComponent/ArrayTypeInput.tsx +++ b/src/routes/safe/components/Balances/SendModal/screens/ContractInteraction/RenderInputParams/InputComponent/ArrayTypeInput.tsx @@ -45,7 +45,7 @@ const typePlaceholder = (text: string, type: string): string => { return `${text} E.g.: ["first value", "second value", "third value"]` } -const ArrayTypeInput = ({ name, text, type }: { name: string; text: string; type: string }): JSX.Element => ( +const ArrayTypeInput = ({ name, text, type }: { name: string; text: string; type: string }): React.ReactElement => ( ) diff --git a/src/routes/safe/components/Balances/SendModal/screens/ContractInteraction/RenderInputParams/InputComponent/index.tsx b/src/routes/safe/components/Balances/SendModal/screens/ContractInteraction/RenderInputParams/InputComponent/index.tsx index 477bb3b9..bbad4ed0 100644 --- a/src/routes/safe/components/Balances/SendModal/screens/ContractInteraction/RenderInputParams/InputComponent/index.tsx +++ b/src/routes/safe/components/Balances/SendModal/screens/ContractInteraction/RenderInputParams/InputComponent/index.tsx @@ -15,7 +15,7 @@ type Props = { placeholder: string } -const InputComponent = ({ type, keyValue, placeholder }: Props): JSX.Element => { +const InputComponent = ({ type, keyValue, placeholder }: Props): React.ReactElement => { if (!type) { return null } diff --git a/src/routes/safe/components/Balances/SendModal/screens/ContractInteraction/RenderInputParams/index.tsx b/src/routes/safe/components/Balances/SendModal/screens/ContractInteraction/RenderInputParams/index.tsx index a441cdf7..70cc2d79 100644 --- a/src/routes/safe/components/Balances/SendModal/screens/ContractInteraction/RenderInputParams/index.tsx +++ b/src/routes/safe/components/Balances/SendModal/screens/ContractInteraction/RenderInputParams/index.tsx @@ -7,7 +7,7 @@ import InputComponent from './InputComponent' import { generateFormFieldKey } from '../utils' import { AbiItemExtended } from 'src/logic/contractInteraction/sources/ABIService' -const RenderInputParams = (): JSX.Element => { +const RenderInputParams = (): React.ReactElement => { const { meta: { valid: validABI }, } = useField('abi', { subscription: { valid: true, value: true } }) diff --git a/src/routes/safe/components/Balances/Tokens/screens/AssetsList/index.tsx b/src/routes/safe/components/Balances/Tokens/screens/AssetsList/index.tsx index 4a06d39c..59be5bff 100644 --- a/src/routes/safe/components/Balances/Tokens/screens/AssetsList/index.tsx +++ b/src/routes/safe/components/Balances/Tokens/screens/AssetsList/index.tsx @@ -130,12 +130,12 @@ const AssetsList = (props) => { - {!nftAssetsList.size && ( + {!nftAssetsList.length && ( )} - {nftAssetsList.size > 0 && ( + {nftAssetsList.length > 0 && ( { - const assetColumn = { +export const generateColumns = (): List => { + const assetColumn: TableColumn = { id: BALANCE_TABLE_ASSET_ID, order: true, disablePadding: false, @@ -62,7 +63,7 @@ export const generateColumns = () => { width: 250, } - const balanceColumn = { + const balanceColumn: TableColumn = { id: BALANCE_TABLE_BALANCE_ID, align: 'right', order: true, @@ -71,7 +72,7 @@ export const generateColumns = () => { custom: false, } - const actions = { + const actions: TableColumn = { id: 'actions', order: false, disablePadding: false, @@ -80,7 +81,7 @@ export const generateColumns = () => { static: true, } - const value = { + const value: TableColumn = { id: BALANCE_TABLE_VALUE_ID, order: false, label: 'Value', diff --git a/src/routes/safe/components/Settings/Advanced/ModulesTable.tsx b/src/routes/safe/components/Settings/Advanced/ModulesTable.tsx new file mode 100644 index 00000000..1dd23a9e --- /dev/null +++ b/src/routes/safe/components/Settings/Advanced/ModulesTable.tsx @@ -0,0 +1,125 @@ +import { Button, Text } from '@gnosis.pm/safe-react-components' +import { makeStyles } from '@material-ui/core/styles' +import TableContainer from '@material-ui/core/TableContainer' +import styled from 'styled-components' +import cn from 'classnames' +import React from 'react' +import { useSelector } from 'react-redux' + +import { generateColumns, ModuleAddressColumn, MODULES_TABLE_ADDRESS_ID } from './dataFetcher' +import RemoveModuleModal from './RemoveModuleModal' +import { styles } from './style' + +import { grantedSelector } from 'src/routes/safe/container/selector' +import { ModulePair } from 'src/routes/safe/store/models/safe' +import Table from 'src/components/Table' +import { TableCell, TableRow } from 'src/components/layout/Table' +import Block from 'src/components/layout/Block' +import Identicon from 'src/components/Identicon' +import Row from 'src/components/layout/Row' + +const REMOVE_MODULE_BTN_TEST_ID = 'remove-module-btn' +const MODULES_ROW_TEST_ID = 'owners-row' + +const useStyles = makeStyles(styles) + +const AddressText = styled(Text)` + margin-left: 12px; +` + +const TableActionButton = styled(Button)` + background-color: transparent; + + &:hover { + background-color: transparent; + } +` + +interface ModulesTableProps { + moduleData: ModuleAddressColumn | null +} + +const ModulesTable = ({ moduleData }: ModulesTableProps): React.ReactElement => { + const classes = useStyles() + + const columns = generateColumns() + const autoColumns = columns.filter(({ custom }) => !custom) + + const granted = useSelector(grantedSelector) + + const [viewRemoveModuleModal, setViewRemoveModuleModal] = React.useState(false) + const hideRemoveModuleModal = () => setViewRemoveModuleModal(false) + + const [selectedModule, setSelectedModule] = React.useState(null) + const triggerRemoveSelectedModule = (module: ModulePair): void => { + setSelectedModule(module) + setViewRemoveModuleModal(true) + } + + return ( + <> + + + {(sortedData) => + sortedData.map((row, index) => ( + = 3 && index === sortedData.size - 1 && classes.noBorderBottom)} + data-testid={MODULES_ROW_TEST_ID} + key={index} + tabIndex={-1} + > + {autoColumns.map((column, index) => { + const columnId = column.id + const rowElement = row[columnId] + + return ( + + + {columnId === MODULES_TABLE_ADDRESS_ID ? ( + + + {rowElement[0]} + + ) : ( + rowElement + )} + + + + {granted && ( + triggerRemoveSelectedModule(rowElement)} + data-testid={REMOVE_MODULE_BTN_TEST_ID} + > + {null} + + )} + + + + ) + })} + + )) + } +
+
+ {viewRemoveModuleModal && } + + ) +} + +export default ModulesTable diff --git a/src/routes/safe/components/Settings/Advanced/RemoveModuleModal.tsx b/src/routes/safe/components/Settings/Advanced/RemoveModuleModal.tsx new file mode 100644 index 00000000..a518b25d --- /dev/null +++ b/src/routes/safe/components/Settings/Advanced/RemoveModuleModal.tsx @@ -0,0 +1,144 @@ +import { Button } from '@gnosis.pm/safe-react-components' +import IconButton from '@material-ui/core/IconButton' +import { makeStyles } from '@material-ui/core/styles' +import Close from '@material-ui/icons/Close' +import OpenInNew from '@material-ui/icons/OpenInNew' +import cn from 'classnames' +import { useSnackbar } from 'notistack' +import React from 'react' +import { useDispatch, useSelector } from 'react-redux' +import styled from 'styled-components' + +import { styles } from './style' + +import { ModulePair } from 'src/routes/safe/store/models/safe' +import { safeParamAddressFromStateSelector } from 'src/routes/safe/store/selectors' +import { getGnosisSafeInstanceAt } from 'src/logic/contracts/safeContracts' +import createTransaction from 'src/routes/safe/store/actions/createTransaction' +import { TX_NOTIFICATION_TYPES } from 'src/logic/safe/transactions' +import Modal from 'src/components/Modal' +import Row from 'src/components/layout/Row' +import Paragraph from 'src/components/layout/Paragraph' +import Hairline from 'src/components/layout/Hairline' +import Block from 'src/components/layout/Block' +import Col from 'src/components/layout/Col' +import Identicon from 'src/components/Identicon' +import Link from 'src/components/layout/Link' +import { getEtherScanLink } from 'src/logic/wallets/getWeb3' +import { md, secondary } from 'src/theme/variables' + +const useStyles = makeStyles(styles) + +const FooterWrapper = styled.div` + display: flex; + justify-content: space-around; +` + +const openIconStyle = { + height: md, + color: secondary, +} + +interface RemoveModuleModal { + onClose: () => void + selectedModule: ModulePair +} + +const RemoveModuleModal = ({ onClose, selectedModule }: RemoveModuleModal): React.ReactElement => { + const classes = useStyles() + + const safeAddress = useSelector(safeParamAddressFromStateSelector) + + const { enqueueSnackbar, closeSnackbar } = useSnackbar() + const dispatch = useDispatch() + + const removeSelectedModule = async (): Promise => { + try { + const safeInstance = await getGnosisSafeInstanceAt(safeAddress) + const [module, prevModule] = selectedModule + const txData = safeInstance.contract.methods.disableModule(prevModule, module).encodeABI() + + dispatch( + createTransaction({ + safeAddress, + to: safeAddress, + valueInWei: '0', + txData, + notifiedTransaction: TX_NOTIFICATION_TYPES.SETTINGS_CHANGE_TX, + enqueueSnackbar, + closeSnackbar, + }), + ) + } catch (e) { + console.error(`failed to remove the module ${selectedModule}`, e.message) + } + } + + return ( + <> + + + + Remove Module + + + + + + + + + + + + + + + {selectedModule[0]} + + + + {selectedModule[0]} + + + + + + + + + + + + After removing this module, any feature or app that uses this module might no longer work. If this Safe + requires more then one signature, the module removal will have to be confirmed by other owners as well. + + + + + + + + + + + + + ) +} + +export default RemoveModuleModal diff --git a/src/routes/safe/components/Settings/Advanced/dataFetcher.ts b/src/routes/safe/components/Settings/Advanced/dataFetcher.ts new file mode 100644 index 00000000..853fc79c --- /dev/null +++ b/src/routes/safe/components/Settings/Advanced/dataFetcher.ts @@ -0,0 +1,35 @@ +import { List } from 'immutable' +import { TableColumn } from 'src/components/Table/types' +import { ModulePair } from 'src/routes/safe/store/models/safe' + +export const MODULES_TABLE_ADDRESS_ID = 'address' +export const MODULES_TABLE_ACTIONS_ID = 'actions' + +export type ModuleAddressColumn = { [MODULES_TABLE_ADDRESS_ID]: ModulePair }[] + +export const getModuleData = (modulesList: ModulePair[] | null): ModuleAddressColumn | undefined => { + return modulesList?.map((modules) => ({ + [MODULES_TABLE_ADDRESS_ID]: modules, + })) +} + +export const generateColumns = (): List => { + const addressColumn: TableColumn = { + align: 'left', + custom: false, + disablePadding: false, + id: MODULES_TABLE_ADDRESS_ID, + label: 'Address', + order: false, + } + + const actionsColumn: TableColumn = { + custom: true, + disablePadding: false, + id: MODULES_TABLE_ACTIONS_ID, + label: '', + order: false, + } + + return List([addressColumn, actionsColumn]) +} diff --git a/src/routes/safe/components/Settings/Advanced/index.tsx b/src/routes/safe/components/Settings/Advanced/index.tsx new file mode 100644 index 00000000..6fa1a4c6 --- /dev/null +++ b/src/routes/safe/components/Settings/Advanced/index.tsx @@ -0,0 +1,93 @@ +import { Loader, Text, theme, Title } from '@gnosis.pm/safe-react-components' +import { makeStyles } from '@material-ui/core/styles' +import React from 'react' +import { useSelector } from 'react-redux' +import styled from 'styled-components' + +import { getModuleData } from './dataFetcher' +import { styles } from './style' +import ModulesTable from './ModulesTable' + +import Block from 'src/components/layout/Block' +import { safeModulesSelector, safeNonceSelector } from 'src/routes/safe/store/selectors' + +const useStyles = makeStyles(styles) + +const InfoText = styled(Text)` + margin-top: 16px; +` + +const Bold = styled.strong` + color: ${theme.colors.text}; +` + +const NoModuleLegend = (): React.ReactElement => ( + + No modules enabled + +) + +const LoadingModules = (): React.ReactElement => { + const classes = useStyles() + + return ( + + + + ) +} + +const Advanced = (): React.ReactElement => { + const classes = useStyles() + + const nonce = useSelector(safeNonceSelector) + const modules = useSelector(safeModulesSelector) + const moduleData = getModuleData(modules) ?? null + + return ( + <> + {/* Nonce */} + + + Safe Nonce + + + For security reasons, transactions made with the Safe need to be executed in order. The nonce shows you which + transaction was executed most recently. You can find the nonce for a transaction in the transaction details. + + + Current Nonce: {nonce} + + + + {/* Modules */} + + + Safe Modules + + + Modules allow you to customize the access-control logic of your Safe. Modules are potentially risky, so make + sure to only use modules from trusted sources. Learn more about modules{' '} + + here + + . + + + {moduleData === null ? ( + + ) : moduleData?.length === 0 ? ( + + ) : ( + + )} + + + ) +} + +export default Advanced diff --git a/src/routes/safe/components/Settings/Advanced/style.ts b/src/routes/safe/components/Settings/Advanced/style.ts new file mode 100644 index 00000000..54f6243a --- /dev/null +++ b/src/routes/safe/components/Settings/Advanced/style.ts @@ -0,0 +1,112 @@ +import { createStyles } from '@material-ui/core' +import { background, border, error, fontColor, lg, md, secondaryText, sm, smallFontSize, xl } from 'src/theme/variables' + +export const styles = createStyles({ + title: { + padding: lg, + paddingBottom: 0, + }, + hide: { + '&:hover': { + backgroundColor: '#fff3e2', + }, + '&:hover $actions': { + visibility: 'initial', + }, + }, + actions: { + justifyContent: 'flex-end', + visibility: 'hidden', + minWidth: '100px', + }, + noBorderBottom: { + '& > td': { + borderBottom: 'none', + }, + }, + annotation: { + paddingLeft: lg, + }, + ownersText: { + color: secondaryText, + '& b': { + color: fontColor, + }, + }, + container: { + padding: lg, + }, + buttonRow: { + padding: lg, + position: 'absolute', + left: 0, + bottom: 0, + boxSizing: 'border-box', + width: '100%', + justifyContent: 'flex-end', + borderTop: `2px solid ${border}`, + }, + modifyBtn: { + height: xl, + fontSize: smallFontSize, + }, + removeModuleIcon: { + marginLeft: lg, + cursor: 'pointer', + }, + modalHeading: { + boxSizing: 'border-box', + justifyContent: 'space-between', + maxHeight: '75px', + padding: `${sm} ${lg}`, + }, + modalContainer: { + minHeight: '369px', + }, + modalManage: { + fontSize: lg, + }, + modalClose: { + height: '35px', + width: '35px', + }, + modalButtonRow: { + height: '84px', + justifyContent: 'center', + }, + modalButtonRemove: { + color: '#fff', + backgroundColor: error, + height: '42px', + }, + modalName: { + textOverflow: 'ellipsis', + overflow: 'hidden', + }, + modalUserName: { + whiteSpace: 'nowrap', + }, + modalOwner: { + backgroundColor: background, + padding: md, + alignItems: 'center', + }, + modalUser: { + justifyContent: 'left', + }, + modalDescription: { + padding: md, + }, + modalOpen: { + paddingLeft: sm, + width: 'auto', + '&:hover': { + cursor: 'pointer', + }, + }, + modal: { + height: 'auto', + maxWidth: 'calc(100% - 30px)', + overflow: 'hidden', + }, +}) diff --git a/src/routes/safe/components/Settings/ManageOwners/AddOwnerModal/index.tsx b/src/routes/safe/components/Settings/ManageOwners/AddOwnerModal/index.tsx index 0d6f477d..6e984338 100644 --- a/src/routes/safe/components/Settings/ManageOwners/AddOwnerModal/index.tsx +++ b/src/routes/safe/components/Settings/ManageOwners/AddOwnerModal/index.tsx @@ -34,7 +34,7 @@ export const sendAddOwner = async (values, safeAddress, ownersOld, enqueueSnackb createTransaction({ safeAddress, to: safeAddress, - valueInWei: 0, + valueInWei: '0', txData, notifiedTransaction: TX_NOTIFICATION_TYPES.SETTINGS_CHANGE_TX, enqueueSnackbar, diff --git a/src/routes/safe/components/Settings/ManageOwners/RemoveOwnerModal/index.tsx b/src/routes/safe/components/Settings/ManageOwners/RemoveOwnerModal/index.tsx index 3cbd926e..fed7cd23 100644 --- a/src/routes/safe/components/Settings/ManageOwners/RemoveOwnerModal/index.tsx +++ b/src/routes/safe/components/Settings/ManageOwners/RemoveOwnerModal/index.tsx @@ -53,7 +53,7 @@ export const sendRemoveOwner = async ( createTransaction({ safeAddress, to: safeAddress, - valueInWei: 0, + valueInWei: '0', txData, notifiedTransaction: TX_NOTIFICATION_TYPES.SETTINGS_CHANGE_TX, enqueueSnackbar, diff --git a/src/routes/safe/components/Settings/ManageOwners/ReplaceOwnerModal/index.tsx b/src/routes/safe/components/Settings/ManageOwners/ReplaceOwnerModal/index.tsx index ceb43cf8..8162efab 100644 --- a/src/routes/safe/components/Settings/ManageOwners/ReplaceOwnerModal/index.tsx +++ b/src/routes/safe/components/Settings/ManageOwners/ReplaceOwnerModal/index.tsx @@ -47,7 +47,7 @@ export const sendReplaceOwner = async ( createTransaction({ safeAddress, to: safeAddress, - valueInWei: 0, + valueInWei: '0', txData, notifiedTransaction: TX_NOTIFICATION_TYPES.SETTINGS_CHANGE_TX, enqueueSnackbar, diff --git a/src/routes/safe/components/Settings/ManageOwners/dataFetcher.ts b/src/routes/safe/components/Settings/ManageOwners/dataFetcher.ts index 25b2ff48..ea7f9702 100644 --- a/src/routes/safe/components/Settings/ManageOwners/dataFetcher.ts +++ b/src/routes/safe/components/Settings/ManageOwners/dataFetcher.ts @@ -1,4 +1,5 @@ import { List } from 'immutable' +import { TableColumn } from 'src/components/Table/types' export const OWNERS_TABLE_NAME_ID = 'name' export const OWNERS_TABLE_ADDRESS_ID = 'address' @@ -13,8 +14,8 @@ export const getOwnerData = (owners) => { return rows } -export const generateColumns = () => { - const nameColumn = { +export const generateColumns = (): List => { + const nameColumn: TableColumn = { id: OWNERS_TABLE_NAME_ID, order: false, disablePadding: false, @@ -24,7 +25,7 @@ export const generateColumns = () => { align: 'left', } - const addressColumn = { + const addressColumn: TableColumn = { id: OWNERS_TABLE_ADDRESS_ID, order: false, disablePadding: false, @@ -33,7 +34,7 @@ export const generateColumns = () => { align: 'left', } - const actionsColumn = { + const actionsColumn: TableColumn = { id: OWNERS_TABLE_ACTIONS_ID, order: false, disablePadding: false, diff --git a/src/routes/safe/components/Settings/ThresholdSettings/index.tsx b/src/routes/safe/components/Settings/ThresholdSettings/index.tsx index 72b0dfcb..d7fda083 100644 --- a/src/routes/safe/components/Settings/ThresholdSettings/index.tsx +++ b/src/routes/safe/components/Settings/ThresholdSettings/index.tsx @@ -43,7 +43,7 @@ const ThresholdSettings = ({ classes, closeSnackbar, enqueueSnackbar }) => { createTransaction({ safeAddress, to: safeAddress, - valueInWei: 0, + valueInWei: '0', txData, notifiedTransaction: TX_NOTIFICATION_TYPES.SETTINGS_CHANGE_TX, enqueueSnackbar, diff --git a/src/routes/safe/components/Settings/index.tsx b/src/routes/safe/components/Settings/index.tsx index 69cbfd67..ecd0d42a 100644 --- a/src/routes/safe/components/Settings/index.tsx +++ b/src/routes/safe/components/Settings/index.tsx @@ -1,17 +1,16 @@ +import { IconText } from '@gnosis.pm/safe-react-components' import Badge from '@material-ui/core/Badge' -import { withStyles } from '@material-ui/core/styles' +import { makeStyles } from '@material-ui/core/styles' import cn from 'classnames' import * as React from 'react' import { useState } from 'react' import { useSelector } from 'react-redux' +import Advanced from './Advanced' import ManageOwners from './ManageOwners' import { RemoveSafeModal } from './RemoveSafeModal' import SafeDetails from './SafeDetails' import ThresholdSettings from './ThresholdSettings' -import { OwnersIcon } from './assets/icons/OwnersIcon' -import { RequiredConfirmationsIcon } from './assets/icons/RequiredConfirmationsIcon' -import { SafeDetailsIcon } from './assets/icons/SafeDetailsIcon' import RemoveSafeIcon from './assets/icons/bin.svg' import { styles } from './style' @@ -25,9 +24,8 @@ import Paragraph from 'src/components/layout/Paragraph' import Row from 'src/components/layout/Row' import Span from 'src/components/layout/Span' import { getAddressBook } from 'src/logic/addressBook/store/selectors' -import { safeNeedsUpdate } from 'src/logic/safe/utils/safeVersion' import { grantedSelector } from 'src/routes/safe/container/selector' -import { safeOwnersSelector } from 'src/routes/safe/store/selectors' +import { safeNeedsUpdateSelector, safeOwnersSelector } from 'src/routes/safe/store/selectors' export const OWNERS_SETTINGS_TAB_TEST_ID = 'owner-settings-tab' @@ -36,10 +34,13 @@ const INITIAL_STATE = { menuOptionIndex: 1, } -const Settings = (props) => { +const useStyles = makeStyles(styles) + +const Settings: React.FC = () => { + const classes = useStyles() const [state, setState] = useState(INITIAL_STATE) const owners = useSelector(safeOwnersSelector) - const needsUpdate = useSelector(safeNeedsUpdate) + const needsUpdate = useSelector(safeNeedsUpdateSelector) const granted = useSelector(grantedSelector) const addressBook = useSelector(getAddressBook) @@ -56,7 +57,6 @@ const Settings = (props) => { } const { menuOptionIndex, showRemoveSafe } = state - const { classes } = props return !owners ? ( @@ -73,7 +73,6 @@ const Settings = (props) => { - { style={{ paddingRight: '10px' }} variant="dot" > - Safe details + @@ -90,16 +95,36 @@ const Settings = (props) => { onClick={handleChange(2)} testId={OWNERS_SETTINGS_TAB_TEST_ID} > - - Owners + {owners.size} - - Policies + + + + + @@ -109,6 +134,7 @@ const Settings = (props) => { {menuOptionIndex === 1 && } {menuOptionIndex === 2 && } {menuOptionIndex === 3 && } + {menuOptionIndex === 4 && } @@ -116,4 +142,4 @@ const Settings = (props) => { ) } -export default withStyles(styles as any)(Settings) +export default Settings diff --git a/src/routes/safe/components/Settings/style.ts b/src/routes/safe/components/Settings/style.ts index 7d6fb657..14019764 100644 --- a/src/routes/safe/components/Settings/style.ts +++ b/src/routes/safe/components/Settings/style.ts @@ -1,3 +1,5 @@ +import { createStyles } from '@material-ui/core' + import { background, bolderFont, @@ -11,7 +13,7 @@ import { xs, } from 'src/theme/variables' -export const styles = () => ({ +export const styles = createStyles({ root: { backgroundColor: 'white', borderRadius: sm, @@ -31,7 +33,7 @@ export const styles = () => ({ menuWrapper: { display: 'flex', flexDirection: 'row', - flexGrow: '0', + flexGrow: 0, maxWidth: '100%', [`@media (min-width: ${screenSm}px)`]: { @@ -43,7 +45,7 @@ export const styles = () => ({ borderBottom: `solid 2px ${border}`, display: 'flex', flexDirection: 'row', - flexGrow: '1', + flexGrow: 1, height: '100%', width: '100%', @@ -59,8 +61,8 @@ export const styles = () => ({ borderRight: `solid 1px ${border}`, boxSizing: 'border-box', cursor: 'pointer', - flexGrow: '1', - flexShrink: '1', + flexGrow: 1, + flexShrink: 1, fontSize: '13px', justifyContent: 'center', lineHeight: '1.2', @@ -113,7 +115,7 @@ export const styles = () => ({ }, }, container: { - flexGrow: '1', + flexGrow: 1, height: '100%', position: 'relative', }, diff --git a/src/routes/safe/components/Transactions/TxsTable/ExpandedTx/RejectTxModal/index.tsx b/src/routes/safe/components/Transactions/TxsTable/ExpandedTx/RejectTxModal/index.tsx index 92a52001..b63f00ab 100644 --- a/src/routes/safe/components/Transactions/TxsTable/ExpandedTx/RejectTxModal/index.tsx +++ b/src/routes/safe/components/Transactions/TxsTable/ExpandedTx/RejectTxModal/index.tsx @@ -53,7 +53,7 @@ const RejectTxModal = ({ classes, closeSnackbar, enqueueSnackbar, isOpen, onClos createTransaction({ safeAddress, to: safeAddress, - valueInWei: 0, + valueInWei: '0', notifiedTransaction: TX_NOTIFICATION_TYPES.CANCELLATION_TX, enqueueSnackbar, closeSnackbar, diff --git a/src/routes/safe/components/Transactions/TxsTable/ExpandedTx/TxDescription/index.tsx b/src/routes/safe/components/Transactions/TxsTable/ExpandedTx/TxDescription/index.tsx index a3548e12..eee870b5 100644 --- a/src/routes/safe/components/Transactions/TxsTable/ExpandedTx/TxDescription/index.tsx +++ b/src/routes/safe/components/Transactions/TxsTable/ExpandedTx/TxDescription/index.tsx @@ -10,7 +10,7 @@ import Bold from 'src/components/layout/Bold' import LinkWithRef from 'src/components/layout/Link' import Paragraph from 'src/components/layout/Paragraph' import { getNameFromAddressBook } from 'src/logic/addressBook/store/selectors' -import { SAFE_METHODS_NAMES } from 'src/logic/contracts/methodIds' +import { SAFE_METHODS_NAMES, SafeMethods } from 'src/logic/contracts/methodIds' import { shortVersionOf } from 'src/logic/wallets/ethAddresses' import OwnerAddressTableCell from 'src/routes/safe/components/Settings/ManageOwners/OwnerAddressTableCell' import { getTxAmount } from 'src/routes/safe/components/Transactions/TxsTable/columns' @@ -23,6 +23,8 @@ export const TRANSACTIONS_DESC_CHANGE_THRESHOLD_TEST_ID = 'tx-description-change export const TRANSACTIONS_DESC_SEND_TEST_ID = 'tx-description-send' export const TRANSACTIONS_DESC_CUSTOM_VALUE_TEST_ID = 'tx-description-custom-value' export const TRANSACTIONS_DESC_CUSTOM_DATA_TEST_ID = 'tx-description-custom-data' +export const TRANSACTIONS_DESC_ADD_MODULE_TEST_ID = 'tx-description-add-module' +export const TRANSACTIONS_DESC_REMOVE_MODULE_TEST_ID = 'tx-description-remove-module' export const TRANSACTIONS_DESC_NO_DATA = 'tx-description-no-data' export const styles = () => ({ @@ -43,7 +45,12 @@ export const styles = () => ({ }, }) -const TransferDescription = ({ amount = '', recipient }) => { +interface TransferDescriptionProps { + amount: string + recipient: string +} + +const TransferDescription = ({ amount = '', recipient }: TransferDescriptionProps): React.ReactElement => { const recipientName = useSelector((state) => getNameFromAddressBook(state, recipient)) return ( @@ -57,7 +64,11 @@ const TransferDescription = ({ amount = '', recipient }) => { ) } -const RemovedOwner = ({ removedOwner }) => { +interface RemovedOwnerProps { + removedOwner: string +} + +const RemovedOwner = ({ removedOwner }: RemovedOwnerProps): React.ReactElement => { const ownerChangedName = useSelector((state) => getNameFromAddressBook(state, removedOwner)) return ( @@ -72,7 +83,11 @@ const RemovedOwner = ({ removedOwner }) => { ) } -const AddedOwner = ({ addedOwner }) => { +interface AddedOwnerProps { + addedOwner: string +} + +const AddedOwner = ({ addedOwner }: AddedOwnerProps): React.ReactElement => { const ownerChangedName = useSelector((state) => getNameFromAddressBook(state, addedOwner)) return ( @@ -87,7 +102,11 @@ const AddedOwner = ({ addedOwner }) => { ) } -const NewThreshold = ({ newThreshold }) => ( +interface NewThresholdProps { + newThreshold: string +} + +const NewThreshold = ({ newThreshold }: NewThresholdProps): React.ReactElement => ( Change required confirmations: @@ -96,7 +115,43 @@ const NewThreshold = ({ newThreshold }) => ( ) -const SettingsDescription = ({ action, addedOwner, newThreshold, removedOwner }) => { +interface AddModuleProps { + module: string +} + +const AddModule = ({ module }: AddModuleProps): React.ReactElement => ( + + Add module: + + +) + +interface RemoveModuleProps { + module: string +} + +const RemoveModule = ({ module }: RemoveModuleProps): React.ReactElement => ( + + Remove module: + + +) + +interface SettingsDescriptionProps { + action: SafeMethods + addedOwner?: string + newThreshold?: string + removedOwner?: string + module?: string +} + +const SettingsDescription = ({ + action, + addedOwner, + newThreshold, + removedOwner, + module, +}: SettingsDescriptionProps): React.ReactElement => { if (action === SAFE_METHODS_NAMES.REMOVE_OWNER && removedOwner && newThreshold) { return ( <> @@ -128,6 +183,14 @@ const SettingsDescription = ({ action, addedOwner, newThreshold, removedOwner }) ) } + if (action === SAFE_METHODS_NAMES.ENABLE_MODULE && module) { + return + } + + if (action === SAFE_METHODS_NAMES.DISABLE_MODULE && module) { + return + } + return ( No data available for current transaction @@ -207,6 +270,7 @@ const TxDescription = ({ classes, tx }) => { customTx, data, modifySettingsTx, + module, newThreshold, recipient, removedOwner, @@ -221,6 +285,7 @@ const TxDescription = ({ classes, tx }) => { addedOwner={addedOwner} newThreshold={newThreshold} removedOwner={removedOwner} + module={module} /> )} {!upgradeTx && customTx && ( diff --git a/src/routes/safe/components/Transactions/TxsTable/ExpandedTx/TxDescription/utils.ts b/src/routes/safe/components/Transactions/TxsTable/ExpandedTx/TxDescription/utils.ts index 4e2bfd0a..64f02975 100644 --- a/src/routes/safe/components/Transactions/TxsTable/ExpandedTx/TxDescription/utils.ts +++ b/src/routes/safe/components/Transactions/TxsTable/ExpandedTx/TxDescription/utils.ts @@ -47,6 +47,14 @@ export const getTxData = (tx) => { txData.action = SAFE_METHODS_NAMES.SWAP_OWNER txData.removedOwner = oldOwner txData.addedOwner = newOwner + } else if (tx.decodedParams[SAFE_METHODS_NAMES.ENABLE_MODULE]) { + const { module } = tx.decodedParams[SAFE_METHODS_NAMES.ENABLE_MODULE] + txData.action = SAFE_METHODS_NAMES.ENABLE_MODULE + txData.module = module + } else if (tx.decodedParams[SAFE_METHODS_NAMES.DISABLE_MODULE]) { + const { module } = tx.decodedParams[SAFE_METHODS_NAMES.DISABLE_MODULE] + txData.action = SAFE_METHODS_NAMES.DISABLE_MODULE + txData.module = module } } else if (tx.multiSendTx) { txData.recipient = tx.recipient diff --git a/src/routes/safe/container/hooks/useFetchTokens.tsx b/src/routes/safe/container/hooks/useFetchTokens.tsx index 538ae235..c471d0ea 100644 --- a/src/routes/safe/container/hooks/useFetchTokens.tsx +++ b/src/routes/safe/container/hooks/useFetchTokens.tsx @@ -8,9 +8,10 @@ import activateAssetsByBalance from 'src/logic/tokens/store/actions/activateAsse import fetchSafeTokens from 'src/logic/tokens/store/actions/fetchSafeTokens' import { fetchTokens } from 'src/logic/tokens/store/actions/fetchTokens' import { COINS_LOCATION_REGEX, COLLECTIBLES_LOCATION_REGEX } from 'src/routes/safe/components/Balances' +import { Dispatch } from 'src/routes/safe/store/actions/types' export const useFetchTokens = (safeAddress: string): void => { - const dispatch = useDispatch() + const dispatch = useDispatch() const location = useLocation() useMemo(() => { diff --git a/src/routes/safe/container/hooks/useLoadSafe.tsx b/src/routes/safe/container/hooks/useLoadSafe.tsx index ae704154..2a365098 100644 --- a/src/routes/safe/container/hooks/useLoadSafe.tsx +++ b/src/routes/safe/container/hooks/useLoadSafe.tsx @@ -7,10 +7,11 @@ import fetchSafeTokens from 'src/logic/tokens/store/actions/fetchSafeTokens' import fetchLatestMasterContractVersion from 'src/routes/safe/store/actions/fetchLatestMasterContractVersion' import fetchSafe from 'src/routes/safe/store/actions/fetchSafe' import fetchTransactions from 'src/routes/safe/store/actions/transactions/fetchTransactions' -import fetchSafeCreationTx from '../../store/actions/fetchSafeCreationTx' +import fetchSafeCreationTx from 'src/routes/safe/store/actions/fetchSafeCreationTx' +import { Dispatch } from 'src/routes/safe/store/actions/types' -export const useLoadSafe = (safeAddress) => { - const dispatch = useDispatch() +export const useLoadSafe = (safeAddress: string): void => { + const dispatch = useDispatch() useEffect(() => { const fetchData = () => { @@ -28,6 +29,7 @@ export const useLoadSafe = (safeAddress) => { }) } } + fetchData() }, [dispatch, safeAddress]) } diff --git a/src/routes/safe/container/index.tsx b/src/routes/safe/container/index.tsx index 8bb4ef28..e0953e69 100644 --- a/src/routes/safe/container/index.tsx +++ b/src/routes/safe/container/index.tsx @@ -17,7 +17,7 @@ const INITIAL_STATE = { showReceive: false, } -const SafeView = (): JSX.Element => { +const SafeView = (): React.ReactElement => { const [state, setState] = useState(INITIAL_STATE) const safeAddress = useSelector(safeParamAddressFromStateSelector) diff --git a/src/routes/safe/container/selector.ts b/src/routes/safe/container/selector.ts index 9c9fb0b4..1abb8816 100644 --- a/src/routes/safe/container/selector.ts +++ b/src/routes/safe/container/selector.ts @@ -1,4 +1,4 @@ -import { Map } from 'immutable' +import { List, Map } from 'immutable' import { createSelector } from 'reselect' import { tokensSelector } from 'src/logic/tokens/store/selectors' @@ -7,12 +7,13 @@ import { isUserOwner } from 'src/logic/wallets/ethAddresses' import { userAccountSelector } from 'src/logic/wallets/store/selectors' import { safeActiveTokensSelector, safeBalancesSelector, safeSelector } from 'src/routes/safe/store/selectors' +import { Token } from 'src/logic/tokens/store/model/token' export const grantedSelector = createSelector(userAccountSelector, safeSelector, (userAccount, safe) => isUserOwner(safe, userAccount), ) -const safeEthAsTokenSelector = createSelector(safeSelector, (safe) => { +const safeEthAsTokenSelector = createSelector(safeSelector, (safe): Token | undefined => { if (!safe) { return undefined } @@ -25,8 +26,8 @@ export const extendedSafeTokensSelector = createSelector( safeBalancesSelector, tokensSelector, safeEthAsTokenSelector, - (safeTokens, balances, tokensList, ethAsToken) => { - const extendedTokens = Map().withMutations((map) => { + (safeTokens, balances, tokensList, ethAsToken): List => { + const extendedTokens = Map().withMutations((map) => { safeTokens.forEach((tokenAddress) => { const baseToken = tokensList.get(tokenAddress) const tokenBalance = balances.get(tokenAddress) diff --git a/src/routes/safe/store/actions/addSafeModules.ts b/src/routes/safe/store/actions/addSafeModules.ts new file mode 100644 index 00000000..6c36075c --- /dev/null +++ b/src/routes/safe/store/actions/addSafeModules.ts @@ -0,0 +1,7 @@ +import { createAction } from 'redux-actions' + +export const ADD_SAFE_MODULES = 'ADD_SAFE_MODULES' + +const addSafeModules = createAction(ADD_SAFE_MODULES) + +export default addSafeModules diff --git a/src/routes/safe/store/actions/createTransaction.ts b/src/routes/safe/store/actions/createTransaction.ts index 2dbfd554..d06bbe51 100644 --- a/src/routes/safe/store/actions/createTransaction.ts +++ b/src/routes/safe/store/actions/createTransaction.ts @@ -1,7 +1,9 @@ import { push } from 'connected-react-router' import { List, Map } from 'immutable' +import { WithSnackbarProps } from 'notistack' import { batch } from 'react-redux' import semverSatisfies from 'semver/functions/satisfies' +import { ThunkAction } from 'redux-thunk' import { onboardUser } from 'src/components/ConnectButton' import { getGnosisSafeInstanceAt } from 'src/logic/contracts/safeContracts' @@ -34,9 +36,17 @@ import { getErrorMessage } from 'src/test/utils/ethereumErrors' import { makeConfirmation } from '../models/confirmation' import fetchTransactions from './transactions/fetchTransactions' import { safeTransactionsSelector } from 'src/routes/safe/store/selectors' -import { TransactionStatus, TxArgs } from 'src/routes/safe/store/models/types/transaction' +import { Transaction, TransactionStatus, TxArgs } from 'src/routes/safe/store/models/types/transaction' +import { AnyAction } from 'redux' +import { AppReduxState } from 'src/store' +import { Dispatch } from './types' -export const removeTxFromStore = (tx, safeAddress, dispatch, state) => { +export const removeTxFromStore = ( + tx: Transaction, + safeAddress: string, + dispatch: Dispatch, + state: AppReduxState, +): void => { if (tx.isCancellationTx) { const newTxStatus = TransactionStatus.AWAITING_YOUR_CONFIRMATION const transactions = safeTransactionsSelector(state) @@ -53,7 +63,12 @@ export const removeTxFromStore = (tx, safeAddress, dispatch, state) => { } } -export const storeTx = async (tx, safeAddress, dispatch, state) => { +export const storeTx = async ( + tx: Transaction, + safeAddress: string, + dispatch: Dispatch, + state: AppReduxState, +): Promise => { if (tx.isCancellationTx) { let newTxStatus: TransactionStatus = TransactionStatus.AWAITING_YOUR_CONFIRMATION @@ -79,6 +94,20 @@ export const storeTx = async (tx, safeAddress, dispatch, state) => { } } +interface CreateTransaction extends WithSnackbarProps { + navigateToTransactionsTab?: boolean + notifiedTransaction: string + operation?: number + origin?: string + safeAddress: string + to: string + txData?: string + txNonce?: number | string + valueInWei: string +} + +type CreateTransactionAction = ThunkAction, AppReduxState, undefined, AnyAction> + const createTransaction = ({ safeAddress, to, @@ -91,7 +120,10 @@ const createTransaction = ({ operation = CALL, navigateToTransactionsTab = true, origin = null, -}) => async (dispatch, getState) => { +}: CreateTransaction): CreateTransactionAction => async ( + dispatch: Dispatch, + getState: () => AppReduxState, +): Promise => { const state = getState() if (navigateToTransactionsTab) { @@ -241,7 +273,6 @@ const createTransaction = ({ dispatch, state, ) - dispatch(fetchTransactions(safeAddress)) return receipt.transactionHash diff --git a/src/routes/safe/store/actions/fetchEtherBalance.ts b/src/routes/safe/store/actions/fetchEtherBalance.ts index 0b0e421e..9a15f616 100644 --- a/src/routes/safe/store/actions/fetchEtherBalance.ts +++ b/src/routes/safe/store/actions/fetchEtherBalance.ts @@ -11,7 +11,7 @@ const fetchEtherBalance = (safeAddress: string) => async ( ): Promise => { try { const state = getState() - const ethBalance = state[SAFE_REDUCER_ID].getIn([SAFE_REDUCER_ID, safeAddress, 'ethBalance']) + const ethBalance = state[SAFE_REDUCER_ID].getIn(['safes', safeAddress, 'ethBalance']) const newEthBalance = await backOff(() => getBalanceInEtherOf(safeAddress)) if (newEthBalance !== ethBalance) { dispatch(updateSafe({ address: safeAddress, ethBalance: newEthBalance })) diff --git a/src/routes/safe/store/actions/fetchSafe.ts b/src/routes/safe/store/actions/fetchSafe.ts index 11297b44..b73f5b73 100644 --- a/src/routes/safe/store/actions/fetchSafe.ts +++ b/src/routes/safe/store/actions/fetchSafe.ts @@ -13,8 +13,10 @@ import updateSafe from 'src/routes/safe/store/actions/updateSafe' import { makeOwner } from 'src/routes/safe/store/models/owner' import { checksumAddress } from 'src/utils/checksumAddress' -import { SafeOwner } from '../models/safe' +import { ModulePair, SafeOwner } from 'src/routes/safe/store/models/safe' import { Dispatch } from 'redux' +import addSafeModules from './addSafeModules' +import { SENTINEL_ADDRESS } from 'src/logic/contracts/safeContracts' const buildOwnersFrom = ( safeOwners, @@ -37,6 +39,16 @@ const buildOwnersFrom = ( }) }) +const buildModulesLinkedList = (modules: string[], nextModule: string): Array | null => { + if (modules.length) { + return modules.map((moduleAddress, index, modules) => { + const prevModule = modules[index + 1] + return [moduleAddress, prevModule !== undefined ? prevModule : nextModule] + }) + } + return null +} + export const buildSafe = async (safeAdd, safeName, latestMasterContractVersion?: any) => { const safeAddress = checksumAddress(safeAdd) @@ -75,8 +87,14 @@ export const buildSafe = async (safeAdd, safeName, latestMasterContractVersion?: export const checkAndUpdateSafe = (safeAdd: string) => async (dispatch: Dispatch): Promise => { const safeAddress = checksumAddress(safeAdd) // Check if the owner's safe did change and update them - const safeParams = ['getThreshold', 'nonce', 'getOwners'] - const [[remoteThreshold, remoteNonce, remoteOwners], localSafe] = await Promise.all([ + const safeParams = [ + 'getThreshold', + 'nonce', + 'getOwners', + // TODO: 100 is an arbitrary large number, to avoid the need for pagination. But pagination must be properly handled + { method: 'getModulesPaginated', args: [SENTINEL_ADDRESS, 100] }, + ] + const [[remoteThreshold, remoteNonce, remoteOwners, modules], localSafe] = await Promise.all([ generateBatchRequests({ abi: GnosisSafeSol.abi, address: safeAddress, @@ -90,6 +108,13 @@ export const checkAndUpdateSafe = (safeAdd: string) => async (dispatch: Dispatch const localThreshold = localSafe ? localSafe.threshold : undefined const localNonce = localSafe ? localSafe.nonce : undefined + dispatch( + addSafeModules({ + safeAddress, + modulesAddresses: buildModulesLinkedList(modules.array, modules.next), + }), + ) + if (localNonce !== Number(remoteNonce)) { dispatch(updateSafe({ address: safeAddress, nonce: Number(remoteNonce) })) } diff --git a/src/routes/safe/store/actions/transactions/fetchTransactions/index.ts b/src/routes/safe/store/actions/transactions/fetchTransactions/index.ts index c927a4a0..10af95e6 100644 --- a/src/routes/safe/store/actions/transactions/fetchTransactions/index.ts +++ b/src/routes/safe/store/actions/transactions/fetchTransactions/index.ts @@ -1,4 +1,7 @@ import { batch } from 'react-redux' +import { ThunkAction, ThunkDispatch } from 'redux-thunk' +import { AnyAction } from 'redux' +import { backOff } from 'exponential-backoff' import { addIncomingTransactions } from '../../addIncomingTransactions' @@ -7,12 +10,13 @@ import { loadOutgoingTransactions } from './loadOutgoingTransactions' import { addOrUpdateCancellationTransactions } from 'src/routes/safe/store/actions/transactions/addOrUpdateCancellationTransactions' import { addOrUpdateTransactions } from 'src/routes/safe/store/actions/transactions/addOrUpdateTransactions' -import { Dispatch } from 'redux' -import { backOff } from 'exponential-backoff' +import { AppReduxState } from 'src/store' const noFunc = () => {} -export default (safeAddress: string) => async (dispatch: Dispatch): Promise => { +export default (safeAddress: string): ThunkAction, AppReduxState, undefined, AnyAction> => async ( + dispatch: ThunkDispatch, +): Promise => { try { const transactions = await backOff(() => loadOutgoingTransactions(safeAddress)) diff --git a/src/routes/safe/store/actions/transactions/fetchTransactions/loadOutgoingTransactions.ts b/src/routes/safe/store/actions/transactions/fetchTransactions/loadOutgoingTransactions.ts index b88911bc..01124891 100644 --- a/src/routes/safe/store/actions/transactions/fetchTransactions/loadOutgoingTransactions.ts +++ b/src/routes/safe/store/actions/transactions/fetchTransactions/loadOutgoingTransactions.ts @@ -196,7 +196,7 @@ export const loadOutgoingTransactions = async (safeAddress: string): Promise = state[TOKEN_REDUCER_ID] - const safe: SafeRecord = state[SAFE_REDUCER_ID].getIn([SAFE_REDUCER_ID, safeAddress]) + const safe: SafeRecord = state[SAFE_REDUCER_ID].getIn(['safes', safeAddress]) const cancellationTxs = state[CANCELLATION_TRANSACTIONS_REDUCER_ID].get(safeAddress) || Map() const outgoingTxs = state[TRANSACTIONS_REDUCER_ID].get(safeAddress) || List() diff --git a/src/routes/safe/store/actions/types.d.ts b/src/routes/safe/store/actions/types.d.ts new file mode 100644 index 00000000..0006a041 --- /dev/null +++ b/src/routes/safe/store/actions/types.d.ts @@ -0,0 +1,6 @@ +import { ThunkDispatch } from 'redux-thunk' +import { AnyAction } from 'redux' + +import { AppReduxState } from 'src/store' + +export type Dispatch = ThunkDispatch diff --git a/src/routes/safe/store/middleware/notificationsMiddleware.ts b/src/routes/safe/store/middleware/notificationsMiddleware.ts index 48b752ae..081fb19f 100644 --- a/src/routes/safe/store/middleware/notificationsMiddleware.ts +++ b/src/routes/safe/store/middleware/notificationsMiddleware.ts @@ -105,7 +105,7 @@ const notificationsMiddleware = (store) => (next) => async (action) => { action.payload.forEach((incomingTransactions, safeAddress) => { const { latestIncomingTxBlock } = state.safes.get('safes').get(safeAddress) const viewedSafes = state.currentSession ? state.currentSession.get('viewedSafes') : [] - const recurringUser = viewedSafes.includes(safeAddress) + const recurringUser = viewedSafes?.includes(safeAddress) const newIncomingTransactions = incomingTransactions.filter((tx) => tx.blockNumber > latestIncomingTxBlock) diff --git a/src/routes/safe/store/models/safe.ts b/src/routes/safe/store/models/safe.ts index 50b7b10f..5ae80e6f 100644 --- a/src/routes/safe/store/models/safe.ts +++ b/src/routes/safe/store/models/safe.ts @@ -5,12 +5,15 @@ export type SafeOwner = { address: string } +export type ModulePair = [string, string] + export type SafeRecordProps = { name: string address: string threshold: number ethBalance: string owners: List<{ name: string; address: string }> + modules: ModulePair[] | null activeTokens: Set activeAssets: Set blacklistedTokens: Set @@ -30,6 +33,7 @@ const makeSafe = Record({ threshold: 0, ethBalance: '0', owners: List([]), + modules: [], activeTokens: Set(), activeAssets: Set(), blacklistedTokens: Set(), diff --git a/src/routes/safe/store/reducer/safe.ts b/src/routes/safe/store/reducer/safe.ts index 4bdeac13..63226e77 100644 --- a/src/routes/safe/store/reducer/safe.ts +++ b/src/routes/safe/store/reducer/safe.ts @@ -15,6 +15,7 @@ import { makeOwner } from 'src/routes/safe/store/models/owner' import makeSafe from 'src/routes/safe/store/models/safe' import { checksumAddress } from 'src/utils/checksumAddress' import { SafeReducerMap } from './types/safe' +import { ADD_SAFE_MODULES } from 'src/routes/safe/store/actions/addSafeModules' export const SAFE_REDUCER_ID = 'safes' export const DEFAULT_SAFE_INITIAL_STATE = 'NOT_ASKED' @@ -49,7 +50,7 @@ export default handleActions( const safeAddress = safe.address return state.updateIn( - [SAFE_REDUCER_ID, safeAddress], + ['safes', safeAddress], makeSafe({ name: 'LOADED SAFE', address: safeAddress }), (prevSafe) => prevSafe.merge(safe), ) @@ -59,13 +60,13 @@ export default handleActions( return state.withMutations((map) => { map - .get(SAFE_REDUCER_ID) + .get('safes') .keySeq() .forEach((safeAddress) => { - const safeActiveTokens = map.getIn([SAFE_REDUCER_ID, safeAddress, 'activeTokens']) + const safeActiveTokens = map.getIn(['safes', safeAddress, 'activeTokens']) const activeTokens = safeActiveTokens.add(tokenAddress) - map.updateIn([SAFE_REDUCER_ID, safeAddress], (prevSafe) => prevSafe.merge({ activeTokens })) + map.updateIn(['safes', safeAddress], (prevSafe) => prevSafe.merge({ activeTokens })) }) }) }, @@ -76,21 +77,21 @@ export default handleActions( // in case of update it shouldn't, because a record would be initialized // with initial props and it would overwrite existing ones - if (state.hasIn([SAFE_REDUCER_ID, safe.address])) { - return state.updateIn([SAFE_REDUCER_ID, safe.address], (prevSafe) => prevSafe.merge(safe)) + if (state.hasIn(['safes', safe.address])) { + return state.updateIn(['safes', safe.address], (prevSafe) => prevSafe.merge(safe)) } - return state.setIn([SAFE_REDUCER_ID, safe.address], makeSafe(safe)) + return state.setIn(['safes', safe.address], makeSafe(safe)) }, [REMOVE_SAFE]: (state: SafeReducerMap, action) => { const safeAddress = action.payload - return state.deleteIn([SAFE_REDUCER_ID, safeAddress]) + return state.deleteIn(['safes', safeAddress]) }, [ADD_SAFE_OWNER]: (state: SafeReducerMap, action) => { const { ownerAddress, ownerName, safeAddress } = action.payload - return state.updateIn([SAFE_REDUCER_ID, safeAddress], (prevSafe) => + return state.updateIn(['safes', safeAddress], (prevSafe) => prevSafe.merge({ owners: prevSafe.owners.push(makeOwner({ address: ownerAddress, name: ownerName })), }), @@ -99,7 +100,7 @@ export default handleActions( [REMOVE_SAFE_OWNER]: (state: SafeReducerMap, action) => { const { ownerAddress, safeAddress } = action.payload - return state.updateIn([SAFE_REDUCER_ID, safeAddress], (prevSafe) => + return state.updateIn(['safes', safeAddress], (prevSafe) => prevSafe.merge({ owners: prevSafe.owners.filter((o) => o.address.toLowerCase() !== ownerAddress.toLowerCase()), }), @@ -108,7 +109,7 @@ export default handleActions( [REPLACE_SAFE_OWNER]: (state: SafeReducerMap, action) => { const { oldOwnerAddress, ownerAddress, ownerName, safeAddress } = action.payload - return state.updateIn([SAFE_REDUCER_ID, safeAddress], (prevSafe) => + return state.updateIn(['safes', safeAddress], (prevSafe) => prevSafe.merge({ owners: prevSafe.owners .filter((o) => o.address.toLowerCase() !== oldOwnerAddress.toLowerCase()) @@ -119,7 +120,7 @@ export default handleActions( [EDIT_SAFE_OWNER]: (state: SafeReducerMap, action) => { const { ownerAddress, ownerName, safeAddress } = action.payload - return state.updateIn([SAFE_REDUCER_ID, safeAddress], (prevSafe) => { + return state.updateIn(['safes', safeAddress], (prevSafe) => { const ownerToUpdateIndex = prevSafe.owners.findIndex( (o) => o.address.toLowerCase() === ownerAddress.toLowerCase(), ) @@ -127,8 +128,13 @@ export default handleActions( return prevSafe.merge({ owners: updatedOwners }) }) }, - [SET_DEFAULT_SAFE]: (state, action) => state.set('defaultSafe', action.payload), - [SET_LATEST_MASTER_CONTRACT_VERSION]: (state, action) => state.set('latestMasterContractVersion', action.payload), + [ADD_SAFE_MODULES]: (state: SafeReducerMap, action) => { + const { modulesAddresses, safeAddress } = action.payload + return state.setIn(['safes', safeAddress, 'modules'], modulesAddresses) + }, + [SET_DEFAULT_SAFE]: (state: SafeReducerMap, action) => state.set('defaultSafe', action.payload), + [SET_LATEST_MASTER_CONTRACT_VERSION]: (state: SafeReducerMap, action) => + state.set('latestMasterContractVersion', action.payload), }, Map({ defaultSafe: DEFAULT_SAFE_INITIAL_STATE, diff --git a/src/routes/safe/store/selectors/index.ts b/src/routes/safe/store/selectors/index.ts index 8d63988e..93e5c5fa 100644 --- a/src/routes/safe/store/selectors/index.ts +++ b/src/routes/safe/store/selectors/index.ts @@ -10,11 +10,11 @@ import { TRANSACTIONS_REDUCER_ID } from 'src/routes/safe/store/reducer/transacti import { AppReduxState } from 'src/store' import { checksumAddress } from 'src/utils/checksumAddress' -import { SafeRecord } from 'src/routes/safe/store/models/safe' +import makeSafe, { SafeRecord, SafeRecordProps } from '../models/safe' const safesStateSelector = (state: AppReduxState) => state[SAFE_REDUCER_ID] -export const safesMapSelector = (state: AppReduxState): SafesMap => state[SAFE_REDUCER_ID].get('safes') +export const safesMapSelector = (state: AppReduxState): SafesMap => safesStateSelector(state).get('safes') export const safesListSelector = createSelector(safesMapSelector, (safes) => safes.toList()) @@ -105,14 +105,14 @@ export const safeIncomingTransactionsSelector = createSelector( }, ) -export const safeSelector = createSelector(safesMapSelector, safeParamAddressFromStateSelector, (safes, address) => { +export const safeSelector = createSelector(safesMapSelector, safeParamAddressFromStateSelector, (safes, address): + | SafeRecord + | undefined => { if (!address) { return undefined } const checksumed = checksumAddress(address) - const safe = safes.get(checksumed) - - return safe + return safes.get(checksumed) }) export const safeActiveTokensSelector = createSelector( @@ -151,13 +151,16 @@ export const safeBlacklistedTokensSelector = createSelector(safeSelector, (safe) return safe.blacklistedTokens }) -export const safeBlacklistedAssetsSelector = createSelector(safeSelector, (safe) => { - if (!safe) { - return List() - } +export const safeBlacklistedAssetsSelector = createSelector( + safeSelector, + (safe): Set => { + if (!safe) { + return Set() + } - return safe.blacklistedAssets -}) + return safe.blacklistedAssets + }, +) export const safeActiveAssetsSelectorBySafe = (safeAddress: string, safes: SafesMap) => safes.get(safeAddress).get('activeAssets') @@ -165,15 +168,22 @@ export const safeActiveAssetsSelectorBySafe = (safeAddress: string, safes: Safes export const safeBlacklistedAssetsSelectorBySafe = (safeAddress, safes) => safes.get(safeAddress).get('blacklistedAssets') -export const safeBalancesSelector = createSelector(safeSelector, (safe) => { - if (!safe) { - return Map() - } +export const safeBalancesSelector = createSelector( + safeSelector, + (safe): Map => { + if (!safe) { + return Map() + } - return safe.balances -}) + return safe.balances + }, +) -export const safeFieldSelector = (field: string) => (safe: SafeRecord) => safe?.[field] +const baseSafe = makeSafe() + +export const safeFieldSelector = (field: K) => ( + safe: SafeRecord, +): SafeRecordProps[K] | null => (safe ? safe.get(field, baseSafe.get(field)) : null) export const safeNameSelector = createSelector(safeSelector, safeFieldSelector('name')) @@ -189,6 +199,8 @@ export const safeNonceSelector = createSelector(safeSelector, safeFieldSelector( export const safeOwnersSelector = createSelector(safeSelector, safeFieldSelector('owners')) +export const safeModulesSelector = createSelector(safeSelector, safeFieldSelector('modules')) + export const safeFeaturesEnabledSelector = createSelector(safeSelector, safeFieldSelector('featuresEnabled')) export const getActiveTokensAddressesForAllSafes = createSelector(safesListSelector, (safes) => { diff --git a/src/store/index.ts b/src/store/index.ts index ea870736..15d93e8f 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -1,6 +1,7 @@ +import { Map } from 'immutable' import { connectRouter, routerMiddleware, RouterState } from 'connected-react-router' import { createHashHistory } from 'history' -import { applyMiddleware, CombinedState, combineReducers, compose, createStore } from 'redux' +import { applyMiddleware, combineReducers, compose, createStore, CombinedState } from 'redux' import thunk from 'redux-thunk' import addressBookMiddleware from 'src/logic/addressBook/store/middleware/addressBookMiddleware' @@ -16,9 +17,9 @@ import currencyValuesStorageMiddleware from 'src/logic/currencyValues/store/midd import currencyValues, { CURRENCY_VALUES_KEY } from 'src/logic/currencyValues/store/reducer/currencyValues' import currentSession, { CURRENT_SESSION_REDUCER_ID } from 'src/logic/currentSession/store/reducer/currentSession' import notifications, { NOTIFICATIONS_REDUCER_ID } from 'src/logic/notifications/store/reducer/notifications' -import tokens, { TOKEN_REDUCER_ID } from 'src/logic/tokens/store/reducer/tokens' +import tokens, { TOKEN_REDUCER_ID, TokenState } from 'src/logic/tokens/store/reducer/tokens' import providerWatcher from 'src/logic/wallets/store/middlewares/providerWatcher' -import provider, { PROVIDER_REDUCER_ID } from 'src/logic/wallets/store/reducer/provider' +import provider, { PROVIDER_REDUCER_ID, ProviderState } from 'src/logic/wallets/store/reducer/provider' import notificationsMiddleware from 'src/routes/safe/store/middleware/notificationsMiddleware' import safeStorage from 'src/routes/safe/store/middleware/safeStorage' import cancellationTransactions, { @@ -29,10 +30,7 @@ import incomingTransactions, { } from 'src/routes/safe/store/reducer/incomingTransactions' import safe, { SAFE_REDUCER_ID, SafeReducerMap } from 'src/routes/safe/store/reducer/safe' import transactions, { TRANSACTIONS_REDUCER_ID } from 'src/routes/safe/store/reducer/transactions' -import { Map } from 'immutable' -import { NFTAssets, NFTTokens } from '../logic/collectibles/sources/OpenSea' -import { ProviderRecord } from '../logic/wallets/store/model/provider' -import { Token } from 'src/logic/tokens/store/model/token' +import { NFTAssets, NFTTokens } from 'src/logic/collectibles/sources/OpenSea' export const history = createHashHistory({ hashType: 'slash' }) @@ -68,11 +66,11 @@ const reducers = combineReducers({ }) export type AppReduxState = CombinedState<{ - [PROVIDER_REDUCER_ID]?: ProviderRecord + [PROVIDER_REDUCER_ID]: ProviderState [SAFE_REDUCER_ID]: SafeReducerMap - [NFT_ASSETS_REDUCER_ID]?: NFTAssets - [NFT_TOKENS_REDUCER_ID]?: NFTTokens - [TOKEN_REDUCER_ID]?: Map + [NFT_ASSETS_REDUCER_ID]: NFTAssets + [NFT_TOKENS_REDUCER_ID]: NFTTokens + [TOKEN_REDUCER_ID]: TokenState [TRANSACTIONS_REDUCER_ID]: Map [CANCELLATION_TRANSACTIONS_REDUCER_ID]: Map [INCOMING_TRANSACTIONS_REDUCER_ID]: Map diff --git a/yarn.lock b/yarn.lock index bf481696..c3f7ca9f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1340,13 +1340,13 @@ solc "0.5.14" truffle "^5.1.21" -"@gnosis.pm/safe-react-components@^0.1.3": - version "0.1.3" - resolved "https://registry.yarnpkg.com/@gnosis.pm/safe-react-components/-/safe-react-components-0.1.3.tgz#ac80029862fd2a042a4400361db46e92e29a81c0" - integrity sha512-N3EMk1bvsPUaKeuUXirlF8aXEdJucIxt+QjA1IjShrEy40zKT9S3Gj2H0nqMqLVcIo6IpZ0iRwv+MulYfpbqbQ== +"@gnosis.pm/safe-react-components@^0.2.0": + version "0.2.0" + resolved "https://registry.yarnpkg.com/@gnosis.pm/safe-react-components/-/safe-react-components-0.2.0.tgz#a35947fd5f016b7aabe5ac6105b6b873e00de730" + integrity sha512-8tAJZPywtxqPZw48z92iRVNWfn173mUeHf+mkWVlw9nqy+eurXEKPM5Jy1fnEcLsWQfiafGzunB3OzsHvXq+BQ== dependencies: classnames "^2.2.6" - polished "3.6.3" + polished "3.6.5" react-docgen-typescript-loader "^3.7.2" react-media "^1.10.0" url-loader "^4.1.0" @@ -2138,7 +2138,7 @@ "@types/minimatch" "*" "@types/node" "*" -"@types/hoist-non-react-statics@*": +"@types/hoist-non-react-statics@*", "@types/hoist-non-react-statics@^3.3.0": version "3.3.1" resolved "https://registry.yarnpkg.com/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz#1124aafe5118cb591977aeb1ceaaed1070eb039f" integrity sha512-iMIqiko6ooLrTh1joXodJK5X9xeEALT1kM5G3ZLhD3hszxBdIEd5C75U834D9mLcINgD4OyZf5uQXjkuYydWvA== @@ -2241,6 +2241,16 @@ dependencies: "@types/react" "*" +"@types/react-redux@^7.1.9": + version "7.1.9" + resolved "https://registry.yarnpkg.com/@types/react-redux/-/react-redux-7.1.9.tgz#280c13565c9f13ceb727ec21e767abe0e9b4aec3" + integrity sha512-mpC0jqxhP4mhmOl3P4ipRsgTgbNofMRXJb08Ms6gekViLj61v1hOZEKWDCyWsdONr6EjEA6ZHXC446wdywDe0w== + dependencies: + "@types/hoist-non-react-statics" "^3.3.0" + "@types/react" "*" + hoist-non-react-statics "^3.3.0" + redux "^4.0.0" + "@types/react-transition-group@^4.2.0": version "4.4.0" resolved "https://registry.yarnpkg.com/@types/react-transition-group/-/react-transition-group-4.4.0.tgz#882839db465df1320e4753e6e9f70ca7e9b4d46d" @@ -12500,13 +12510,6 @@ pocket-js-core@0.0.3: dependencies: axios "^0.18.0" -polished@3.6.3: - version "3.6.3" - resolved "https://registry.yarnpkg.com/polished/-/polished-3.6.3.tgz#68f4fe7ffad46530733029b939dd12978200cb59" - integrity sha512-QJ0q0b6gX1+0OJtPMfgVJxV0vg5XTa4im+Rca989dAtmsd/fEky3X+0A+V+OUXq1nyiDGplJwqD853dTS0gkPg== - dependencies: - "@babel/runtime" "^7.9.2" - polished@3.6.5: version "3.6.5" resolved "https://registry.yarnpkg.com/polished/-/polished-3.6.5.tgz#dbefdde64c675935ec55119fe2a2ab627ca82e9c" @@ -14087,7 +14090,7 @@ redux-thunk@^2.3.0: resolved "https://registry.yarnpkg.com/redux-thunk/-/redux-thunk-2.3.0.tgz#51c2c19a185ed5187aaa9a2d08b666d0d6467622" integrity sha512-km6dclyFnmcvxhAcrQV2AkZmPQjzPDjgVlQtR0EQjxZPyJ0BnMf3in1ryuR8A2qU0HldVRfxYXbFSKlI3N7Slw== -redux@4.0.5: +redux@4.0.5, redux@^4.0.0: version "4.0.5" resolved "https://registry.yarnpkg.com/redux/-/redux-4.0.5.tgz#4db5de5816e17891de8a80c424232d06f051d93f" integrity sha512-VSz1uMAH24DM6MF72vcojpYPtrTUu3ByVWfPL1nPfVRb5mZVTve5GnNCUV53QM/BZ66xfWrm0CTWoM+Xlz8V1w==