diff --git a/src/components/forms/validator.js b/src/components/forms/validator.js index 0d0e595659..38e654626a 100644 --- a/src/components/forms/validator.js +++ b/src/components/forms/validator.js @@ -71,3 +71,5 @@ export const inLimit = (limit: number, base: number, baseText: string, symbol: s return `Should not exceed ${max} ${symbol} (amount to reach ${baseText})` } + +export const noErrorsOn = (name: string, errors: Object) => errors[name] === undefined diff --git a/src/logic/contracts/safeContracts.js b/src/logic/contracts/safeContracts.js index c1cd1c9c9a..af120c89f5 100644 --- a/src/logic/contracts/safeContracts.js +++ b/src/logic/contracts/safeContracts.js @@ -54,6 +54,12 @@ const createMasterCopies = async () => { export const initContracts = ensureOnce(process.env.NODE_ENV === 'test' ? createMasterCopies : instanciateMasterCopies) +export const getSafeMasterContract = async () => { + await initContracts() + + return safeMaster +} + export const deploySafeContract = async ( safeAccounts: string[], numConfirmations: number, diff --git a/src/routes/index.js b/src/routes/index.js index e02b1dbf87..fc2c8be32a 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -4,7 +4,7 @@ import Loadable from 'react-loadable' import { Switch, Redirect, Route } from 'react-router-dom' import Loader from '~/components/Loader' import Welcome from './welcome/container' -import { SAFELIST_ADDRESS, OPEN_ADDRESS, SAFE_PARAM_ADDRESS, WELCOME_ADDRESS, SETTINS_ADDRESS, OPENING_ADDRESS } from './routes' +import { SAFELIST_ADDRESS, OPEN_ADDRESS, SAFE_PARAM_ADDRESS, WELCOME_ADDRESS, SETTINS_ADDRESS, OPENING_ADDRESS, LOAD_ADDRESS } from './routes' const Safe = Loadable({ loader: () => import('./safe/container'), @@ -31,6 +31,11 @@ const Opening = Loadable({ loading: Loader, }) +const Load = Loadable({ + loader: () => import('./load/container/Load'), + loading: Loader, +}) + const SAFE_ADDRESS = `${SAFELIST_ADDRESS}/:${SAFE_PARAM_ADDRESS}` const SAFE_SETTINGS = `${SAFE_ADDRESS}${SETTINS_ADDRESS}` @@ -45,6 +50,7 @@ const Routes = () => ( + ) diff --git a/src/routes/load/components/DetailsForm/index.jsx b/src/routes/load/components/DetailsForm/index.jsx new file mode 100644 index 0000000000..a8f849d390 --- /dev/null +++ b/src/routes/load/components/DetailsForm/index.jsx @@ -0,0 +1,130 @@ +// @flow +import * as React from 'react' +import contract from 'truffle-contract' +import { withStyles } from '@material-ui/core/styles' +import Field from '~/components/forms/Field' +import { composeValidators, required, noErrorsOn, mustBeEthereumAddress } from '~/components/forms/validator' +import TextField from '~/components/forms/TextField' +import InputAdornment from '@material-ui/core/InputAdornment' +import CheckCircle from '@material-ui/icons/CheckCircle' +import Block from '~/components/layout/Block' +import Paragraph from '~/components/layout/Paragraph' +import OpenPaper from '~/components/Stepper/OpenPaper' +import { FIELD_LOAD_NAME, FIELD_LOAD_ADDRESS } from '~/routes/load/components/fields' +import { getWeb3 } from '~/logic/wallets/getWeb3' +import { promisify } from '~/utils/promisify' +import SafeProxy from '#/Proxy.json' +import { getSafeMasterContract } from '~/logic/contracts/safeContracts' + +type Props = { + classes: Object, + errors: Object, +} + +const styles = () => ({ + root: { + display: 'flex', + maxWidth: '460px', + }, + check: { + color: '#03AE60', + height: '20px', + }, +}) + +export const SAFE_INSTANCE_ERROR = 'Address given is not a safe instance' +export const SAFE_MASTERCOPY_ERROR = 'Mastercopy used by this safe is not the same' + +export const safeFieldsValidation = async (values: Object) => { + const errors = {} + + const web3 = getWeb3() + const safeAddress = values[FIELD_LOAD_ADDRESS] + if (!safeAddress || mustBeEthereumAddress(safeAddress) !== undefined) { + return errors + } + + // https://solidity.readthedocs.io/en/latest/metadata.html#usage-for-source-code-verification + const metaData = 'a165' + + const code = await promisify(cb => web3.eth.getCode(safeAddress, cb)) + const codeWithoutMetadata = code.substring(0, code.lastIndexOf(metaData)) + + const proxyCode = SafeProxy.deployedBytecode + const proxyCodeWithoutMetadata = proxyCode.substring(0, proxyCode.lastIndexOf(metaData)) + + const safeInstance = codeWithoutMetadata === proxyCodeWithoutMetadata + if (!safeInstance) { + errors[FIELD_LOAD_ADDRESS] = SAFE_INSTANCE_ERROR + + return errors + } + + // check mastercopy + const proxy = contract(SafeProxy) + proxy.setProvider(web3.currentProvider) + const proxyInstance = proxy.at(safeAddress) + const proxyImplementation = await proxyInstance.implementation() + + const safeMaster = await getSafeMasterContract() + const masterCopy = safeMaster.address + + const sameMasterCopy = proxyImplementation === masterCopy + if (!sameMasterCopy) { + errors[FIELD_LOAD_ADDRESS] = SAFE_MASTERCOPY_ERROR + } + + return errors +} + +const Details = ({ classes, errors }: Props) => ( + + + + Adding an existing Safe only requires the Safe address. Optionally you can give it a name. + In case your connected client is not the owner of the Safe, the interface will essentially provide you a + read-only view. + + + + + + + + + + ), + }} + type="text" + validate={composeValidators(required, mustBeEthereumAddress)} + placeholder="Safe Address*" + text="Safe Address" + /> + + +) + +const DetailsForm = withStyles(styles)(Details) + +const DetailsPage = () => (controls: React$Node, { errors }: Object) => ( + + + + + +) + + +export default DetailsPage diff --git a/src/routes/load/components/Layout.jsx b/src/routes/load/components/Layout.jsx new file mode 100644 index 0000000000..95beb9c966 --- /dev/null +++ b/src/routes/load/components/Layout.jsx @@ -0,0 +1,68 @@ +// @flow +import * as React from 'react' +import ChevronLeft from '@material-ui/icons/ChevronLeft' +import Stepper from '~/components/Stepper' +import Block from '~/components/layout/Block' +import Heading from '~/components/layout/Heading' +import Row from '~/components/layout/Row' +import IconButton from '@material-ui/core/IconButton' +import ReviewInformation from '~/routes/load/components/ReviewInformation' +import DetailsForm, { safeFieldsValidation } from '~/routes/load/components/DetailsForm' +import { history } from '~/store' +import { secondary } from '~/theme/variables' +import { type SelectorProps } from '~/routes/load/container/selector' + +const getSteps = () => [ + 'Details', 'Review', +] + +type Props = SelectorProps & { + onLoadSafeSubmit: (values: Object) => Promise, +} + +const iconStyle = { + color: secondary, + width: '32px', + height: '32px', +} + +const back = () => { + history.goBack() +} + +const Layout = ({ + provider, onLoadSafeSubmit, network, userAddress, +}: Props) => { + const steps = getSteps() + + return ( + + { provider + ? ( + + + + + + Load existing Safe + + + + { DetailsForm } + + + { ReviewInformation } + + + + ) + :
No metamask detected
+ } +
+ ) +} + +export default Layout diff --git a/src/routes/load/components/ReviewInformation/index.jsx b/src/routes/load/components/ReviewInformation/index.jsx new file mode 100644 index 0000000000..1790e910a1 --- /dev/null +++ b/src/routes/load/components/ReviewInformation/index.jsx @@ -0,0 +1,147 @@ +// @flow +import * as React from 'react' +import Block from '~/components/layout/Block' +import { withStyles } from '@material-ui/core/styles' +import OpenInNew from '@material-ui/icons/OpenInNew' +import Identicon from '~/components/Identicon' +import OpenPaper from '~/components/Stepper/OpenPaper' +import Row from '~/components/layout/Row' +import Paragraph from '~/components/layout/Paragraph' +import { xs, sm, lg, border, secondary } from '~/theme/variables' +import { openAddressInEtherScan, getWeb3 } from '~/logic/wallets/getWeb3' +import { FIELD_LOAD_NAME, FIELD_LOAD_ADDRESS } from '~/routes/load/components/fields' +import { sameAddress } from '~/logic/wallets/ethAddresses' +import { getGnosisSafeContract } from '~/logic/contracts/safeContracts' + +const openIconStyle = { + height: '16px', + color: secondary, +} + +const styles = () => ({ + details: { + padding: lg, + borderRight: `solid 1px ${border}`, + height: '100%', + }, + name: { + letterSpacing: '-0.6px', + }, + container: { + marginTop: xs, + alignItems: 'center', + }, + address: { + paddingLeft: '6px', + }, + open: { + paddingLeft: sm, + width: 'auto', + '&:hover': { + cursor: 'pointer', + }, + }, +}) + +type LayoutProps = { + network: string, + userAddress: string, +} + +type Props = LayoutProps & { + values: Object, + classes: Object, +} + +type State = { + isOwner: boolean, +} + +class ReviewComponent extends React.PureComponent { + state = { + isOwner: false, + } + + componentDidMount = async () => { + this.mounted = true + + const { values, userAddress } = this.props + const safeAddress = values[FIELD_LOAD_ADDRESS] + const web3 = getWeb3() + + const GnosisSafe = getGnosisSafeContract(web3) + const gnosisSafe = GnosisSafe.at(safeAddress) + const owners = await gnosisSafe.getOwners() + if (!owners) { + return + } + + const isOwner = owners.find((owner: string) => sameAddress(owner, userAddress)) !== undefined + if (this.mounted) { + this.setState(() => ({ isOwner })) + } + } + + componentWillUnmount() { + this.mounted = false + } + + mounted = false + + render() { + const { values, classes, network } = this.props + const { isOwner } = this.state + + const safeAddress = values[FIELD_LOAD_ADDRESS] + + return ( + + + + + Name of the Safe + + + {values[FIELD_LOAD_NAME]} + + + + + Safe address + + + + {safeAddress} + + + + + + Connected wallet client is owner? + + + { isOwner ? 'Yes' : 'No (read-only)' } + + + + + ) + } +} + +const ReviewPage = withStyles(styles)(ReviewComponent) + +const Review = ({ network, userAddress }: LayoutProps) => (controls: React$Node, { values }: Object) => ( + + + + + +) + + +export default Review diff --git a/src/routes/load/components/fields.js b/src/routes/load/components/fields.js new file mode 100644 index 0000000000..9e2693a554 --- /dev/null +++ b/src/routes/load/components/fields.js @@ -0,0 +1,4 @@ +// @flow +export const FIELD_LOAD_NAME: string = 'name' +export const FIELD_LOAD_ADDRESS: string = 'address' + diff --git a/src/routes/load/container/Load.jsx b/src/routes/load/container/Load.jsx new file mode 100644 index 0000000000..9fe4192163 --- /dev/null +++ b/src/routes/load/container/Load.jsx @@ -0,0 +1,61 @@ +// @flow +import * as React from 'react' +import { connect } from 'react-redux' +import Page from '~/components/layout/Page' +import { buildSafe } from '~/routes/safe/store/actions/fetchSafe' +import { SAFES_KEY, load, saveSafes } from '~/utils/localStorage' +import { SAFELIST_ADDRESS } from '~/routes/routes' +import { history } from '~/store' +import selector, { type SelectorProps } from './selector' +import actions, { type Actions, type UpdateSafe } from './actions' +import Layout from '../components/Layout' +import { FIELD_LOAD_NAME, FIELD_LOAD_ADDRESS } from '../components/fields' + +type Props = SelectorProps & Actions + +export const loadSafe = async (safeName: string, safeAddress: string, updateSafe: UpdateSafe) => { + const safeRecord = await buildSafe(safeAddress, safeName) + + await updateSafe(safeRecord) + + const storedSafes = load(SAFES_KEY) || {} + storedSafes[safeAddress] = safeRecord.toJSON() + + saveSafes(storedSafes) +} + +class Load extends React.Component { + onLoadSafeSubmit = async (values: Object) => { + try { + const { updateSafe } = this.props + const safeName = values[FIELD_LOAD_NAME] + const safeAddress = values[FIELD_LOAD_ADDRESS] + + await loadSafe(safeName, safeAddress, updateSafe) + const url = `${SAFELIST_ADDRESS}/${safeAddress}` + history.push(url) + } catch (error) { + // eslint-disable-next-line + console.log('Error while loading the Safe' + error) + } + } + + render() { + const { + provider, network, userAddress, + } = this.props + + return ( + + + + ) + } +} + +export default connect(selector, actions)(Load) diff --git a/src/routes/load/container/actions.js b/src/routes/load/container/actions.js new file mode 100644 index 0000000000..08a56d7eea --- /dev/null +++ b/src/routes/load/container/actions.js @@ -0,0 +1,12 @@ +// @flow +import updateSafe from '~/routes/safe/store/actions/updateSafe' + +export type UpdateSafe = typeof updateSafe + +export type Actions = { + updateSafe: typeof updateSafe, +} + +export default { + updateSafe, +} diff --git a/src/routes/load/container/selector.js b/src/routes/load/container/selector.js new file mode 100644 index 0000000000..28f673b95f --- /dev/null +++ b/src/routes/load/container/selector.js @@ -0,0 +1,19 @@ +// @flow +import { createStructuredSelector, type Selector } from 'reselect' +import { providerNameSelector, networkSelector, userAccountSelector } from '~/logic/wallets/store/selectors' +import { type GlobalState } from '~/store' + +export type SelectorProps = { + provider: string, + network: string, + userAddress: string, +} + +const structuredSelector: Selector = createStructuredSelector({ + provider: providerNameSelector, + network: networkSelector, + userAddress: userAccountSelector, +}) + +export default structuredSelector + diff --git a/src/routes/open/components/SafeOwnersForm/index.jsx b/src/routes/open/components/SafeOwnersForm/index.jsx index 297327e447..4ef98bab8b 100644 --- a/src/routes/open/components/SafeOwnersForm/index.jsx +++ b/src/routes/open/components/SafeOwnersForm/index.jsx @@ -3,7 +3,7 @@ import * as React from 'react' import { withStyles } from '@material-ui/core/styles' import Field from '~/components/forms/Field' import TextField from '~/components/forms/TextField' -import { required, composeValidators, uniqueAddress, mustBeEthereumAddress } from '~/components/forms/validator' +import { required, composeValidators, uniqueAddress, mustBeEthereumAddress, noErrorsOn } from '~/components/forms/validator' import Block from '~/components/layout/Block' import Button from '~/components/layout/Button' import Row from '~/components/layout/Row' @@ -75,8 +75,6 @@ const getAddressValidators = (addresses: string[], position: number) => { return composeValidators(required, mustBeEthereumAddress, uniqueAddress(copy)) } -const noErrorsOn = (name: string, errors: Object) => errors[name] === undefined - export const ADD_OWNER_BUTTON = '+ ADD ANOTHER OWNER' export const calculateValuesAfterRemoving = (index: number, notRemovedOwners: number, values: Object) => { diff --git a/src/routes/routes.js b/src/routes/routes.js index ca32621818..a3f95255d8 100644 --- a/src/routes/routes.js +++ b/src/routes/routes.js @@ -4,6 +4,7 @@ import { history } from '~/store' export const SAFE_PARAM_ADDRESS = 'address' export const SAFELIST_ADDRESS = '/safes' export const OPEN_ADDRESS = '/open' +export const LOAD_ADDRESS = '/load' export const WELCOME_ADDRESS = '/welcome' export const SETTINS_ADDRESS = '/settings' export const OPENING_ADDRESS = '/opening' diff --git a/src/routes/safe/store/reducer/safe.js b/src/routes/safe/store/reducer/safe.js index 95091ae101..48f7a3bc6a 100644 --- a/src/routes/safe/store/reducer/safe.js +++ b/src/routes/safe/store/reducer/safe.js @@ -71,6 +71,7 @@ export default handleActions({ const safes = state.set(action.payload.address, safe) saveSafes(safes.toJSON()) + return safes }, }, Map()) diff --git a/src/routes/welcome/components/Layout.jsx b/src/routes/welcome/components/Layout.jsx index 10985fb905..dc152c08f4 100644 --- a/src/routes/welcome/components/Layout.jsx +++ b/src/routes/welcome/components/Layout.jsx @@ -5,7 +5,7 @@ import Heading from '~/components/layout/Heading' import Img from '~/components/layout/Img' import Button from '~/components/layout/Button' import Link from '~/components/layout/Link' -import { OPEN_ADDRESS } from '~/routes/routes' +import { OPEN_ADDRESS, LOAD_ADDRESS } from '~/routes/routes' import { marginButtonImg } from '~/theme/variables' import styles from './Layout.scss' @@ -43,7 +43,7 @@ export const CreateSafe = ({ size, provider }: SafeProps) => ( export const LoadSafe = ({ size, provider }: SafeProps) => (