Merge pull request #51 from gnosis/feature/WA-232-disable-tokens

WA-232 - Feature: Enable & disable default safe's tokens
This commit is contained in:
Adolfo Panizo 2018-07-16 12:04:58 +02:00 committed by GitHub
commit d2a99f858d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
48 changed files with 993 additions and 374 deletions

View File

@ -4,13 +4,18 @@ import Loadable from 'react-loadable'
import { Switch, Redirect, Route } from 'react-router-dom'
import Loader from '~/components/Loader'
import Welcome from './welcome/container'
import { SAFELIST_ADDRESS, OPEN_ADDRESS, SAFE_PARAM_ADDRESS, WELCOME_ADDRESS } from './routes'
import { SAFELIST_ADDRESS, OPEN_ADDRESS, SAFE_PARAM_ADDRESS, WELCOME_ADDRESS, SETTINS_ADDRESS } from './routes'
const Safe = Loadable({
loader: () => import('./safe/container'),
loading: Loader,
})
const Settings = Loadable({
loader: () => import('./tokens/container'),
loading: Loader,
})
const SafeList = Loadable({
loader: () => import('./safeList/container'),
loading: Loader,
@ -22,6 +27,9 @@ const Open = Loadable({
})
const SAFE_ADDRESS = `${SAFELIST_ADDRESS}/:${SAFE_PARAM_ADDRESS}`
const SAFE_SETTINGS = `${SAFE_ADDRESS}${SETTINS_ADDRESS}`
export const settingsUrlFrom = (safeAddress: string) => `${SAFELIST_ADDRESS}/${safeAddress}/settings`
const Routes = () => (
<Switch>
@ -30,6 +38,7 @@ const Routes = () => (
<Route exact path={OPEN_ADDRESS} component={Open} />
<Route exact path={SAFELIST_ADDRESS} component={SafeList} />
<Route exact path={SAFE_ADDRESS} component={Safe} />
<Route exact path={SAFE_SETTINGS} component={Settings} />
</Switch>
)

View File

@ -3,3 +3,4 @@ export const SAFE_PARAM_ADDRESS = 'address'
export const SAFELIST_ADDRESS = '/safes'
export const OPEN_ADDRESS = '/open'
export const WELCOME_ADDRESS = '/welcome'
export const SETTINS_ADDRESS = '/settings'

View File

@ -7,11 +7,11 @@ import GnoSafe from './Safe'
type Props = SelectorProps
const Layout = ({
safe, balances, provider, userAddress,
safe, activeTokens, provider, userAddress,
}: Props) => (
<React.Fragment>
{ safe
? <GnoSafe safe={safe} balances={balances} userAddress={userAddress} />
? <GnoSafe safe={safe} tokens={activeTokens} userAddress={userAddress} />
: <NoSafe provider={provider} text="Not found safe" />
}
</React.Fragment>

View File

@ -4,7 +4,7 @@ import * as React from 'react'
import { Map } from 'immutable'
import styles from '~/components/layout/PageFrame/index.scss'
import { SafeFactory } from '~/routes/safe/store/test/builder/safe.builder'
import { makeBalance } from '~/routes/safe/store/model/balance'
import { makeToken } from '~/routes/tokens/store/model/token'
import Component from './Layout'
@ -14,7 +14,7 @@ const FrameDecorator = story => (
</div>
)
const ethBalance = makeBalance({
const ethBalance = makeToken({
address: '0',
name: 'Ether',
symbol: 'ETH',
@ -30,7 +30,7 @@ storiesOf('Routes /safe:address', module)
userAddress="foo"
safe={undefined}
provider="METAMASK"
balances={Map()}
activeTokens={Map()}
fetchBalance={() => {}}
/>
))
@ -39,7 +39,7 @@ storiesOf('Routes /safe:address', module)
userAddress="foo"
safe={undefined}
provider=""
balances={Map()}
activeTokens={Map()}
fetchBalance={() => {}}
/>
))
@ -51,7 +51,7 @@ storiesOf('Routes /safe:address', module)
userAddress="foo"
safe={safe}
provider="METAMASK"
balances={Map().set('ETH', ethBalance)}
activeTokens={Map().set('ETH', ethBalance)}
fetchBalance={() => {}}
/>
)
@ -64,7 +64,7 @@ storiesOf('Routes /safe:address', module)
userAddress="foo"
safe={safe}
provider="METAMASK"
balances={Map().set('ETH', ethBalance)}
activeTokens={Map().set('ETH', ethBalance)}
fetchBalance={() => {}}
/>
)

View File

@ -1,7 +1,9 @@
// @flow
import * as React from 'react'
import classNames from 'classnames'
import Link from '~/components/layout/Link'
import AccountBalance from '@material-ui/icons/AccountBalance'
import Settings from '@material-ui/icons/Settings'
import Avatar from '@material-ui/core/Avatar'
import Collapse from '@material-ui/core/Collapse'
import IconButton from '@material-ui/core/IconButton'
@ -17,11 +19,13 @@ import { Map } from 'immutable'
import Button from '~/components/layout/Button'
import openHoc, { type Open } from '~/components/hoc/OpenHoc'
import { type WithStyles } from '~/theme/mui'
import { type Balance } from '~/routes/safe/store/model/balance'
import { type Token } from '~/routes/tokens/store/model/token'
import { settingsUrlFrom } from '~/routes'
type Props = Open & WithStyles & {
balances: Map<string, Balance>,
onMoveFunds: (balance: Balance) => void,
safeAddress: string,
tokens: Map<string, Token>,
onMoveFunds: (token: Token) => void,
}
const styles = {
@ -33,9 +37,10 @@ const styles = {
export const MOVE_FUNDS_BUTTON_TEXT = 'Move'
const BalanceComponent = openHoc(({
open, toggle, balances, classes, onMoveFunds,
open, toggle, tokens, classes, onMoveFunds, safeAddress,
}: Props) => {
const hasBalances = balances.count() > 0
const hasBalances = tokens.count() > 0
const settingsUrl = settingsUrlFrom(safeAddress)
return (
<React.Fragment>
@ -44,6 +49,11 @@ const BalanceComponent = openHoc(({
<AccountBalance />
</Avatar>
<ListItemText primary="Balance" secondary="List of different token balances" />
<ListItemIcon>
<IconButton to={settingsUrl} disabled={!hasBalances} component={Link} className={classes.button}>
<Settings />
</IconButton>
</ListItemIcon>
<ListItemIcon>
{open
? <IconButton disableRipple><ExpandLess /></IconButton>
@ -53,18 +63,18 @@ const BalanceComponent = openHoc(({
</ListItem>
<Collapse in={open} timeout="auto">
<List component="div" disablePadding>
{balances.valueSeq().map((balance: Balance) => {
const symbol = balance.get('symbol')
const name = balance.get('name')
const disabled = Number(balance.get('funds')) === 0
const onMoveFundsClick = () => onMoveFunds(balance)
{tokens.valueSeq().map((token: Token) => {
const symbol = token.get('symbol')
const name = token.get('name')
const disabled = Number(token.get('funds')) === 0
const onMoveFundsClick = () => onMoveFunds(token)
return (
<ListItem key={symbol} className={classNames(classes.nested, symbol)}>
<ListItemIcon>
<Img src={balance.get('logoUrl')} height={30} alt={name} />
<Img src={token.get('logoUrl')} height={30} alt={name} />
</ListItemIcon>
<ListItemText primary={name} secondary={`${balance.get('funds')} ${symbol}`} />
<ListItemText primary={name} secondary={`${token.get('funds')} ${symbol}`} />
<Button variant="raised" color="primary" onClick={onMoveFundsClick} disabled={disabled}>
{MOVE_FUNDS_BUTTON_TEXT}
</Button>

View File

@ -9,7 +9,7 @@ import Img from '~/components/layout/Img'
import Paragraph from '~/components/layout/Paragraph'
import Row from '~/components/layout/Row'
import { type Safe } from '~/routes/safe/store/model/safe'
import { type Balance } from '~/routes/safe/store/model/balance'
import { type Token } from '~/routes/tokens/store/model/token'
import Withdraw from '~/routes/safe/component/Withdraw'
import Transactions from '~/routes/safe/component/Transactions'
@ -30,7 +30,7 @@ const safeIcon = require('./assets/gnosis_safe.svg')
type SafeProps = {
safe: Safe,
balances: Map<string, Balance>,
tokens: Map<string, Token>,
userAddress: string,
}
@ -42,13 +42,13 @@ const listStyle = {
width: '100%',
}
const getEthBalanceFrom = (balances: Map<string, Balance>) => {
const ethBalance = balances.get('ETH')
if (!ethBalance) {
const getEthBalanceFrom = (tokens: List<Token>) => {
const ethToken = tokens.filter(token => token.get('symbol') === 'ETH')
if (ethToken.count() === 0) {
return 0
}
return Number(ethBalance.get('funds'))
return Number(ethToken.get(0).get('funds'))
}
class GnoSafe extends React.PureComponent<SafeProps, State> {
@ -93,13 +93,13 @@ class GnoSafe extends React.PureComponent<SafeProps, State> {
this.setState({ component: <RemoveOwner safeAddress={safe.get('address')} threshold={safe.get('threshold')} safe={safe} name={name} userToRemove={address} /> })
}
onMoveTokens = (ercToken: Balance) => {
onMoveTokens = (ercToken: Token) => {
const { safe } = this.props
this.setState({
component: <SendToken
safe={safe}
balance={ercToken}
token={ercToken}
key={ercToken.get('symbol')}
onReset={this.onListTransactions}
/>,
@ -107,15 +107,16 @@ class GnoSafe extends React.PureComponent<SafeProps, State> {
}
render() {
const { safe, balances, userAddress } = this.props
const { safe, tokens, userAddress } = this.props
const { component } = this.state
const ethBalance = getEthBalanceFrom(balances)
const ethBalance = getEthBalanceFrom(tokens)
const address = safe.get('address')
return (
<Row grow>
<Col sm={12} top="xs" md={5} margin="xl" overflow>
<List style={listStyle}>
<BalanceInfo balances={balances} onMoveFunds={this.onMoveTokens} />
<BalanceInfo tokens={tokens} onMoveFunds={this.onMoveTokens} safeAddress={address} />
<Owners
owners={safe.owners}
onAddOwner={this.onAddOwner}
@ -123,7 +124,7 @@ class GnoSafe extends React.PureComponent<SafeProps, State> {
onRemoveOwner={this.onRemoveOwner}
/>
<Confirmations confirmations={safe.get('threshold')} onEditThreshold={this.onEditThreshold} />
<Address address={safe.get('address')} />
<Address address={address} />
<DailyLimit balance={ethBalance} dailyLimit={safe.get('dailyLimit')} onWithdraw={this.onWithdraw} onEditDailyLimit={this.onEditDailyLimit} />
<MultisigTx onSeeTxs={this.onListTransactions} />
</List>

View File

@ -5,11 +5,12 @@ import { connect } from 'react-redux'
import Stepper from '~/components/Stepper'
import { sleep } from '~/utils/timer'
import { type Safe } from '~/routes/safe/store/model/safe'
import { type Balance } from '~/routes/safe/store/model/balance'
import { getStandardTokenContract } from '~/routes/tokens/store/actions/fetchTokens'
import { type Token } from '~/routes/tokens/store/model/token'
import { createTransaction } from '~/wallets/createTransactions'
import { getStandardTokenContract } from '~/routes/safe/store/actions/fetchBalances'
import { EMPTY_DATA } from '~/wallets/ethTransactions'
import { toNative } from '~/wallets/tokens'
import { isEther } from '~/utils/tokens'
import actions, { type Actions } from './actions'
import selector, { type SelectorProps } from './selector'
import SendTokenForm, { TKN_DESTINATION_PARAM, TKN_VALUE_PARAM } from './SendTokenForm'
@ -21,7 +22,7 @@ const getSteps = () => [
type Props = SelectorProps & Actions & {
safe: Safe,
balance: Balance,
token: Token,
onReset: () => void,
}
@ -31,8 +32,6 @@ type State = {
export const SEE_TXS_BUTTON_TEXT = 'VISIT TXS'
const isEther = (symbol: string) => symbol === 'ETH'
const getTransferData = async (tokenAddress: string, to: string, amount: BigNumber) => {
const StandardToken = await getStandardTokenContract()
const myToken = await StandardToken.at(tokenAddress)
@ -40,16 +39,16 @@ const getTransferData = async (tokenAddress: string, to: string, amount: BigNumb
return myToken.contract.transfer.getData(to, amount)
}
const processTokenTransfer = async (safe: Safe, balance: Balance, to: string, amount: number, userAddress: string) => {
const symbol = balance.get('symbol')
const processTokenTransfer = async (safe: Safe, token: Token, to: string, amount: number, userAddress: string) => {
const symbol = token.get('symbol')
const nonce = Date.now()
const name = `Send ${amount} ${balance.get('symbol')} to ${to}`
const name = `Send ${amount} ${token.get('symbol')} to ${to}`
const value = isEther(symbol) ? amount : 0
const tokenAddress = balance.get('address')
const tokenAddress = token.get('address')
const destination = isEther(symbol) ? to : tokenAddress
const data = isEther(symbol)
? EMPTY_DATA
: await getTransferData(tokenAddress, to, await toNative(amount, balance.get('decimals')))
: await getTransferData(tokenAddress, to, await toNative(amount, token.get('decimals')))
return createTransaction(safe, name, destination, value, nonce, userAddress, data)
}
@ -61,12 +60,12 @@ class SendToken extends React.Component<Props, State> {
onTransaction = async (values: Object) => {
try {
const { safe, balance, userAddress } = this.props
const { safe, token, userAddress } = this.props
const amount = values[TKN_VALUE_PARAM]
const destination = values[TKN_DESTINATION_PARAM]
await processTokenTransfer(safe, balance, destination, amount, userAddress)
await processTokenTransfer(safe, token, destination, amount, userAddress)
await sleep(1500)
this.props.fetchTransactions()
this.setState({ done: true })
@ -84,10 +83,10 @@ class SendToken extends React.Component<Props, State> {
render() {
const { done } = this.state
const { balance } = this.props
const { token } = this.props
const steps = getSteps()
const finishedButton = <Stepper.FinishButton title={SEE_TXS_BUTTON_TEXT} />
const symbol = balance.get('symbol')
const symbol = token.get('symbol')
return (
<React.Fragment>
@ -98,7 +97,7 @@ class SendToken extends React.Component<Props, State> {
steps={steps}
onReset={this.onReset}
>
<Stepper.Page funds={balance.get('funds')} symbol={symbol}>
<Stepper.Page funds={token.get('funds')} symbol={symbol}>
{ SendTokenForm }
</Stepper.Page>
<Stepper.Page symbol={symbol}>

View File

@ -1,13 +1,13 @@
// @flow
import fetchSafe from '~/routes/safe/store/actions/fetchSafe'
import { fetchBalances } from '~/routes/safe/store/actions/fetchBalances'
import { fetchTokens } from '~/routes/tokens/store/actions/fetchTokens'
export type Actions = {
fetchSafe: typeof fetchSafe,
fetchBalances: typeof fetchBalances,
fetchTokens: typeof fetchTokens,
}
export default {
fetchSafe,
fetchBalances,
fetchTokens,
}

View File

@ -16,12 +16,14 @@ const TIMEOUT = process.env.NODE_ENV === 'test' ? 1500 : 15000
class SafeView extends React.PureComponent<Props> {
componentDidMount() {
this.intervalId = setInterval(() => {
const { safe, fetchBalances, fetchSafe } = this.props
const {
safe, fetchTokens, fetchSafe,
} = this.props
if (!safe) {
return
}
const safeAddress = safe.get('address')
fetchBalances(safeAddress)
fetchTokens(safeAddress)
fetchSafe(safe)
}, TIMEOUT)
}
@ -33,7 +35,7 @@ class SafeView extends React.PureComponent<Props> {
if (this.props.safe) {
const safeAddress = this.props.safe.get('address')
this.props.fetchBalances(safeAddress)
this.props.fetchTokens(safeAddress)
}
}
@ -45,13 +47,13 @@ class SafeView extends React.PureComponent<Props> {
render() {
const {
safe, provider, balances, granted, userAddress,
safe, provider, activeTokens, granted, userAddress,
} = this.props
return (
<Page>
{ granted
? <Layout balances={balances} provider={provider} safe={safe} userAddress={userAddress} />
? <Layout activeTokens={activeTokens} provider={provider} safe={safe} userAddress={userAddress} />
: <NoRights />
}
</Page>

View File

@ -1,18 +1,19 @@
// @flow
import { List, Map } from 'immutable'
import { createSelector, createStructuredSelector, type Selector } from 'reselect'
import { balanceSelector, 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 { type Safe } from '~/routes/safe/store/model/safe'
import { type Owner } from '~/routes/safe/store/model/owner'
import { type GlobalState } from '~/store/index'
import { sameAddress } from '~/wallets/ethAddresses'
import { type Balance } from '~/routes/safe/store/model/balance'
import { activeTokensSelector } from '~/routes/tokens/store/selectors'
import { type Token } from '~/routes/tokens/store/model/token'
export type SelectorProps = {
safe: SafeSelectorProps,
provider: string,
balances: Map<string, Balance>,
activeTokens: Map<string, Token>,
userAddress: string,
}
@ -40,7 +41,7 @@ export const grantedSelector: Selector<GlobalState, RouterProps, boolean> = crea
export default createStructuredSelector({
safe: safeSelector,
provider: providerNameSelector,
balances: balanceSelector,
activeTokens: activeTokensSelector,
granted: grantedSelector,
userAddress: userAccountSelector,
})

View File

@ -1,21 +0,0 @@
// @flow
import { Map } from 'immutable'
import { createAction } from 'redux-actions'
import { type Balance } from '~/routes/safe/store/model/balance'
export const ADD_BALANCES = 'ADD_BALANCES'
type BalanceProps = {
safeAddress: string,
balances: Map<string, Balance>,
}
const addBalances = createAction(
ADD_BALANCES,
(safeAddress: string, balances: Map<string, Balance>): BalanceProps => ({
safeAddress,
balances,
}),
)
export default addBalances

View File

@ -1,65 +0,0 @@
// @flow
import { Map } from 'immutable'
import contract from 'truffle-contract'
import type { Dispatch as ReduxDispatch } from 'redux'
import StandardToken from '@gnosis.pm/util-contracts/build/contracts/StandardToken.json'
import { getBalanceInEtherOf, getWeb3 } from '~/wallets/getWeb3'
import { type GlobalState } from '~/store/index'
import { makeBalance, type Balance, type BalanceProps } from '~/routes/safe/store/model/balance'
import logo from '~/assets/icons/icon_etherTokens.svg'
import addBalances from './addBalances'
export const getStandardTokenContract = async () => {
const web3 = getWeb3()
const erc20Token = await contract(StandardToken)
erc20Token.setProvider(web3.currentProvider)
return erc20Token
}
export const calculateBalanceOf = async (tokenAddress: string, address: string, decimals: number) => {
const erc20Token = await getStandardTokenContract()
return erc20Token.at(tokenAddress)
.then(instance => instance.balanceOf(address).then(funds => funds.div(10 ** decimals).toString()))
.catch(() => '0')
}
export const fetchBalances = (safeAddress: string) => async (dispatch: ReduxDispatch<GlobalState>) => {
const balance = await getBalanceInEtherOf(safeAddress)
const ethBalance = makeBalance({
address: '0',
name: 'Ether',
symbol: 'ETH',
decimals: 18,
logoUrl: logo,
funds: balance,
})
const header = new Headers({
'Access-Control-Allow-Origin': '*',
})
const sentData = {
mode: 'cors',
header,
}
const response = await fetch('https://gist.githubusercontent.com/rmeissner/98911fcf74b0ea9731e2dae2441c97a4/raw/', sentData)
if (!response.ok) {
throw new Error('Error querying safe balances')
}
const json = await response.json()
return Promise.all(json.map(async (item: BalanceProps) => {
const funds = await calculateBalanceOf(item.address, safeAddress, item.decimals)
return makeBalance({ ...item, funds })
})).then((balancesRecords) => {
const balances: Map<string, Balance> = Map().withMutations((map) => {
balancesRecords.forEach(record => map.set(record.get('symbol'), record))
map.set('ETH', ethBalance)
})
return dispatch(addBalances(safeAddress, balances))
})
}

View File

@ -37,7 +37,14 @@ export const buildSafe = async (storedSafe: Object) => {
}
export default (safe: Safe) => async (dispatch: ReduxDispatch<GlobalState>) => {
const safeRecord = await buildSafe(safe.toJSON())
try {
const safeRecord = await buildSafe(safe.toJSON())
return dispatch(updateSafe(safeRecord))
return dispatch(updateSafe(safeRecord))
} catch (err) {
// eslint-disable-next-line
console.log("Error while updating safe information")
return Promise.resolve()
}
}

View File

@ -11,15 +11,23 @@ const buildSafesFrom = async (loadedSafes: Object): Promise<Map<string, Safe>> =
const safes = Map()
const keys = Object.keys(loadedSafes)
const safeRecords = await Promise.all(keys.map((address: string) => buildSafe(loadedSafes[address])))
try {
const safeRecords = await Promise.all(keys.map((address: string) => buildSafe(loadedSafes[address])))
return safes.withMutations(async (map) => {
safeRecords.forEach((safe: Safe) => map.set(safe.get('address'), safe))
})
return safes.withMutations(async (map) => {
safeRecords.forEach((safe: Safe) => map.set(safe.get('address'), safe))
})
} catch (err) {
// eslint-disable-next-line
console.log("Error while fetching safes information")
return Map()
}
}
export default () => async (dispatch: ReduxDispatch<GlobalState>) => {
const storedSafes = load(SAFES_KEY)
const safes = storedSafes ? await buildSafesFrom(storedSafes) : Map()
return dispatch(updateSafes(safes))

View File

@ -1,20 +0,0 @@
// @flow
import { Map } from 'immutable'
import { handleActions, type ActionType } from 'redux-actions'
import addBalances, { ADD_BALANCES } from '~/routes/safe/store/actions/addBalances'
import { type Balance } from '~/routes/safe/store/model/balance'
export const BALANCE_REDUCER_ID = 'balances'
export type State = Map<string, Map<string, Balance>>
export default handleActions({
[ADD_BALANCES]: (state: State, action: ActionType<typeof addBalances>): State =>
state.update(action.payload.safeAddress, (prevSafe: Map<string, Balance>) => {
if (!prevSafe) {
return action.payload.balances
}
return prevSafe.equals(action.payload.balances) ? prevSafe : action.payload.balances
}),
}, Map())

View File

@ -6,11 +6,9 @@ import { type GlobalState } from '~/store/index'
import { SAFE_PARAM_ADDRESS } from '~/routes/routes'
import { type Safe } from '~/routes/safe/store/model/safe'
import { safesMapSelector } from '~/routes/safeList/store/selectors'
import { BALANCE_REDUCER_ID } from '~/routes/safe/store/reducer/balances'
import { type State as TransactionsState, TRANSACTIONS_REDUCER_ID } from '~/routes/safe/store/reducer/transactions'
import { type Transaction } from '~/routes/safe/store/model/transaction'
import { type Confirmation } from '~/routes/safe/store/model/confirmation'
import { type Balance } from '~/routes/safe/store/model/balance'
export type RouterProps = {
match: Match,
@ -26,14 +24,12 @@ type TransactionProps = {
const safePropAddressSelector = (state: GlobalState, props: SafeProps) => props.safeAddress
const safeParamAddressSelector = (state: GlobalState, props: RouterProps) => props.match.params[SAFE_PARAM_ADDRESS] || ''
const balancesSelector = (state: GlobalState) => state[BALANCE_REDUCER_ID]
const transactionsSelector = (state: GlobalState): TransactionsState => state[TRANSACTIONS_REDUCER_ID]
const oneTransactionSelector = (state: GlobalState, props: TransactionProps) => props.transaction
export const safeParamAddressSelector = (state: GlobalState, props: RouterProps) => props.match.params[SAFE_PARAM_ADDRESS] || ''
export const safeTransactionsSelector: Selector<GlobalState, SafeProps, List<Transaction>> = createSelector(
transactionsSelector,
safePropAddressSelector,
@ -82,18 +78,6 @@ export const safeSelector: Selector<GlobalState, RouterProps, SafeSelectorProps>
},
)
export const balanceSelector: Selector<GlobalState, RouterProps, Map<string, Balance>> = createSelector(
balancesSelector,
safeParamAddressSelector,
(balances: Map<string, Map<string, Balance>>, address: string) => {
if (!address) {
return Map()
}
return balances.get(address) || Map()
},
)
export default createStructuredSelector({
safe: safeSelector,
})

View File

@ -29,7 +29,7 @@ const grantedSelectorTests = () => {
const reduxStore = {
safes: Map(),
providers: makeProvider(),
balances: Map(),
tokens: Map(),
transactions: Map(),
}
@ -67,7 +67,7 @@ const grantedSelectorTests = () => {
const reduxStore = {
safes: Map(),
providers: makeProvider(),
balances: Map(),
tokens: Map(),
transactions: Map(),
}
@ -82,7 +82,7 @@ const grantedSelectorTests = () => {
const reduxStore = {
safes: Map(),
providers: makeProvider(),
balances: Map(),
tokens: Map(),
transactions: Map(),
}

View File

@ -26,7 +26,7 @@ const grantedSelectorTests = () => {
const reduxStore = {
[SAFE_REDUCER_ID]: map,
providers: makeProvider(provider),
balances: undefined,
tokens: undefined,
transactions: undefined,
}
@ -47,7 +47,7 @@ const grantedSelectorTests = () => {
const reduxStore = {
[SAFE_REDUCER_ID]: map,
providers: makeProvider(provider),
balances: undefined,
tokens: undefined,
transactions: undefined,
}
@ -68,7 +68,7 @@ const grantedSelectorTests = () => {
const reduxStore = {
[SAFE_REDUCER_ID]: map,
providers: makeProvider(provider),
balances: undefined,
tokens: undefined,
transactions: undefined,
}

View File

@ -14,7 +14,7 @@ const safeSelectorTests = () => {
const reduxStore = {
[SAFE_REDUCER_ID]: Map(),
providers: undefined,
balances: undefined,
tokens: undefined,
transactions: undefined,
}
const match: Match = buildMathPropsFrom('fooAddress')
@ -38,7 +38,7 @@ const safeSelectorTests = () => {
const reduxStore = {
[SAFE_REDUCER_ID]: map,
providers: undefined,
balances: undefined,
tokens: undefined,
transactions: undefined,
}

View File

@ -14,7 +14,7 @@ const grantedSelectorTests = () => {
const reduxStore = {
[SAFE_REDUCER_ID]: Map(),
providers: makeProvider(),
balances: undefined,
tokens: undefined,
transactions: Map(),
}
@ -46,7 +46,7 @@ const grantedSelectorTests = () => {
const reduxStore = {
[SAFE_REDUCER_ID]: Map(),
providers: makeProvider(),
balances: undefined,
tokens: undefined,
transactions: Map({ fooAddress: List([transaction]) }),
}
@ -81,7 +81,7 @@ const grantedSelectorTests = () => {
const reduxStore = {
[SAFE_REDUCER_ID]: Map(),
providers: makeProvider(),
balances: undefined,
tokens: undefined,
transactions: Map({ fooAddress: List([transaction]) }),
}
@ -113,7 +113,7 @@ const grantedSelectorTests = () => {
const reduxStore = {
[SAFE_REDUCER_ID]: Map(),
providers: makeProvider(),
balances: undefined,
tokens: undefined,
transactions: Map({ fooAddress: List([transaction]) }),
}

View File

@ -1,114 +0,0 @@
// @flow
import * as React from 'react'
import TestUtils from 'react-dom/test-utils'
import { Provider } from 'react-redux'
import { ConnectedRouter } from 'react-router-redux'
import { type Match } from 'react-router-dom'
import Button from '~/components/layout/Button'
import { aNewStore, history } from '~/store'
import { addEtherTo } from '~/test/utils/tokenMovements'
import { aDeployedSafe, executeWithdrawOn } from '~/routes/safe/store/test/builder/deployedSafe.builder'
import { SAFELIST_ADDRESS } from '~/routes/routes'
import SafeView from '~/routes/safe/component/Safe'
import AppRoutes from '~/routes'
import { WITHDRAW_BUTTON_TEXT } from '~/routes/safe/component/Safe/DailyLimit'
import WithdrawComponent, { SEE_TXS_BUTTON_TEXT } from '~/routes/safe/component/Withdraw'
import { getBalanceInEtherOf } from '~/wallets/getWeb3'
import { sleep } from '~/utils/timer'
import { getDailyLimitFrom } from '~/routes/safe/component/Withdraw/withdraw'
import { type DailyLimitProps } from '~/routes/safe/store/model/dailyLimit'
import { WITHDRAW_INDEX } from '~/test/builder/safe.dom.utils'
import { buildMathPropsFrom } from '~/test/utils/buildReactRouterProps'
import { safeSelector } from '~/routes/safe/store/selectors/index'
import { filterMoveButtonsFrom } from '~/test/builder/safe.dom.builder'
describe('React DOM TESTS > Withdraw funds from safe', () => {
let SafeDom
let store
let address
beforeEach(async () => {
// create store
store = aNewStore()
// deploy safe updating store
address = await aDeployedSafe(store)
// navigate to SAFE route
history.push(`${SAFELIST_ADDRESS}/${address}`)
SafeDom = TestUtils.renderIntoDocument((
<Provider store={store}>
<ConnectedRouter history={history}>
<AppRoutes />
</ConnectedRouter>
</Provider>
))
})
it('should withdraw funds under dailyLimit without needing confirmations', async () => {
// add funds to safe
await addEtherTo(address, '0.1')
await sleep(3000)
const Safe = TestUtils.findRenderedComponentWithType(SafeDom, SafeView)
// $FlowFixMe
const expandedButtons = TestUtils.scryRenderedDOMComponentsWithTag(Safe, 'button')
const buttons = filterMoveButtonsFrom(expandedButtons)
const addWithdrawButton = buttons[WITHDRAW_INDEX]
expect(addWithdrawButton.getElementsByTagName('span')[0].innerHTML).toEqual(WITHDRAW_BUTTON_TEXT)
TestUtils.Simulate.click(addWithdrawButton)
await sleep(4000)
const Withdraw = TestUtils.findRenderedComponentWithType(SafeDom, WithdrawComponent)
// $FlowFixMe
const inputs = TestUtils.scryRenderedDOMComponentsWithTag(Withdraw, 'input')
const amountInEth = inputs[0]
const toAddress = inputs[1]
TestUtils.Simulate.change(amountInEth, { target: { value: '0.01' } })
TestUtils.Simulate.change(toAddress, { target: { value: store.getState().providers.account } })
// $FlowFixMe
const form = TestUtils.findRenderedDOMComponentWithTag(Withdraw, 'form')
TestUtils.Simulate.submit(form) // fill the form
TestUtils.Simulate.submit(form) // confirming data
await sleep(6000)
const safeBalance = await getBalanceInEtherOf(address)
expect(safeBalance).toBe('0.09')
// $FlowFixMe
const withdrawButtons = TestUtils.scryRenderedComponentsWithType(Withdraw, Button)
const visitTxsButton = withdrawButtons[0]
expect(visitTxsButton.props.children).toEqual(SEE_TXS_BUTTON_TEXT)
})
it('spentToday dailyLimitModule property is updated correctly', async () => {
// add funds to safe
await addEtherTo(address, '0.1')
const match: Match = buildMathPropsFrom(address)
const safe = safeSelector(store.getState(), { match })
if (!safe) throw new Error()
await executeWithdrawOn(safe, 0.01)
await executeWithdrawOn(safe, 0.01)
const ethAddress = 0
const dailyLimit: DailyLimitProps = await getDailyLimitFrom(address, ethAddress)
// THEN
expect(dailyLimit.value).toBe(0.5)
expect(dailyLimit.spentToday).toBe(0.02)
})
it('Withdraw button disabled when balance is 0', async () => {
const Safe = TestUtils.findRenderedComponentWithType(SafeDom, SafeView)
// $FlowFixMe
const buttons = TestUtils.scryRenderedDOMComponentsWithTag(Safe, 'button')
const addWithdrawButton = buttons[WITHDRAW_INDEX]
expect(addWithdrawButton.getElementsByTagName('span')[0].innerHTML).toEqual(WITHDRAW_BUTTON_TEXT)
expect(addWithdrawButton.hasAttribute('disabled')).toBe(true)
await addEtherTo(address, '0.1')
await sleep(1800)
expect(addWithdrawButton.hasAttribute('disabled')).toBe(false)
})
})

View File

@ -21,7 +21,7 @@ const safesListSelectorTests = () => {
const reduxStore = {
[PROVIDER_REDUCER_ID]: walletRecord,
[SAFE_REDUCER_ID]: Map(),
balances: undefined,
tokens: undefined,
transactions: undefined,
}
const emptyList = List([])
@ -42,7 +42,7 @@ const safesListSelectorTests = () => {
const reduxStore = {
[PROVIDER_REDUCER_ID]: walletRecord,
[SAFE_REDUCER_ID]: map,
balances: undefined,
tokens: undefined,
transactions: undefined,
}
@ -62,7 +62,7 @@ const safesListSelectorTests = () => {
const reduxStore = {
[PROVIDER_REDUCER_ID]: walletRecord,
[SAFE_REDUCER_ID]: map,
balances: undefined,
tokens: undefined,
transactions: undefined,
}
@ -83,7 +83,7 @@ const safesListSelectorTests = () => {
const reduxStore = {
[SAFE_REDUCER_ID]: map,
[PROVIDER_REDUCER_ID]: walletRecord,
balances: undefined,
tokens: undefined,
transactions: undefined,
}
@ -105,7 +105,7 @@ const safesListSelectorTests = () => {
const reduxStore = {
[SAFE_REDUCER_ID]: map,
[PROVIDER_REDUCER_ID]: walletRecord,
balances: undefined,
tokens: undefined,
transactions: undefined,
}

View File

@ -0,0 +1,99 @@
// @flow
import MuiList from '@material-ui/core/List'
import * as React from 'react'
import Block from '~/components/layout/Block'
import Col from '~/components/layout/Col'
import AccountBalanceWallet from '@material-ui/icons/AccountBalanceWallet'
import Link from '~/components/layout/Link'
import Bold from '~/components/layout/Bold'
import Img from '~/components/layout/Img'
import IconButton from '@material-ui/core/IconButton'
import Paragraph from '~/components/layout/Paragraph'
import Row from '~/components/layout/Row'
import { type Token } from '~/routes/tokens/store/model/token'
import { type SelectorProps } from '~/routes/tokens/container/selector'
import { type Actions } from '~/routes/tokens/container/actions'
import { SAFELIST_ADDRESS } from '~/routes/routes'
import TokenComponent from './Token'
// import AddToken from '~/routes/tokens/component/AddToken'
// import RemoveToken from '~/routes/tokens/component/RemoveToken'
const safeIcon = require('~/routes/safe/component/Safe/assets/gnosis_safe.svg')
type TokenProps = SelectorProps & Actions
type State = {
component: React$Node,
}
const listStyle = {
width: '100%',
paddingBottom: 0,
}
class TokenLayout extends React.PureComponent<TokenProps, State> {
state = {
component: undefined,
}
/*
onAddToken = () => {
const { addresses } = this.props
this.setState({ component: <AddToken/> })
}
onRemoveToken = () => {
this.setState({ component: <RemoveToken /> })
}
*/
onEnableToken = (token: Token) => {
const { enableToken, safe } = this.props
const safeAddress = safe.get('address')
enableToken(safeAddress, token)
}
onDisableToken = (token: Token) => {
const { disableToken, safe } = this.props
const safeAddress = safe.get('address')
disableToken(safeAddress, token)
}
render() {
const { safe, safeAddress, tokens } = this.props
const { component } = this.state
const name = safe ? safe.get('name') : ''
return (
<Row grow>
<Col sm={12} top="xs" md={5} margin="xl" overflow>
<MuiList style={listStyle}>
{tokens.map((token: Token) => (<TokenComponent
key={token.get('symbol')}
token={token}
onDisableToken={this.onDisableToken}
onEnableToken={this.onEnableToken}
/>))}
</MuiList>
</Col>
<Col sm={12} center="xs" md={7} margin="xl" layout="column">
<Block margin="xl">
<Paragraph size="lg" noMargin align="right">
<IconButton to={`${SAFELIST_ADDRESS}/${safeAddress}`} component={Link}>
<AccountBalanceWallet />
</IconButton>
<Bold>{name}</Bold>
</Paragraph>
</Block>
<Row grow>
<Col sm={12} center={component ? undefined : 'sm'} middle={component ? undefined : 'sm'} layout="column">
{ component || <Img alt="Safe Icon" src={safeIcon} height={330} /> }
</Col>
</Row>
</Col>
</Row>
)
}
}
export default TokenLayout

View File

@ -0,0 +1,98 @@
// @flow
import * as React from 'react'
import { type Token } from '~/routes/tokens/store/model/token'
import { withStyles } from '@material-ui/core/styles'
import Block from '~/components/layout/Block'
import Checkbox from '@material-ui/core/Checkbox'
import Card from '@material-ui/core/Card'
import CardContent from '@material-ui/core/CardContent'
import CardMedia from '@material-ui/core/CardMedia'
import Typography from '@material-ui/core/Typography'
import { isEther } from '~/utils/tokens'
// import Delete from '@material-ui/icons/Delete'
// import IconButton from '@material-ui/core/IconButton'
import { type WithStyles } from '~/theme/mui'
type Props = WithStyles & {
token: Token,
onRemoveToken: (balance: Token)=> void,
onEnableToken: (token: Token) => void,
onDisableToken: (token: Token) => void,
}
type State = {
checked: boolean,
}
const styles = () => ({
card: {
display: 'flex',
},
details: {
display: 'flex',
flexDirection: 'column',
},
content: {
flex: '1 0 auto',
},
cover: {
width: 150,
margin: 10,
backgroundSize: '50%',
},
})
class TokenComponent extends React.Component<Props, State> {
state = {
checked: this.props.token.get('status'),
}
// onRemoveClick = () => this.props.onRemoveToken(this.props.token)
handleChange = (e: SyntheticInputEvent<HTMLInputElement>) => {
const { checked } = e.target
const callback = checked ? this.props.onEnableToken : this.props.onDisableToken
this.setState(() => ({ checked }), () => callback(this.props.token))
}
render() {
const { classes, token } = this.props
const name = token.get('name')
const symbol = token.get('symbol')
const disabled = isEther(symbol)
return (
<Card className={classes.card}>
<Block className={classes.details}>
<CardContent className={classes.content}>
<Typography variant="headline">{name}</Typography>
<Typography variant="subheading" color="textSecondary">
<Checkbox
disabled={disabled}
checked={!!this.state.checked}
onChange={this.handleChange}
color="primary"
/>
{symbol}
</Typography>
</CardContent>
</Block>
{/*
<Block className={classes.controls}>
<IconButton aria-label="Delete" onClick={this.onRemoveClick}>
<Delete />
</IconButton>
</Block>
*/}
<CardMedia
className={classes.cover}
image={token.get('logoUrl')}
title={name}
/>
</Card>
)
}
}
export default withStyles(styles, { withTheme: true })(TokenComponent)

View File

@ -0,0 +1,15 @@
// @flow
import enableToken from '~/routes/tokens/store/actions/enableToken'
import disableToken from '~/routes/tokens/store/actions/disableToken'
import { fetchTokens } from '~/routes/tokens/store/actions/fetchTokens'
export type Actions = {
enableToken: typeof enableToken,
disableToken: typeof disableToken,
}
export default {
enableToken,
disableToken,
fetchTokens,
}

View File

@ -0,0 +1,43 @@
// @flow
import * as React from 'react'
import { connect } from 'react-redux'
import Page from '~/components/layout/Page'
import Layout from '~/routes/tokens/component/Layout'
import { fetchTokens } from '~/routes/tokens/store/actions/fetchTokens'
import selector, { type SelectorProps } from './selector'
import actions, { type Actions } from './actions'
type Props = Actions & SelectorProps & {
fetchTokens: typeof fetchTokens,
}
class TokensView extends React.PureComponent<Props> {
componentDidUpdate() {
const { safeAddress } = this.props
if (this.props.tokens.count() === 0) {
this.props.fetchTokens(safeAddress)
}
}
render() {
const {
tokens, addresses, safe, safeAddress, disableToken, enableToken,
} = this.props
return (
<Page>
<Layout
tokens={tokens}
addresses={addresses}
safe={safe}
safeAddress={safeAddress}
disableToken={disableToken}
enableToken={enableToken}
/>
</Page>
)
}
}
export default connect(selector, actions)(TokensView)

View File

@ -0,0 +1,21 @@
// @flow
import { List } from 'immutable'
import { createStructuredSelector } from 'reselect'
import { tokenListSelector, tokenAddressesSelector } from '~/routes/tokens/store/selectors'
import { type Safe } from '~/routes/safe/store/model/safe'
import { safeSelector, safeParamAddressSelector } from '~/routes/safe/store/selectors'
import { type Token } from '~/routes/tokens/store/model/token'
export type SelectorProps = {
tokens: List<Token>,
addresses: List<String>,
safe: Safe,
safeAddress: string,
}
export default createStructuredSelector({
safe: safeSelector,
safeAddress: safeParamAddressSelector,
tokens: tokenListSelector,
addresses: tokenAddressesSelector,
})

View File

@ -0,0 +1,21 @@
// @flow
import { Map } from 'immutable'
import { createAction } from 'redux-actions'
import { type Token } from '~/routes/tokens/store/model/token'
export const ADD_TOKENS = 'ADD_TOKENS'
type TokenProps = {
safeAddress: string,
tokens: Map<string, Token>,
}
const addTokens = createAction(
ADD_TOKENS,
(safeAddress: string, tokens: Map<string, Token>): TokenProps => ({
safeAddress,
tokens,
}),
)
export default addTokens

View File

@ -0,0 +1,16 @@
// @flow
import { createAction } from 'redux-actions'
import { type Token } from '~/routes/tokens/store/model/token'
export const DISABLE_TOKEN = 'DISABLE_TOKEN'
const disableToken = createAction(
DISABLE_TOKEN,
(safeAddress: string, token: Token) => ({
safeAddress,
symbol: token.get('symbol'),
address: token.get('address'),
}),
)
export default disableToken

View File

@ -0,0 +1,16 @@
// @flow
import { createAction } from 'redux-actions'
import { type Token } from '~/routes/tokens/store/model/token'
export const ENABLE_TOKEN = 'ENABLE_TOKEN'
const enableToken = createAction(
ENABLE_TOKEN,
(safeAddress: string, token: Token) => ({
safeAddress,
symbol: token.get('symbol'),
address: token.get('address'),
}),
)
export default enableToken

View File

@ -0,0 +1,67 @@
// @flow
import { List, Map } from 'immutable'
import contract from 'truffle-contract'
import type { Dispatch as ReduxDispatch } from 'redux'
import StandardToken from '@gnosis.pm/util-contracts/build/contracts/StandardToken.json'
import { getWeb3 } from '~/wallets/getWeb3'
import { type GlobalState } from '~/store/index'
import { makeToken, type Token, type TokenProps } from '~/routes/tokens/store/model/token'
import { ensureOnce } from '~/utils/singleton'
import { getTokens } from '~/utils/localStorage/tokens'
import { getSafeEthToken } from '~/utils/tokens'
import { enhancedFetch } from '~/utils/fetch'
import addTokens from './addTokens'
const createStandardTokenContract = async () => {
const web3 = getWeb3()
const erc20Token = await contract(StandardToken)
erc20Token.setProvider(web3.currentProvider)
return erc20Token
}
export const getStandardTokenContract = ensureOnce(createStandardTokenContract)
export const calculateBalanceOf = async (tokenAddress: string, address: string, decimals: number) => {
const erc20Token = await getStandardTokenContract()
return erc20Token.at(tokenAddress)
.then(instance => instance.balanceOf(address).then(funds => funds.div(10 ** decimals).toString()))
.catch(() => '0')
}
export const fetchTokensData = async () => {
const url = 'https://gist.githubusercontent.com/rmeissner/98911fcf74b0ea9731e2dae2441c97a4/raw/'
const errMsg = 'Error querying safe balances'
return enhancedFetch(url, errMsg)
}
export const fetchTokens = (safeAddress: string) =>
async (dispatch: ReduxDispatch<GlobalState>) => {
const tokens: List<string> = getTokens(safeAddress)
const ethBalance = await getSafeEthToken(safeAddress)
const json = await exports.fetchTokensData()
try {
const balancesRecords = await Promise.all(json.map(async (item: TokenProps) => {
const status = tokens.includes(item.address)
const funds = status ? await calculateBalanceOf(item.address, safeAddress, item.decimals) : '0'
return makeToken({ ...item, status, funds })
}))
const balances: Map<string, Token> = Map().withMutations((map) => {
balancesRecords.forEach(record => map.set(record.get('symbol'), record))
map.set('ETH', ethBalance)
})
return dispatch(addTokens(safeAddress, balances))
} catch (err) {
// eslint-disable-next-line
console.log("Error fetching token balances... " + err)
return Promise.resolve()
}
}

View File

@ -2,22 +2,26 @@
import { Record } from 'immutable'
import type { RecordFactory, RecordOf } from 'immutable'
export type BalanceProps = {
export type TokenProps = {
address: string,
name: string,
symbol: string,
decimals: number,
logoUrl: string,
funds: string,
status: boolean,
removable: boolean,
}
export const makeBalance: RecordFactory<BalanceProps> = Record({
export const makeToken: RecordFactory<TokenProps> = Record({
address: '',
name: '',
symbol: '',
decimals: 0,
logoUrl: '',
funds: '0',
status: true,
removable: false,
})
export type Balance = RecordOf<BalanceProps>
export type Token = RecordOf<TokenProps>

View File

@ -0,0 +1,50 @@
// @flow
import { List, Map } from 'immutable'
import { handleActions, type ActionType } from 'redux-actions'
import addTokens, { ADD_TOKENS } from '~/routes/tokens/store/actions/addTokens'
import { type Token } from '~/routes/tokens/store/model/token'
import disableToken, { DISABLE_TOKEN } from '~/routes/tokens/store/actions/disableToken'
import enableToken, { ENABLE_TOKEN } from '~/routes/tokens/store/actions/enableToken'
import { setTokens, getTokens } from '~/utils/localStorage/tokens'
import { ensureOnce } from '~/utils/singleton'
import { calculateActiveErc20TokensFrom } from '~/utils/tokens'
export const TOKEN_REDUCER_ID = 'tokens'
export type State = Map<string, Map<string, Token>>
const setTokensOnce = ensureOnce(setTokens)
export default handleActions({
[ADD_TOKENS]: (state: State, action: ActionType<typeof addTokens>): State => {
const { safeAddress, tokens } = action.payload
const activeAddresses: List<Token> = calculateActiveErc20TokensFrom(tokens.toList())
setTokensOnce(safeAddress, activeAddresses)
return state.update(safeAddress, (prevSafe: Map<string, Token>) => {
if (!prevSafe) {
return tokens
}
return prevSafe.equals(tokens) ? prevSafe : tokens
})
},
[DISABLE_TOKEN]: (state: State, action: ActionType<typeof disableToken>): State => {
const { address, safeAddress, symbol } = action.payload
const activeTokens = getTokens(safeAddress)
const index = activeTokens.indexOf(address)
setTokens(safeAddress, activeTokens.delete(index))
return state.setIn([safeAddress, symbol, 'status'], false)
},
[ENABLE_TOKEN]: (state: State, action: ActionType<typeof enableToken>): State => {
const { address, safeAddress, symbol } = action.payload
const activeTokens = getTokens(safeAddress)
setTokens(safeAddress, activeTokens.push(address))
return state.setIn([safeAddress, symbol, 'status'], true)
},
}, Map())

View File

@ -0,0 +1,41 @@
// @flow
import { List, Map } from 'immutable'
import { createSelector, type Selector } from 'reselect'
import { safeParamAddressSelector, type RouterProps } from '~/routes/safe/store/selectors'
import { type GlobalState } from '~/store'
import { TOKEN_REDUCER_ID } from '~/routes/tokens/store/reducer/tokens'
import { type Token } from '~/routes/tokens/store/model/token'
const balancesSelector = (state: GlobalState) => state[TOKEN_REDUCER_ID]
export const tokensSelector: Selector<GlobalState, RouterProps, Map<string, Token>> = createSelector(
balancesSelector,
safeParamAddressSelector,
(tokens: Map<string, Map<string, Token>>, address: string) => {
if (!address) {
return Map()
}
return tokens.get(address) || Map()
},
)
export const tokenListSelector = createSelector(
tokensSelector,
(tokens: Map<string, Token>) => tokens.toList(),
)
export const activeTokensSelector = createSelector(
tokenListSelector,
(tokens: List<Token>) => tokens.filter((token: Token) => token.get('status')),
)
export const tokenAddressesSelector = createSelector(
tokenListSelector,
(balances: List<Token>) => {
const addresses = List().withMutations(list =>
balances.map(token => list.push(token.address)))
return addresses
},
)

View File

@ -5,7 +5,7 @@ import { combineReducers, createStore, applyMiddleware, compose, type Reducer, t
import thunk from 'redux-thunk'
import provider, { PROVIDER_REDUCER_ID, type State as ProviderState } from '~/wallets/store/reducer/provider'
import safe, { SAFE_REDUCER_ID, type State as SafeState } from '~/routes/safe/store/reducer/safe'
import balances, { BALANCE_REDUCER_ID, type State as BalancesState } from '~/routes/safe/store/reducer/balances'
import tokens, { TOKEN_REDUCER_ID, type State as TokensState } from '~/routes/tokens/store/reducer/tokens'
import transactions, { type State as TransactionsState, transactionsInitialState, TRANSACTIONS_REDUCER_ID } from '~/routes/safe/store/reducer/transactions'
export const history = createBrowserHistory()
@ -20,7 +20,7 @@ const finalCreateStore = composeEnhancers(applyMiddleware(
export type GlobalState = {
providers: ProviderState,
safes: SafeState,
balances: BalancesState,
tokens: TokensState,
transactions: TransactionsState,
}
@ -28,7 +28,7 @@ const reducers: Reducer<GlobalState> = combineReducers({
routing: routerReducer,
[PROVIDER_REDUCER_ID]: provider,
[SAFE_REDUCER_ID]: safe,
[BALANCE_REDUCER_ID]: balances,
[TOKEN_REDUCER_ID]: tokens,
[TRANSACTIONS_REDUCER_ID]: transactions,
})

View File

@ -8,7 +8,7 @@ import { sleep } from '~/utils/timer'
import { Provider } from 'react-redux'
import { ConnectedRouter } from 'react-router-redux'
import AppRoutes from '~/routes'
import { SAFELIST_ADDRESS } from '~/routes/routes'
import { SAFELIST_ADDRESS, SETTINS_ADDRESS } from '~/routes/routes'
import { history, type GlobalState } from '~/store'
import { EMPTY_DATA } from '~/wallets/ethTransactions'
@ -93,15 +93,25 @@ export const refreshTransactions = async (store: Store<GlobalState>) => {
await sleep(1500)
}
export const travelToSafe = (store: Store, address: string): React$Component<{}> => {
history.push(`${SAFELIST_ADDRESS}/${address}`)
const SafeDom = TestUtils.renderIntoDocument((
const createDom = (store: Store): React$Component<{}> => (
TestUtils.renderIntoDocument((
<Provider store={store}>
<ConnectedRouter history={history}>
<AppRoutes />
</ConnectedRouter>
</Provider>
))
)
return SafeDom
export const travelToSafe = (store: Store, address: string): React$Component<{}> => {
history.push(`${SAFELIST_ADDRESS}/${address}`)
return createDom(store)
}
export const travelToTokens = (store: Store, address: string): React$Component<{}> => {
const url = `${SAFELIST_ADDRESS}/${address}${SETTINS_ADDRESS}`
history.push(url)
return createDom(store)
}

View File

@ -0,0 +1,29 @@
// @flow
import * as TestUtils from 'react-dom/test-utils'
import { travelToTokens } from '~/test/builder/safe.dom.utils'
import { sleep } from '~/utils/timer'
import { type Token } from '~/routes/tokens/store/model/token'
export const enableFirstToken = async (store: Store, safeAddress: string) => {
const TokensDom = await travelToTokens(store, safeAddress)
await sleep(400)
// WHEN
const inputs = TestUtils.scryRenderedDOMComponentsWithTag(TokensDom, 'input')
const ethTokenInput = inputs[2]
expect(ethTokenInput.hasAttribute('disabled')).toBe(true)
const firstTokenInput = inputs[0]
expect(firstTokenInput.hasAttribute('disabled')).toBe(false)
TestUtils.Simulate.change(firstTokenInput, { target: { checked: 'true' } })
}
export const testToken = (token: Token | typeof undefined, symbol: string, status: boolean, funds?: string) => {
if (!token) throw new Error()
expect(token.get('symbol')).toBe(symbol)
expect(token.get('status')).toBe(status)
if (funds) {
expect(token.get('funds')).toBe(funds)
}
}

View File

@ -1,10 +1,10 @@
// @flow
import TestUtils from 'react-dom/test-utils'
import * as fetchBalancesAction from '~/routes/safe/store/actions/fetchBalances'
import * as fetchBalancesAction from '~/routes/tokens/store/actions/fetchTokens'
import { aNewStore } from '~/store'
import { aMinedSafe } from '~/test/builder/safe.redux.builder'
import { addTknTo, getTokenContract } from '~/test/utils/tokenMovements'
import { addTknTo, getFirstTokenContract } from '~/test/utils/tokenMovements'
import { EXPAND_BALANCE_INDEX, travelToSafe } from '~/test/builder/safe.dom.utils'
import { promisify } from '~/utils/promisify'
import { getWeb3 } from '~/wallets/getWeb3'
@ -47,14 +47,14 @@ describe('DOM > Feature > SAFE ERC20 TOKENS', () => {
const receiverFunds = await fetchBalancesAction.calculateBalanceOf(tokenAddress, receiver, 18)
expect(Number(receiverFunds)).toBe(20)
const token = await getTokenContract(getWeb3(), accounts[0])
const token = await getFirstTokenContract(getWeb3(), accounts[0])
const nativeSafeFunds = await token.balanceOf(safeAddress)
expect(Number(nativeSafeFunds.valueOf())).toEqual(80 * (10 ** 18))
})
it('disables send token button when balance is 0', async () => {
// GIVEN
const token = await getTokenContract(getWeb3(), accounts[0])
const token = await getFirstTokenContract(getWeb3(), accounts[0])
await dispatchTknBalance(store, token.address, safeAddress)
// WHEN

View File

@ -1,10 +1,10 @@
// @flow
import { Map } from 'immutable'
import { BALANCE_REDUCER_ID } from '~/routes/safe/store/reducer/balances'
import * as fetchBalancesAction from '~/routes/safe/store/actions/fetchBalances'
import * as fetchTokensAction from '~/routes/tokens/store/actions/fetchTokens'
import { aNewStore } from '~/store'
import { aMinedSafe } from '~/test/builder/safe.redux.builder'
import { type Balance } from '~/routes/safe/store/model/balance'
import { type Token } from '~/routes/tokens/store/model/token'
import { TOKEN_REDUCER_ID } from '~/routes/tokens/store/reducer/tokens'
import { addEtherTo, addTknTo } from '~/test/utils/tokenMovements'
import { dispatchTknBalance } from '~/test/utils/transactions/moveTokens.helper'
@ -21,13 +21,13 @@ describe('Safe - redux balance property', () => {
const tokenList = ['WE', '<3', 'GNO', 'OMG', 'RDN']
// WHEN
await store.dispatch(fetchBalancesAction.fetchBalances(address))
await store.dispatch(fetchTokensAction.fetchTokens(address))
// THEN
const balances: Map<string, Map<string, Balance>> | typeof undefined = store.getState()[BALANCE_REDUCER_ID]
if (!balances) throw new Error()
const tokens: Map<string, Map<string, Token>> | typeof undefined = store.getState()[TOKEN_REDUCER_ID]
if (!tokens) throw new Error()
const safeBalances: Map<string, Balance> | typeof undefined = balances.get(address)
const safeBalances: Map<string, Token> | typeof undefined = tokens.get(address)
if (!safeBalances) throw new Error()
expect(safeBalances.size).toBe(6)
@ -41,13 +41,13 @@ describe('Safe - redux balance property', () => {
it('reducer should return 0.03456 ETH as funds to safe with 0.03456 ETH', async () => {
// WHEN
await addEtherTo(address, '0.03456')
await store.dispatch(fetchBalancesAction.fetchBalances(address))
await store.dispatch(fetchTokensAction.fetchTokens(address))
// THEN
const balances: Map<string, Map<string, Balance>> | typeof undefined = store.getState()[BALANCE_REDUCER_ID]
if (!balances) throw new Error()
const tokens: Map<string, Map<string, Token>> | typeof undefined = store.getState()[TOKEN_REDUCER_ID]
if (!tokens) throw new Error()
const safeBalances: Map<string, Balance> | typeof undefined = balances.get(address)
const safeBalances: Map<string, Token> | typeof undefined = tokens.get(address)
if (!safeBalances) throw new Error()
expect(safeBalances.size).toBe(6)
@ -65,7 +65,7 @@ describe('Safe - redux balance property', () => {
await dispatchTknBalance(store, tokenAddress, address)
// THEN
const safeBalances = store.getState()[BALANCE_REDUCER_ID].get(address)
const safeBalances = store.getState()[TOKEN_REDUCER_ID].get(address)
expect(safeBalances.size).toBe(1)
const tknBalance = safeBalances.get('TKN')

View File

@ -0,0 +1,84 @@
// @flow
import * as React from 'react'
import TestUtils from 'react-dom/test-utils'
import { Provider } from 'react-redux'
import { ConnectedRouter } from 'react-router-redux'
import { aNewStore, history } from '~/store'
import { addEtherTo } from '~/test/utils/tokenMovements'
import { executeWithdrawOn } from '~/routes/safe/store/test/builder/deployedSafe.builder'
import { SAFELIST_ADDRESS } from '~/routes/routes'
import SafeView from '~/routes/safe/component/Safe'
import AppRoutes from '~/routes'
import { WITHDRAW_BUTTON_TEXT } from '~/routes/safe/component/Safe/DailyLimit'
import { getBalanceInEtherOf } from '~/wallets/getWeb3'
import { sleep } from '~/utils/timer'
import { getDailyLimitFrom } from '~/routes/safe/component/Withdraw/withdraw'
import { type DailyLimitProps } from '~/routes/safe/store/model/dailyLimit'
import { WITHDRAW_INDEX } from '~/test/builder/safe.dom.utils'
import { aMinedSafe } from '~/test/builder/safe.redux.builder'
import { getSafeFrom } from '~/test/utils/safeHelper'
import { filterMoveButtonsFrom } from '~/test/builder/safe.dom.builder'
import { fetchTokens } from '~/routes/tokens/store/actions/fetchTokens'
describe('React DOM TESTS > Withdraw funds from safe', () => {
let store
let safeAddress: string
beforeEach(async () => {
store = aNewStore()
safeAddress = await aMinedSafe(store)
})
it('should withdraw funds under dailyLimit without needing confirmations', async () => {
// add funds to safe
await addEtherTo(safeAddress, '0.1')
const safe = getSafeFrom(store.getState(), safeAddress)
await executeWithdrawOn(safe, 0.01)
const safeBalance = await getBalanceInEtherOf(safeAddress)
expect(safeBalance).toBe('0.09')
})
it('spentToday dailyLimitModule property is updated correctly', async () => {
// add funds to safe
await addEtherTo(safeAddress, '0.1')
const safe = getSafeFrom(store.getState(), safeAddress)
await executeWithdrawOn(safe, 0.01)
await executeWithdrawOn(safe, 0.01)
const ethAddress = 0
const dailyLimit: DailyLimitProps = await getDailyLimitFrom(safeAddress, ethAddress)
// THEN
expect(dailyLimit.value).toBe(0.5)
expect(dailyLimit.spentToday).toBe(0.02)
})
it('Withdraw button disabled when balance is 0', async () => {
// navigate to SAFE route
history.push(`${SAFELIST_ADDRESS}/${safeAddress}`)
const SafeDom = TestUtils.renderIntoDocument((
<Provider store={store}>
<ConnectedRouter history={history}>
<AppRoutes />
</ConnectedRouter>
</Provider>
))
await sleep(300)
const Safe = TestUtils.findRenderedComponentWithType(SafeDom, SafeView)
// $FlowFixMe
const buttons = TestUtils.scryRenderedDOMComponentsWithTag(Safe, 'button')
const filteredButtons = filterMoveButtonsFrom(buttons)
const addWithdrawButton = filteredButtons[WITHDRAW_INDEX]
expect(addWithdrawButton.getElementsByTagName('span')[0].innerHTML).toEqual(WITHDRAW_BUTTON_TEXT)
expect(addWithdrawButton.hasAttribute('disabled')).toBe(true)
await addEtherTo(safeAddress, '0.1')
await store.dispatch(fetchTokens(safeAddress))
await sleep(150)
expect(addWithdrawButton.hasAttribute('disabled')).toBe(false)
})
})

View File

@ -0,0 +1,126 @@
// @flow
import * as TestUtils from 'react-dom/test-utils'
import { List } from 'immutable'
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 Checkbox from '@material-ui/core/Checkbox'
import { getFirstTokenContract, getSecondTokenContract, addTknTo } 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, activeTokensSelector } from '~/routes/tokens/store/selectors'
import { getTokens } from '~/utils/localStorage/tokens'
import { enableFirstToken, testToken } from '~/test/builder/tokens.dom.utils'
import * as fetchTokensModule from '~/routes/tokens/store/actions/fetchTokens'
import * as enhancedFetchModule from '~/utils/fetch'
describe('DOM > Feature > Enable and disable default 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',
},
{
address: secondErc20Token.address,
name: 'Second Token Example',
symbol: 'STE',
decimals: 18,
logoUrl: 'https://upload.wikimedia.org/wikipedia/commons/c/c0/Earth_simple_icon.png',
},
]))
})
it('retrieves only ether as active token in first moment', async () => {
// GIVEN
const store = aNewStore()
const safeAddress = await aMinedSafe(store)
await store.dispatch(fetchTokensModule.fetchTokens(safeAddress))
// WHEN
const TokensDom = await travelToTokens(store, safeAddress)
await sleep(400)
// THEN
const tokens = TestUtils.scryRenderedComponentsWithType(TokensDom, TokenComponent)
expect(tokens.length).toBe(3)
testToken(tokens[0].props.token, 'FTE', false)
testToken(tokens[1].props.token, 'STE', false)
testToken(tokens[2].props.token, 'ETH', true)
const ethCheckbox = TestUtils.findRenderedComponentWithType(tokens[2], Checkbox)
if (!ethCheckbox) throw new Error()
expect(ethCheckbox.props.disabled).toBe(true)
})
it('fetch balances of only enabled tokens', async () => {
// GIVEN
const store = aNewStore()
const safeAddress = await aMinedSafe(store)
await addTknTo(safeAddress, 50, firstErc20Token)
await addTknTo(safeAddress, 50, secondErc20Token)
await store.dispatch(fetchTokensModule.fetchTokens(safeAddress))
await enableFirstToken(store, safeAddress)
// THEN
const match: Match = buildMathPropsFrom(safeAddress)
const tokenList = tokenListSelector(store.getState(), { match })
testToken(tokenList.get(0), 'FTE', true)
testToken(tokenList.get(1), 'STE', false)
testToken(tokenList.get(2), 'ETH', true)
const activeTokenList = activeTokensSelector(store.getState(), { match })
expect(activeTokenList.count()).toBe(2)
testToken(activeTokenList.get(0), 'FTE', true)
testToken(activeTokenList.get(1), 'ETH', true)
await store.dispatch(fetchTokensModule.fetchTokens(safeAddress))
const fundedTokenList = tokenListSelector(store.getState(), { match })
expect(fundedTokenList.count()).toBe(3)
testToken(fundedTokenList.get(0), 'FTE', true, '50')
testToken(fundedTokenList.get(1), 'STE', false, '0')
testToken(fundedTokenList.get(2), 'ETH', true, '0')
})
it('localStorage always returns a list', async () => {
const store = aNewStore()
const safeAddress = await aMinedSafe(store)
let tokens: List<string> = getTokens(safeAddress)
expect(tokens).toEqual(List([]))
await store.dispatch(fetchTokensModule.fetchTokens(safeAddress))
tokens = getTokens(safeAddress)
expect(tokens.count()).toBe(0)
await enableFirstToken(store, safeAddress)
tokens = getTokens(safeAddress)
expect(tokens.count()).toBe(1)
await store.dispatch(fetchTokensModule.fetchTokens(safeAddress))
tokens = getTokens(safeAddress)
expect(tokens.count()).toBe(1)
})
})

View File

@ -39,13 +39,14 @@ const createTokenContract = async (web3: any, executor: string) => {
return token.new({ from: executor, gas: '5000000' })
}
export const getTokenContract = ensureOnce(createTokenContract)
export const getFirstTokenContract = ensureOnce(createTokenContract)
export const getSecondTokenContract = ensureOnce(createTokenContract)
export const addTknTo = async (safe: string, value: number) => {
export const addTknTo = async (safe: string, value: number, tokenContract?: any) => {
const web3 = getWeb3()
const accounts = await promisify(cb => getWeb3().eth.getAccounts(cb))
const myToken = await getTokenContract(web3, accounts[0])
const myToken = tokenContract || await getFirstTokenContract(web3, accounts[0])
const nativeValue = await toNative(value, 18)
await myToken.transfer(safe, nativeValue.valueOf(), { from: accounts[0], gas: '5000000' })

View File

@ -2,12 +2,12 @@
import { Map } from 'immutable'
import TestUtils from 'react-dom/test-utils'
import { sleep } from '~/utils/timer'
import * as fetchBalancesAction from '~/routes/safe/store/actions/fetchBalances'
import * as fetchTokensAction from '~/routes/tokens/store/actions/fetchTokens'
import { checkMinedTx, checkPendingTx, EXPAND_BALANCE_INDEX } from '~/test/builder/safe.dom.utils'
import { makeBalance, type Balance } from '~/routes/safe/store/model/balance'
import addBalances from '~/routes/safe/store/actions/addBalances'
import { whenExecuted } from '~/test/utils/logTransactions'
import SendToken from '~/routes/safe/component/SendToken'
import { makeToken, type Token } from '~/routes/tokens/store/model/token'
import addTokens from '~/routes/tokens/store/actions/addTokens'
export const sendMoveTokensForm = async (
SafeDom: React$Component<any, any>,
@ -44,9 +44,9 @@ export const sendMoveTokensForm = async (
}
export const dispatchTknBalance = async (store: Store, tokenAddress: string, address: string) => {
const fetchBalancesMock = jest.spyOn(fetchBalancesAction, 'fetchBalances')
const funds = await fetchBalancesAction.calculateBalanceOf(tokenAddress, address, 18)
const balances: Map<string, Balance> = Map().set('TKN', makeBalance({
const fetchBalancesMock = jest.spyOn(fetchTokensAction, 'fetchTokens')
const funds = await fetchTokensAction.calculateBalanceOf(tokenAddress, address, 18)
const balances: Map<string, Token> = Map().set('TKN', makeToken({
address: tokenAddress,
name: 'Token',
symbol: 'TKN',
@ -54,8 +54,8 @@ export const dispatchTknBalance = async (store: Store, tokenAddress: string, add
logoUrl: 'https://github.com/TrustWallet/tokens/blob/master/images/0x6810e776880c02933d47db1b9fc05908e5386b96.png?raw=true',
funds,
}))
fetchBalancesMock.mockImplementation(() => store.dispatch(addBalances(address, balances)))
await store.dispatch(fetchBalancesAction.fetchBalances(address))
fetchBalancesMock.mockImplementation(() => store.dispatch(addTokens(address, balances)))
await store.dispatch(fetchTokensAction.fetchTokens(address))
fetchBalancesMock.mockRestore()
}

19
src/utils/fetch.js Normal file
View File

@ -0,0 +1,19 @@
// @flow
export const enhancedFetch = async (url: string, errMsg: string) => {
const header = new Headers({
'Access-Control-Allow-Origin': '*',
})
const sentData = {
mode: 'cors',
header,
}
const response = await fetch(url, sentData)
if (!response.ok) {
throw new Error(errMsg)
}
return response.json()
}

View File

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

View File

@ -0,0 +1,28 @@
// @flow
import { List } from 'immutable'
import { load, TOKENS_KEY } from '~/utils/localStorage'
const getTokensKey = (safeAddress: string) => `${TOKENS_KEY}-${safeAddress}`
export const setTokens = (safeAddress: string, tokens: List<string>) => {
try {
const serializedState = JSON.stringify(tokens)
const key = getTokensKey(safeAddress)
localStorage.setItem(key, serializedState)
} catch (err) {
// eslint-disable-next-line
console.log('Error storing tokens in localstorage')
}
}
export const getTokens = (safeAddress: string): List<string> => {
const key = getTokensKey(safeAddress)
const data = load(key)
return data ? List(data) : List()
}
export const storedTokensBefore = (safeAddress: string) => {
const key = getTokensKey(safeAddress)
return localStorage.getItem(key) === null
}

39
src/utils/tokens.js Normal file
View File

@ -0,0 +1,39 @@
// @flow
import { List } from 'immutable'
import logo from '~/assets/icons/icon_etherTokens.svg'
import { getBalanceInEtherOf } from '~/wallets/getWeb3'
import { makeToken, type Token } from '~/routes/tokens/store/model/token'
export const isEther = (symbol: string) => symbol === 'ETH'
export const getSafeEthToken = async (safeAddress: string) => {
const balance = await getBalanceInEtherOf(safeAddress)
const ethBalance = makeToken({
address: '0',
name: 'Ether',
symbol: 'ETH',
decimals: 18,
logoUrl: logo,
funds: balance,
})
return ethBalance
}
export const calculateActiveErc20TokensFrom = (tokens: List<Token>) => {
const addresses = List().withMutations(list =>
tokens.forEach((token: Token) => {
if (isEther(token.get('symbol'))) {
return
}
if (!token.get('status')) {
return
}
list.push(token.address)
}))
return addresses
}

View File

@ -2,6 +2,7 @@
import { BigNumber } from 'bignumber.js'
import { getWeb3 } from '~/wallets/getWeb3'
import { promisify } from '~/utils/promisify'
import { enhancedFetch } from '~/utils/fetch'
// const MAINNET_NETWORK = 1
export const EMPTY_DATA = '0x'
@ -40,21 +41,9 @@ export const calculateGasPrice = async () => {
return '20000000000'
}
const header = new Headers({
'Access-Control-Allow-Origin': '*',
})
const sentData = {
mode: 'cors',
header,
}
const response = await fetch('https://ethgasstation.info/json/ethgasAPI.json', sentData)
if (!response.ok) {
throw new Error('Error querying gast station')
}
const json = await response.json()
const url = 'https://ethgasstation.info/json/ethgasAPI.json'
const errMsg = 'Error querying gas station'
const json = await enhancedFetch(url, errMsg)
return new BigNumber(json.average).multipliedBy(1e8).toString()
}