Merge pull request #1719 from gnosis/release/v2.17.0

Release v2.17.0
This commit is contained in:
Daniel Sanchez 2020-12-16 09:20:11 +01:00 committed by GitHub
commit b3541705e5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
69 changed files with 4715 additions and 4056 deletions

2
.gitignore vendored
View File

@ -3,6 +3,7 @@
.DS_Store
yarn-error.log
.env*
.eslintcache
/.idea
dist
electron-builder.yml
@ -11,3 +12,4 @@ yalc.lock
# testing
/coverage/
src/types/contracts/
tsconfig.tsbuildinfo

1
.rescriptsrc Normal file
View File

@ -0,0 +1 @@
module.exports = [require.resolve('./scripts/rescripts/webpack.js')]

View File

@ -1,6 +1,5 @@
if: (branch = development) OR (branch = master) OR (type = pull_request) OR (tag IS present)
sudo: required
dist: bionic
dist: focal
language: node_js
node_js:
- '12'
@ -43,14 +42,15 @@ matrix:
- REACT_APP_GOOGLE_ANALYTICS=${REACT_APP_GOOGLE_ANALYTICS_ID_EWC}
if: (branch = master AND NOT type = pull_request) OR tag IS present
cache:
yarn: true
npm: false
yarn: false
before_script:
- if [[ -n "$TRAVIS_TAG" ]]; then export REACT_APP_ENV='production'; fi;
- if [ $TRAVIS_PULL_REQUEST != "false" ]; then export PUBLIC_URL="/${REACT_APP_NETWORK}/app"; fi;
before_install:
# Needed to deploy pull request and releases
- sudo apt-get update
- sudo apt-get -y install python-pip python-dev libusb-1.0-0-dev libudev-dev
- sudo apt-get -y install python3-pip python3-dev libusb-1.0-0-dev libudev-dev
- pip install awscli --upgrade --user
script:
- yarn lint:check

View File

@ -1,14 +0,0 @@
const webpack = require('webpack')
module.exports = function override(config) {
if (!config.plugins) {
config.plugins = []
}
config.plugins.push(
new webpack.ContextReplacementPlugin(/@truffle\/(contract|interface-adapter)/, (data) => {
delete data.dependencies[0].critical
return data
}),
)
return config
}

View File

@ -1,7 +1,7 @@
import Web3 from 'web3'
const window = global.window || {}
window.web3 = window.web3 || {}
window.web3 = {}
window.web3.currentProvider = new Web3.providers.HttpProvider('http://localhost:8545')
global.window = window

View File

