Merge pull request #52 from gnosis/feature/WA-232-add-custom-tokens

WA-232 - Feature: Add custom ERC 20 tokens
This commit is contained in:
Adolfo Panizo 2018-07-27 13:13:34 +02:00 committed by GitHub
commit cb3e5f5445
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
40 changed files with 5516 additions and 33448 deletions

View File

@ -1,7 +1,7 @@
import 'babel-polyfill' import 'babel-polyfill'
import { addDecorator, configure } from '@storybook/react' import { addDecorator, configure } from '@storybook/react'
import { withKnobs } from '@storybook/addon-knobs' import { withKnobs } from '@storybook/addon-knobs'
import { MuiThemeProvider } from 'material-ui/styles' import { MuiThemeProvider } from '@material-ui/core/styles'
import * as React from 'react' import * as React from 'react'
import { Provider } from 'react-redux' import { Provider } from 'react-redux'
import StoryRouter from 'storybook-router' import StoryRouter from 'storybook-router'

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -6,16 +6,16 @@ type ControlProps = {
next: string, next: string,
onPrevious: () => void, onPrevious: () => void,
firstPage: boolean, firstPage: boolean,
submitting: boolean, disabled: boolean,
} }
const ControlButtons = ({ const ControlButtons = ({
next, firstPage, onPrevious, submitting, next, firstPage, onPrevious, disabled,
}: ControlProps) => ( }: ControlProps) => (
<React.Fragment> <React.Fragment>
<Button <Button
type="button" type="button"
disabled={firstPage || submitting} disabled={firstPage || disabled}
onClick={onPrevious} onClick={onPrevious}
> >
Back Back
@ -24,7 +24,7 @@ const ControlButtons = ({
variant="raised" variant="raised"
color="primary" color="primary"
type="submit" type="submit"
disabled={submitting} disabled={disabled}
> >
{next} {next}
</Button> </Button>
@ -37,16 +37,16 @@ type Props = {
onPrevious: () => void, onPrevious: () => void,
firstPage: boolean, firstPage: boolean,
lastPage: boolean, lastPage: boolean,
submitting: boolean, disabled: boolean,
} }
const Controls = ({ const Controls = ({
finishedTx, finishedButton, onPrevious, firstPage, lastPage, submitting, finishedTx, finishedButton, onPrevious, firstPage, lastPage, disabled,
}: Props) => ( }: Props) => (
finishedTx finishedTx
? <React.Fragment>{finishedButton}</React.Fragment> ? <React.Fragment>{finishedButton}</React.Fragment>
: <ControlButtons : <ControlButtons
submitting={submitting} disabled={disabled}
next={lastPage ? 'Finish' : 'Next'} next={lastPage ? 'Finish' : 'Next'}
firstPage={firstPage} firstPage={firstPage}
onPrevious={onPrevious} onPrevious={onPrevious}

View File

@ -13,6 +13,7 @@ import Controls from './Controls'
export { default as Step } from './Step' export { default as Step } from './Step'
type Props = { type Props = {
disabledWhenValidating?: boolean,
classes: Object, classes: Object,
steps: string[], steps: string[],
finishedTransaction: boolean, finishedTransaction: boolean,
@ -30,6 +31,7 @@ type State = {
type PageProps = { type PageProps = {
children: Function, children: Function,
prepareNextInitialProps: (values: Object) => {},
} }
class GnoStepper extends React.PureComponent<Props, State> { class GnoStepper extends React.PureComponent<Props, State> {
@ -62,8 +64,10 @@ class GnoStepper extends React.PureComponent<Props, State> {
})) }))
} }
getPageProps = (pages: React$Node): PageProps => React.Children.toArray(pages)[this.state.page].props
getActivePageFrom = (pages: React$Node) => { getActivePageFrom = (pages: React$Node) => {
const activePageProps = React.Children.toArray(pages)[this.state.page].props const activePageProps = this.getPageProps(pages)
const { children, ...props } = activePageProps const { children, ...props } = activePageProps
return children(props) return children(props)
@ -76,18 +80,28 @@ class GnoStepper extends React.PureComponent<Props, State> {
return activePage.props.validate ? activePage.props.validate(values) : {} return activePage.props.validate ? activePage.props.validate(values) : {}
} }
next = (values: Object) => next = async (values: Object) => {
const activePageProps = this.getPageProps(this.props.children)
const { prepareNextInitialProps } = activePageProps
let pageInitialProps
if (prepareNextInitialProps) {
pageInitialProps = await prepareNextInitialProps(values)
}
const finalValues = { ...values, ...pageInitialProps }
this.setState(state => ({ this.setState(state => ({
page: Math.min(state.page + 1, React.Children.count(this.props.children) - 1), page: Math.min(state.page + 1, React.Children.count(this.props.children) - 1),
values, values: finalValues,
})) }))
}
previous = () => previous = () =>
this.setState(state => ({ this.setState(state => ({
page: Math.max(state.page - 1, 0), page: Math.max(state.page - 1, 0),
})) }))
handleSubmit = (values: Object) => { handleSubmit = async (values: Object) => {
const { children, onSubmit } = this.props const { children, onSubmit } = this.props
const { page } = this.state const { page } = this.state
const isLastPage = page === React.Children.count(children) - 1 const isLastPage = page === React.Children.count(children) - 1
@ -100,7 +114,7 @@ class GnoStepper extends React.PureComponent<Props, State> {
render() { render() {
const { const {
steps, children, finishedTransaction, finishedButton, classes, steps, children, finishedTransaction, finishedButton, classes, disabledWhenValidating = false,
} = this.props } = this.props
const { page, values } = this.state const { page, values } = this.state
const activePage = this.getActivePageFrom(children) const activePage = this.getActivePageFrom(children)
@ -123,11 +137,14 @@ class GnoStepper extends React.PureComponent<Props, State> {
validation={this.validate} validation={this.validate}
render={activePage} render={activePage}
> >
{(submitting: boolean) => ( {(submitting: boolean, validating: boolean) => {
const disabled = disabledWhenValidating ? submitting || validating : submitting
return (
<Row align="end" margin="lg" grow> <Row align="end" margin="lg" grow>
<Col xs={12} center="xs"> <Col xs={12} center="xs">
<Controls <Controls
submitting={submitting} disabled={disabled}
finishedTx={finishedTransaction} finishedTx={finishedTransaction}
finishedButton={finished} finishedButton={finished}
onPrevious={this.previous} onPrevious={this.previous}
@ -136,7 +153,8 @@ class GnoStepper extends React.PureComponent<Props, State> {
/> />
</Col> </Col>
</Row> </Row>
)} )
}}
</GnoForm> </GnoForm>
</React.Fragment> </React.Fragment>
) )

View File

@ -1,4 +1,8 @@
// @flow // @flow
import * as React from 'react'
import { Field } from 'react-final-form' import { Field } from 'react-final-form'
export default Field // $FlowFixMe
const GnoField = ({ ...props }): React.Element<*> => <Field {...props} />
export default GnoField

View File

@ -35,7 +35,7 @@ const GnoForm = ({
render={({ handleSubmit, ...rest }) => ( render={({ handleSubmit, ...rest }) => (
<form onSubmit={handleSubmit} style={stylesBasedOn(padding)}> <form onSubmit={handleSubmit} style={stylesBasedOn(padding)}>
{render(rest)} {render(rest)}
{children(rest.submitting, rest.submitSucceeded)} {children(rest.submitting, rest.validating)}
</form> </form>
)} )}
/> />

View File

@ -20,6 +20,16 @@ export const greaterThan = (min: number) => (value: string) => {
return `Should be greater than ${min}` return `Should be greater than ${min}`
} }
const regexQuery = /^(?:(?:https?|ftp):\/\/)(?:\S+(?::\S*)?@)?(?:(?!(?:10|127)(?:\.\d{1,3}){3})(?!(?:169\.254|192\.168)(?:\.\d{1,3}){2})(?!172\.(?:1[6-9]|2\d|3[0-1])(?:\.\d{1,3}){2})(?:[1-9]\d?|1\d\d|2[01]\d|22[0-3])(?:\.(?:1?\d{1,2}|2[0-4]\d|25[0-5])){2}(?:\.(?:[1-9]\d?|1\d\d|2[0-4]\d|25[0-4]))|(?:(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)(?:\.(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)*(?:\.(?:[a-z\u00a1-\uffff]{2,}))\.?)(?::\d{2,5})?(?:[/?#]\S*)?$/i
const url = new RegExp(regexQuery)
export const mustBeUrl = (value: string) => {
if (url.test(value)) {
return undefined
}
return 'Please, provide a valid url'
}
export const minValue = (min: number) => (value: string) => { export const minValue = (min: number) => (value: string) => {
if (Number.isNaN(Number(value)) || Number.parseFloat(value) >= Number(min)) { if (Number.isNaN(Number(value)) || Number.parseFloat(value) >= Number(min)) {
return undefined return undefined

View File

@ -1,6 +1,6 @@
// @flow // @flow
import * as React from 'react' import * as React from 'react'
import { Field } from 'react-final-form' import Field from '~/components/forms/Field'
import TextField from '~/components/forms/TextField' import TextField from '~/components/forms/TextField'
import { composeValidators, minValue, mustBeInteger, required } from '~/components/forms/validator' import { composeValidators, minValue, mustBeInteger, required } from '~/components/forms/validator'
import Block from '~/components/layout/Block' import Block from '~/components/layout/Block'

View File

@ -1,7 +1,7 @@
// @flow // @flow
import { storiesOf } from '@storybook/react' import { storiesOf } from '@storybook/react'
import * as React from 'react' import * as React from 'react'
import { Map } from 'immutable' import { List } from 'immutable'
import styles from '~/components/layout/PageFrame/index.scss' import styles from '~/components/layout/PageFrame/index.scss'
import { SafeFactory } from '~/routes/safe/store/test/builder/safe.builder' import { SafeFactory } from '~/routes/safe/store/test/builder/safe.builder'
import { makeToken } from '~/routes/tokens/store/model/token' import { makeToken } from '~/routes/tokens/store/model/token'
@ -30,7 +30,7 @@ storiesOf('Routes /safe:address', module)
userAddress="foo" userAddress="foo"
safe={undefined} safe={undefined}
provider="METAMASK" provider="METAMASK"
activeTokens={Map()} activeTokens={List([])}
fetchBalance={() => {}} fetchBalance={() => {}}
/> />
)) ))
@ -39,7 +39,7 @@ storiesOf('Routes /safe:address', module)
userAddress="foo" userAddress="foo"
safe={undefined} safe={undefined}
provider="" provider=""
activeTokens={Map()} activeTokens={List([])}
fetchBalance={() => {}} fetchBalance={() => {}}
/> />
)) ))
@ -51,7 +51,7 @@ storiesOf('Routes /safe:address', module)
userAddress="foo" userAddress="foo"
safe={safe} safe={safe}
provider="METAMASK" provider="METAMASK"
activeTokens={Map().set('ETH', ethBalance)} activeTokens={List([]).push(ethBalance)}
fetchBalance={() => {}} fetchBalance={() => {}}
/> />
) )
@ -64,7 +64,7 @@ storiesOf('Routes /safe:address', module)
userAddress="foo" userAddress="foo"
safe={safe} safe={safe}
provider="METAMASK" provider="METAMASK"
activeTokens={Map().set('ETH', ethBalance)} activeTokens={List([]).push(ethBalance)}
fetchBalance={() => {}} fetchBalance={() => {}}
/> />
) )

View File

@ -1,7 +1,7 @@
// @flow // @flow
import List from '@material-ui/core/List' import ListComponent from '@material-ui/core/List'
import * as React from 'react' import * as React from 'react'
import { Map } from 'immutable' import { List } from 'immutable'
import Block from '~/components/layout/Block' import Block from '~/components/layout/Block'
import Col from '~/components/layout/Col' import Col from '~/components/layout/Col'
import Bold from '~/components/layout/Bold' import Bold from '~/components/layout/Bold'
@ -30,7 +30,7 @@ const safeIcon = require('./assets/gnosis_safe.svg')
type SafeProps = { type SafeProps = {
safe: Safe, safe: Safe,
tokens: Map<string, Token>, tokens: List<Token>,
userAddress: string, userAddress: string,
} }
@ -43,12 +43,17 @@ const listStyle = {
} }
const getEthBalanceFrom = (tokens: List<Token>) => { const getEthBalanceFrom = (tokens: List<Token>) => {
const ethToken = tokens.filter(token => token.get('symbol') === 'ETH') const filteredTokens = tokens.filter(token => token.get('symbol') === 'ETH')
if (ethToken.count() === 0) { if (filteredTokens.count() === 0) {
return 0 return 0
} }
return Number(ethToken.get(0).get('funds')) const ethToken = filteredTokens.get(0)
if (!ethToken) {
return 0
}
return Number(ethToken.get('funds'))
} }
class GnoSafe extends React.PureComponent<SafeProps, State> { class GnoSafe extends React.PureComponent<SafeProps, State> {
@ -100,7 +105,7 @@ class GnoSafe extends React.PureComponent<SafeProps, State> {
component: <SendToken component: <SendToken
safe={safe} safe={safe}
token={ercToken} token={ercToken}
key={ercToken.get('symbol')} key={ercToken.get('address')}
onReset={this.onListTransactions} onReset={this.onListTransactions}
/>, />,
}) })
@ -115,7 +120,7 @@ class GnoSafe extends React.PureComponent<SafeProps, State> {
return ( return (
<Row grow> <Row grow>
<Col sm={12} top="xs" md={5} margin="xl" overflow> <Col sm={12} top="xs" md={5} margin="xl" overflow>
<List style={listStyle}> <ListComponent style={listStyle}>
<BalanceInfo tokens={tokens} onMoveFunds={this.onMoveTokens} safeAddress={address} /> <BalanceInfo tokens={tokens} onMoveFunds={this.onMoveTokens} safeAddress={address} />
<Owners <Owners
owners={safe.owners} owners={safe.owners}
@ -127,7 +132,7 @@ class GnoSafe extends React.PureComponent<SafeProps, State> {
<Address address={address} /> <Address address={address} />
<DailyLimit balance={ethBalance} dailyLimit={safe.get('dailyLimit')} onWithdraw={this.onWithdraw} onEditDailyLimit={this.onEditDailyLimit} /> <DailyLimit balance={ethBalance} dailyLimit={safe.get('dailyLimit')} onWithdraw={this.onWithdraw} onEditDailyLimit={this.onEditDailyLimit} />
<MultisigTx onSeeTxs={this.onListTransactions} /> <MultisigTx onSeeTxs={this.onListTransactions} />
</List> </ListComponent>
</Col> </Col>
<Col sm={12} center="xs" md={7} margin="xl" layout="column"> <Col sm={12} center="xs" md={7} margin="xl" layout="column">
<Block margin="xl"> <Block margin="xl">

View File

@ -42,7 +42,7 @@ const getTransferData = async (tokenAddress: string, to: string, amount: BigNumb
const processTokenTransfer = async (safe: Safe, token: Token, to: string, amount: number, userAddress: string) => { const processTokenTransfer = async (safe: Safe, token: Token, to: string, amount: number, userAddress: string) => {
const symbol = token.get('symbol') const symbol = token.get('symbol')
const nonce = Date.now() const nonce = Date.now()
const name = `Send ${amount} ${token.get('symbol')} to ${to}` const name = `Send ${amount} ${symbol} to ${to}`
const value = isEther(symbol) ? amount : 0 const value = isEther(symbol) ? amount : 0
const tokenAddress = token.get('address') const tokenAddress = token.get('address')
const destination = isEther(symbol) ? to : tokenAddress const destination = isEther(symbol) ? to : tokenAddress

View File

@ -1,5 +1,5 @@
// @flow // @flow
import { List, Map } from 'immutable' import { List } from 'immutable'
import { createSelector, createStructuredSelector, type Selector } from 'reselect' import { createSelector, createStructuredSelector, type Selector } from 'reselect'
import { safeSelector, type RouterProps, type SafeSelectorProps } from '~/routes/safe/store/selectors' import { safeSelector, type RouterProps, type SafeSelectorProps } from '~/routes/safe/store/selectors'
import { providerNameSelector, userAccountSelector } from '~/wallets/store/selectors/index' import { providerNameSelector, userAccountSelector } from '~/wallets/store/selectors/index'
@ -13,7 +13,7 @@ import { type Token } from '~/routes/tokens/store/model/token'
export type SelectorProps = { export type SelectorProps = {
safe: SafeSelectorProps, safe: SafeSelectorProps,
provider: string, provider: string,
activeTokens: Map<string, Token>, activeTokens: List<Token>,
userAddress: string, userAddress: string,
} }

View File

@ -0,0 +1,18 @@
// @flow
import { storiesOf } from '@storybook/react'
import * as React from 'react'
import { List } from 'immutable'
import styles from '~/components/layout/PageFrame/index.scss'
import AddTokenForm from './index'
const FrameDecorator = story => (
<div className={styles.frame} style={{ textAlign: 'center' }}>
{ story() }
</div>
)
storiesOf('Components', module)
.addDecorator(FrameDecorator)
.add('AddTokenForm', () => (
// $FlowFixMe
<AddTokenForm tokens={List([]).toArray()} safeAddress="" />
))

View File

@ -0,0 +1,56 @@
// @flow
import * as React from 'react'
import Field from '~/components/forms/Field'
import TextField from '~/components/forms/TextField'
import { composeValidators, required, mustBeEthereumAddress, uniqueAddress } from '~/components/forms/validator'
import Block from '~/components/layout/Block'
import Heading from '~/components/layout/Heading'
import { promisify } from '~/utils/promisify'
import { getWeb3 } from '~/wallets/getWeb3'
import { EMPTY_DATA } from '~/wallets/ethTransactions'
import { getStandardTokenContract } from '~/routes/tokens/store/actions/fetchTokens'
type Props = {
addresses: string[],
}
export const TOKEN_ADRESS_PARAM = 'tokenAddress'
export const token = async (tokenAddress: string) => {
const code = await promisify(cb => getWeb3().eth.getCode(tokenAddress, cb))
const isDeployed = code !== EMPTY_DATA
if (!isDeployed) {
return 'Specified address is not deployed on the current network'
}
const erc20Token = await getStandardTokenContract()
const instance = await erc20Token.at(tokenAddress)
const supply = await instance.totalSupply()
if (Number(supply) === 0) {
return 'Specified address is not a valid standard token'
}
return undefined
}
const FirstPage = ({ addresses }: Props) => () => (
<Block margin="md">
<Heading tag="h2" margin="lg">
Add Custom ERC20 Token
</Heading>
<Block margin="md">
<Field
name={TOKEN_ADRESS_PARAM}
component={TextField}
type="text"
validate={composeValidators(required, mustBeEthereumAddress, uniqueAddress(addresses), token)}
placeholder="ERC20 Token Address*"
text="ERC20 Token Address"
/>
</Block>
</Block>
)
export default FirstPage

View File

@ -0,0 +1,45 @@
// @flow
import * as React from 'react'
import CircularProgress from '@material-ui/core/CircularProgress'
import Block from '~/components/layout/Block'
import Bold from '~/components/layout/Bold'
import Heading from '~/components/layout/Heading'
import Paragraph from '~/components/layout/Paragraph'
import { TOKEN_ADRESS_PARAM } from '~/routes/tokens/component/AddToken/FirstPage'
import { TOKEN_LOGO_URL_PARAM, TOKEN_NAME_PARAM, TOKEN_SYMBOL_PARAM, TOKEN_DECIMALS_PARAM } from '~/routes/tokens/component/AddToken/SecondPage'
type FormProps = {
values: Object,
submitting: boolean,
}
const spinnerStyle = {
minHeight: '50px',
}
const Review = () => ({ values, submitting }: FormProps) => (
<Block>
<Heading tag="h2">Review ERC20 Token operation</Heading>
<Paragraph align="left">
<Bold>Token address: </Bold> {values[TOKEN_ADRESS_PARAM]}
</Paragraph>
<Paragraph align="left">
<Bold>Token name: </Bold> {values[TOKEN_NAME_PARAM]}
</Paragraph>
<Paragraph align="left">
<Bold>Token symbol: </Bold> {values[TOKEN_SYMBOL_PARAM]}
</Paragraph>
<Paragraph align="left">
<Bold>Token decimals: </Bold> {values[TOKEN_DECIMALS_PARAM]}
</Paragraph>
<Paragraph align="left">
<Bold>Token logo: </Bold> {values[TOKEN_LOGO_URL_PARAM]}
</Paragraph>
<Block style={spinnerStyle}>
{ submitting && <CircularProgress size={50} /> }
</Block>
</Block>
)
export default Review

View File

@ -0,0 +1,62 @@
// @flow
import * as React from 'react'
import Field from '~/components/forms/Field'
import TextField from '~/components/forms/TextField'
import { composeValidators, required, mustBeInteger, mustBeUrl } from '~/components/forms/validator'
import Block from '~/components/layout/Block'
import Heading from '~/components/layout/Heading'
export const TOKEN_NAME_PARAM = 'tokenName'
export const TOKEN_SYMBOL_PARAM = 'tokenSymbol'
export const TOKEN_DECIMALS_PARAM = 'tokenDecimals'
export const TOKEN_LOGO_URL_PARAM = 'tokenLogo'
const SecondPage = () => () => (
<Block margin="md">
<Heading tag="h2" margin="lg">
Complete Custom Token information
</Heading>
<Block margin="md">
<Field
name={TOKEN_NAME_PARAM}
component={TextField}
type="text"
validate={required}
placeholder="ERC20 Token Name*"
text="ERC20 Token Name"
/>
</Block>
<Block margin="md">
<Field
name={TOKEN_SYMBOL_PARAM}
component={TextField}
type="text"
validate={required}
placeholder="ERC20 Token Symbol*"
text="ERC20 Token Symbol"
/>
</Block>
<Block margin="md">
<Field
name={TOKEN_DECIMALS_PARAM}
component={TextField}
type="text"
validate={composeValidators(required, mustBeInteger)}
placeholder="ERC20 Token Decimals*"
text="ERC20 Token Decimals"
/>
</Block>
<Block margin="md">
<Field
name={TOKEN_LOGO_URL_PARAM}
component={TextField}
type="text"
validate={composeValidators(required, mustBeUrl)}
placeholder="ERC20 Token Logo url*"
text="ERC20 Token Logo"
/>
</Block>
</Block>
)
export default SecondPage

View File

@ -0,0 +1,128 @@
// @flow
import * as React from 'react'
import Stepper from '~/components/Stepper'
import { getHumanFriendlyToken } from '~/routes/tokens/store/actions/fetchTokens'
import FirstPage, { TOKEN_ADRESS_PARAM } from '~/routes/tokens/component/AddToken/FirstPage'
import SecondPage, { TOKEN_SYMBOL_PARAM, TOKEN_DECIMALS_PARAM, TOKEN_LOGO_URL_PARAM, TOKEN_NAME_PARAM } from '~/routes/tokens/component/AddToken/SecondPage'
import { makeToken, type Token } from '~/routes/tokens/store/model/token'
import addTokenAction from '~/routes/tokens/store/actions/addToken'
import { getWeb3 } from '~/wallets/getWeb3'
import { promisify } from '~/utils/promisify'
import { EMPTY_DATA } from '~/wallets/ethTransactions'
import Review from './Review'
export const getSteps = () => [
'Fill Add Token Form', 'Check optional attributes', 'Review Information',
]
type Props = {
tokens: string[],
safeAddress: string,
addToken: typeof addTokenAction,
}
type State = {
done: boolean,
}
export const ADD_TOKEN_RESET_BUTTON_TEXT = 'RESET'
export const addTokenFnc = async (values: Object, addToken: typeof addTokenAction, safeAddress: string) => {
const address = values[TOKEN_ADRESS_PARAM]
const name = values[TOKEN_NAME_PARAM]
const symbol = values[TOKEN_SYMBOL_PARAM]
const decimals = values[TOKEN_DECIMALS_PARAM]
const logo = values[TOKEN_LOGO_URL_PARAM]
const token: Token = makeToken({
address,
name,
symbol,
decimals: Number(decimals),
logoUrl: logo,
status: true,
removable: true,
})
return addToken(safeAddress, token)
}
class AddToken extends React.Component<Props, State> {
state = {
done: false,
}
onAddToken = async (values: Object) => {
const { addToken, safeAddress } = this.props
const result = addTokenFnc(values, addToken, safeAddress)
this.setState({ done: true })
return result
}
onReset = () => {
this.setState({ done: false })
}
fetchInitialPropsSecondPage = async (values: Object) => {
const tokenAddress = values[TOKEN_ADRESS_PARAM]
const erc20Token = await getHumanFriendlyToken()
const instance = await erc20Token.at(tokenAddress)
const dataName = await instance.contract.name.getData()
const nameResult = await promisify(cb => getWeb3().eth.call({ to: tokenAddress, data: dataName }, cb))
const hasName = nameResult !== EMPTY_DATA
const dataSymbol = await instance.contract.symbol.getData()
const symbolResult = await promisify(cb => getWeb3().eth.call({ to: tokenAddress, data: dataSymbol }, cb))
const hasSymbol = symbolResult !== EMPTY_DATA
const dataDecimals = await instance.contract.decimals.getData()
const decimalsResult = await promisify(cb => getWeb3().eth.call({ to: tokenAddress, data: dataDecimals }, cb))
const hasDecimals = decimalsResult !== EMPTY_DATA
const name = hasName ? await instance.name() : undefined
const symbol = hasSymbol ? await instance.symbol() : undefined
const decimals = hasDecimals ? `${await instance.decimals()}` : undefined
return ({
[TOKEN_SYMBOL_PARAM]: symbol,
[TOKEN_DECIMALS_PARAM]: decimals,
[TOKEN_NAME_PARAM]: name,
})
}
render() {
const { tokens, safeAddress } = this.props
const { done } = this.state
const steps = getSteps()
const finishedButton = <Stepper.FinishButton title={ADD_TOKEN_RESET_BUTTON_TEXT} />
return (
<React.Fragment>
<Stepper
finishedTransaction={done}
finishedButton={finishedButton}
onSubmit={this.onAddToken}
steps={steps}
onReset={this.onReset}
disabledWhenValidating
>
<Stepper.Page addresses={tokens} prepareNextInitialProps={this.fetchInitialPropsSecondPage}>
{ FirstPage }
</Stepper.Page>
<Stepper.Page safeAddress={safeAddress}>
{ SecondPage }
</Stepper.Page>
<Stepper.Page>
{ Review }
</Stepper.Page>
</Stepper>
</React.Fragment>
)
}
}
export default AddToken

View File

@ -4,6 +4,7 @@ import * as React from 'react'
import Block from '~/components/layout/Block' import Block from '~/components/layout/Block'
import Col from '~/components/layout/Col' import Col from '~/components/layout/Col'
import AccountBalanceWallet from '@material-ui/icons/AccountBalanceWallet' import AccountBalanceWallet from '@material-ui/icons/AccountBalanceWallet'
import AddCircle from '@material-ui/icons/AddCircle'
import Link from '~/components/layout/Link' import Link from '~/components/layout/Link'
import Bold from '~/components/layout/Bold' import Bold from '~/components/layout/Bold'
import Img from '~/components/layout/Img' import Img from '~/components/layout/Img'
@ -14,8 +15,8 @@ import { type Token } from '~/routes/tokens/store/model/token'
import { type SelectorProps } from '~/routes/tokens/container/selector' import { type SelectorProps } from '~/routes/tokens/container/selector'
import { type Actions } from '~/routes/tokens/container/actions' import { type Actions } from '~/routes/tokens/container/actions'
import { SAFELIST_ADDRESS } from '~/routes/routes' import { SAFELIST_ADDRESS } from '~/routes/routes'
import AddToken from '~/routes/tokens/component/AddToken'
import TokenComponent from './Token' import TokenComponent from './Token'
// import AddToken from '~/routes/tokens/component/AddToken'
// import RemoveToken from '~/routes/tokens/component/RemoveToken' // import RemoveToken from '~/routes/tokens/component/RemoveToken'
const safeIcon = require('~/routes/safe/component/Safe/assets/gnosis_safe.svg') const safeIcon = require('~/routes/safe/component/Safe/assets/gnosis_safe.svg')
@ -35,12 +36,20 @@ class TokenLayout extends React.PureComponent<TokenProps, State> {
state = { state = {
component: undefined, component: undefined,
} }
/*
onAddToken = () => { onAddToken = () => {
const { addresses } = this.props const { addresses, safeAddress, addToken } = this.props
this.setState({ component: <AddToken/> })
this.setState({
component: <AddToken
addToken={addToken}
tokens={addresses.toArray()}
safeAddress={safeAddress}
/>,
})
} }
/*
onRemoveToken = () => { onRemoveToken = () => {
this.setState({ component: <RemoveToken /> }) this.setState({ component: <RemoveToken /> })
} }
@ -68,12 +77,14 @@ class TokenLayout extends React.PureComponent<TokenProps, State> {
<Row grow> <Row grow>
<Col sm={12} top="xs" md={5} margin="xl" overflow> <Col sm={12} top="xs" md={5} margin="xl" overflow>
<MuiList style={listStyle}> <MuiList style={listStyle}>
{tokens.map((token: Token) => (<TokenComponent {tokens.map((token: Token) => (
key={token.get('symbol')} <TokenComponent
key={token.get('address')}
token={token} token={token}
onDisableToken={this.onDisableToken} onDisableToken={this.onDisableToken}
onEnableToken={this.onEnableToken} onEnableToken={this.onEnableToken}
/>))} />
))}
</MuiList> </MuiList>
</Col> </Col>
<Col sm={12} center="xs" md={7} margin="xl" layout="column"> <Col sm={12} center="xs" md={7} margin="xl" layout="column">
@ -82,6 +93,9 @@ class TokenLayout extends React.PureComponent<TokenProps, State> {
<IconButton to={`${SAFELIST_ADDRESS}/${safeAddress}`} component={Link}> <IconButton to={`${SAFELIST_ADDRESS}/${safeAddress}`} component={Link}>
<AccountBalanceWallet /> <AccountBalanceWallet />
</IconButton> </IconButton>
<IconButton onClick={this.onAddToken}>
<AddCircle />
</IconButton>
<Bold>{name}</Bold> <Bold>{name}</Bold>
</Paragraph> </Paragraph>
</Block> </Block>

View File

@ -1,4 +1,5 @@
// @flow // @flow
import addToken from '~/routes/tokens/store/actions/addToken'
import enableToken from '~/routes/tokens/store/actions/enableToken' import enableToken from '~/routes/tokens/store/actions/enableToken'
import disableToken from '~/routes/tokens/store/actions/disableToken' import disableToken from '~/routes/tokens/store/actions/disableToken'
import { fetchTokens } from '~/routes/tokens/store/actions/fetchTokens' import { fetchTokens } from '~/routes/tokens/store/actions/fetchTokens'
@ -6,9 +7,11 @@ import { fetchTokens } from '~/routes/tokens/store/actions/fetchTokens'
export type Actions = { export type Actions = {
enableToken: typeof enableToken, enableToken: typeof enableToken,
disableToken: typeof disableToken, disableToken: typeof disableToken,
addToken: typeof addToken,
} }
export default { export default {
addToken,
enableToken, enableToken,
disableToken, disableToken,
fetchTokens, fetchTokens,

View File

@ -22,7 +22,7 @@ class TokensView extends React.PureComponent<Props> {
render() { render() {
const { const {
tokens, addresses, safe, safeAddress, disableToken, enableToken, tokens, addresses, safe, safeAddress, disableToken, enableToken, addToken,
} = this.props } = this.props
return ( return (
@ -34,6 +34,7 @@ class TokensView extends React.PureComponent<Props> {
safeAddress={safeAddress} safeAddress={safeAddress}
disableToken={disableToken} disableToken={disableToken}
enableToken={enableToken} enableToken={enableToken}
addToken={addToken}
/> />
</Page> </Page>
) )

View File

@ -8,7 +8,7 @@ import { type Token } from '~/routes/tokens/store/model/token'
export type SelectorProps = { export type SelectorProps = {
tokens: List<Token>, tokens: List<Token>,
addresses: List<String>, addresses: List<string>,
safe: Safe, safe: Safe,
safeAddress: string, safeAddress: string,
} }

View File

@ -0,0 +1,20 @@
// @flow
import { createAction } from 'redux-actions'
import { type Token } from '~/routes/tokens/store/model/token'
export const ADD_TOKEN = 'ADD_TOKEN'
type AddTokenProps = {
safeAddress: string,
token: Token,
}
const addToken = createAction(
ADD_TOKEN,
(safeAddress: string, token: Token): AddTokenProps => ({
safeAddress,
token,
}),
)
export default addToken

View File

@ -8,7 +8,6 @@ const disableToken = createAction(
DISABLE_TOKEN, DISABLE_TOKEN,
(safeAddress: string, token: Token) => ({ (safeAddress: string, token: Token) => ({
safeAddress, safeAddress,
symbol: token.get('symbol'),
address: token.get('address'), address: token.get('address'),
}), }),
) )

View File

@ -8,7 +8,6 @@ const enableToken = createAction(
ENABLE_TOKEN, ENABLE_TOKEN,
(safeAddress: string, token: Token) => ({ (safeAddress: string, token: Token) => ({
safeAddress, safeAddress,
symbol: token.get('symbol'),
address: token.get('address'), address: token.get('address'),
}), }),
) )

View File

@ -3,11 +3,12 @@ import { List, Map } from 'immutable'
import contract from 'truffle-contract' import contract from 'truffle-contract'
import type { Dispatch as ReduxDispatch } from 'redux' import type { Dispatch as ReduxDispatch } from 'redux'
import StandardToken from '@gnosis.pm/util-contracts/build/contracts/StandardToken.json' import StandardToken from '@gnosis.pm/util-contracts/build/contracts/StandardToken.json'
import HumanFriendlyToken from '@gnosis.pm/util-contracts/build/contracts/HumanFriendlyToken.json'
import { getWeb3 } from '~/wallets/getWeb3' import { getWeb3 } from '~/wallets/getWeb3'
import { type GlobalState } from '~/store/index' import { type GlobalState } from '~/store/index'
import { makeToken, type Token, type TokenProps } from '~/routes/tokens/store/model/token' import { makeToken, type Token, type TokenProps } from '~/routes/tokens/store/model/token'
import { ensureOnce } from '~/utils/singleton' import { ensureOnce } from '~/utils/singleton'
import { getTokens } from '~/utils/localStorage/tokens' import { getActiveTokenAddresses, getTokens } from '~/utils/localStorage/tokens'
import { getSafeEthToken } from '~/utils/tokens' import { getSafeEthToken } from '~/utils/tokens'
import { enhancedFetch } from '~/utils/fetch' import { enhancedFetch } from '~/utils/fetch'
import addTokens from './addTokens' import addTokens from './addTokens'
@ -19,6 +20,15 @@ const createStandardTokenContract = async () => {
return erc20Token return erc20Token
} }
const createHumanFriendlyTokenContract = async () => {
const web3 = getWeb3()
const humanErc20Token = await contract(HumanFriendlyToken)
humanErc20Token.setProvider(web3.currentProvider)
return humanErc20Token
}
export const getHumanFriendlyToken = ensureOnce(createHumanFriendlyTokenContract)
export const getStandardTokenContract = ensureOnce(createStandardTokenContract) export const getStandardTokenContract = ensureOnce(createStandardTokenContract)
@ -38,9 +48,9 @@ export const fetchTokensData = async () => {
export const fetchTokens = (safeAddress: string) => export const fetchTokens = (safeAddress: string) =>
async (dispatch: ReduxDispatch<GlobalState>) => { async (dispatch: ReduxDispatch<GlobalState>) => {
const tokens: List<string> = getTokens(safeAddress) const tokens: List<string> = getActiveTokenAddresses(safeAddress)
const ethBalance = await getSafeEthToken(safeAddress) const ethBalance = await getSafeEthToken(safeAddress)
const customTokens = getTokens(safeAddress)
const json = await exports.fetchTokensData() const json = await exports.fetchTokensData()
try { try {
@ -51,10 +61,18 @@ export const fetchTokens = (safeAddress: string) =>
return makeToken({ ...item, status, funds }) return makeToken({ ...item, status, funds })
})) }))
const balances: Map<string, Token> = Map().withMutations((map) => { const customTokenRecords = await Promise.all(customTokens.map(async (item: TokenProps) => {
balancesRecords.forEach(record => map.set(record.get('symbol'), record)) const status = tokens.includes(item.address)
const funds = status ? await calculateBalanceOf(item.address, safeAddress, item.decimals) : '0'
map.set('ETH', ethBalance) return makeToken({ ...item, status, funds })
}))
const balances: Map<string, Token> = Map().withMutations((map) => {
balancesRecords.forEach(record => map.set(record.get('address'), record))
customTokenRecords.forEach(record => map.set(record.get('address'), record))
map.set(ethBalance.get('address'), ethBalance)
}) })
return dispatch(addTokens(safeAddress, balances)) return dispatch(addTokens(safeAddress, balances))

View File

@ -1,11 +1,12 @@
// @flow // @flow
import { List, Map } from 'immutable' import { List, Map } from 'immutable'
import { handleActions, type ActionType } from 'redux-actions' import { handleActions, type ActionType } from 'redux-actions'
import addToken, { ADD_TOKEN } from '~/routes/tokens/store/actions/addToken'
import addTokens, { ADD_TOKENS } from '~/routes/tokens/store/actions/addTokens' import addTokens, { ADD_TOKENS } from '~/routes/tokens/store/actions/addTokens'
import { type Token } from '~/routes/tokens/store/model/token' import { type Token } from '~/routes/tokens/store/model/token'
import disableToken, { DISABLE_TOKEN } from '~/routes/tokens/store/actions/disableToken' import disableToken, { DISABLE_TOKEN } from '~/routes/tokens/store/actions/disableToken'
import enableToken, { ENABLE_TOKEN } from '~/routes/tokens/store/actions/enableToken' import enableToken, { ENABLE_TOKEN } from '~/routes/tokens/store/actions/enableToken'
import { setTokens, getTokens } from '~/utils/localStorage/tokens' import { setActiveTokenAddresses, getActiveTokenAddresses, setToken } from '~/utils/localStorage/tokens'
import { ensureOnce } from '~/utils/singleton' import { ensureOnce } from '~/utils/singleton'
import { calculateActiveErc20TokensFrom } from '~/utils/tokens' import { calculateActiveErc20TokensFrom } from '~/utils/tokens'
@ -13,7 +14,7 @@ export const TOKEN_REDUCER_ID = 'tokens'
export type State = Map<string, Map<string, Token>> export type State = Map<string, Map<string, Token>>
const setTokensOnce = ensureOnce(setTokens) const setTokensOnce = ensureOnce(setActiveTokenAddresses)
export default handleActions({ export default handleActions({
[ADD_TOKENS]: (state: State, action: ActionType<typeof addTokens>): State => { [ADD_TOKENS]: (state: State, action: ActionType<typeof addTokens>): State => {
@ -30,21 +31,30 @@ export default handleActions({
return prevSafe.equals(tokens) ? prevSafe : tokens return prevSafe.equals(tokens) ? prevSafe : tokens
}) })
}, },
[ADD_TOKEN]: (state: State, action: ActionType<typeof addToken>): State => {
const { safeAddress, token } = action.payload
const tokenAddress = token.get('address')
const activeTokens = getActiveTokenAddresses(safeAddress)
setActiveTokenAddresses(safeAddress, activeTokens.push(tokenAddress))
setToken(safeAddress, token)
return state.setIn([safeAddress, tokenAddress], token)
},
[DISABLE_TOKEN]: (state: State, action: ActionType<typeof disableToken>): State => { [DISABLE_TOKEN]: (state: State, action: ActionType<typeof disableToken>): State => {
const { address, safeAddress, symbol } = action.payload const { address, safeAddress } = action.payload
const activeTokens = getTokens(safeAddress) const activeTokens = getActiveTokenAddresses(safeAddress)
const index = activeTokens.indexOf(address) const index = activeTokens.indexOf(address)
setTokens(safeAddress, activeTokens.delete(index)) setActiveTokenAddresses(safeAddress, activeTokens.delete(index))
return state.setIn([safeAddress, symbol, 'status'], false) return state.setIn([safeAddress, address, 'status'], false)
}, },
[ENABLE_TOKEN]: (state: State, action: ActionType<typeof enableToken>): State => { [ENABLE_TOKEN]: (state: State, action: ActionType<typeof enableToken>): State => {
const { address, safeAddress, symbol } = action.payload const { address, safeAddress } = action.payload
const activeTokens = getTokens(safeAddress) const activeTokens = getActiveTokenAddresses(safeAddress)
setTokens(safeAddress, activeTokens.push(address)) setActiveTokenAddresses(safeAddress, activeTokens.push(address))
return state.setIn([safeAddress, symbol, 'status'], true) return state.setIn([safeAddress, address, 'status'], true)
}, },
}, Map()) }, Map())

View File

@ -7,6 +7,7 @@ import { type Token } from '~/routes/tokens/store/model/token'
import { TOKEN_REDUCER_ID } from '~/routes/tokens/store/reducer/tokens' import { TOKEN_REDUCER_ID } from '~/routes/tokens/store/reducer/tokens'
import { addEtherTo, addTknTo } from '~/test/utils/tokenMovements' import { addEtherTo, addTknTo } from '~/test/utils/tokenMovements'
import { dispatchTknBalance } from '~/test/utils/transactions/moveTokens.helper' import { dispatchTknBalance } from '~/test/utils/transactions/moveTokens.helper'
import { ETH_ADDRESS } from '~/utils/tokens'
describe('Safe - redux balance property', () => { describe('Safe - redux balance property', () => {
let store let store
@ -18,7 +19,16 @@ describe('Safe - redux balance property', () => {
it('reducer should return 0 to just deployed safe', async () => { it('reducer should return 0 to just deployed safe', async () => {
// GIVEN // GIVEN
const tokenList = ['WE', '<3', 'GNO', 'OMG', 'RDN'] const tokenList = [
'0x975be7f72cea31fd83d0cb2a197f9136f38696b7', // WE
'0xb3a4bc89d8517e0e2c9b66703d09d3029ffa1e6d', // <3
'0x5f92161588c6178130ede8cbdc181acec66a9731', // GNO
'0xb63d06025d580a94d59801f2513f5d309c079559', // OMG
'0x3615757011112560521536258c1E7325Ae3b48AE', // RDN
'0xc778417E063141139Fce010982780140Aa0cD5Ab', // Wrapped Ether
'0x979861dF79C7408553aAF20c01Cfb3f81CCf9341', // OLY
'0', // ETH
]
// WHEN // WHEN
await store.dispatch(fetchTokensAction.fetchTokens(address)) await store.dispatch(fetchTokensAction.fetchTokens(address))
@ -29,7 +39,7 @@ describe('Safe - redux balance property', () => {
const safeBalances: Map<string, Token> | typeof undefined = tokens.get(address) const safeBalances: Map<string, Token> | typeof undefined = tokens.get(address)
if (!safeBalances) throw new Error() if (!safeBalances) throw new Error()
expect(safeBalances.size).toBe(6) expect(safeBalances.size).toBe(8)
tokenList.forEach((token: string) => { tokenList.forEach((token: string) => {
const record = safeBalances.get(token) const record = safeBalances.get(token)
@ -49,9 +59,9 @@ describe('Safe - redux balance property', () => {
const safeBalances: Map<string, Token> | typeof undefined = tokens.get(address) const safeBalances: Map<string, Token> | typeof undefined = tokens.get(address)
if (!safeBalances) throw new Error() if (!safeBalances) throw new Error()
expect(safeBalances.size).toBe(6) expect(safeBalances.size).toBe(8)
const ethBalance = safeBalances.get('ETH') const ethBalance = safeBalances.get(ETH_ADDRESS)
if (!ethBalance) throw new Error() if (!ethBalance) throw new Error()
expect(ethBalance.get('funds')).toBe('0.03456') expect(ethBalance.get('funds')).toBe('0.03456')
}) })

View File

@ -0,0 +1,70 @@
// @flow
import * as TestUtils from 'react-dom/test-utils'
import { getWeb3 } from '~/wallets/getWeb3'
import { type Match } from 'react-router-dom'
import { promisify } from '~/utils/promisify'
import TokenComponent from '~/routes/tokens/component/Token'
import { getFirstTokenContract, getSecondTokenContract } from '~/test/utils/tokenMovements'
import { aNewStore } from '~/store'
import { aMinedSafe } from '~/test/builder/safe.redux.builder'
import { travelToTokens } from '~/test/builder/safe.dom.utils'
import { sleep } from '~/utils/timer'
import { buildMathPropsFrom } from '~/test/utils/buildReactRouterProps'
import { tokenListSelector } from '~/routes/tokens/store/selectors'
import { testToken } from '~/test/builder/tokens.dom.utils'
import * as fetchTokensModule from '~/routes/tokens/store/actions/fetchTokens'
import * as enhancedFetchModule from '~/utils/fetch'
import { clickOnAddToken, fillAddress, fillHumanReadableInfo } from '~/test/utils/tokens/addToken.helper'
describe('DOM > Feature > Add new ERC 20 Tokens', () => {
let web3
let accounts
let firstErc20Token
let secondErc20Token
beforeAll(async () => {
web3 = getWeb3()
accounts = await promisify(cb => web3.eth.getAccounts(cb))
firstErc20Token = await getFirstTokenContract(web3, accounts[0])
secondErc20Token = await getSecondTokenContract(web3, accounts[0])
// $FlowFixMe
enhancedFetchModule.enhancedFetch = jest.fn()
enhancedFetchModule.enhancedFetch.mockImplementation(() => Promise.resolve([
{
address: firstErc20Token.address,
name: 'First Token Example',
symbol: 'FTE',
decimals: 18,
logoUrl: 'https://upload.wikimedia.org/wikipedia/commons/c/c0/Earth_simple_icon.png',
},
]))
})
it('adds a second erc 20 token filling the form', async () => {
// GIVEN
const store = aNewStore()
const safeAddress = await aMinedSafe(store)
await store.dispatch(fetchTokensModule.fetchTokens(safeAddress))
const TokensDom = await travelToTokens(store, safeAddress)
await sleep(400)
const tokens = TestUtils.scryRenderedComponentsWithType(TokensDom, TokenComponent)
expect(tokens.length).toBe(2)
testToken(tokens[0].props.token, 'FTE', false)
testToken(tokens[1].props.token, 'ETH', true)
// WHEN
await clickOnAddToken(TokensDom)
await fillAddress(TokensDom, secondErc20Token)
await fillHumanReadableInfo(TokensDom)
// THEN
const match: Match = buildMathPropsFrom(safeAddress)
const tokenList = tokenListSelector(store.getState(), { match })
expect(tokenList.count()).toBe(3)
testToken(tokenList.get(0), 'FTE', false)
testToken(tokenList.get(1), 'ETH', true)
testToken(tokenList.get(2), 'TKN', true)
})
})

View File

@ -13,7 +13,7 @@ import { travelToTokens } from '~/test/builder/safe.dom.utils'
import { sleep } from '~/utils/timer' import { sleep } from '~/utils/timer'
import { buildMathPropsFrom } from '~/test/utils/buildReactRouterProps' import { buildMathPropsFrom } from '~/test/utils/buildReactRouterProps'
import { tokenListSelector, activeTokensSelector } from '~/routes/tokens/store/selectors' import { tokenListSelector, activeTokensSelector } from '~/routes/tokens/store/selectors'
import { getTokens } from '~/utils/localStorage/tokens' import { getActiveTokenAddresses } from '~/utils/localStorage/tokens'
import { enableFirstToken, testToken } from '~/test/builder/tokens.dom.utils' import { enableFirstToken, testToken } from '~/test/builder/tokens.dom.utils'
import * as fetchTokensModule from '~/routes/tokens/store/actions/fetchTokens' import * as fetchTokensModule from '~/routes/tokens/store/actions/fetchTokens'
import * as enhancedFetchModule from '~/utils/fetch' import * as enhancedFetchModule from '~/utils/fetch'
@ -79,12 +79,16 @@ describe('DOM > Feature > Enable and disable default tokens', () => {
await addTknTo(safeAddress, 50, firstErc20Token) await addTknTo(safeAddress, 50, firstErc20Token)
await addTknTo(safeAddress, 50, secondErc20Token) await addTknTo(safeAddress, 50, secondErc20Token)
await store.dispatch(fetchTokensModule.fetchTokens(safeAddress)) await store.dispatch(fetchTokensModule.fetchTokens(safeAddress))
const match: Match = buildMathPropsFrom(safeAddress)
let tokenList = tokenListSelector(store.getState(), { match })
expect(tokenList.count()).toBe(3)
await enableFirstToken(store, safeAddress) await enableFirstToken(store, safeAddress)
tokenList = tokenListSelector(store.getState(), { match })
expect(tokenList.count()).toBe(3) // assuring the enableToken do not add extra info
// THEN // THEN
const match: Match = buildMathPropsFrom(safeAddress)
const tokenList = tokenListSelector(store.getState(), { match })
testToken(tokenList.get(0), 'FTE', true) testToken(tokenList.get(0), 'FTE', true)
testToken(tokenList.get(1), 'STE', false) testToken(tokenList.get(1), 'STE', false)
testToken(tokenList.get(2), 'ETH', true) testToken(tokenList.get(2), 'ETH', true)
@ -108,19 +112,19 @@ describe('DOM > Feature > Enable and disable default tokens', () => {
it('localStorage always returns a list', async () => { it('localStorage always returns a list', async () => {
const store = aNewStore() const store = aNewStore()
const safeAddress = await aMinedSafe(store) const safeAddress = await aMinedSafe(store)
let tokens: List<string> = getTokens(safeAddress) let tokens: List<string> = getActiveTokenAddresses(safeAddress)
expect(tokens).toEqual(List([])) expect(tokens).toEqual(List([]))
await store.dispatch(fetchTokensModule.fetchTokens(safeAddress)) await store.dispatch(fetchTokensModule.fetchTokens(safeAddress))
tokens = getTokens(safeAddress) tokens = getActiveTokenAddresses(safeAddress)
expect(tokens.count()).toBe(0) expect(tokens.count()).toBe(0)
await enableFirstToken(store, safeAddress) await enableFirstToken(store, safeAddress)
tokens = getTokens(safeAddress) tokens = getActiveTokenAddresses(safeAddress)
expect(tokens.count()).toBe(1) expect(tokens.count()).toBe(1)
await store.dispatch(fetchTokensModule.fetchTokens(safeAddress)) await store.dispatch(fetchTokensModule.fetchTokens(safeAddress))
tokens = getTokens(safeAddress) tokens = getActiveTokenAddresses(safeAddress)
expect(tokens.count()).toBe(1) expect(tokens.count()).toBe(1)
}) })
}) })

View File

@ -0,0 +1,75 @@
// @flow
import { getWeb3 } from '~/wallets/getWeb3'
import { type Match } from 'react-router-dom'
import { promisify } from '~/utils/promisify'
import { getFirstTokenContract, getSecondTokenContract } from '~/test/utils/tokenMovements'
import { aNewStore } from '~/store'
import { aMinedSafe } from '~/test/builder/safe.redux.builder'
import { travelToSafe } from '~/test/builder/safe.dom.utils'
import { buildMathPropsFrom } from '~/test/utils/buildReactRouterProps'
import { testToken } from '~/test/builder/tokens.dom.utils'
import * as fetchTokensModule from '~/routes/tokens/store/actions/fetchTokens'
import * as enhancedFetchModule from '~/utils/fetch'
import { TOKEN_ADRESS_PARAM } from '~/routes/tokens/component/AddToken/FirstPage'
import { TOKEN_NAME_PARAM, TOKEN_DECIMALS_PARAM, TOKEN_SYMBOL_PARAM, TOKEN_LOGO_URL_PARAM } from '~/routes/tokens/component/AddToken/SecondPage'
import addToken from '~/routes/tokens/store/actions/addToken'
import { addTokenFnc } from '~/routes/tokens/component/AddToken'
import { activeTokensSelector } from '~/routes/tokens/store/selectors'
describe('DOM > Feature > Add new ERC 20 Tokens', () => {
let web3
let accounts
let firstErc20Token
let secondErc20Token
beforeAll(async () => {
web3 = getWeb3()
accounts = await promisify(cb => web3.eth.getAccounts(cb))
firstErc20Token = await getFirstTokenContract(web3, accounts[0])
secondErc20Token = await getSecondTokenContract(web3, accounts[0])
// $FlowFixMe
enhancedFetchModule.enhancedFetch = jest.fn()
enhancedFetchModule.enhancedFetch.mockImplementation(() => Promise.resolve([
{
address: firstErc20Token.address,
name: 'First Token Example',
symbol: 'FTE',
decimals: 18,
logoUrl: 'https://upload.wikimedia.org/wikipedia/commons/c/c0/Earth_simple_icon.png',
},
]))
})
it('persist added custom ERC 20 tokens as active when reloading the page', async () => {
// GIVEN
const store = aNewStore()
const safeAddress = await aMinedSafe(store)
await store.dispatch(fetchTokensModule.fetchTokens(safeAddress))
const values = {
[TOKEN_ADRESS_PARAM]: secondErc20Token.address,
[TOKEN_NAME_PARAM]: 'Custom ERC20 Token',
[TOKEN_SYMBOL_PARAM]: 'CTS',
[TOKEN_DECIMALS_PARAM]: '10',
[TOKEN_LOGO_URL_PARAM]: 'https://example.com',
}
const customAddTokensFn: any = (...args) => store.dispatch(addToken(...args))
await addTokenFnc(values, customAddTokensFn, safeAddress)
travelToSafe(store, safeAddress)
// WHEN
const reloadedStore = aNewStore()
await reloadedStore.dispatch(fetchTokensModule.fetchTokens(safeAddress))
travelToSafe(reloadedStore, safeAddress) // reload
// THEN
const match: Match = buildMathPropsFrom(safeAddress)
const activeTokenList = activeTokensSelector(reloadedStore.getState(), { match })
expect(activeTokenList.count()).toBe(2)
testToken(activeTokenList.get(0), 'CTS', true)
testToken(activeTokenList.get(1), 'ETH', true)
})
})

View File

@ -1,6 +1,7 @@
// @flow // @flow
import { getGnosisSafeInstanceAt } from '~/wallets/safeContracts' import { getGnosisSafeInstanceAt } from '~/wallets/safeContracts'
import Stepper from '~/components/Stepper' import GnoStepper from '~/components/Stepper'
import Stepper from '@material-ui/core/Stepper'
import TestUtils from 'react-dom/test-utils' import TestUtils from 'react-dom/test-utils'
export const printOutApprove = async ( export const printOutApprove = async (
@ -29,9 +30,11 @@ export const printOutApprove = async (
const MAX_TIMES_EXECUTED = 35 const MAX_TIMES_EXECUTED = 35
const INTERVAL = 500 const INTERVAL = 500
type FinsihedTx = { type FinsihedTx = {
finishedTransaction: boolean, finishedTransaction: boolean,
} }
export const whenExecuted = ( export const whenExecuted = (
SafeDom: React$Component<any, any>, SafeDom: React$Component<any, any>,
ParentComponent: React$ElementType, ParentComponent: React$ElementType,
@ -45,9 +48,9 @@ export const whenExecuted = (
// $FlowFixMe // $FlowFixMe
const SafeComponent = TestUtils.findRenderedComponentWithType(SafeDom, ParentComponent) const SafeComponent = TestUtils.findRenderedComponentWithType(SafeDom, ParentComponent)
type StepperType = React$Component<FinsihedTx, any> type GnoStepperType = React$Component<FinsihedTx, any>
// $FlowFixMe // $FlowFixMe
const StepperComponent: StepperType = TestUtils.findRenderedComponentWithType(SafeComponent, Stepper) const StepperComponent: GnoStepperType = TestUtils.findRenderedComponentWithType(SafeComponent, GnoStepper)
if (StepperComponent.props.finishedTransaction === true) { if (StepperComponent.props.finishedTransaction === true) {
clearInterval(interval) clearInterval(interval)
@ -56,3 +59,32 @@ export const whenExecuted = (
times += 1 times += 1
}, INTERVAL) }, INTERVAL)
}) })
type MiddleStep = {
activeStep: number
}
export const whenOnNext = (
SafeDom: React$Component<any, any>,
ParentComponent: React$ElementType,
position: number,
): Promise<void> => new Promise((resolve, reject) => {
let times = 0
const interval = setInterval(() => {
if (times >= MAX_TIMES_EXECUTED) {
clearInterval(interval)
reject()
}
// $FlowFixMe
const SafeComponent = TestUtils.findRenderedComponentWithType(SafeDom, ParentComponent)
type StepperType = React$Component<MiddleStep, any>
// $FlowFixMe
const StepperComponent: StepperType = TestUtils.findRenderedComponentWithType(SafeComponent, Stepper)
if (StepperComponent.props.activeStep === position) {
clearInterval(interval)
resolve()
}
times += 1
}, INTERVAL)
})

View File

@ -4,7 +4,7 @@ import { getProviderInfo, getBalanceInEtherOf, getWeb3 } from '~/wallets/getWeb3
import { promisify } from '~/utils/promisify' import { promisify } from '~/utils/promisify'
import withdraw, { DESTINATION_PARAM, VALUE_PARAM } from '~/routes/safe/component/Withdraw/withdraw' import withdraw, { DESTINATION_PARAM, VALUE_PARAM } from '~/routes/safe/component/Withdraw/withdraw'
import { type Safe } from '~/routes/safe/store/model/safe' import { type Safe } from '~/routes/safe/store/model/safe'
import Token from '#/test/FixedSupplyToken.json' import Token from '#/test/TestToken.json'
import { ensureOnce } from '~/utils/singleton' import { ensureOnce } from '~/utils/singleton'
import { toNative } from '~/wallets/tokens' import { toNative } from '~/wallets/tokens'

View File

@ -0,0 +1,46 @@
// @flow
import { sleep } from '~/utils/timer'
import * as TestUtils from 'react-dom/test-utils'
import AddToken from '~/routes/tokens/component/AddToken'
import { whenOnNext, whenExecuted } from '~/test/utils/logTransactions'
export const clickOnAddToken = async (TokensDom: React$Component<any, any>) => {
const buttons = TestUtils.scryRenderedDOMComponentsWithTag(TokensDom, 'button')
expect(buttons.length).toBe(1)
TestUtils.Simulate.click(buttons[0])
await sleep(400)
}
export const fillAddress = async (TokensDom: React$Component<any, any>, secondErc20Token: any) => {
// fill the form
const AddTokenComponent = TestUtils.findRenderedComponentWithType(TokensDom, AddToken)
if (!AddTokenComponent) throw new Error()
const inputs = TestUtils.scryRenderedDOMComponentsWithTag(AddTokenComponent, 'input')
expect(inputs.length).toBe(1)
const tokenAddressInput = inputs[0]
TestUtils.Simulate.change(tokenAddressInput, { target: { value: `${secondErc20Token.address}` } })
// $FlowFixMe
const form = TestUtils.findRenderedDOMComponentWithTag(AddTokenComponent, 'form')
// submit it
TestUtils.Simulate.submit(form)
return whenOnNext(TokensDom, AddToken, 1)
}
export const fillHumanReadableInfo = async (TokensDom: React$Component<any, any>) => {
// fill the form
const AddTokenComponent = TestUtils.findRenderedComponentWithType(TokensDom, AddToken)
if (!AddTokenComponent) throw new Error()
const inputs = TestUtils.scryRenderedDOMComponentsWithTag(AddTokenComponent, 'input')
expect(inputs.length).toBe(4)
TestUtils.Simulate.change(inputs[3], { target: { value: 'https://my.token.image/foo' } })
const form = TestUtils.findRenderedDOMComponentWithTag(AddTokenComponent, 'form')
// submit it
TestUtils.Simulate.submit(form)
TestUtils.Simulate.submit(form)
return whenExecuted(TokensDom, AddToken)
}

View File

@ -5,7 +5,6 @@ import { type Owner } from '~/routes/safe/store/model/owner'
export const SAFES_KEY = 'SAFES' export const SAFES_KEY = 'SAFES'
export const TX_KEY = 'TX' export const TX_KEY = 'TX'
export const OWNERS_KEY = 'OWNERS' export const OWNERS_KEY = 'OWNERS'
export const TOKENS_KEY = 'TOKENS'
export const load = (key: string) => { export const load = (key: string) => {
try { try {

View File

@ -1,13 +1,18 @@
// @flow // @flow
import { List } from 'immutable' import { List } from 'immutable'
import { load, TOKENS_KEY } from '~/utils/localStorage' import { load } from '~/utils/localStorage'
import { type Token, type TokenProps } from '~/routes/tokens/store/model/token'
export const ACTIVE_TOKENS_KEY = 'ACTIVE_TOKENS'
export const TOKENS_KEY = 'TOKENS'
const getActiveTokensKey = (safeAddress: string) => `${ACTIVE_TOKENS_KEY}-${safeAddress}`
const getTokensKey = (safeAddress: string) => `${TOKENS_KEY}-${safeAddress}` const getTokensKey = (safeAddress: string) => `${TOKENS_KEY}-${safeAddress}`
export const setTokens = (safeAddress: string, tokens: List<string>) => { export const setActiveTokenAddresses = (safeAddress: string, tokens: List<string>) => {
try { try {
const serializedState = JSON.stringify(tokens) const serializedState = JSON.stringify(tokens)
const key = getTokensKey(safeAddress) const key = getActiveTokensKey(safeAddress)
localStorage.setItem(key, serializedState) localStorage.setItem(key, serializedState)
} catch (err) { } catch (err) {
// eslint-disable-next-line // eslint-disable-next-line
@ -15,14 +20,34 @@ export const setTokens = (safeAddress: string, tokens: List<string>) => {
} }
} }
export const getTokens = (safeAddress: string): List<string> => { export const getActiveTokenAddresses = (safeAddress: string): List<string> => {
const key = getTokensKey(safeAddress) const key = getActiveTokensKey(safeAddress)
const data = load(key) const data = load(key)
return data ? List(data) : List() return data ? List(data) : List()
} }
export const storedTokensBefore = (safeAddress: string) => { export const storedTokensBefore = (safeAddress: string) => {
const key = getTokensKey(safeAddress) const key = getActiveTokensKey(safeAddress)
return localStorage.getItem(key) === null return localStorage.getItem(key) === null
} }
export const getTokens = (safeAddress: string): List<TokenProps> => {
const key = getTokensKey(safeAddress)
const data = load(key)
return data ? List(data) : List()
}
export const setToken = (safeAddress: string, token: Token) => {
const data: List<TokenProps> = getTokens(safeAddress)
try {
const serializedState = JSON.stringify(data.push(token))
const key = getTokensKey(safeAddress)
localStorage.setItem(key, serializedState)
} catch (err) {
// eslint-disable-next-line
console.log('Error adding token in localstorage')
}
}

View File

@ -4,6 +4,7 @@ import logo from '~/assets/icons/icon_etherTokens.svg'
import { getBalanceInEtherOf } from '~/wallets/getWeb3' import { getBalanceInEtherOf } from '~/wallets/getWeb3'
import { makeToken, type Token } from '~/routes/tokens/store/model/token' import { makeToken, type Token } from '~/routes/tokens/store/model/token'
export const ETH_ADDRESS = '0'
export const isEther = (symbol: string) => symbol === 'ETH' export const isEther = (symbol: string) => symbol === 'ETH'
export const getSafeEthToken = async (safeAddress: string) => { export const getSafeEthToken = async (safeAddress: string) => {

View File

@ -5448,8 +5448,8 @@ fill-range@^4.0.0:
to-regex-range "^2.1.0" to-regex-range "^2.1.0"
final-form@^4.2.1: final-form@^4.2.1:
version "4.8.2" version "4.8.3"
resolved "https://registry.yarnpkg.com/final-form/-/final-form-4.8.2.tgz#905de0a5107f889b1eb86dbbdea96f2d40fcb20d" resolved "https://registry.yarnpkg.com/final-form/-/final-form-4.8.3.tgz#86a03da6cd6459ed8fe3737dbd9dc87ed40c11d7"
finalhandler@1.1.1: finalhandler@1.1.1:
version "1.1.1" version "1.1.1"
@ -9799,8 +9799,8 @@ react-event-listener@^0.6.0:
warning "^4.0.1" warning "^4.0.1"
react-final-form@^3.1.2: react-final-form@^3.1.2:
version "3.6.2" version "3.6.4"
resolved "https://registry.yarnpkg.com/react-final-form/-/react-final-form-3.6.2.tgz#fc61b5abfc8e4afb0fd4dea1a4b70cca07aa3e05" resolved "https://registry.yarnpkg.com/react-final-form/-/react-final-form-3.6.4.tgz#1ca37935c2af0bc659a53b293dd84a75d2381548"
react-fuzzy@^0.5.2: react-fuzzy@^0.5.2:
version "0.5.2" version "0.5.2"