Merge pull request #87 from gnosis/feature/#75-load-existing-safe
Feature #75 - Load existing safe
This commit is contained in:
commit
3e305d3267
|
@ -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})`
|
return `Should not exceed ${max} ${symbol} (amount to reach ${baseText})`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const noErrorsOn = (name: string, errors: Object) => errors[name] === undefined
|
||||||
|
|
|
@ -54,6 +54,12 @@ const createMasterCopies = async () => {
|
||||||
|
|
||||||
export const initContracts = ensureOnce(process.env.NODE_ENV === 'test' ? createMasterCopies : instanciateMasterCopies)
|
export const initContracts = ensureOnce(process.env.NODE_ENV === 'test' ? createMasterCopies : instanciateMasterCopies)
|
||||||
|
|
||||||
|
export const getSafeMasterContract = async () => {
|
||||||
|
await initContracts()
|
||||||
|
|
||||||
|
return safeMaster
|
||||||
|
}
|
||||||
|
|
||||||
export const deploySafeContract = async (
|
export const deploySafeContract = async (
|
||||||
safeAccounts: string[],
|
safeAccounts: string[],
|
||||||
numConfirmations: number,
|
numConfirmations: number,
|
||||||
|
|
|
@ -4,7 +4,7 @@ import Loadable from 'react-loadable'
|
||||||
import { Switch, Redirect, Route } from 'react-router-dom'
|
import { Switch, Redirect, Route } from 'react-router-dom'
|
||||||
import Loader from '~/components/Loader'
|
import Loader from '~/components/Loader'
|
||||||
import Welcome from './welcome/container'
|
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({
|
const Safe = Loadable({
|
||||||
loader: () => import('./safe/container'),
|
loader: () => import('./safe/container'),
|
||||||
|
@ -31,6 +31,11 @@ const Opening = Loadable({
|
||||||
loading: Loader,
|
loading: Loader,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const Load = Loadable({
|
||||||
|
loader: () => import('./load/container/Load'),
|
||||||
|
loading: Loader,
|
||||||
|
})
|
||||||
|
|
||||||
const SAFE_ADDRESS = `${SAFELIST_ADDRESS}/:${SAFE_PARAM_ADDRESS}`
|
const SAFE_ADDRESS = `${SAFELIST_ADDRESS}/:${SAFE_PARAM_ADDRESS}`
|
||||||
const SAFE_SETTINGS = `${SAFE_ADDRESS}${SETTINS_ADDRESS}`
|
const SAFE_SETTINGS = `${SAFE_ADDRESS}${SETTINS_ADDRESS}`
|
||||||
|
|
||||||
|
@ -45,6 +50,7 @@ const Routes = () => (
|
||||||
<Route exact path={SAFE_ADDRESS} component={Safe} />
|
<Route exact path={SAFE_ADDRESS} component={Safe} />
|
||||||
<Route exact path={SAFE_SETTINGS} component={Settings} />
|
<Route exact path={SAFE_SETTINGS} component={Settings} />
|
||||||
<Route exact path={OPENING_ADDRESS} component={Opening} />
|
<Route exact path={OPENING_ADDRESS} component={Opening} />
|
||||||
|
<Route exact path={LOAD_ADDRESS} component={Load} />
|
||||||
</Switch>
|
</Switch>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -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) => (
|
||||||
|
<React.Fragment>
|
||||||
|
<Block margin="sm">
|
||||||
|
<Paragraph noMargin size="md" color="primary">
|
||||||
|
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.
|
||||||
|
</Paragraph>
|
||||||
|
</Block>
|
||||||
|
<Block className={classes.root}>
|
||||||
|
<Field
|
||||||
|
name={FIELD_LOAD_NAME}
|
||||||
|
component={TextField}
|
||||||
|
type="text"
|
||||||
|
validate={required}
|
||||||
|
placeholder="Name of the Safe"
|
||||||
|
text="Safe name"
|
||||||
|
/>
|
||||||
|
</Block>
|
||||||
|
<Block margin="lg" className={classes.root}>
|
||||||
|
<Field
|
||||||
|
name={FIELD_LOAD_ADDRESS}
|
||||||
|
component={TextField}
|
||||||
|
inputAdornment={noErrorsOn(FIELD_LOAD_ADDRESS, errors) && {
|
||||||
|
endAdornment: (
|
||||||
|
<InputAdornment position="end">
|
||||||
|
<CheckCircle className={classes.check} />
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
type="text"
|
||||||
|
validate={composeValidators(required, mustBeEthereumAddress)}
|
||||||
|
placeholder="Safe Address*"
|
||||||
|
text="Safe Address"
|
||||||
|
/>
|
||||||
|
</Block>
|
||||||
|
</React.Fragment>
|
||||||
|
)
|
||||||
|
|
||||||
|
const DetailsForm = withStyles(styles)(Details)
|
||||||
|
|
||||||
|
const DetailsPage = () => (controls: React$Node, { errors }: Object) => (
|
||||||
|
<React.Fragment>
|
||||||
|
<OpenPaper controls={controls} container={605}>
|
||||||
|
<DetailsForm errors={errors} />
|
||||||
|
</OpenPaper>
|
||||||
|
</React.Fragment>
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
export default DetailsPage
|
|
@ -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<void>,
|
||||||
|
}
|
||||||
|
|
||||||
|
const iconStyle = {
|
||||||
|
color: secondary,
|
||||||
|
width: '32px',
|
||||||
|
height: '32px',
|
||||||
|
}
|
||||||
|
|
||||||
|
const back = () => {
|
||||||
|
history.goBack()
|
||||||
|
}
|
||||||
|
|
||||||
|
const Layout = ({
|
||||||
|
provider, onLoadSafeSubmit, network, userAddress,
|
||||||
|
}: Props) => {
|
||||||
|
const steps = getSteps()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<React.Fragment>
|
||||||
|
{ provider
|
||||||
|
? (
|
||||||
|
<Block>
|
||||||
|
<Row align="center">
|
||||||
|
<IconButton onClick={back} style={iconStyle} disableRipple>
|
||||||
|
<ChevronLeft />
|
||||||
|
</IconButton>
|
||||||
|
<Heading tag="h2">Load existing Safe</Heading>
|
||||||
|
</Row>
|
||||||
|
<Stepper
|
||||||
|
onSubmit={onLoadSafeSubmit}
|
||||||
|
steps={steps}
|
||||||
|
>
|
||||||
|
<Stepper.Page validate={safeFieldsValidation}>
|
||||||
|
{ DetailsForm }
|
||||||
|
</Stepper.Page>
|
||||||
|
<Stepper.Page network={network} userAddress={userAddress}>
|
||||||
|
{ ReviewInformation }
|
||||||
|
</Stepper.Page>
|
||||||
|
</Stepper>
|
||||||
|
</Block>
|
||||||
|
)
|
||||||
|
: <div>No metamask detected</div>
|
||||||
|
}
|
||||||
|
</React.Fragment>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Layout
|
|
@ -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<Props, State> {
|
||||||
|
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 (
|
||||||
|
<React.Fragment>
|
||||||
|
<Block className={classes.details}>
|
||||||
|
<Block margin="lg">
|
||||||
|
<Paragraph size="sm" color="disabled" noMargin>
|
||||||
|
Name of the Safe
|
||||||
|
</Paragraph>
|
||||||
|
<Paragraph size="lg" color="primary" noMargin weight="bolder" className={classes.name}>
|
||||||
|
{values[FIELD_LOAD_NAME]}
|
||||||
|
</Paragraph>
|
||||||
|
</Block>
|
||||||
|
<Block margin="lg">
|
||||||
|
<Paragraph size="sm" color="disabled" noMargin>
|
||||||
|
Safe address
|
||||||
|
</Paragraph>
|
||||||
|
<Row className={classes.container}>
|
||||||
|
<Identicon address={safeAddress} diameter={32} />
|
||||||
|
<Paragraph size="md" color="disabled" noMargin className={classes.address}>{safeAddress}</Paragraph>
|
||||||
|
<OpenInNew
|
||||||
|
className={classes.open}
|
||||||
|
style={openIconStyle}
|
||||||
|
onClick={openAddressInEtherScan(safeAddress, network)}
|
||||||
|
/>
|
||||||
|
</Row>
|
||||||
|
</Block>
|
||||||
|
<Block margin="lg">
|
||||||
|
<Paragraph size="sm" color="disabled" noMargin>
|
||||||
|
Connected wallet client is owner?
|
||||||
|
</Paragraph>
|
||||||
|
<Paragraph size="lg" color="primary" noMargin weight="bolder" className={classes.name}>
|
||||||
|
{ isOwner ? 'Yes' : 'No (read-only)' }
|
||||||
|
</Paragraph>
|
||||||
|
</Block>
|
||||||
|
</Block>
|
||||||
|
</React.Fragment>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ReviewPage = withStyles(styles)(ReviewComponent)
|
||||||
|
|
||||||
|
const Review = ({ network, userAddress }: LayoutProps) => (controls: React$Node, { values }: Object) => (
|
||||||
|
<React.Fragment>
|
||||||
|
<OpenPaper controls={controls} padding={false}>
|
||||||
|
<ReviewPage network={network} values={values} userAddress={userAddress} />
|
||||||
|
</OpenPaper>
|
||||||
|
</React.Fragment>
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
export default Review
|
|
@ -0,0 +1,4 @@
|
||||||
|
// @flow
|
||||||
|
export const FIELD_LOAD_NAME: string = 'name'
|
||||||
|
export const FIELD_LOAD_ADDRESS: string = 'address'
|
||||||
|
|
|
@ -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<Props> {
|
||||||
|
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 (
|
||||||
|
<Page>
|
||||||
|
<Layout
|
||||||
|
network={network}
|
||||||
|
provider={provider}
|
||||||
|
onLoadSafeSubmit={this.onLoadSafeSubmit}
|
||||||
|
userAddress={userAddress}
|
||||||
|
/>
|
||||||
|
</Page>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default connect(selector, actions)(Load)
|
|
@ -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,
|
||||||
|
}
|
|
@ -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<GlobalState, any, any> = createStructuredSelector({
|
||||||
|
provider: providerNameSelector,
|
||||||
|
network: networkSelector,
|
||||||
|
userAddress: userAccountSelector,
|
||||||
|
})
|
||||||
|
|
||||||
|
export default structuredSelector
|
||||||
|
|
|
@ -3,7 +3,7 @@ import * as React from 'react'
|
||||||
import { withStyles } from '@material-ui/core/styles'
|
import { withStyles } from '@material-ui/core/styles'
|
||||||
import Field from '~/components/forms/Field'
|
import Field from '~/components/forms/Field'
|
||||||
import TextField from '~/components/forms/TextField'
|
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 Block from '~/components/layout/Block'
|
||||||
import Button from '~/components/layout/Button'
|
import Button from '~/components/layout/Button'
|
||||||
import Row from '~/components/layout/Row'
|
import Row from '~/components/layout/Row'
|
||||||
|
@ -75,8 +75,6 @@ const getAddressValidators = (addresses: string[], position: number) => {
|
||||||
return composeValidators(required, mustBeEthereumAddress, uniqueAddress(copy))
|
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 ADD_OWNER_BUTTON = '+ ADD ANOTHER OWNER'
|
||||||
|
|
||||||
export const calculateValuesAfterRemoving = (index: number, notRemovedOwners: number, values: Object) => {
|
export const calculateValuesAfterRemoving = (index: number, notRemovedOwners: number, values: Object) => {
|
||||||
|
|
|
@ -4,6 +4,7 @@ import { history } from '~/store'
|
||||||
export const SAFE_PARAM_ADDRESS = 'address'
|
export const SAFE_PARAM_ADDRESS = 'address'
|
||||||
export const SAFELIST_ADDRESS = '/safes'
|
export const SAFELIST_ADDRESS = '/safes'
|
||||||
export const OPEN_ADDRESS = '/open'
|
export const OPEN_ADDRESS = '/open'
|
||||||
|
export const LOAD_ADDRESS = '/load'
|
||||||
export const WELCOME_ADDRESS = '/welcome'
|
export const WELCOME_ADDRESS = '/welcome'
|
||||||
export const SETTINS_ADDRESS = '/settings'
|
export const SETTINS_ADDRESS = '/settings'
|
||||||
export const OPENING_ADDRESS = '/opening'
|
export const OPENING_ADDRESS = '/opening'
|
||||||
|
|
|
@ -71,6 +71,7 @@ export default handleActions({
|
||||||
|
|
||||||
const safes = state.set(action.payload.address, safe)
|
const safes = state.set(action.payload.address, safe)
|
||||||
saveSafes(safes.toJSON())
|
saveSafes(safes.toJSON())
|
||||||
|
|
||||||
return safes
|
return safes
|
||||||
},
|
},
|
||||||
}, Map())
|
}, Map())
|
||||||
|
|
|
@ -5,7 +5,7 @@ import Heading from '~/components/layout/Heading'
|
||||||
import Img from '~/components/layout/Img'
|
import Img from '~/components/layout/Img'
|
||||||
import Button from '~/components/layout/Button'
|
import Button from '~/components/layout/Button'
|
||||||
import Link from '~/components/layout/Link'
|
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 { marginButtonImg } from '~/theme/variables'
|
||||||
import styles from './Layout.scss'
|
import styles from './Layout.scss'
|
||||||
|
|
||||||
|
@ -43,7 +43,7 @@ export const CreateSafe = ({ size, provider }: SafeProps) => (
|
||||||
export const LoadSafe = ({ size, provider }: SafeProps) => (
|
export const LoadSafe = ({ size, provider }: SafeProps) => (
|
||||||
<Button
|
<Button
|
||||||
component={Link}
|
component={Link}
|
||||||
to={OPEN_ADDRESS}
|
to={LOAD_ADDRESS}
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
size={size || 'medium'}
|
size={size || 'medium'}
|
||||||
color="primary"
|
color="primary"
|
||||||
|
|
|
@ -115,3 +115,23 @@ export const travelToTokens = (store: Store, address: string): React$Component<{
|
||||||
|
|
||||||
return createDom(store)
|
return createDom(store)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const INTERVAL = 500
|
||||||
|
const MAX_TIMES_EXECUTED = 30
|
||||||
|
export const whenSafeDeployed = (): Promise<string> => new Promise((resolve, reject) => {
|
||||||
|
let times = 0
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
if (times >= MAX_TIMES_EXECUTED) {
|
||||||
|
clearInterval(interval)
|
||||||
|
reject()
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `${window.location}`
|
||||||
|
const regex = /.*safes\/(0x[a-f0-9A-F]*)/
|
||||||
|
const safeAddress = url.match(regex)
|
||||||
|
if (safeAddress) {
|
||||||
|
resolve(safeAddress[1])
|
||||||
|
}
|
||||||
|
times += 1
|
||||||
|
}, INTERVAL)
|
||||||
|
})
|
||||||
|
|
|
@ -14,6 +14,7 @@ import addProvider from '~/logic/wallets/store/actions/addProvider'
|
||||||
import { makeProvider } from '~/logic/wallets/store/model/provider'
|
import { makeProvider } from '~/logic/wallets/store/model/provider'
|
||||||
import { promisify } from '~/utils/promisify'
|
import { promisify } from '~/utils/promisify'
|
||||||
import { getGnosisSafeInstanceAt } from '~/logic/contracts/safeContracts'
|
import { getGnosisSafeInstanceAt } from '~/logic/contracts/safeContracts'
|
||||||
|
import { whenSafeDeployed } from './builder/safe.dom.utils'
|
||||||
|
|
||||||
const fillOpenSafeForm = async (localStore: Store<GlobalState>) => {
|
const fillOpenSafeForm = async (localStore: Store<GlobalState>) => {
|
||||||
const provider = await getProviderInfo()
|
const provider = await getProviderInfo()
|
||||||
|
@ -31,26 +32,6 @@ const fillOpenSafeForm = async (localStore: Store<GlobalState>) => {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const INTERVAL = 500
|
|
||||||
const MAX_TIMES_EXECUTED = 30
|
|
||||||
const whenSafeDeployed = () => new Promise((resolve, reject) => {
|
|
||||||
let times = 0
|
|
||||||
const interval = setInterval(() => {
|
|
||||||
if (times >= MAX_TIMES_EXECUTED) {
|
|
||||||
clearInterval(interval)
|
|
||||||
reject()
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = `${window.location}`
|
|
||||||
const regex = /.*safes\/(0x[a-f0-9A-F]*)/
|
|
||||||
const safeAddress = url.match(regex)
|
|
||||||
if (safeAddress) {
|
|
||||||
resolve(safeAddress[1])
|
|
||||||
}
|
|
||||||
times += 1
|
|
||||||
}, INTERVAL)
|
|
||||||
})
|
|
||||||
|
|
||||||
const deploySafe = async (safe: React$Component<{}>, threshold: number, numOwners: number) => {
|
const deploySafe = async (safe: React$Component<{}>, threshold: number, numOwners: number) => {
|
||||||
const web3 = getWeb3()
|
const web3 = getWeb3()
|
||||||
const accounts = await promisify(cb => web3.eth.getAccounts(cb))
|
const accounts = await promisify(cb => web3.eth.getAccounts(cb))
|
||||||
|
|
|
@ -0,0 +1,60 @@
|
||||||
|
// @flow
|
||||||
|
import * as React from 'react'
|
||||||
|
import { type Store } from 'redux'
|
||||||
|
import TestUtils from 'react-dom/test-utils'
|
||||||
|
import { Provider } from 'react-redux'
|
||||||
|
import { ConnectedRouter } from 'react-router-redux'
|
||||||
|
import Load from '~/routes/load/container/Load'
|
||||||
|
import { aNewStore, history, type GlobalState } from '~/store'
|
||||||
|
import { sleep } from '~/utils/timer'
|
||||||
|
import { getProviderInfo } from '~/logic/wallets/getWeb3'
|
||||||
|
import addProvider from '~/logic/wallets/store/actions/addProvider'
|
||||||
|
import { makeProvider } from '~/logic/wallets/store/model/provider'
|
||||||
|
import { aMinedSafe } from './builder/safe.redux.builder'
|
||||||
|
import { whenSafeDeployed } from './builder/safe.dom.utils'
|
||||||
|
|
||||||
|
const travelToLoadRoute = async (localStore: Store<GlobalState>) => {
|
||||||
|
const provider = await getProviderInfo()
|
||||||
|
const walletRecord = makeProvider(provider)
|
||||||
|
localStore.dispatch(addProvider(walletRecord))
|
||||||
|
|
||||||
|
return (
|
||||||
|
TestUtils.renderIntoDocument((
|
||||||
|
<Provider store={localStore}>
|
||||||
|
<ConnectedRouter history={history}>
|
||||||
|
<Load />
|
||||||
|
</ConnectedRouter>
|
||||||
|
</Provider>
|
||||||
|
))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('DOM > Feature > LOAD a safe', () => {
|
||||||
|
it('load correctly a created safe', async () => {
|
||||||
|
const store = aNewStore()
|
||||||
|
const address = await aMinedSafe(store)
|
||||||
|
const LoadDom = await travelToLoadRoute(store)
|
||||||
|
|
||||||
|
const form = TestUtils.findRenderedDOMComponentWithTag(LoadDom, 'form')
|
||||||
|
const inputs = TestUtils.scryRenderedDOMComponentsWithTag(LoadDom, 'input')
|
||||||
|
|
||||||
|
// Fill Safe's name
|
||||||
|
const fieldName = inputs[0]
|
||||||
|
TestUtils.Simulate.change(fieldName, { target: { value: 'Adolfo Safe' } })
|
||||||
|
const fieldAddress = inputs[1]
|
||||||
|
TestUtils.Simulate.change(fieldAddress, { target: { value: address } })
|
||||||
|
await sleep(400)
|
||||||
|
|
||||||
|
// Click next
|
||||||
|
TestUtils.Simulate.submit(form)
|
||||||
|
await sleep(400)
|
||||||
|
|
||||||
|
// Submit
|
||||||
|
TestUtils.Simulate.submit(form)
|
||||||
|
await sleep(400)
|
||||||
|
|
||||||
|
|
||||||
|
const deployedAddress = await whenSafeDeployed()
|
||||||
|
expect(deployedAddress).toBe(address)
|
||||||
|
})
|
||||||
|
})
|
|
@ -0,0 +1,113 @@
|
||||||
|
// @flow
|
||||||
|
import { Map, List } from 'immutable'
|
||||||
|
import { type Safe } from '~/routes/safe/store/model/safe'
|
||||||
|
import { aNewStore } from '~/store'
|
||||||
|
import { aMinedSafe } from '~/test/builder/safe.redux.builder'
|
||||||
|
import updateSafe from '~/routes/safe/store/actions/updateSafe'
|
||||||
|
import { loadSafe } from '~/routes/load/container/Load'
|
||||||
|
import { safesMapSelector } from '~/routes/safeList/store/selectors'
|
||||||
|
import { makeOwner, type Owner } from '~/routes/safe/store/model/owner'
|
||||||
|
import { getWeb3 } from '~/logic/wallets/getWeb3'
|
||||||
|
import { promisify } from '~/utils/promisify'
|
||||||
|
import { safesInitialState } from '~/routes/safe/store/reducer/safe'
|
||||||
|
import { setOwners, OWNERS_KEY } from '~/utils/localStorage'
|
||||||
|
|
||||||
|
describe('Safe - redux load safe', () => {
|
||||||
|
let store
|
||||||
|
let address: string
|
||||||
|
let accounts
|
||||||
|
beforeEach(async () => {
|
||||||
|
store = aNewStore()
|
||||||
|
address = await aMinedSafe(store)
|
||||||
|
localStorage.clear()
|
||||||
|
accounts = await promisify(cb => getWeb3().eth.getAccounts(cb))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('if safe is not present, store and persist it with default names', async () => {
|
||||||
|
const safeName = 'Loaded Safe'
|
||||||
|
const safeAddress = address
|
||||||
|
const updateSafeFn: any = (...args) => store.dispatch(updateSafe(...args))
|
||||||
|
|
||||||
|
await loadSafe(safeName, safeAddress, updateSafeFn)
|
||||||
|
|
||||||
|
const safes: Map<string, Safe> = safesMapSelector(store.getState())
|
||||||
|
expect(safes.size).toBe(1)
|
||||||
|
if (!safes) throw new Error()
|
||||||
|
const safe = safes.get(safeAddress)
|
||||||
|
if (!safe) throw new Error()
|
||||||
|
|
||||||
|
expect(safe.get('name')).toBe(safeName)
|
||||||
|
expect(safe.get('threshold')).toBe(1)
|
||||||
|
expect(safe.get('address')).toBe(safeAddress)
|
||||||
|
expect(safe.get('owners')).toEqual(List([makeOwner({ name: 'UNKNOWN', address: accounts[0] })]))
|
||||||
|
|
||||||
|
expect(safesInitialState()).toEqual(safes)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('if safe is not present but owners, store and persist it with stored names', async () => {
|
||||||
|
const safeName = 'Loaded Safe'
|
||||||
|
const safeAddress = address
|
||||||
|
const ownerName = 'Foo Bar Restores'
|
||||||
|
const updateSafeFn: any = (...args) => store.dispatch(updateSafe(...args))
|
||||||
|
const owner: Owner = makeOwner({ name: ownerName, address: accounts[0] })
|
||||||
|
setOwners(safeAddress, List([owner]))
|
||||||
|
|
||||||
|
await loadSafe(safeName, safeAddress, updateSafeFn)
|
||||||
|
|
||||||
|
const safes: Map<string, Safe> = safesMapSelector(store.getState())
|
||||||
|
expect(safes.size).toBe(1)
|
||||||
|
if (!safes) throw new Error()
|
||||||
|
const safe = safes.get(safeAddress)
|
||||||
|
if (!safe) throw new Error()
|
||||||
|
|
||||||
|
expect(safe.get('name')).toBe(safeName)
|
||||||
|
expect(safe.get('threshold')).toBe(1)
|
||||||
|
expect(safe.get('address')).toBe(safeAddress)
|
||||||
|
expect(safe.get('owners')).toEqual(List([makeOwner({ name: ownerName, address: accounts[0] })]))
|
||||||
|
|
||||||
|
expect(safesInitialState()).toEqual(safes)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('if safe is present but no owners, store and persist it with default names', async () => {
|
||||||
|
const safeAddress = await aMinedSafe(store)
|
||||||
|
localStorage.removeItem(`${OWNERS_KEY}-${safeAddress}`)
|
||||||
|
|
||||||
|
const safeName = 'Loaded Safe'
|
||||||
|
const updateSafeFn: any = (...args) => store.dispatch(updateSafe(...args))
|
||||||
|
await loadSafe(safeName, safeAddress, updateSafeFn)
|
||||||
|
|
||||||
|
const safes: Map<string, Safe> = safesMapSelector(store.getState())
|
||||||
|
expect(safes.size).toBe(2)
|
||||||
|
if (!safes) throw new Error()
|
||||||
|
const safe = safes.get(safeAddress)
|
||||||
|
if (!safe) throw new Error()
|
||||||
|
|
||||||
|
expect(safe.get('name')).toBe(safeName)
|
||||||
|
expect(safe.get('threshold')).toBe(1)
|
||||||
|
expect(safe.get('address')).toBe(safeAddress)
|
||||||
|
expect(safe.get('owners')).toEqual(List([makeOwner({ name: 'UNKNOWN', address: accounts[0] })]))
|
||||||
|
|
||||||
|
expect(safesInitialState()).toEqual(safes)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('if safe is present but owners, store and persist it with stored names', async () => {
|
||||||
|
const safeAddress = await aMinedSafe(store)
|
||||||
|
|
||||||
|
const safeName = 'Loaded Safe'
|
||||||
|
const updateSafeFn: any = (...args) => store.dispatch(updateSafe(...args))
|
||||||
|
await loadSafe(safeName, safeAddress, updateSafeFn)
|
||||||
|
|
||||||
|
const safes: Map<string, Safe> = safesMapSelector(store.getState())
|
||||||
|
expect(safes.size).toBe(2)
|
||||||
|
if (!safes) throw new Error()
|
||||||
|
const safe = safes.get(safeAddress)
|
||||||
|
if (!safe) throw new Error()
|
||||||
|
|
||||||
|
expect(safe.get('name')).toBe(safeName)
|
||||||
|
expect(safe.get('threshold')).toBe(1)
|
||||||
|
expect(safe.get('address')).toBe(safeAddress)
|
||||||
|
expect(safe.get('owners')).toEqual(List([makeOwner({ name: 'Adol 1 Eth Account', address: accounts[0] })]))
|
||||||
|
|
||||||
|
expect(safesInitialState()).toEqual(safes)
|
||||||
|
})
|
||||||
|
})
|
Loading…
Reference in New Issue