@ -1,6 +1,6 @@
{
"name": "safe-react",
"version": "2.16.2",
"version": "2.17.0",
"description": "Allowing crypto users manage funds in a safer way",
"website": "https://github.com/gnosis/safe-react#readme",
"bugs": {
@ -16,13 +16,12 @@
"email": "safe@gnosis.io"
},
"main": "public/electron.js",
"postinstall": "patch-package electron-builder install-app-deps",
"scripts": {
"analyze": "source-map-explorer 'build/static/js/*.js'",
"build-desktop": "cross-env REACT_APP_BUILD_FOR_DESKTOP=true REACT_APP_ENV=production yarn build-mainnet",
"build-mainnet": "cross-env REACT_APP_NETWORK=mainnet yarn build",
"build": "cross-env REACT_APP_APP_VERSION=$npm_package_version react-app-rewired --max-old-space-size=8192 build",
"eject": "react-app-rewired eject",
"build": "cross-env REACT_APP_APP_VERSION=$npm_package_version rescripts --max-old-space-size=8192 build",
"eject": "rescripts eject",
"electron-build": "electron-builder --mac --windows --linux",
"electron-dev": "concurrently \"BROWSER=none yarn start\" \"wait-on http://localhost:3000 && electron .\"",
"format:staged": "lint-staged",
@ -38,8 +37,8 @@
"prettier": "prettier './src/**/*.{js,jsx,ts,tsx}'",
"release": "electron-builder --mac --linux --windows -p always",
"start-mainnet": "REACT_APP_NETWORK=mainnet yarn start",
"start": "react-app-rewired start",
"test": "react-app-rewired test --env=jsdom",
"start": "rescripts start",
"test": "rescripts test --env=jsdom",
"test:coverage": "yarn test --coverage --watchAll=false",
"coveralls": "cat ./coverage/lcov.info | coveralls",
"storybook": "start-storybook -p 9009 -s public",
@ -49,7 +48,7 @@
"husky": {
"hooks": {
"pre-commit": "lint-staged --allow-empty",
"pre-push": "tsc"
"pre-push": "tsc --noEmit --incremental"
}
},
"lint-staged": {
@ -150,9 +149,8 @@
}
},
"resolutions": {
"@typescript-eslint/eslint-plugin": "^4.5.0",
"@typescript-eslint/parser": "^4.5.0",
"node-gyp": "^7.1.0"
"@babel/core": "^7.12.0",
"sass-loader": "^9.0.0"
},
"browserslist": {
"production": [
@ -167,22 +165,23 @@
]
},
"dependencies": {
"@gnosis.pm/safe-apps-sdk": "https://github.com/gnosis/safe-apps-sdk.git#3f0689f",
"@gnosis.pm/safe-apps-sdk": "1.0.0-beta.4",
"@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#ff29c3c",
"@gnosis.pm/safe-react-components": "https://github.com/gnosis/safe-react-components.git#bf3a84486b7353bd25447ddff39c406f6fafecc6",
"@gnosis.pm/util-contracts": "2.0.6",
"@ledgerhq/hw-transport-node-hid-singleton": "5.30.0",
"@material-ui/core": "4.11.0",
"@ledgerhq/hw-transport-node-hid-singleton": "5.34.0",
"@material-ui/core": "^4.11.0",
"@material-ui/icons": "4.9.1",
"@material-ui/lab": "4.0.0-alpha.56",
"@openzeppelin/contracts": "3.1.0",
"@sentry/react": "^5.27.0",
"@sentry/tracing": "^5.27.0",
"@sentry/react": "^5.28.0",
"@sentry/tracing": "^5.28.0",
"@truffle/contract": "4.2.30",
"async-sema": "^3.1.0",
"axios": "0.21.0",
"bignumber.js": "9.0.1",
"bnc-onboard": "1.14.0",
"bnc-onboard": "^1.16.1",
"classnames": "^2.2.6",
"concurrently": "^5.3.0",
"connected-react-router": "6.8.0",
@ -215,14 +214,13 @@
"react-dom": "16.13.1",
"react-final-form": "^6.5.2",
"react-final-form-listeners": "^1.0.2",
"react-ga": "3.2.1",
"react-ga": "3.3.0",
"react-hot-loader": "4.13.0",
"react-qr-reader": "^2.2.1",
"react-redux": "7.2.2",
"react-router-dom": "5.2.0",
"react-scripts": "^3.4.3",
"react-scripts": "^4.0.1",
"react-window": "^1.8.6",
"recompose": "^0.30.0",
"redux": "4.0.5",
"redux-actions": "^2.6.5",
"redux-thunk": "^2.3.0",
@ -235,34 +233,35 @@
"web3-utils": "^1.2.11"
},
"devDependencies": {
"@rescripts/cli": "^0.0.15",
"@sentry/cli": "^1.59.0",
"@storybook/addon-actions": "^5.3.19",
"@storybook/addon-links": "^5.3.19",
"@storybook/addons": "^5.3.19",
"@storybook/preset-create-react-app": "^3.1.5",
"@storybook/react": "^5.3.19",
"@testing-library/jest-dom": "5.11.5",
"@testing-library/react": "10.4.9",
"@typechain/web3-v1": "^1.0.0",
"@testing-library/jest-dom": "5.11.6",
"@testing-library/react": "11.2.2",
"@typechain/web3-v1": "^2.0.0",
"@types/history": "4.6.2",
"@types/jest": "^26.0.15",
"@types/jest": "^26.0.16",
"@types/lodash.memoize": "^4.1.6",
"@types/node": "^14.14.6",
"@types/node": "^14.14.10",
"@types/react": "^16.9.55",
"@types/react-dom": "^16.9.9",
"@types/react-redux": "^7.1.11",
"@types/react-router-dom": "^5.1.6",
"@types/styled-components": "^5.1.4",
"@typescript-eslint/eslint-plugin": "4.6.1",
"@typescript-eslint/parser": "4.6.1",
"@typescript-eslint/eslint-plugin": "^4.6.0",
"@typescript-eslint/parser": "^4.6.0",
"autoprefixer": "9.8.6",
"cross-env": "^7.0.2",
"cross-env": "^7.0.3",
"dotenv": "^8.2.0",
"dotenv-expand": "^5.1.0",
"electron": "^9.3.3",
"electron": "^9.3.5",
"electron-builder": "22.9.1",
"electron-notarize": "1.0.0",
"eslint": "6.8.0",
"eslint": "^7.11.0",
"eslint-config-prettier": "^6.14.0",
"eslint-plugin-import": "^2.22.1",
"eslint-plugin-jsx-a11y": "^6.3.1",
@ -271,12 +270,11 @@
"eslint-plugin-sort-destructure-keys": "^1.3.5",
"ethereumjs-abi": "0.6.8",
"husky": "^4.3.0",
"lint-staged": "^10.5.1",
"node-sass": "^4.14.1",
"lint-staged": "^10.5.2",
"patch-package": "^6.2.2",
"postinstall-postinstall": "^2.1.0",
"prettier": "^2.1.2",
"react-app-rewired": "^2.1.6",
"prettier": "^2.2.0",
"sass": "^1.29.0",
"typechain": "^4.0.0",
"typescript": "4.0.5",
"wait-on": "5.2.0"

View File

@ -0,0 +1,6 @@
const { removeWebpackPlugin } = require('@rescripts/utilities')
module.exports = config => {
const webpackWithoutEsLint = removeWebpackPlugin('ESLintWebpackPlugin', config)
return webpackWithoutEsLint
}

View File

@ -80,8 +80,8 @@ const App: React.FC = ({ children }) => {
const granted = useSelector(grantedSelector)
const sidebarItems = useSidebarItems()
useLoadSafe(safeAddress)
useSafeScheduledUpdates(safeAddress)
const safeLoaded = useLoadSafe(safeAddress)
useSafeScheduledUpdates(safeLoaded, safeAddress)
const sendFunds = safeActionsState.sendFunds
const formattedTotalBalance = currentSafeBalance ? formatAmountInUsFormat(currentSafeBalance) : ''

View File

@ -1,13 +1,13 @@
import { createStyles, makeStyles } from '@material-ui/core/styles'
import Dot from '@material-ui/icons/FiberManualRecord'
import * as React from 'react'
import React, { ReactElement } from 'react'
import Block from 'src/components/layout/Block'
import Img from 'src/components/layout/Img'
import { border, fancy, screenSm, warning } from 'src/theme/variables'
const key = require('../assets/key.svg')
const triangle = require('../assets/triangle.svg')
import KeyIcon from '../assets/key.svg'
import TriangleIcon from '../assets/triangle.svg'
const styles = createStyles({
root: {
@ -71,12 +71,12 @@ export const KeyRing = ({
hideDot = false,
keySize,
mode,
}: Props): React.ReactElement => {
}: Props): ReactElement => {
const classes = useStyles(styles)
const keyStyle = buildKeyStyleFrom(circleSize, center, dotSize)
const dotStyle = buildDotStyleFrom(dotSize, dotTop, dotRight, mode)
const isWarning = mode === 'warning'
const img = isWarning ? triangle : key
const img = isWarning ? TriangleIcon : KeyIcon
return (
<>

View File

@ -9,13 +9,13 @@ import { Link } from 'react-router-dom'
import Provider from './Provider'
import Spacer from 'src/components/Spacer'
import openHoc from 'src/components/hoc/OpenHoc'
import Col from 'src/components/layout/Col'
import Img from 'src/components/layout/Img'
import Row from 'src/components/layout/Row'
import { border, headerHeight, md, screenSm, sm } from 'src/theme/variables'
import { useStateHandler } from 'src/logic/hooks/useStateHandler'
const logo = require('../assets/gnosis-safe-multisig-logo.svg')
import SafeLogo from '../assets/gnosis-safe-multisig-logo.svg'
const styles = () => ({
root: {
@ -55,41 +55,45 @@ const styles = () => ({
},
})
const Layout = openHoc(({ classes, clickAway, open, providerDetails, providerInfo, toggle }) => (
<Row className={classes.summary}>
<Col className={classes.logo} middle="xs" start="xs">
<Link to="/">
<Img alt="Gnosis Team Safe" height={36} src={logo} testId="heading-gnosis-logo" />
</Link>
</Col>
<Spacer />
<Provider
info={providerInfo}
open={open}
toggle={toggle}
render={(providerRef) => (
<Popper
anchorEl={providerRef.current}
className={classes.popper}
open={open}
placement="bottom"
popperOptions={{ positionFixed: true }}
>
{({ TransitionProps }) => (
<Grow {...TransitionProps}>
<>
<ClickAwayListener mouseEvent="onClick" onClickAway={clickAway} touchEvent={false}>
<List className={classes.root} component="div">
{providerDetails}
</List>
</ClickAwayListener>
</>
</Grow>
)}
</Popper>
)}
/>
</Row>
))
const Layout = ({ classes, providerDetails, providerInfo }) => {
const { clickAway, open, toggle } = useStateHandler()
return (
<Row className={classes.summary}>
<Col className={classes.logo} middle="xs" start="xs">
<Link to="/">
<Img alt="Gnosis Team Safe" height={36} src={SafeLogo} testId="heading-gnosis-logo" />
</Link>
</Col>
<Spacer />
<Provider
info={providerInfo}
open={open}
toggle={toggle}
render={(providerRef) => (
<Popper
anchorEl={providerRef.current}
className={classes.popper}
open={open}
placement="bottom"
popperOptions={{ positionFixed: true }}
>
{({ TransitionProps }) => (
<Grow {...TransitionProps}>
<>
<ClickAwayListener mouseEvent="onClick" onClickAway={clickAway} touchEvent={false}>
<List className={classes.root} component="div">
{providerDetails}
</List>
</ClickAwayListener>
</>
</Grow>
)}
</Popper>
)}
/>
</Row>
)
}
export default withStyles(styles as any)(Layout)

View File

@ -19,7 +19,7 @@ import { KeyRing } from 'src/components/AppLayout/Header/components/KeyRing'
import { CircleDot } from '../CircleDot'
import { createStyles } from '@material-ui/core'
const walletIcon = require('../../assets/wallet.svg')
import WalletIcon from '../../assets/wallet.svg'
const styles = createStyles({
container: {
@ -150,7 +150,7 @@ export const UserDetails = ({
Wallet
</Paragraph>
<Spacer />
<Img alt="Wallet icon" className={classes.logo} height={14} src={walletIcon} />
<Img alt="Wallet icon" className={classes.logo} height={14} src={WalletIcon} />
<Paragraph align="right" className={classes.labels} noMargin weight="bolder">
{upperFirst(provider)}
</Paragraph>

View File

@ -44,6 +44,7 @@ const IconContainer = styled.div`
justify-content: space-evenly;
`
const StyledButton = styled(Button)`
padding: 0 18px;
*:first-child {
margin: 0 4px 0 0;
}

View File

@ -63,21 +63,17 @@ export const onboardUser = async (): Promise<boolean> => {
return walletSelected && onboard.walletCheck()
}
const ConnectButton = (props): React.ReactElement => (
<Button
color="primary"
minWidth={140}
onClick={async () => {
const walletSelected = await onboard.walletSelect()
export const onConnectButtonClick = async () => {
const walletSelected = await onboard.walletSelect()
// perform wallet checks only if user selected a wallet
if (walletSelected) {
await onboard.walletCheck()
}
}}
variant="contained"
{...props}
>
// perform wallet checks only if user selected a wallet
if (walletSelected) {
await onboard.walletCheck()
}
}
const ConnectButton = (props): React.ReactElement => (
<Button color="primary" minWidth={140} onClick={onConnectButtonClick} variant="contained" {...props}>
Connect
</Button>
)

View File

@ -1,22 +0,0 @@
import * as React from 'react'
import Bold from 'src/components/layout/Bold'
import Col from 'src/components/layout/Col'
import Paragraph from 'src/components/layout/Paragraph/index'
import Row from 'src/components/layout/Row'
import { CreateSafe } from 'src/routes/welcome/components/Layout'
const NoSafe = ({ provider, text }) => (
<Row>
<Col center="xs" margin="md" sm={10} smOffset={2} start="sm" xs={12}>
<Paragraph size="lg">
<Bold>{text}</Bold>
</Paragraph>
</Col>
<Col center="xs" margin="md" sm={10} smOffset={2} start="sm" xs={12}>
<CreateSafe provider={provider} />
</Col>
</Row>
)
export default NoSafe

View File

@ -26,7 +26,7 @@ export interface StepperPageFormProps {
}
interface StepperPageProps {
validate?: (...args: unknown[]) => undefined | string[] | Promise<undefined | Record<string, string>>
validate?: (...args: unknown[]) => undefined | Record<string, string> | Promise<undefined | Record<string, string>>
component: (
...args: unknown[]
) => (controls: React.ReactElement, formProps: StepperPageFormProps) => React.ReactElement

View File

@ -166,22 +166,16 @@ describe('Forms > Validators', () => {
})
describe('uniqueAddress validator', () => {
it('Returns undefined for an address not contained in the passed array', async () => {
it('Returns undefined if `addresses` does not contains the provided address', async () => {
const addresses = ['0xde0B295669a9FD93d5F28D9Ec85E40f4cb697BAe']
expect(uniqueAddress(addresses)()).toBeUndefined()
expect(uniqueAddress(addresses)('0x2D6F2B448b0F711Eb81f2929566504117d67E44F')).toBeUndefined()
})
it('Returns an error message for an array with duplicated values', async () => {
const addresses = ['0xde0B295669a9FD93d5F28D9Ec85E40f4cb697BAe', '0xde0B295669a9FD93d5F28D9Ec85E40f4cb697BAe']
it('Returns an error message if address is in the `addresses` list already', async () => {
const addresses = ['0xde0B295669a9FD93d5F28D9Ec85E40f4cb697BAe', '0x2D6F2B448b0F711Eb81f2929566504117d67E44F']
expect(uniqueAddress(addresses)()).toEqual(ADDRESS_REPEATED_ERROR)
})
it('Returns an error message for an array with duplicated checksum and not checksum values', async () => {
const addresses = ['0xde0B295669a9FD93d5F28D9Ec85E40f4cb697BAe', '0xde0b295669a9fd93d5f28d9ec85e40f4cb697bae']
expect(uniqueAddress(addresses)()).toEqual(ADDRESS_REPEATED_ERROR)
expect(uniqueAddress(addresses)('0xde0B295669a9FD93d5F28D9Ec85E40f4cb697BAe')).toEqual(ADDRESS_REPEATED_ERROR)
})
})

View File

@ -1,8 +1,10 @@
import { getWeb3 } from 'src/logic/wallets/getWeb3'
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 { List } from 'immutable'
type ValidatorReturnType = string | undefined
export type GenericValidatorType = (...args: unknown[]) => ValidatorReturnType
@ -87,17 +89,9 @@ export const minMaxLength = (minLen: number, maxLen: number) => (value: string):
export const ADDRESS_REPEATED_ERROR = 'Address already introduced'
export const uniqueAddress = (addresses: string[] | List<string>): GenericValidatorType => (): ValidatorReturnType => {
// @ts-expect-error both list and array have signatures for map but TS thinks they're not compatible
const lowercaseAddresses = addresses.map((address) => address.toLowerCase())
const uniqueAddresses = new Set(lowercaseAddresses)
const lengthPropName = 'size' in addresses ? 'size' : 'length'
if (uniqueAddresses.size !== addresses?.[lengthPropName]) {
return ADDRESS_REPEATED_ERROR
}
return undefined
export const uniqueAddress = (addresses: string[] | List<string> = []) => (address?: string): string | undefined => {
const addressExists = addresses.some((addressFromList) => sameAddress(addressFromList, address))
return addressExists ? ADDRESS_REPEATED_ERROR : undefined
}
export const composeValidators = (...validators: Validator[]) => (value: unknown): ValidatorReturnType =>

View File

@ -1,6 +0,0 @@
import { withStateHandlers } from 'recompose'
export default withStateHandlers(() => ({ open: false }), {
toggle: ({ open }) => () => ({ open: !open }),
clickAway: () => () => ({ open: false }),
})

View File

@ -7,19 +7,6 @@
padding: 12px 0 0 0;
}
@media only screen and (max-width: #{$screenLg}px) {
.page {
padding: 72px $lg 0 $lg;
}
}
@media only screen and (min-width: #{$screenLg}px) and (max-width: 1360px) {
.page {
padding: 96px 120px 0 120px;
}
}
.center {
align-self: center;
}

View File

@ -11,6 +11,9 @@ import loadDefaultSafe from 'src/logic/safe/store/actions/loadDefaultSafe'
import loadSafesFromStorage from 'src/logic/safe/store/actions/loadSafesFromStorage'
import { store } from 'src/store'
import { SENTRY_DSN } from './utils/constants'
import { disableMMAutoRefreshWarning } from './utils/mm_warnings'
disableMMAutoRefreshWarning()
BigNumber.set({ EXPONENTIAL_AT: [-7, 255] })

View File

@ -8,9 +8,10 @@ import { AddressBookState } from 'src/logic/addressBook/model/addressBook'
export const addressBookSelector = (state: AppReduxState): AddressBookState => state[ADDRESS_BOOK_REDUCER_ID]
export const addressBookAddressesListSelector = createSelector(addressBookSelector, (addressBook): string[] => {
export const addressBookAddressesListSelector = (state: AppReduxState): string[] => {
const addressBook = addressBookSelector(state)
return addressBook.map((entry) => entry.address)
})
}
export const getNameFromAddressBookSelector = createSelector(
addressBookSelector,

View File

@ -64,5 +64,5 @@ export const extractUsefulMethods = (abi: AbiItem[]): AbiItemExtended[] => {
}
export const isPayable = (method: AbiItem | AbiItemExtended): boolean => {
return !!method?.payable
return Boolean(method?.payable) || method.stateMutability === 'payable'
}

View File

@ -57,6 +57,10 @@ const generateBatchRequests = <ReturnValues>({
if (type !== undefined) {
request = web3[type][method].request(...args, resolver)
} else {
if (address === null) {
resolve()
return
}
request = contractInstance.methods[method](...args).call.request(resolver)
}

View File

@ -1,7 +1,10 @@
import axios from 'axios'
import { getSafeServiceBaseUrl } from 'src/config'
import { fetchTokenCurrenciesBalances } from 'src/logic/currencyValues/api/fetchTokenCurrenciesBalances'
import {
fetchTokenCurrenciesBalances,
BalanceEndpoint,
} from 'src/logic/currencyValues/api/fetchTokenCurrenciesBalances'
import { aNewStore } from 'src/store'
jest.mock('axios')
@ -20,8 +23,7 @@ describe('fetchTokenCurrenciesBalances', () => {
// given
const expectedResult = [
{
tokenAddress: null,
token: null,
tokenAddress: '',
balance: '849890000000000000',
fiatBalance: '337.2449',
fiatConversion: '396.81',
@ -30,6 +32,8 @@ describe('fetchTokenCurrenciesBalances', () => {
{
tokenAddress: '0x5592EC0cfb4dbc12D3aB100b257153436a1f0FEa',
token: {
address: '0x5592EC0cfb4dbc12D3aB100b257153436a1f0FEa',
balance: '24698677800000000000',
name: 'Dai',
symbol: 'DAI',
decimals: 18,
@ -41,10 +45,11 @@ describe('fetchTokenCurrenciesBalances', () => {
fiatCode: 'USD',
},
]
const apiUrl = getSafeServiceBaseUrl(safeAddress)
// @ts-ignore
axios.get.mockImplementationOnce(() => Promise.resolve(expectedResult))
axios.get.mockImplementationOnce(() => Promise.resolve({ data: expectedResult }))
// when
const result = await fetchTokenCurrenciesBalances(safeAddress, excludeSpamTokens)

View File

@ -14,9 +14,9 @@ const fetchCurrenciesRates = async (
let rate = 0
if (sameString(targetCurrencyValue, AVAILABLE_CURRENCIES.NETWORK)) {
try {
const result = await fetchTokenCurrenciesBalances(safeAddress)
if (result?.data?.length) {
rate = new BigNumber(1).div(result.data[0].fiatConversion).toNumber()
const tokenCurrenciesBalances = await fetchTokenCurrenciesBalances(safeAddress)
if (tokenCurrenciesBalances?.length) {
rate = new BigNumber(1).div(tokenCurrenciesBalances[0].fiatConversion).toNumber()
}
} catch (error) {
console.error(`Fetching ${AVAILABLE_CURRENCIES.NETWORK} data from the relayer errored`, error)

View File

@ -1,4 +1,4 @@
import axios, { AxiosResponse } from 'axios'
import axios from 'axios'
import { getSafeServiceBaseUrl } from 'src/config'
import { TokenProps } from 'src/logic/tokens/store/model/token'
@ -15,8 +15,8 @@ export type BalanceEndpoint = {
export const fetchTokenCurrenciesBalances = (
safeAddress: string,
excludeSpamTokens = true,
): Promise<AxiosResponse<BalanceEndpoint[]>> => {
): Promise<BalanceEndpoint[]> => {
const url = `${getSafeServiceBaseUrl(safeAddress)}/balances/usd/?exclude_spam=${excludeSpamTokens}`
return axios.get(url)
return axios.get(url).then(({ data }) => data)
}

View File

@ -0,0 +1,17 @@
import { useState } from 'react'
type ReturnValue = {
open: boolean
toggle: () => void
clickAway: () => void
}
export const useStateHandler = (openInitialValue = false): ReturnValue => {
const [open, setOpen] = useState(openInitialValue)
return {
open,
toggle: () => setOpen((open) => !open),
clickAway: () => setOpen(false),
}
}

View File

@ -1,4 +1,4 @@
import { useEffect } from 'react'
import { useEffect, useState } from 'react'
import { useDispatch } from 'react-redux'
import loadAddressBookFromStorage from 'src/logic/addressBook/store/actions/loadAddressBookFromStorage'
@ -10,26 +10,26 @@ import fetchTransactions from 'src/logic/safe/store/actions/transactions/fetchTr
import fetchSafeCreationTx from 'src/logic/safe/store/actions/fetchSafeCreationTx'
import { Dispatch } from 'src/logic/safe/store/actions/types.d'
export const useLoadSafe = (safeAddress?: string): void => {
export const useLoadSafe = (safeAddress?: string): boolean => {
const dispatch = useDispatch<Dispatch>()
const [isSafeLoaded, setIsSafeLoaded] = useState(false)
useEffect(() => {
const fetchData = () => {
const fetchData = async () => {
if (safeAddress) {
dispatch(fetchLatestMasterContractVersion())
.then(() => {
dispatch(fetchSafe(safeAddress))
return dispatch(fetchSafeTokens(safeAddress))
})
.then(() => {
dispatch(fetchSafeCreationTx(safeAddress))
dispatch(fetchTransactions(safeAddress))
return dispatch(addViewedSafe(safeAddress))
})
await dispatch(fetchLatestMasterContractVersion())
await dispatch(fetchSafe(safeAddress))
setIsSafeLoaded(true)
await dispatch(fetchSafeTokens(safeAddress))
dispatch(fetchSafeCreationTx(safeAddress))
dispatch(fetchTransactions(safeAddress))
dispatch(addViewedSafe(safeAddress))
}
}
dispatch(loadAddressBookFromStorage())
fetchData()
}, [dispatch, safeAddress])
return isSafeLoaded
}

View File

@ -8,7 +8,7 @@ import { checkAndUpdateSafe } from 'src/logic/safe/store/actions/fetchSafe'
import fetchTransactions from 'src/logic/safe/store/actions/transactions/fetchTransactions'
import { TIMEOUT } from 'src/utils/constants'
export const useSafeScheduledUpdates = (safeAddress?: string): void => {
export const useSafeScheduledUpdates = (safeLoaded: boolean, safeAddress?: string): void => {
const dispatch = useDispatch()
const timer = useRef<number>()
@ -34,7 +34,7 @@ export const useSafeScheduledUpdates = (safeAddress?: string): void => {
}
}
if (safeAddress) {
if (safeAddress && safeLoaded) {
fetchSafeData(safeAddress)
}
@ -42,5 +42,5 @@ export const useSafeScheduledUpdates = (safeAddress?: string): void => {
mounted = false
clearTimeout(timer.current)
}
}, [dispatch, safeAddress])
}, [dispatch, safeAddress, safeLoaded])
}

View File

@ -57,6 +57,7 @@ export interface CreateTransactionArgs {
type CreateTransactionAction = ThunkAction<Promise<void | string>, AppReduxState, DispatchReturn, AnyAction>
type ConfirmEventHandler = (safeTxHash: string) => void
type ErrorEventHandler = () => void
export const METAMASK_REJECT_CONFIRM_TX_ERROR_CODE = 4001
const createTransaction = (
{
@ -210,20 +211,21 @@ const createTransaction = (
? `${notificationsQueue.afterExecutionError.message} - ${err.message}`
: notificationsQueue.afterExecutionError.message
console.error(`Error creating the TX: `, err)
dispatch(closeSnackbarAction({ key: beforeExecutionKey }))
if (pendingExecutionKey) {
dispatch(closeSnackbarAction({ key: pendingExecutionKey }))
}
dispatch(enqueueSnackbar(errorMsg))
dispatch(enqueueSnackbar({ key: err.code, message: errorMsg, options: { persist: true, variant: 'error' } }))
const executeDataUsedSignatures = safeInstance.methods
.execTransaction(to, valueInWei, txData, operation, 0, 0, 0, ZERO_ADDRESS, ZERO_ADDRESS, sigs)
.encodeABI()
const errMsg = await getErrorMessage(safeInstance.options.address, 0, executeDataUsedSignatures, from)
console.error(`Error creating the TX - an attempt to get the error message: ${errMsg}`)
if (err.code !== METAMASK_REJECT_CONFIRM_TX_ERROR_CODE) {
const executeDataUsedSignatures = safeInstance.methods
.execTransaction(to, valueInWei, txData, operation, 0, 0, 0, ZERO_ADDRESS, ZERO_ADDRESS, sigs)
.encodeABI()
const errMsg = await getErrorMessage(safeInstance.options.address, 0, executeDataUsedSignatures, from)
console.error(`Error creating the TX - an attempt to get the error message: ${errMsg}`)
}
}
return txHash

View File

@ -117,19 +117,17 @@ export const checkAndUpdateSafe = (safeAdd: string) => async (dispatch: Dispatch
const modules = await getModules(safeInfo)
dispatch(
updateSafe({
address: safeAddress,
name: localSafe?.name,
modules,
spendingLimits,
nonce: Number(remoteNonce),
threshold: Number(remoteThreshold),
featuresEnabled: localSafe?.currentVersion
? enabledFeatures(localSafe?.currentVersion)
: localSafe?.featuresEnabled,
}),
)
const updatedSafe = {
address: safeAddress,
name: localSafe?.name,
modules,
spendingLimits,
nonce: Number(remoteNonce),
threshold: Number(remoteThreshold),
featuresEnabled: localSafe?.currentVersion ? enabledFeatures(localSafe.currentVersion) : localSafe?.featuresEnabled,
}
dispatch(updateSafe(updatedSafe))
// If the remote owners does not contain a local address, we remove that local owner
localOwners.forEach((localAddress) => {

View File

@ -97,7 +97,7 @@ const processTransaction = ({
const signature = await tryOffchainSigning(tx.safeTxHash, { ...txArgs, safeAddress }, hardwareWallet)
if (signature) {
dispatch(closeSnackbarAction(beforeExecutionKey))
dispatch(closeSnackbarAction({ key: beforeExecutionKey }))
await saveTxToHistory({ ...txArgs, signature })
// TODO: while we wait for the tx to be stored in the service and later update the tx info
@ -130,7 +130,7 @@ const processTransaction = ({
.send(sendParams)
.once('transactionHash', async (hash: string) => {
txHash = hash
dispatch(closeSnackbarAction(beforeExecutionKey))
dispatch(closeSnackbarAction({ key: beforeExecutionKey }))
pendingExecutionKey = dispatch(enqueueSnackbar(notificationsQueue.pendingExecution))
@ -141,19 +141,19 @@ const processTransaction = ({
])
dispatch(fetchTransactions(safeAddress))
} catch (e) {
dispatch(closeSnackbarAction(pendingExecutionKey))
dispatch(closeSnackbarAction({ key: pendingExecutionKey }))
await storeTx({ transaction: tx, safeAddress, dispatch, state })
console.error(e)
}
})
.on('error', (error) => {
dispatch(closeSnackbarAction(pendingExecutionKey))
dispatch(closeSnackbarAction({ key: pendingExecutionKey }))
storeTx({ transaction: tx, safeAddress, dispatch, state })
console.error('Processing transaction error: ', error)
})
.then(async (receipt) => {
if (pendingExecutionKey) {
dispatch(closeSnackbarAction(pendingExecutionKey))
dispatch(closeSnackbarAction({ key: pendingExecutionKey }))
}
dispatch(
@ -178,17 +178,16 @@ const processTransaction = ({
const errorMsg = err.message
? `${notificationsQueue.afterExecutionError.message} - ${err.message}`
: notificationsQueue.afterExecutionError.message
console.error(err)
if (txHash !== undefined) {
dispatch(closeSnackbarAction(beforeExecutionKey))
dispatch(closeSnackbarAction({ key: beforeExecutionKey }))
if (pendingExecutionKey) {
dispatch(closeSnackbarAction(pendingExecutionKey))
}
if (pendingExecutionKey) {
dispatch(closeSnackbarAction({ key: pendingExecutionKey }))
}
dispatch(enqueueSnackbar(errorMsg))
dispatch(enqueueSnackbar({ key: err.code, message: errorMsg, options: { persist: true, variant: 'error' } }))
if (txHash) {
const executeData = safeInstance.methods.approveHash(txHash).encodeABI()
const errMsg = await getErrorMessage(safeInstance.options.address, 0, executeData, from)
console.error(`Error executing the TX: ${errMsg}`)

View File

@ -18,6 +18,7 @@ import { checksumAddress } from 'src/utils/checksumAddress'
import { SafeReducerMap } from 'src/routes/safe/store/reducer/types/safe'
import { ADD_OR_UPDATE_SAFE, buildOwnersFrom } from 'src/logic/safe/store/actions/addOrUpdateSafe'
import { sameAddress } from 'src/logic/wallets/ethAddresses'
import { shouldSafeStoreBeUpdated } from 'src/logic/safe/utils/shouldSafeStoreBeUpdated'
export const SAFE_REDUCER_ID = 'safes'
export const DEFAULT_SAFE_INITIAL_STATE = 'NOT_ASKED'
@ -78,11 +79,15 @@ export default handleActions(
const safe = action.payload
const safeAddress = safe.address
return state.updateIn(
['safes', safeAddress],
makeSafe({ name: safe?.name || 'LOADED SAFE', address: safeAddress }),
(prevSafe) => updateSafeProps(prevSafe, safe),
)
const shouldUpdate = shouldSafeStoreBeUpdated(safe, state.getIn(['safes', safeAddress]))
return shouldUpdate
? state.updateIn(
['safes', safeAddress],
makeSafe({ name: safe?.name || 'LOADED SAFE', address: safeAddress }),
(prevSafe) => updateSafeProps(prevSafe, safe),
)
: state
},
[ACTIVATE_TOKEN_FOR_ALL_SAFES]: (state: SafeReducerMap, action) => {
const tokenAddress = action.payload
@ -107,11 +112,15 @@ export default handleActions(
return state.setIn(['safes', safe.address], makeSafe(safe))
}
return state.updateIn(
['safes', safe.address],
makeSafe({ name: safe?.name || 'LOADED SAFE', address: safe.address }),
(prevSafe) => updateSafeProps(prevSafe, safe),
)
const shouldUpdate = shouldSafeStoreBeUpdated(safe, state.getIn(['safes', safe.safeAddress]))
return shouldUpdate
? state.updateIn(
['safes', safe.address],
makeSafe({ name: safe?.name || 'LOADED SAFE', address: safe.address }),
(prevSafe) => updateSafeProps(prevSafe, safe),
)
: state
},
[REMOVE_SAFE]: (state: SafeReducerMap, action) => {
const safeAddress = action.payload

View File

@ -72,14 +72,13 @@ export const safeTransactionsSelector = createSelector(
},
)
export const addressBookQueryParamsSelector = (state: AppReduxState): string | null => {
export const addressBookQueryParamsSelector = (state: AppReduxState): string | undefined => {
const { location } = state.router
let entryAddressToEditOrCreateNew = null
if (location && location.query) {
if (location?.query) {
const { entryAddress } = location.query
entryAddressToEditOrCreateNew = entryAddress
return entryAddress
}
return entryAddressToEditOrCreateNew
}
export const safeCancellationTransactionsSelector = createSelector(

View File

@ -1,4 +1,4 @@
import { Transaction } from '@gnosis.pm/safe-apps-sdk'
import { Transaction } from '@gnosis.pm/safe-apps-sdk-v1'
import { AbiItem } from 'web3-utils'
import { MultiSend } from 'src/types/contracts/MultiSend.d'
import { getWeb3 } from 'src/logic/wallets/getWeb3'

View File

@ -1,5 +1,6 @@
import { getEIP712Signer } from './EIP712Signer'
import { ethSigner } from './ethSigner'
import { METAMASK_REJECT_CONFIRM_TX_ERROR_CODE } from 'src/logic/safe/store/actions/createTransaction'
// 1. we try to sign via EIP-712 if user's wallet supports it
// 2. If not, try to use eth_sign (Safe version has to be >1.1.1)
@ -29,9 +30,8 @@ export const tryOffchainSigning = async (safeTxHash: string, txArgs, isHW: boole
break
} catch (err) {
console.error(err)
// Metamask sign request error code
if (err.code === 4001) {
throw new Error('User denied sign request')
if (err.code === METAMASK_REJECT_CONFIRM_TX_ERROR_CODE) {
throw err
}
}
}

View File

@ -0,0 +1,369 @@
import { SafeRecordProps } from 'src/logic/safe/store/models/safe'
import { List, Set, Map } from 'immutable'
import { shouldSafeStoreBeUpdated } from 'src/logic/safe/utils/shouldSafeStoreBeUpdated'
const getMockedOldSafe = ({
address,
needsUpdate,
balances,
recurringUser,
blacklistedAssets,
blacklistedTokens,
activeAssets,
activeTokens,
owners,
featuresEnabled,
currentVersion,
latestIncomingTxBlock,
ethBalance,
threshold,
name,
nonce,
modules,
spendingLimits,
}: Partial<SafeRecordProps>): SafeRecordProps => {
const owner1 = {
name: 'MockedOwner1',
address: '0x3bE3c2dE077FBC409ae50AFFA66a94a9aE669A8d',
}
const owner2 = {
name: 'MockedOwner2',
address: '0xA2366b0c2607de70777d87aCdD1D22F0708fA6a3',
}
const mockedActiveTokenAddress1 = '0x36591cd3DA96b21Ac9ca54cFaf80fe45107294F1'
const mockedActiveTokenAddress2 = '0x92aF97cbF10742dD2527ffaBA70e34C03CFFC2c1'
const mockedActiveAssetsAddress1 = '0x503ab2a6A70c6C6ec8b25a4C87C784e1c8f8e8CD'
const mockedActiveAssetsAddress2 = '0xfdd4E685361CB7E89a4D27e03DCd0001448d731F'
const mockedBlacklistedTokenAddress1 = '0xc7d892dca37a244Fb1A7461e6141e58Ead460282'
const mockedBlacklistedAssetAddress1 = '0x0ac539137c4c99001f16Dd132E282F99A02Ddc3F'
return {
name: name || 'MockedSafe',
address: address || '0xAE173F30ec9A293d37c44BA68d3fCD35F989Ce9F',
threshold: threshold || 2,
ethBalance: ethBalance || '10',
owners: owners || List([owner1, owner2]),
modules: modules || [],
spendingLimits: spendingLimits || [],
activeTokens: activeTokens || Set([mockedActiveTokenAddress1, mockedActiveTokenAddress2]),
activeAssets: activeAssets || Set([mockedActiveAssetsAddress1, mockedActiveAssetsAddress2]),
blacklistedTokens: blacklistedTokens || Set([mockedBlacklistedTokenAddress1]),
blacklistedAssets: blacklistedAssets || Set([mockedBlacklistedAssetAddress1]),
balances:
balances ||
Map({
[mockedActiveTokenAddress1]: '100',
[mockedActiveTokenAddress2]: '10',
}),
nonce: nonce || 2,
latestIncomingTxBlock: latestIncomingTxBlock || 1,
recurringUser: recurringUser || false,
currentVersion: currentVersion || 'v1.1.1',
needsUpdate: needsUpdate || false,
featuresEnabled: featuresEnabled || [],
}
}
describe('shouldSafeStoreBeUpdated', () => {
it(`Given two equal safes, should return false`, () => {
// given
const oldSafe = getMockedOldSafe({})
// When
const expectedResult = shouldSafeStoreBeUpdated(oldSafe, oldSafe)
// Then
expect(expectedResult).toEqual(false)
})
it(`Given an old safe and a new address for the safe, should return true`, () => {
// given
const oldAddress = '0x123'
const newAddress = '0x'
const oldSafe = getMockedOldSafe({ address: oldAddress })
const newSafeProps: Partial<SafeRecordProps> = {
address: newAddress,
}
// When
const expectedResult = shouldSafeStoreBeUpdated(newSafeProps, oldSafe)
// 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
const newThreshold = 2
const oldSafe = getMockedOldSafe({ threshold: oldThreshold })
const newSafeProps: Partial<SafeRecordProps> = {
threshold: newThreshold,
}
// When
const expectedResult = shouldSafeStoreBeUpdated(newSafeProps, oldSafe)
// Then
expect(expectedResult).toEqual(true)
})
it(`Given an old ethBalance and a new ethBalance for the safe, should return true`, () => {
// given
const oldEthBalance = '1'
const newEthBalance = '2'
const oldSafe = getMockedOldSafe({ ethBalance: oldEthBalance })
const newSafeProps: Partial<SafeRecordProps> = {
ethBalance: newEthBalance,
}
// When
const expectedResult = shouldSafeStoreBeUpdated(newSafeProps, oldSafe)
// Then
expect(expectedResult).toEqual(true)
})
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]),
}
// When
const expectedResult = shouldSafeStoreBeUpdated(newSafeProps, oldSafe)
// Then
expect(expectedResult).toEqual(true)
})
it(`Given an old modules list and a new modules list for the safe, should return true`, () => {
// given
const oldModulesList = []
const newModulesList = null
const oldSafe = getMockedOldSafe({ modules: oldModulesList })
const newSafeProps: Partial<SafeRecordProps> = {
modules: newModulesList,
}
// When
const expectedResult = shouldSafeStoreBeUpdated(newSafeProps, oldSafe)
// Then
expect(expectedResult).toEqual(true)
})
it(`Given an old spendingLimits list and a new spendingLimits list for the safe, should return true`, () => {
// given
const oldSpendingLimitsList = []
const newSpendingLimitsList = null
const oldSafe = getMockedOldSafe({ spendingLimits: oldSpendingLimitsList })
const newSafeProps: Partial<SafeRecordProps> = {
modules: newSpendingLimitsList,
}
// When
const expectedResult = shouldSafeStoreBeUpdated(newSafeProps, oldSafe)
// Then
expect(expectedResult).toEqual(true)
})
it(`Given an old activeTokens list and a new activeTokens list for the safe, should return true`, () => {
// given
const mockedActiveTokenAddress1 = '0x36591cd3DA96b21Ac9ca54cFaf80fe45107294F1'
const mockedActiveTokenAddress2 = '0x92aF97cbF10742dD2527ffaBA70e34C03CFFC2c1'
const oldActiveTokens = Set([mockedActiveTokenAddress1, mockedActiveTokenAddress2])
const newActiveTokens = Set([mockedActiveTokenAddress1])
const oldSafe = getMockedOldSafe({ activeTokens: oldActiveTokens })
const newSafeProps: Partial<SafeRecordProps> = {
activeTokens: newActiveTokens,
}
// When
const expectedResult = shouldSafeStoreBeUpdated(newSafeProps, oldSafe)
// Then
expect(expectedResult).toEqual(true)
})
it(`Given an old activeAssets list and a new activeAssets list for the safe, should return true`, () => {
// given
const mockedActiveTokenAddress1 = '0x36591cd3DA96b21Ac9ca54cFaf80fe45107294F1'
const mockedActiveTokenAddress2 = '0x92aF97cbF10742dD2527ffaBA70e34C03CFFC2c1'
const oldActiveAssets = Set([mockedActiveTokenAddress1, mockedActiveTokenAddress2])
const newActiveAssets = Set([mockedActiveTokenAddress1])
const oldSafe = getMockedOldSafe({ activeAssets: oldActiveAssets })
const newSafeProps: Partial<SafeRecordProps> = {
activeAssets: newActiveAssets,
}
// When
const expectedResult = shouldSafeStoreBeUpdated(newSafeProps, oldSafe)
// Then
expect(expectedResult).toEqual(true)
})
it(`Given an old blacklistedTokens list and a new blacklistedTokens list for the safe, should return true`, () => {
// given
const mockedActiveTokenAddress1 = '0x36591cd3DA96b21Ac9ca54cFaf80fe45107294F1'
const mockedActiveTokenAddress2 = '0x92aF97cbF10742dD2527ffaBA70e34C03CFFC2c1'
const oldBlacklistedTokens = Set([mockedActiveTokenAddress1, mockedActiveTokenAddress2])
const newBlacklistedTokens = Set([mockedActiveTokenAddress1])
const oldSafe = getMockedOldSafe({ blacklistedTokens: oldBlacklistedTokens })
const newSafeProps: Partial<SafeRecordProps> = {
blacklistedTokens: newBlacklistedTokens,
}
// When
const expectedResult = shouldSafeStoreBeUpdated(newSafeProps, oldSafe)
// Then
expect(expectedResult).toEqual(true)
})
it(`Given an old blacklistedAssets list and a new blacklistedAssets list for the safe, should return true`, () => {
// given
const mockedActiveTokenAddress1 = '0x36591cd3DA96b21Ac9ca54cFaf80fe45107294F1'
const mockedActiveTokenAddress2 = '0x92aF97cbF10742dD2527ffaBA70e34C03CFFC2c1'
const oldBlacklistedAssets = Set([mockedActiveTokenAddress1, mockedActiveTokenAddress2])
const newBlacklistedAssets = Set([mockedActiveTokenAddress1])
const oldSafe = getMockedOldSafe({ blacklistedAssets: oldBlacklistedAssets })
const newSafeProps: Partial<SafeRecordProps> = {
blacklistedAssets: newBlacklistedAssets,
}
// When
const expectedResult = shouldSafeStoreBeUpdated(newSafeProps, oldSafe)
// Then
expect(expectedResult).toEqual(true)
})
it(`Given an old balances list and a new balances list for the safe, should return true`, () => {
// given
const mockedActiveTokenAddress1 = '0x36591cd3DA96b21Ac9ca54cFaf80fe45107294F1'
const mockedActiveTokenAddress2 = '0x92aF97cbF10742dD2527ffaBA70e34C03CFFC2c1'
const oldBalances = Map({
[mockedActiveTokenAddress1]: '100',
[mockedActiveTokenAddress2]: '10',
})
const newBalances = Map({
[mockedActiveTokenAddress1]: '100',
})
const oldSafe = getMockedOldSafe({ balances: oldBalances })
const newSafeProps: Partial<SafeRecordProps> = {
balances: newBalances,
}
// When
const expectedResult = shouldSafeStoreBeUpdated(newSafeProps, oldSafe)
// Then
expect(expectedResult).toEqual(true)
})
it(`Given an old nonce and a new nonce for the safe, should return true`, () => {
// given
const oldNonce = 1
const newNonce = 2
const oldSafe = getMockedOldSafe({ nonce: oldNonce })
const newSafeProps: Partial<SafeRecordProps> = {
nonce: newNonce,
}
// When
const expectedResult = shouldSafeStoreBeUpdated(newSafeProps, oldSafe)
// Then
expect(expectedResult).toEqual(true)
})
it(`Given an old newLatestIncomingTxBlock and a new newLatestIncomingTxBlock for the safe, should return true`, () => {
// given
const oldLatestIncomingTxBlock = 1
const newLatestIncomingTxBlock = 2
const oldSafe = getMockedOldSafe({ latestIncomingTxBlock: oldLatestIncomingTxBlock })
const newSafeProps: Partial<SafeRecordProps> = {
latestIncomingTxBlock: newLatestIncomingTxBlock,
}
// When
const expectedResult = shouldSafeStoreBeUpdated(newSafeProps, oldSafe)
// Then
expect(expectedResult).toEqual(true)
})
it(`Given an old recurringUser and a new recurringUser for the safe, should return true`, () => {
// given
const oldRecurringUser = true
const newRecurringUser = false
const oldSafe = getMockedOldSafe({ recurringUser: oldRecurringUser })
const newSafeProps: Partial<SafeRecordProps> = {
recurringUser: newRecurringUser,
}
// When
const expectedResult = shouldSafeStoreBeUpdated(newSafeProps, oldSafe)
// Then
expect(expectedResult).toEqual(true)
})
it(`Given an old recurringUser and a new recurringUser for the safe, should return true`, () => {
// given
const oldCurrentVersion = '1.1.1'
const newCurrentVersion = '1.0.0'
const oldSafe = getMockedOldSafe({ currentVersion: oldCurrentVersion })
const newSafeProps: Partial<SafeRecordProps> = {
currentVersion: newCurrentVersion,
}
// When
const expectedResult = shouldSafeStoreBeUpdated(newSafeProps, oldSafe)
// Then
expect(expectedResult).toEqual(true)
})
it(`Given an old needsUpdate and a new needsUpdate for the safe, should return true`, () => {
// given
const oldNeedsUpdate = false
const newNeedsUpdate = true
const oldSafe = getMockedOldSafe({ needsUpdate: oldNeedsUpdate })
const newSafeProps: Partial<SafeRecordProps> = {
needsUpdate: newNeedsUpdate,
}
// When
const expectedResult = shouldSafeStoreBeUpdated(newSafeProps, oldSafe)
// Then
expect(expectedResult).toEqual(true)
})
it(`Given an old featuresEnabled and a new featuresEnabled for the safe, should return true`, () => {
// given
const oldFeaturesEnabled = []
const newFeaturesEnabled = undefined
const oldSafe = getMockedOldSafe({ featuresEnabled: oldFeaturesEnabled })
const newSafeProps: Partial<SafeRecordProps> = {
featuresEnabled: newFeaturesEnabled,
}
// When
const expectedResult = shouldSafeStoreBeUpdated(newSafeProps, oldSafe)
// Then
expect(expectedResult).toEqual(true)
})
})

View File

@ -0,0 +1,26 @@
import { Map } from 'immutable'
import { SafeRecordProps } from 'src/logic/safe/store/models/safe'
// This function checks if an object is a Subset of a Safe State and that they have the same values
const isStateSubset = (superObj, subObj) => {
return Object.keys(subObj).every((key) => {
if (subObj[key] && typeof subObj[key] == 'object') {
if (Map.isMap(subObj[key]) || subObj[key].size >= 0) {
// If type is Immutable Map, List or Object we use Immutable equals
return superObj[key].equals(subObj[key])
}
return isStateSubset(superObj[key], subObj[key])
}
return subObj[key] === superObj[key]
})
}
export const shouldSafeStoreBeUpdated = (
newSafeProps: Partial<SafeRecordProps>,
oldSafeProps?: SafeRecordProps,
): boolean => {
if (!oldSafeProps) return true
return !isStateSubset(oldSafeProps, newSafeProps)
}

View File

@ -77,14 +77,14 @@ const fetchSafeTokens = (safeAddress: string) => async (
return
}
const result = await backOff(() => fetchTokenCurrenciesBalances(safeAddress))
const tokenCurrenciesBalances = await backOff(() => fetchTokenCurrenciesBalances(safeAddress))
const currentEthBalance = safeEthBalanceSelector(state)
const safeBalances = safeBalancesSelector(state)
const alreadyActiveTokens = safeActiveTokensSelector(state)
const blacklistedTokens = safeBlacklistedTokensSelector(state)
const currencyValues = currencyValuesSelector(state)
const { balances, currencyList, ethBalance, tokens } = result.data.reduce<ExtractedData>(
const { balances, currencyList, ethBalance, tokens } = tokenCurrenciesBalances.reduce<ExtractedData>(
extractDataFromResult(currentTokens),
{
balances: Map(),

View File

@ -35,7 +35,7 @@ const httpProviderOptions = {
export const web3ReadOnly = new Web3(
process.env.NODE_ENV !== 'test'
? new Web3.providers.HttpProvider(getRpcServiceUrl(), httpProviderOptions)
: window.web3?.currentProvider || 'ws://localhost:8545',
: 'ws://localhost:8545',
)
let web3 = web3ReadOnly

View File

@ -1,5 +1,5 @@
import closeSnackbar from 'src/logic/notifications/store/actions/closeSnackbar'
import { WALLET_PROVIDER, getProviderInfo, getWeb3 } from 'src/logic/wallets/getWeb3'
import { getProviderInfo, getWeb3 } from 'src/logic/wallets/getWeb3'
import { fetchProvider } from 'src/logic/wallets/store/actions'
import { ADD_PROVIDER } from 'src/logic/wallets/store/actions/addProvider'
import { REMOVE_PROVIDER } from 'src/logic/wallets/store/actions/removeProvider'
@ -29,9 +29,6 @@ const providerWatcherMware = (store) => (next) => async (action) => {
clearInterval(watcherInterval)
}
if (currentProviderProps.name.toUpperCase() === WALLET_PROVIDER.METAMASK && (window as any).ethereum) {
;(window as any).ethereum.autoRefreshOnNetworkChange = false
}
saveToStorage(LAST_USED_PROVIDER_KEY, currentProviderProps.name)
watcherInterval = setInterval(async () => {

View File

@ -1,6 +1,6 @@
import React, { useEffect, useState } from 'react'
import { useSelector } from 'react-redux'
import { Redirect, Route, Switch, withRouter } from 'react-router-dom'
import { Redirect, Route, Switch, useLocation, useRouteMatch } from 'react-router-dom'
import { LOAD_ADDRESS, OPEN_ADDRESS, SAFELIST_ADDRESS, SAFE_PARAM_ADDRESS, WELCOME_ADDRESS } from './routes'
@ -19,8 +19,13 @@ const Load = React.lazy(() => import('./load/container/Load'))
const SAFE_ADDRESS = `${SAFELIST_ADDRESS}/:${SAFE_PARAM_ADDRESS}`
const Routes = ({ location }) => {
const Routes = (): React.ReactElement => {
const [isInitialLoad, setInitialLoad] = useState(true)
const location = useLocation()
const matchSafeWithAction = useRouteMatch<{ safeAddress: string; safeAction: string }>({
path: `${SAFELIST_ADDRESS}/:safeAddress/:safeAction`,
})
const defaultSafe = useSelector(defaultSafeSelector)
const { trackPage } = useAnalytics()
@ -31,9 +36,18 @@ const Routes = ({ location }) => {
}, [location.pathname, isInitialLoad])
useEffect(() => {
const page = location.pathname + location.search
trackPage(page)
}, [location.pathname, location.search, trackPage])
if (matchSafeWithAction) {
// prevent logging safeAddress
let safePage = `${SAFELIST_ADDRESS}/SAFE_ADDRESS`
if (matchSafeWithAction.params?.safeAction) {
safePage += `/${matchSafeWithAction.params?.safeAction}`
}
trackPage(safePage)
} else {
const page = `${location.pathname}${location.search}`
trackPage(page)
}
}, [location, matchSafeWithAction, trackPage])
return (
<Switch>
@ -65,4 +79,4 @@ const Routes = ({ location }) => {
)
}
export default withRouter(Routes)
export default Routes

View File

@ -9,7 +9,7 @@ import Row from 'src/components/layout/Row'
import { instantiateSafeContracts } from 'src/logic/contracts/safeContracts'
import { Review } from 'src/routes/open/components/ReviewInformation'
import SafeNameField from 'src/routes/open/components/SafeNameForm'
import { SafeOwnersPage } from 'src/routes/open/components/SafeOwnersConfirmationsForm'
import { SafeOwnersPage, validateOwnersForm } from 'src/routes/open/components/SafeOwnersConfirmationsForm'
import {
FIELD_CONFIRMATIONS,
FIELD_CREATION_PROXY_SALT,
@ -133,7 +133,7 @@ export const Layout = (props: LayoutProps): React.ReactElement => {
testId="create-safe-form"
>
<StepperPage component={SafeNameField} />
<StepperPage component={SafeOwnersPage} />
<StepperPage component={SafeOwnersPage} validate={validateOwnersForm} />
<StepperPage network={network} userAccount={userAccount} component={Review} />
</Stepper>
</Block>

View File

@ -4,7 +4,6 @@ import { makeStyles } from '@material-ui/core/styles'
import CheckCircle from '@material-ui/icons/CheckCircle'
import * as React from 'react'
import { styles } from './style'
import { getAddressValidator } from './validators'
import QRIcon from 'src/assets/icons/qrcode.svg'
import trash from 'src/assets/icons/trash.svg'
@ -21,6 +20,7 @@ import {
noErrorsOn,
required,
minMaxLength,
ADDRESS_REPEATED_ERROR,
} from 'src/components/forms/validator'
import Block from 'src/components/layout/Block'
import Button from 'src/components/layout/Button'
@ -44,30 +44,70 @@ const { useState } = React
export const ADD_OWNER_BUTTON = '+ Add another owner'
export const calculateValuesAfterRemoving = (index, notRemovedOwners, values) => {
const initialValues = { ...values }
/**
* Validates the whole OwnersForm, specially checks for non-repeated addresses
*
* If finds a repeated address, marks it as invalid
* @param {Object<string, string>} values
* @return Object<string, string>
*/
export const validateOwnersForm = (values: Record<string, string>): Record<string, string> => {
const { errors } = Object.keys(values).reduce(
(result, key) => {
if (/owner\d+Address/.test(key)) {
const address = values[key].toLowerCase()
const numOwnersAfterRemoving = notRemovedOwners - 1
if (result.addresses.includes(address)) {
result.errors[key] = ADDRESS_REPEATED_ERROR
}
for (let i = index; i < numOwnersAfterRemoving; i += 1) {
initialValues[getOwnerNameBy(i)] = values[getOwnerNameBy(i + 1)]
initialValues[getOwnerAddressBy(i)] = values[getOwnerAddressBy(i + 1)]
}
if (+values[FIELD_CONFIRMATIONS] === notRemovedOwners) {
initialValues[FIELD_CONFIRMATIONS] = numOwnersAfterRemoving.toString()
}
delete initialValues[getOwnerNameBy(index)]
delete initialValues[getOwnerAddressBy(index)]
return initialValues
result.addresses.push(address)
}
return result
},
{ addresses: [] as string[], errors: {} },
)
return errors
}
export const calculateValuesAfterRemoving = (index: number, values: Record<string, string>): Record<string, string> =>
Object.keys(values)
.sort()
.reduce((newValues, key) => {
const ownerRelatedField = /owner(\d+)(Name|Address)/
if (!ownerRelatedField.test(key)) {
// no owner-related field
newValues[key] = values[key]
return newValues
}
const ownerToRemove = new RegExp(`owner${index}(Name|Address)`)
if (ownerToRemove.test(key)) {
// skip, doing anything with the removed field
return newValues
}
// we only have the owner-related fields to work with
// we must reduce the index value for those owners that come after the deleted owner row
const [, ownerOrder, ownerField] = key.match(ownerRelatedField) as RegExpMatchArray
if (Number(ownerOrder) > index) {
// reduce by one the order of the owner
newValues[`owner${Number(ownerOrder) - 1}${ownerField}`] = values[key]
} else {
// previous owners to the deleted row
newValues[key] = values[key]
}
return newValues
}, {} as Record<string, string>)
const useStyles = makeStyles(styles)
const SafeOwnersForm = (props): React.ReactElement => {
const { errors, form, otherAccounts, values } = props
const { errors, form, values } = props
const classes = useStyles()
const validOwners = getNumOwnersFrom(values)
@ -87,7 +127,7 @@ const SafeOwnersForm = (props): React.ReactElement => {
}
const onRemoveRow = (index) => () => {
const initialValues = calculateValuesAfterRemoving(index, numOwners, values)
const initialValues = calculateValuesAfterRemoving(index, values)
form.reset(initialValues)
setNumOwners(numOwners - 1)
@ -171,7 +211,6 @@ const SafeOwnersForm = (props): React.ReactElement => {
name={addressName}
placeholder="Owner Address*"
text="Owner Address"
validators={[getAddressValidator(otherAccounts, index)]}
testId={`create-safe-address-field-${index}`}
/>
</Col>

View File

@ -0,0 +1,49 @@
import { calculateValuesAfterRemoving } from 'src/routes/open/components/SafeOwnersConfirmationsForm'
describe('calculateValuesAfterRemoving', () => {
it(`should properly remove the last owner row`, () => {
// Given
const formContent = {
name: 'My Safe',
owner0Name: 'Owner 0',
owner0Address: '0x90F8bf6A479f320ead074411a4B0e7944Ea8c9C1',
owner1Name: 'Owner 1',
owner1Address: '0xFFcf8FDEE72ac11b5c542428B35EEF5769C409f0',
}
// When
const newFormContent = calculateValuesAfterRemoving(1, formContent)
// Then
expect(newFormContent).toStrictEqual({
name: 'My Safe',
owner0Name: 'Owner 0',
owner0Address: '0x90F8bf6A479f320ead074411a4B0e7944Ea8c9C1',
})
})
it(`should properly remove an owner and recalculate fields indices`, () => {
// Given
const formContent = {
name: 'My Safe',
owner0Name: 'Owner 0',
owner0Address: '0x90F8bf6A479f320ead074411a4B0e7944Ea8c9C1',
owner1Name: 'Owner 1',
owner1Address: '0xFFcf8FDEE72ac11b5c542428B35EEF5769C409f0',
owner2Name: 'Owner 2',
owner2Address: '0x22d491Bde2303f2f43325b2108D26f1eAbA1e32b',
}
// When
const newFormContent = calculateValuesAfterRemoving(1, formContent)
// Then
expect(newFormContent).toStrictEqual({
name: 'My Safe',
owner0Name: 'Owner 0',
owner0Address: '0x90F8bf6A479f320ead074411a4B0e7944Ea8c9C1',
owner1Name: 'Owner 2',
owner1Address: '0x22d491Bde2303f2f43325b2108D26f1eAbA1e32b',
})
})
})

View File

@ -150,7 +150,6 @@ const Open = (): React.ReactElement => {
ReactGA.event({
category: 'User',
action: 'Created a safe',
value: safeAddress,
})
removeFromStorage(SAFE_PENDING_CREATION_STORAGE_KEY)

View File

@ -16,10 +16,10 @@ import { background, connected } from 'src/theme/variables'
import { providerNameSelector } from 'src/logic/wallets/store/selectors'
import { useSelector } from 'react-redux'
const loaderDotsSvg = require('./assets/loader-dots.svg')
const successSvg = require('./assets/success.svg')
const vaultErrorSvg = require('./assets/vault-error.svg')
const vaultSvg = require('./assets/vault.svg')
import LoaderDotsSvg from './assets/loader-dots.svg'
import SuccessSvg from './assets/success.svg'
import VaultErrorSvg from './assets/vault-error.svg'
import VaultSvg from './assets/vault.svg'
const Wrapper = styled.div`
display: grid;
@ -140,14 +140,14 @@ const SafeDeployment = ({ creationTxHash, onCancel, onRetry, onSuccess, submitte
const getImage = () => {
if (error) {
return vaultErrorSvg
return VaultErrorSvg
}
if (stepIndex <= 4) {
return vaultSvg
return VaultSvg
}
return successSvg
return SuccessSvg
}
useEffect(() => {
@ -323,7 +323,7 @@ const SafeDeployment = ({ creationTxHash, onCancel, onRetry, onSuccess, submitte
<CardTitle>{steps[stepIndex].description || steps[stepIndex].label}</CardTitle>
</BodyDescription>
<BodyLoader>{!error && stepIndex <= 4 && <Img alt="Loader dots" src={loaderDotsSvg} />}</BodyLoader>
<BodyLoader>{!error && stepIndex <= 4 && <Img alt="Loader dots" src={LoaderDotsSvg} />}</BodyLoader>
<BodyInstruction>
<FullParagraph color="primary" inverseColors={confirmationStep} noMargin size="md">

View File

@ -1,10 +1,9 @@
import IconButton from '@material-ui/core/IconButton'
import { withStyles } from '@material-ui/core/styles'
import Close from '@material-ui/icons/Close'
import React from 'react'
import React, { ReactElement } from 'react'
import { useSelector } from 'react-redux'
import { styles } from './style'
import { useStyles } from './style'
import Modal from 'src/components/Modal'
import { ScanQRWrapper } from 'src/components/ScanQRModal/ScanQRWrapper'
@ -20,55 +19,69 @@ 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'
import { Entry } from 'src/routes/safe/components/AddressBook/index'
export const CREATE_ENTRY_INPUT_NAME_ID = 'create-entry-input-name'
export const CREATE_ENTRY_INPUT_ADDRESS_ID = 'create-entry-input-address'
export const SAVE_NEW_ENTRY_BTN_ID = 'save-new-entry-btn-id'
const CreateEditEntryModalComponent = ({
classes,
const formMutators = {
setOwnerAddress: (args, state, utils) => {
utils.changeValue(state, 'address', () => args[0])
},
}
type CreateEditEntryModalProps = {
editEntryModalHandler: (entry: AddressBookEntry) => void
entryToEdit: Entry
isOpen: boolean
newEntryModalHandler: (entry: AddressBookEntry) => void
onClose: () => void
}
export const CreateEditEntryModal = ({
editEntryModalHandler,
entryToEdit,
isOpen,
newEntryModalHandler,
onClose,
}) => {
const onFormSubmitted = (values) => {
if (entryToEdit && !entryToEdit.entry.isNew) {
editEntryModalHandler(values)
} else {
}: CreateEditEntryModalProps): ReactElement => {
const classes = useStyles()
const { isNew, ...initialValues } = entryToEdit.entry
const onFormSubmitted = (values: AddressBookEntry) => {
if (isNew) {
newEntryModalHandler(values)
} else {
editEntryModalHandler(values)
}
}
const addressBookAddressesList = useSelector(addressBookAddressesListSelector)
const entryDoesntExist = uniqueAddress(addressBookAddressesList)
const formMutators = {
setOwnerAddress: (args, state, utils) => {
utils.changeValue(state, 'address', () => args[0])
},
}
const storedAddresses = useSelector(addressBookAddressesListSelector)
const isUniqueAddress = uniqueAddress(storedAddresses)
return (
<Modal
description={entryToEdit ? 'Edit addressBook entry' : 'Create new addressBook entry'}
description={isNew ? 'Create new addressBook entry' : 'Edit addressBook entry'}
handleClose={onClose}
open={isOpen}
paperClassName={classes.smallerModalWindow}
title={entryToEdit ? 'Edit entry' : 'Create new entry'}
title={isNew ? 'Create new entry' : 'Edit entry'}
>
<Row align="center" className={classes.heading} grow>
<Paragraph className={classes.manage} noMargin weight="bolder">
{entryToEdit ? 'Edit entry' : 'Create entry'}
{isNew ? 'Create entry' : 'Edit entry'}
</Paragraph>
<IconButton disableRipple onClick={onClose}>
<Close className={classes.close} />
</IconButton>
</Row>
<Hairline />
<GnoForm formMutators={formMutators} onSubmit={onFormSubmitted}>
<GnoForm formMutators={formMutators} onSubmit={onFormSubmitted} initialValues={initialValues}>
{(...args) => {
const formState = args[2]
const mutators = args[3]
const handleScan = (value, closeQrModal) => {
let scannedAddress = value
@ -86,13 +99,11 @@ const CreateEditEntryModalComponent = ({
<Row margin="md">
<Col xs={11}>
<Field
className={classes.addressInput}
component={TextField}
defaultValue={entryToEdit ? entryToEdit.entry.name : undefined}
name="name"
placeholder={entryToEdit ? 'Entry name' : 'New entry'}
placeholder="Name"
testId={CREATE_ENTRY_INPUT_NAME_ID}
text={entryToEdit ? 'Entry*' : 'New entry*'}
text="Name"
type="text"
validate={composeValidators(required, minMaxLength(1, 50))}
/>
@ -101,18 +112,16 @@ const CreateEditEntryModalComponent = ({
<Row margin="md">
<Col xs={11}>
<AddressInput
className={classes.addressInput}
defaultValue={entryToEdit ? entryToEdit.entry.address : undefined}
disabled={!!entryToEdit}
disabled={!isNew}
fieldMutator={mutators.setOwnerAddress}
name="address"
placeholder="Owner address*"
placeholder="Address*"
testId={CREATE_ENTRY_INPUT_ADDRESS_ID}
text="Owner address*"
validators={entryToEdit ? undefined : [entryDoesntExist]}
text="Address*"
validators={[(value?: string) => (isNew ? isUniqueAddress(value) : undefined)]}
/>
</Col>
{!entryToEdit ? (
{isNew ? (
<Col center="xs" className={classes} middle="xs" xs={1}>
<ScanQRWrapper handleScan={handleScan} />
</Col>
@ -131,8 +140,9 @@ const CreateEditEntryModalComponent = ({
testId={SAVE_NEW_ENTRY_BTN_ID}
type="submit"
variant="contained"
disabled={!formState.valid}
>
{entryToEdit ? 'Save' : 'Create'}
{isNew ? 'Create' : 'Save'}
</Button>
</Row>
</>
@ -142,5 +152,3 @@ const CreateEditEntryModalComponent = ({
</Modal>
)
}
export default withStyles(styles as any)(CreateEditEntryModalComponent)

View File

@ -1,26 +1,30 @@
import { createStyles, makeStyles } from '@material-ui/core/styles'
import { lg, md } from 'src/theme/variables'
export const styles = () => ({
heading: {
padding: lg,
justifyContent: 'space-between',
boxSizing: 'border-box',
},
manage: {
fontSize: lg,
},
container: {
padding: `${md} ${lg}`,
},
close: {
height: '35px',
width: '35px',
},
buttonRow: {
height: '84px',
justifyContent: 'center',
},
smallerModalWindow: {
height: 'auto',
},
})
export const useStyles = makeStyles(
createStyles({
heading: {
padding: lg,
justifyContent: 'space-between',
boxSizing: 'border-box',
},
manage: {
fontSize: lg,
},
container: {
padding: `${md} ${lg}`,
},
close: {
height: '35px',
width: '35px',
},
buttonRow: {
height: '84px',
justifyContent: 'center',
},
smallerModalWindow: {
height: 'auto',
},
}),
)

View File

@ -3,7 +3,7 @@ import TableContainer from '@material-ui/core/TableContainer'
import TableRow from '@material-ui/core/TableRow'
import { makeStyles } from '@material-ui/core/styles'
import cn from 'classnames'
import React, { useEffect, useState } from 'react'
import React, { ReactElement, useEffect, useState } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import { styles } from './style'
@ -21,8 +21,8 @@ import { addAddressBookEntry } from 'src/logic/addressBook/store/actions/addAddr
import { removeAddressBookEntry } from 'src/logic/addressBook/store/actions/removeAddressBookEntry'
import { updateAddressBookEntry } from 'src/logic/addressBook/store/actions/updateAddressBookEntry'
import { addressBookSelector } from 'src/logic/addressBook/store/selectors'
import { isUserAnOwnerOfAnySafe } from 'src/logic/wallets/ethAddresses'
import CreateEditEntryModal from 'src/routes/safe/components/AddressBook/CreateEditEntryModal'
import { isUserAnOwnerOfAnySafe, sameAddress } from 'src/logic/wallets/ethAddresses'
import { CreateEditEntryModal } from 'src/routes/safe/components/AddressBook/CreateEditEntryModal'
import DeleteEntryModal from 'src/routes/safe/components/AddressBook/DeleteEntryModal'
import {
AB_ADDRESS_ID,
@ -47,20 +47,24 @@ interface AddressBookSelectedEntry extends AddressBookEntry {
isNew?: boolean
}
const AddressBookTable = (): React.ReactElement => {
export type Entry = {
entry: AddressBookSelectedEntry
index?: number
isOwnerAddress?: boolean
}
const initialEntryState: Entry = { entry: { address: '', name: '', isNew: true } }
const AddressBookTable = (): ReactElement => {
const classes = useStyles()
const columns = generateColumns()
const autoColumns = columns.filter((c) => !c.custom)
const autoColumns = columns.filter(({ custom }) => !custom)
const dispatch = useDispatch()
const safesList = useSelector(safesListSelector)
const entryAddressToEditOrCreateNew = useSelector(addressBookQueryParamsSelector)
const addressBook = useSelector(addressBookSelector)
const granted = useSelector(grantedSelector)
const [selectedEntry, setSelectedEntry] = useState<{
entry?: AddressBookSelectedEntry
index?: number
isOwnerAddress?: boolean
} | null>(null)
const [selectedEntry, setSelectedEntry] = useState<Entry>(initialEntryState)
const [editCreateEntryModalOpen, setEditCreateEntryModalOpen] = useState(false)
const [deleteEntryModalOpen, setDeleteEntryModalOpen] = useState(false)
const [sendFundsModalOpen, setSendFundsModalOpen] = useState(false)
@ -78,8 +82,9 @@ const AddressBookTable = (): React.ReactElement => {
useEffect(() => {
if (entryAddressToEditOrCreateNew) {
const checksumEntryAdd = checksumAddress(entryAddressToEditOrCreateNew)
const oldEntryIndex = addressBook.findIndex((entry) => entry.address === checksumEntryAdd)
const address = checksumAddress(entryAddressToEditOrCreateNew)
const oldEntryIndex = addressBook.findIndex((entry) => sameAddress(entry.address, address))
if (oldEntryIndex >= 0) {
// Edit old entry
setSelectedEntry({ entry: addressBook[oldEntryIndex], index: oldEntryIndex })
@ -88,7 +93,7 @@ const AddressBookTable = (): React.ReactElement => {
setSelectedEntry({
entry: {
name: '',
address: checksumEntryAdd,
address,
isNew: true,
},
})
@ -96,7 +101,7 @@ const AddressBookTable = (): React.ReactElement => {
}
}, [addressBook, entryAddressToEditOrCreateNew])
const newEntryModalHandler = (entry) => {
const newEntryModalHandler = (entry: AddressBookEntry) => {
setEditCreateEntryModalOpen(false)
const checksumEntries = {
...entry,
@ -105,8 +110,8 @@ const AddressBookTable = (): React.ReactElement => {
dispatch(addAddressBookEntry(makeAddressBookEntry(checksumEntries)))
}
const editEntryModalHandler = (entry) => {
setSelectedEntry(null)
const editEntryModalHandler = (entry: AddressBookEntry) => {
setSelectedEntry(initialEntryState)
setEditCreateEntryModalOpen(false)
const checksumEntries = {
...entry,
@ -116,8 +121,8 @@ const AddressBookTable = (): React.ReactElement => {
}
const deleteEntryModalHandler = () => {
const entryAddress = selectedEntry && selectedEntry.entry ? checksumAddress(selectedEntry.entry.address) : ''
setSelectedEntry(null)
const entryAddress = selectedEntry?.entry ? checksumAddress(selectedEntry.entry.address) : ''
setSelectedEntry(initialEntryState)
setDeleteEntryModalOpen(false)
dispatch(removeAddressBookEntry(entryAddress))
}
@ -128,8 +133,8 @@ const AddressBookTable = (): React.ReactElement => {
<Col end="sm" xs={12}>
<ButtonLink
onClick={() => {
setSelectedEntry(null)
setEditCreateEntryModalOpen(!editCreateEntryModalOpen)
setSelectedEntry(initialEntryState)
setEditCreateEntryModalOpen(true)
}}
size="lg"
testId="manage-tokens-btn"

View File

@ -0,0 +1,106 @@
import { MutableRefObject, useEffect, useState } from 'react'
import {
getSDKVersion,
SDKMessageEvent,
MethodToResponse,
Methods,
ErrorResponse,
MessageFormatter,
METHODS,
} from '@gnosis.pm/safe-apps-sdk'
import { SafeApp } from './types.d'
type MessageHandler = (
msg: SDKMessageEvent,
) => void | MethodToResponse[Methods] | ErrorResponse | Promise<MethodToResponse[Methods] | ErrorResponse | void>
class AppCommunicator {
private iframe: HTMLIFrameElement
private handlers = new Map<Methods, MessageHandler>()
private app: SafeApp
constructor(iframeRef: MutableRefObject<HTMLIFrameElement>, app: SafeApp) {
this.iframe = iframeRef.current
this.app = app
window.addEventListener('message', this.handleIncomingMessage)
}
on = (method: Methods, handler: MessageHandler): void => {
this.handlers.set(method, handler)
}
private isValidMessage = (msg: SDKMessageEvent): boolean => {
// @ts-expect-error .parent doesn't exist on some possible types
const sentFromIframe = msg.source.parent === window.parent
const knownOrigin = this.app.url.includes(msg.origin)
const knownMethod = Object.values(METHODS).includes(msg.data.method)
return knownOrigin && sentFromIframe && knownMethod
}
private canHandleMessage = (msg: SDKMessageEvent): boolean => {
return Boolean(this.handlers.get(msg.data.method))
}
send = (data, requestId, error = false): void => {
const sdkVersion = getSDKVersion()
const msg = error
? MessageFormatter.makeErrorResponse(requestId, data, sdkVersion)
: MessageFormatter.makeResponse(requestId, data, sdkVersion)
this.iframe.contentWindow?.postMessage(msg, this.app.url)
}
handleIncomingMessage = async (msg: SDKMessageEvent): Promise<void> => {
const validMessage = this.isValidMessage(msg)
const hasHandler = this.canHandleMessage(msg)
if (validMessage && hasHandler) {
const handler = this.handlers.get(msg.data.method)
try {
// @ts-expect-error Handler existence is checked in this.canHandleMessage
const response = await handler(msg)
// If response is not returned, it means the response will be send somewhere else
if (typeof response !== 'undefined') {
this.send(response, msg.data.id)
}
} catch (err) {
console.log({ err })
this.send(err.message, msg.data.id, true)
}
}
}
clear = (): void => {
window.removeEventListener('message', this.handleIncomingMessage)
}
}
const useAppCommunicator = (
iframeRef: MutableRefObject<HTMLIFrameElement | null>,
app?: SafeApp,
): AppCommunicator | undefined => {
const [communicator, setCommunicator] = useState<AppCommunicator | undefined>(undefined)
useEffect(() => {
let communicatorInstance
const initCommunicator = (iframeRef: MutableRefObject<HTMLIFrameElement>, app: SafeApp) => {
communicatorInstance = new AppCommunicator(iframeRef, app)
setCommunicator(communicatorInstance)
}
if (app && iframeRef.current !== null) {
initCommunicator(iframeRef as MutableRefObject<HTMLIFrameElement>, app)
}
return () => {
communicatorInstance?.clear()
}
}, [app, iframeRef])
return communicator
}
export { useAppCommunicator }

View File

@ -11,15 +11,10 @@ import {
Menu,
ButtonLink,
} from '@gnosis.pm/safe-react-components'
import { MethodToResponse, RPCPayload } from '@gnosis.pm/safe-apps-sdk'
import { useHistory, useRouteMatch } from 'react-router-dom'
import { useSelector } from 'react-redux'
import {
INTERFACE_MESSAGES,
Transaction,
RequestId,
LowercaseNetworks,
SendTransactionParams,
} from '@gnosis.pm/safe-apps-sdk'
import { INTERFACE_MESSAGES, Transaction, RequestId, LowercaseNetworks } from '@gnosis.pm/safe-apps-sdk-v1'
import {
safeEthBalanceSelector,
@ -27,12 +22,15 @@ import {
safeNameSelector,
} from 'src/logic/safe/store/selectors'
import { grantedSelector } from 'src/routes/safe/container/selector'
import { getNetworkName } from 'src/config'
import { getNetworkName, getTxServiceUrl } from 'src/config'
import { SAFELIST_ADDRESS } from 'src/routes/routes'
import { isSameURL } from 'src/utils/url'
import { useAnalytics, SAFE_NAVIGATION_EVENT } from 'src/utils/googleAnalytics'
import { loadFromStorage, saveToStorage } from 'src/utils/storage'
import { staticAppsList } from 'src/routes/safe/components/Apps/utils'
import { LoadingContainer } from 'src/components/LoaderContainer/index'
import { TIMEOUT } from 'src/utils/constants'
import { web3ReadOnly } from 'src/logic/wallets/getWeb3'
import ConfirmTransactionModal from '../components/ConfirmTransactionModal'
import { useIframeMessageHandler } from '../hooks/useIframeMessageHandler'
@ -40,7 +38,7 @@ import { useLegalConsent } from '../hooks/useLegalConsent'
import LegalDisclaimer from './LegalDisclaimer'
import { APPS_STORAGE_KEY, getAppInfoFromUrl } from '../utils'
import { SafeApp, StoredSafeApp } from '../types.d'
import { LoadingContainer } from 'src/components/LoaderContainer'
import { useAppCommunicator } from '../communicator'
const OwnerDisclaimer = styled.div`
display: flex;
@ -71,11 +69,15 @@ const Breadcrumb = styled.div`
height: 51px;
`
export type TransactionParams = {
safeTxGas?: number
}
type ConfirmTransactionModalState = {
isOpen: boolean
txs: Transaction[]
requestId?: RequestId
params?: SendTransactionParams
params?: TransactionParams
}
type Props = {
@ -112,9 +114,26 @@ const AppFrame = ({ appUrl }: Props): React.ReactElement => {
const [isAppDeletable, setIsAppDeletable] = useState<boolean | undefined>()
const redirectToBalance = () => history.push(`${SAFELIST_ADDRESS}/${safeAddress}/balances`)
const timer = useRef<number>()
const [appTimeout, setAppTimeout] = useState(false)
useEffect(() => {
if (appIsLoading) {
timer.current = setTimeout(() => {
setAppTimeout(true)
}, TIMEOUT)
} else {
clearTimeout(timer.current)
setAppTimeout(false)
}
return () => {
clearTimeout(timer.current)
}
}, [appIsLoading])
const openConfirmationModal = useCallback(
(txs: Transaction[], params: SendTransactionParams | undefined, requestId: RequestId) =>
(txs: Transaction[], params: TransactionParams | undefined, requestId: RequestId) =>
setConfirmTransactionModal({
isOpen: true,
txs,
@ -151,18 +170,78 @@ const AppFrame = ({ appUrl }: Props): React.ReactElement => {
})
}, [ethBalance, safeAddress, appUrl, sendMessageToIframe])
const communicator = useAppCommunicator(iframeRef, safeApp)
useEffect(() => {
communicator?.on('getEnvInfo', () => ({
txServiceUrl: getTxServiceUrl(),
}))
communicator?.on('getSafeInfo', () => ({
safeAddress,
network: NETWORK_NAME,
}))
communicator?.on('rpcCall', async (msg) => {
const params = msg.data.params as RPCPayload
try {
const response = new Promise<MethodToResponse['rpcCall']>((resolve, reject) => {
if (
web3ReadOnly.currentProvider !== null &&
typeof web3ReadOnly.currentProvider !== 'string' &&
'send' in web3ReadOnly.currentProvider
) {
web3ReadOnly.currentProvider?.send?.(
{
jsonrpc: '2.0',
method: params.call,
params: params.params,
id: '1',
},
(err, res) => {
if (err || res?.error) {
reject(err || res?.error)
}
resolve(res?.result)
},
)
}
})
return response
} catch (err) {
return err
}
})
communicator?.on('sendTransactions', (msg) => {
// @ts-expect-error explore ways to fix this
openConfirmationModal(msg.data.params.txs as Transaction[], msg.data.params.params, msg.data.id)
})
}, [communicator, openConfirmationModal, safeAddress])
const onUserTxConfirm = (safeTxHash: string) => {
// Safe Apps SDK V1 Handler
sendMessageToIframe(
{ messageId: INTERFACE_MESSAGES.TRANSACTION_CONFIRMED, data: { safeTxHash } },
confirmTransactionModal.requestId,
)
// Safe Apps SDK V2 Handler
communicator?.send({ safeTxHash }, confirmTransactionModal.requestId)
}
const onTxReject = () => {
// Safe Apps SDK V1 Handler
sendMessageToIframe(
{ messageId: INTERFACE_MESSAGES.TRANSACTION_REJECTED, data: {} },
confirmTransactionModal.requestId,
)
// Safe Apps SDK V2 Handler
communicator?.send('Transaction was rejected', confirmTransactionModal.requestId, true)
}
const openRemoveModal = () => setIsRemoveModalOpen(true)
@ -235,7 +314,12 @@ const AppFrame = ({ appUrl }: Props): React.ReactElement => {
<StyledCard>
{appIsLoading && (
<LoadingContainer>
<LoadingContainer style={{ flexDirection: 'column' }}>
{appTimeout && (
<Title size="xs">
The safe-app is taking longer than usual to load. There might be a problem with the safe-app provider.
</Title>
)}
<Loader size="md" />
</LoadingContainer>
)}

View File

@ -1,6 +1,6 @@
import React, { useEffect, useState } from 'react'
import { Icon, Text, Title, GenericModal, ModalFooterConfirmation } from '@gnosis.pm/safe-react-components'
import { Transaction, SendTransactionParams } from '@gnosis.pm/safe-apps-sdk'
import { Transaction } from '@gnosis.pm/safe-apps-sdk-v1'
import styled from 'styled-components'
import { useDispatch } from 'react-redux'
@ -24,6 +24,7 @@ import { estimateSafeTxGas } from 'src/logic/safe/transactions/gas'
import GasEstimationInfo from './GasEstimationInfo'
import { getNetworkInfo } from 'src/config'
import { TransactionParams } from './AppFrame'
const isTxValid = (t: Transaction): boolean => {
if (!['string', 'number'].includes(typeof t.value)) {
@ -70,7 +71,7 @@ type OwnProps = {
isOpen: boolean
app: SafeApp
txs: Transaction[]
params?: SendTransactionParams
params?: TransactionParams
safeAddress: string
safeName: string
ethBalance: string

View File

@ -9,8 +9,7 @@ import {
RequestId,
Transaction,
LowercaseNetworks,
SendTransactionParams,
} from '@gnosis.pm/safe-apps-sdk'
} 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/'
@ -19,7 +18,7 @@ import {
safeNameSelector,
safeParamAddressFromStateSelector,
} from 'src/logic/safe/store/selectors'
import { web3ReadOnly } from 'src/logic/wallets/getWeb3'
import { TransactionParams } from '../components/AppFrame'
import { SafeApp } from 'src/routes/safe/components/Apps/types.d'
type InterfaceMessageProps<T extends InterfaceMessageIds> = {
@ -31,19 +30,11 @@ type ReturnType = {
sendMessageToIframe: <T extends InterfaceMessageIds>(message: InterfaceMessageProps<T>, requestId?: RequestId) => void
}
interface CustomMessageEvent extends MessageEvent {
data: {
requestId: RequestId
messageId: SDKMessageIds
data: SDKMessageToPayload[SDKMessageIds]
}
}
const NETWORK_NAME = getNetworkName()
const useIframeMessageHandler = (
selectedApp: SafeApp | undefined,
openConfirmationModal: (txs: Transaction[], params: SendTransactionParams | undefined, requestId: RequestId) => void,
openConfirmationModal: (txs: Transaction[], params: TransactionParams | undefined, requestId: RequestId) => void,
closeModal: () => void,
iframeRef: MutableRefObject<HTMLIFrameElement | null>,
): ReturnType => {
@ -58,6 +49,7 @@ const useIframeMessageHandler = (
const requestWithMessage = {
...message,
requestId: requestId || Math.trunc(window.performance.now()),
version: '0.4.2',
}
if (iframeRef && selectedApp) {
@ -93,44 +85,6 @@ const useIframeMessageHandler = (
break
}
case SDK_MESSAGES.SEND_TRANSACTIONS_V2: {
const payload = messagePayload as SDKMessageToPayload[typeof SDK_MESSAGES.SEND_TRANSACTIONS_V2]
if (payload) {
openConfirmationModal(payload.txs, payload.params, requestId)
}
break
}
case SDK_MESSAGES.RPC_CALL: {
const payload = messagePayload as SDKMessageToPayload['RPC_CALL']
if (
web3ReadOnly.currentProvider !== null &&
typeof web3ReadOnly.currentProvider !== 'string' &&
'send' in web3ReadOnly.currentProvider
) {
web3ReadOnly.currentProvider?.send?.(
{
jsonrpc: '2.0',
method: payload?.call,
params: payload?.params,
id: '1',
},
(err, res) => {
if (!err) {
const rpcCallMsg = {
messageId: INTERFACE_MESSAGES.RPC_CALL_RESPONSE,
data: res,
}
sendMessageToIframe(rpcCallMsg, requestId)
}
},
)
}
break
}
case SDK_MESSAGES.SAFE_APP_SDK_INITIALIZED: {
const safeInfoMessage = {
messageId: INTERFACE_MESSAGES.ON_SAFE_INFO,
@ -157,7 +111,13 @@ const useIframeMessageHandler = (
}
}
}
const onIframeMessage = async (message: CustomMessageEvent) => {
const onIframeMessage = async (
message: MessageEvent<{
requestId: RequestId
messageId: SDKMessageIds
data: SDKMessageToPayload[SDKMessageIds]
}>,
) => {
if (message.origin === window.origin) {
return
}

View File

@ -1,10 +1,10 @@
import React from 'react'
import { useLocation } from 'react-router-dom'
import AppFrame from './components/AppFrame'
import AppsList from './components/AppsList'
import { useLocation } from 'react-router-dom'
const useQuery = () => {
return new URLSearchParams(useLocation().search)
}

View File

@ -32,7 +32,7 @@ export const staticAppsList: Array<StaticAppInfo> = [
},
// Aave
{
url: `${process.env.REACT_APP_IPFS_GATEWAY}/QmX1NUtvm9WjbvT79sTdeg3sw1NxZAM273y44nBy5d2jZb`,
url: `${process.env.REACT_APP_IPFS_GATEWAY}/QmQ3w2ezp2zx3u2LYQHyuNzMrLDJFjyL1rjAFTjNMcQ4cK`,
disabled: false,
networks: [ETHEREUM_NETWORK.MAINNET],
},
@ -95,7 +95,7 @@ export const staticAppsList: Array<StaticAppInfo> = [
},
// TX-Builder
{
url: `${process.env.REACT_APP_IPFS_GATEWAY}/QmXdrr9hRbXSaqMb71iKnEp66PwwsAbJDR9XdwByUYSTxB`,
url: `${process.env.REACT_APP_IPFS_GATEWAY}/QmYES1Se6i6679z3PfQ62bydgVVEoSRUabvjB35DfUGPGA`,
disabled: false,
networks: [
ETHEREUM_NETWORK.MAINNET,

View File

@ -34,7 +34,7 @@ const CurrencyDropdown = (): React.ReactElement | null => {
const currenciesList = Object.values(AVAILABLE_CURRENCIES)
const tokenImage = nativeCoin.logoUri
const classes = useDropdownStyles()
const classes = useDropdownStyles({})
const currenciesListFiltered = currenciesList.filter((currency) =>
currency.toLowerCase().includes(searchParams.toLowerCase()),
)

View File

@ -7,7 +7,7 @@ import SettingsDescription from './SettingsDescription'
import CustomDescription from './CustomDescription'
import TransferDescription from './TransferDescription'
import { getTxAmount } from 'src/routes/safe/components/Transactions/TxsTable/columns'
import { getRawTxAmount, getTxAmount } from 'src/routes/safe/components/Transactions/TxsTable/columns'
import Block from 'src/components/layout/Block'
import { Transaction, TransactionTypes } from 'src/logic/safe/store/models/types/transaction'
@ -33,8 +33,9 @@ const UpgradeDescriptionTx = ({ tx }: { tx: Transaction }): React.ReactElement =
const TransferDescriptionTx = ({ tx }: { tx: Transaction }): React.ReactElement => {
const amountWithSymbol = getTxAmount(tx, false)
const { recipient, isTokenTransfer = false } = getTxData(tx)
return <TransferDescription {...{ amountWithSymbol, recipient, isTokenTransfer }} />
const rawAmount = getRawTxAmount(tx)
const { recipient, isTokenTransfer = false, tokenAddress } = getTxData(tx)
return <TransferDescription {...{ amountWithSymbol, recipient, isTokenTransfer, rawAmount, tokenAddress }} />
}
const TxDescription = ({ tx }: { tx: Transaction }): React.ReactElement => {

View File

@ -3,7 +3,7 @@ import IconButton from '@material-ui/core/IconButton'
import TableCell from '@material-ui/core/TableCell'
import TableContainer from '@material-ui/core/TableContainer'
import TableRow from '@material-ui/core/TableRow'
import { withStyles } from '@material-ui/core/styles'
import { makeStyles } from '@material-ui/core/styles'
import ExpandLess from '@material-ui/icons/ExpandLess'
import ExpandMore from '@material-ui/icons/ExpandMore'
import cn from 'classnames'
@ -25,7 +25,10 @@ import { useAnalytics, SAFE_NAVIGATION_EVENT } from 'src/utils/googleAnalytics'
export const TRANSACTION_ROW_TEST_ID = 'transaction-row'
const TxsTable = ({ classes }) => {
const useStyles = makeStyles(styles)
const TxsTable = (): React.ReactElement => {
const classes = useStyles()
const [expandedTx, setExpandedTx] = useState(null)
const cancellationTransactions = useSelector(safeCancellationTransactionsSelector)
const transactions = useSelector(extendedTransactionsSelector)
@ -95,10 +98,7 @@ const TxsTable = ({ classes }) => {
{autoColumns.map((column) => (
<TableCell
align={column.align}
className={cn(
classes.cell,
['cancelled', 'failed'].includes(row.status) && classes.cancelledRow,
)}
className={cn(['cancelled', 'failed'].includes(row.status) && classes.cancelledRow)}
component="td"
key={column.id}
style={cellWidth(column.width)}
@ -139,4 +139,4 @@ const TxsTable = ({ classes }) => {
)
}
export default withStyles(styles as any)(TxsTable)
export default TxsTable

View File

@ -1,4 +1,6 @@
export const styles = () => ({
import { createStyles } from '@material-ui/core'
export const styles = createStyles({
container: {
marginTop: '56px',
},

View File

@ -3,9 +3,7 @@ import React, { useState } from 'react'
import { useSelector } from 'react-redux'
import { Redirect, Route, Switch, useRouteMatch } from 'react-router-dom'
import NoSafe from 'src/components/NoSafe'
import { providerNameSelector } from 'src/logic/wallets/store/selectors'
import { safeFeaturesEnabledSelector, safeParamAddressFromStateSelector } from 'src/logic/safe/store/selectors'
import { safeFeaturesEnabledSelector } from 'src/logic/safe/store/selectors'
import { wrapInSuspense } from 'src/utils/wrapInSuspense'
import { SAFELIST_ADDRESS } from 'src/routes/routes'
import { FEATURES } from 'src/config/networks/network.d'
@ -35,14 +33,8 @@ const Container = (): React.ReactElement => {
onClose: () => {},
})
const safeAddress = useSelector(safeParamAddressFromStateSelector)
const provider = useSelector(providerNameSelector)
const matchSafeWithAddress = useRouteMatch({ path: `${SAFELIST_ADDRESS}/:safeAddress` })
if (!safeAddress) {
return <NoSafe provider={provider} text="Safe not found" />
}
if (!featuresEnabled) {
return (
<LoadingContainer>

View File

@ -1,25 +0,0 @@
@import "src/theme/variables.scss";
.safe {
justify-content: center;
justify-items: center;
margin-top: $xl;
}
.summary {
display: flex;
justify-content: space-around;
}
.safeActions {
display: flex;
justify-content: center;
}
.learnMoreLink {
color: $secondary;
}
.connectWallet {
text-align: center;
}

View File

@ -1,118 +1,189 @@
import OpenInNew from '@material-ui/icons/OpenInNew'
import * as React from 'react'
import React from 'react'
import styled from 'styled-components'
import {
Card,
Button,
Title,
Text,
Divider,
ButtonLink,
Dot,
Icon,
Link as LinkSRC,
} from '@gnosis.pm/safe-react-components'
import styles from './Layout.module.scss'
import ConnectButton from 'src/components/ConnectButton'
import Block from 'src/components/layout/Block'
import Button from 'src/components/layout/Button'
import Heading from 'src/components/layout/Heading'
import Img from 'src/components/layout/Img'
import Link from 'src/components/layout/Link'
import Block from 'src/components/layout/Block'
import { LOAD_ADDRESS, OPEN_ADDRESS } from 'src/routes/routes'
import { marginButtonImg, secondary } from 'src/theme/variables'
import { onConnectButtonClick } from 'src/components/ConnectButton'
const plus = require('../assets/new.svg')
const safe = require('../assets/safe.svg')
const Wrapper = styled.div`
display: flex;
flex-direction: row;
margin: 24px 0 0 0;
`
const StyledCardDouble = styled(Card)`
display: flex;
padding: 0;
`
const StyledCard = styled(Card)`
display: flex;
flex-direction: column;
align-items: flex-start;
margin: 0 20px 0 0;
max-width: 27%;
height: 276px;
`
const CardsCol = styled.div`
display: flex;
flex-direction: column;
align-items: flex-start;
padding: 24px;
width: 50%;
`
const StyledButton = styled(Button)`
margin-top: auto;
text-decoration: none;
`
const TitleWrapper = styled.div`
display: flex;
align-items: center;
justify-content: flex-start;
margin: 0 0 16px 0;
const openIconStyle = {
height: '13px',
color: secondary,
marginBottom: '-2px',
h5 {
color: white;
}
`
const StyledTitle = styled(Title)`
margin: 0 0 0 16px;
`
const StyledTitleOnly = styled(Title)`
margin: 0 0 16px 0;
`
const StyledButtonLink = styled(ButtonLink)`
margin: 16px 0 16px -8px;
`
type Props = {
isOldMultisigMigration?: boolean
provider: any
}
const buttonStyle = {
marginLeft: marginButtonImg,
}
export const CreateSafe = ({ provider, size }: any) => (
<Button
color="primary"
component={Link}
disabled={!provider}
minHeight={42}
minWidth={240}
size={size || 'medium'}
to={OPEN_ADDRESS}
variant="contained"
testId="create-new-safe-btn"
>
<Img alt="Safe" height={14} src={plus} />
<div style={buttonStyle}>Create new Safe</div>
</Button>
)
export const LoadSafe = ({ provider, size }) => (
<Button
color="primary"
component={Link}
disabled={!provider}
minWidth={240}
size={size || 'medium'}
to={LOAD_ADDRESS}
variant="outlined"
testId="load-existing-safe-btn"
>
<Img alt="Safe" height={14} src={safe} />
<div style={buttonStyle}>Load existing Safe</div>
</Button>
)
const Welcome = ({ isOldMultisigMigration, provider }: any) => {
const headingText = isOldMultisigMigration ? (
<>
We will replicate the owner structure from your existing Gnosis MultiSig
<br />
to let you test the new interface.
<br />
As soon as you feel comfortable, start moving funds to your new Safe.
<br />{' '}
</>
) : (
<>
Gnosis Safe Multisig is the most secure way to manage crypto funds
<br />
collectively. It is an improvement of the Gnosis MultiSig, which is used by more than 3000 teams
<br /> and stores over $1B USD worth of digital assets. Gnosis Safe Multisig features a modular
<br /> design, formally verified smart contracts and vastly improved user experience.{' '}
</>
)
const Welcome = ({ isOldMultisigMigration, provider }: Props): React.ReactElement => {
return (
<Block className={styles.safe}>
<Heading align="center" margin="lg" tag="h1" weight="bold">
Welcome to
<br />
Gnosis Safe Multisig
</Heading>
<Heading align="center" margin="xl" tag="h3">
{headingText}
<a
className={styles.learnMoreLink}
href="https://gnosis-safe.io/teams"
rel="noopener noreferrer"
target="_blank"
>
Learn more
<OpenInNew style={openIconStyle} />
</a>
</Heading>
{provider ? (
<>
<Block className={styles.safeActions} margin="md">
<CreateSafe provider={provider} size="large" />
</Block>
<Block className={styles.safeActions} margin="md">
<LoadSafe provider={provider} size="large" />
</Block>
</>
) : (
<Block className={styles.connectWallet} margin="md">
<Heading align="center" margin="md" tag="h3">
Get Started by Connecting a Wallet
</Heading>
<ConnectButton minHeight={42} minWidth={240} data-testid="connect-btn" />
</Block>
)}
<Block>
{/* Title */}
<Title size="md" strong>
Welcome to Gnosis Safe Multisig.
</Title>
{/* Subtitle */}
<Title size="xs">
{isOldMultisigMigration ? (
<>
We will replicate the owner structure from your existing Gnosis MultiSig to let you test the new interface.
As soon as you feel comfortable, start moving funds to your new Safe.
</>
) : (
<>
Gnosis Safe Multisig is the most trusted platform to manage digital assets. <br /> Here is how to get
started:{' '}
</>
)}
</Title>
<>
<Wrapper>
{/* Connect wallet */}
<StyledCard>
<TitleWrapper>
<Dot color="primary">
{!provider ? <Title size="xs">1</Title> : <Icon color="white" type="check" size="md" />}
</Dot>
<StyledTitle size="sm" strong withoutMargin>
Connect wallet
</StyledTitle>
</TitleWrapper>
<Text size="xl">
Gnosis Safe Multisig supports a wide range of wallets that you can choose to be one of the authentication
factors.
</Text>
<StyledButtonLink textSize="xl" color="primary" iconType="externalLink" iconSize="sm">
<LinkSRC
size="xl"
href="https://help.gnosis-safe.io/en/articles/4689442-why-do-i-need-to-connect-a-wallet"
target="_blank"
rel="noopener noreferrer"
title="More info about: Why do I need to connect wallet?"
>
Why do I need to connect wallet?
</LinkSRC>
</StyledButtonLink>
<StyledButton
size="lg"
color="primary"
variant="contained"
onClick={onConnectButtonClick}
disabled={provider}
data-testid="connect-btn"
>
<Text size="xl" color="white">
Connect wallet
</Text>
</StyledButton>
</StyledCard>
<StyledCardDouble disabled={!provider}>
{/* Create safe */}
<CardsCol>
<TitleWrapper>
<Dot color="primary">
<Title size="xs">2</Title>
</Dot>
<StyledTitle size="sm" strong withoutMargin>
Create Safe
</StyledTitle>
</TitleWrapper>
<Text size="xl">
Create a new Safe Multisig that is controlled by one or multiple owners. <br />
You will be required to pay a network fee for creating your new Safe.
</Text>
<StyledButton size="lg" color="primary" variant="contained" component={Link} to={OPEN_ADDRESS}>
<Text size="xl" color="white">
+ Create new Safe
</Text>
</StyledButton>
</CardsCol>
<Divider orientation="vertical" />
{/* Load safe */}
<CardsCol>
<StyledTitleOnly size="sm" strong withoutMargin>
Load existing Safe
</StyledTitleOnly>
<Text size="xl">
Already have a Safe? Do you want to access your Safe Multisig from a different device? Easily load your
Safe Multisig using your Safe address.
</Text>
<StyledButton
variant="bordered"
iconType="safe"
iconSize="sm"
size="lg"
color="secondary"
component={Link}
to={LOAD_ADDRESS}
>
<Text size="xl" color="secondary">
Load existing Safe
</Text>
</StyledButton>
</CardsCol>
</StyledCardDouble>
</Wrapper>
</>
</Block>
)
}

View File

@ -1,10 +1,10 @@
//
function useTestAccountAt(index = 0) {
(window as any).testAccountIndex = index
window.testAccountIndex = index
}
function resetTestAccount() {
delete (window as any).testAccountIndex
delete window.testAccountIndex
}
export { useTestAccountAt, resetTestAccount }

View File

@ -1,8 +1,11 @@
import Web3 from 'web3'
export {}
declare global {
interface Window {
web3?: Web3
testAccountIndex?: string
ethereum?: {
autoRefreshOnNetworkChange: boolean
isMetaMask: boolean
}
testAccountIndex?: string | number
}
}
declare module '@openzeppelin/contracts/build/contracts/ERC721'

View File

@ -1,5 +1,5 @@
import { useCallback, useEffect, useState } from 'react'
import GoogleAnalytics, { EventArgs } from 'react-ga'
import ReactGA, { EventArgs } from 'react-ga'
import { getNetworkInfo } from 'src/config'
import { getGoogleAnalyticsTrackingID } from 'src/config'
@ -20,8 +20,8 @@ export const loadGoogleAnalytics = (): void => {
if (!trackingID) {
console.error('[GoogleAnalytics] - In order to use google analytics you need to add an trackingID')
} else {
GoogleAnalytics.initialize(trackingID)
GoogleAnalytics.set({
ReactGA.initialize(trackingID)
ReactGA.set({
anonymizeIp: true,
appName: `Gnosis Safe Multisig (${networkInfo.label})`,
appId: `io.gnosis.safe.${networkInfo.label.toLowerCase()}`,
@ -50,22 +50,19 @@ export const useAnalytics = (): UseAnalyticsResponse => {
fetchCookiesFromStorage()
}, [])
const trackPage = useCallback(
(page) => {
if (!analyticsAllowed || !analyticsLoaded) {
return
}
GoogleAnalytics.pageview(page)
},
[analyticsAllowed],
)
const trackPage = (page) => {
if (!analyticsAllowed || !analyticsLoaded) {
return
}
ReactGA.pageview(page)
}
const trackEvent = useCallback(
(event: EventArgs) => {
if (!analyticsAllowed || !analyticsLoaded) {
return
}
GoogleAnalytics.event(event)
ReactGA.event(event)
},
[analyticsAllowed],
)

6
src/utils/mm_warnings.ts Normal file
View File

@ -0,0 +1,6 @@
// https://docs.metamask.io/guide/ethereum-provider.html#ethereum-autorefreshonnetworkchange
export const disableMMAutoRefreshWarning = (): void => {
if (window.ethereum && window.ethereum.isMetaMask) {
window.ethereum.autoRefreshOnNetworkChange = false
}
}

View File

@ -2,7 +2,11 @@
"compilerOptions": {
"baseUrl": ".",
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
@ -16,11 +20,18 @@
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react"
"jsx": "react",
"noFallthroughCasesInSwitch": true
},
"paths": {
"src/*": ["./*"]
"src/*": [
"./*"
]
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
"include": [
"src/**/*"
],
"exclude": [
"node_modules"
]
}

6816
yarn.lock

File diff suppressed because it is too large Load Diff