Feature #224: Activate tokens automatically (#300)

* Replace 'Manage Tokens' with 'Manage List'

* prevent 301 redirects

* Add `BLACKLISTED_TOKENS` key to persist through immortal

* Add store/action to extract _activate tokens by its balance_

- keeps already activated tokens
- discards blacklisted tokens
- adds tokens whose vales are bigger than zero and are not blacklisted

* Add `blacklistedTokens` list to safe's store

* Display activeTokensByBalance in 'Balances' screen

* Enable token's blacklisting functionality in Tokens List

* Retrieve balance from API

* Rename action to `activateTokensByBalance`

* Fix linting errors

- line too long
- required return

* Do not persist a separate list into `BLACKLISTED_TOKENS`
This commit is contained in:
Fernando 2019-12-05 06:18:07 -03:00 committed by Mikhail Mikheev
parent 85ff11796e
commit 21b7a59f20
18 changed files with 202 additions and 30 deletions

View File

@ -0,0 +1,16 @@
// @flow
import axios from 'axios'
import { getTxServiceHost } from '~/config/index'
const fetchTokenBalanceList = (safeAddress: string) => {
const apiUrl = getTxServiceHost()
const url = `${apiUrl}safes/${safeAddress}/balances/`
return axios.get(url, {
params: {
limit: 300,
},
})
}
export default fetchTokenBalanceList

View File

@ -4,7 +4,7 @@ import { getRelayUrl } from '~/config/index'
const fetchTokenList = () => {
const apiUrl = getRelayUrl()
const url = `${apiUrl}/tokens`
const url = `${apiUrl}tokens/`
return axios.get(url, {
params: {

View File

@ -0,0 +1,49 @@
// @flow
import type { Dispatch as ReduxDispatch } from 'redux'
import { Set } from 'immutable'
import { type GetState, type GlobalState } from '~/store'
import updateActiveTokens from '~/routes/safe/store/actions/updateActiveTokens'
import {
safeActiveTokensSelectorBySafe,
safeBlacklistedTokensSelectorBySafe,
safesMapSelector,
} from '~/routes/safe/store/selectors'
import fetchTokenBalanceList from '~/logic/tokens/api/fetchTokenBalanceList'
import updateSafe from '~/routes/safe/store/actions/updateSafe'
const activateTokensByBalance = (safeAddress: string) => async (
dispatch: ReduxDispatch<GlobalState>,
getState: GetState,
) => {
try {
const result = await fetchTokenBalanceList(safeAddress)
const safes = safesMapSelector(getState())
const alreadyActiveTokens = safeActiveTokensSelectorBySafe(safeAddress, safes)
const blacklistedTokens = safeBlacklistedTokensSelectorBySafe(safeAddress, safes)
// addresses: potentially active tokens by balance
// balances: tokens' balance returned by the backend
const { addresses, balances } = result.data.reduce((acc, { tokenAddress, balance }) => ({
addresses: [...acc.addresses, tokenAddress],
balances: [...acc.balances, balance],
}), { addresses: [], balances: [] })
// update balance list for the safe
dispatch(updateSafe({ address: safeAddress, balances: Set(balances) }))
// active tokens by balance, excluding those already blacklisted and the `null` address
const activeByBalance = addresses.filter((address) => address !== null && !blacklistedTokens.includes(address))
// need to persist those already active tokens, despite its balances
const activeTokens = alreadyActiveTokens.toSet().union(activeByBalance)
// update list of active tokens
dispatch(updateActiveTokens(safeAddress, activeTokens))
} catch (err) {
console.error('Error fetching token list', err)
}
return null
}
export default activateTokensByBalance

View File

@ -2,7 +2,7 @@
import { createAction } from 'redux-actions'
import type { Dispatch as ReduxDispatch } from 'redux'
import { type Token } from '~/logic/tokens/store/model/token'
import { removeTokenFromStorage, removeFromActiveTokens } from '~/logic/tokens/utils/tokensStorage'
import { removeFromActiveTokens, removeTokenFromStorage } from '~/logic/tokens/utils/tokensStorage'
import { type GlobalState } from '~/store/index'
export const REMOVE_TOKEN = 'REMOVE_TOKEN'

View File

@ -2,11 +2,13 @@
import fetchTokens from '~/logic/tokens/store/actions/fetchTokens'
import { addToken } from '~/logic/tokens/store/actions/addToken'
import updateActiveTokens from '~/routes/safe/store/actions/updateActiveTokens'
import updateBlacklistedTokens from '~/routes/safe/store/actions/updateBlacklistedTokens'
import activateTokenForAllSafes from '~/routes/safe/store/actions/activateTokenForAllSafes'
export type Actions = {
fetchTokens: Function,
updateActiveTokens: Function,
updateBlacklistedTokens: typeof updateBlacklistedTokens,
addToken: Function,
activateTokenForAllSafes: Function,
}
@ -15,5 +17,6 @@ export default {
fetchTokens,
addToken,
updateActiveTokens,
updateBlacklistedTokens,
activateTokenForAllSafes,
}

View File

@ -22,6 +22,7 @@ type Props = Actions & {
tokens: List<Token>,
safeAddress: string,
activeTokens: List<Token>,
blacklistedTokens: List<Token>,
}
type ActiveScreen = 'tokenList' | 'addCustomToken'
@ -32,8 +33,10 @@ const Tokens = (props: Props) => {
classes,
tokens,
activeTokens,
blacklistedTokens,
fetchTokens,
updateActiveTokens,
updateBlacklistedTokens,
safeAddress,
addToken,
activateTokenForAllSafes,
@ -43,7 +46,7 @@ const Tokens = (props: Props) => {
<>
<Row align="center" grow className={classes.heading}>
<Paragraph size="xl" noMargin weight="bolder">
Manage Tokens
Manage List
</Paragraph>
<IconButton onClick={onClose} disableRipple data-testid={MANAGE_TOKENS_MODAL_CLOSE_BUTTON_TEST_ID}>
<Close className={classes.close} />
@ -54,8 +57,10 @@ const Tokens = (props: Props) => {
<TokenList
tokens={tokens}
activeTokens={activeTokens}
blacklistedTokens={blacklistedTokens}
fetchTokens={fetchTokens}
updateActiveTokens={updateActiveTokens}
updateBlacklistedTokens={updateBlacklistedTokens}
safeAddress={safeAddress}
setActiveScreen={setActiveScreen}
/>

View File

@ -25,14 +25,17 @@ type Props = {
tokens: List<Token>,
safeAddress: string,
activeTokens: List<Token>,
fetchTokens: Function,
blacklistedTokens: List<Token>,
updateActiveTokens: Function,
updateBlacklistedTokens: Function,
setActiveScreen: Function,
}
type State = {
filter: string,
activeTokensAddresses: Set<string>,
initialActiveTokensAddresses: Set<string>,
blacklistedTokensAddresses: Set<string>,
}
const filterBy = (filter: string, tokens: List<Token>): List<Token> => tokens.filter(
@ -52,13 +55,10 @@ class Tokens extends React.Component<Props, State> {
state = {
filter: '',
activeTokensAddresses: Set([]),
initialActiveTokensAddresses: Set([]),
blacklistedTokensAddresses: Set([]),
activeTokensCalculated: false,
}
componentDidMount() {
const { fetchTokens } = this.props
fetchTokens()
blacklistedTokensCalculated: false,
}
static getDerivedStateFromProps(nextProps, prevState) {
@ -70,17 +70,29 @@ class Tokens extends React.Component<Props, State> {
return {
activeTokensAddresses: Set(activeTokens.map(({ address }) => address)),
initialActiveTokensAddresses: Set(activeTokens.map(({ address }) => address)),
activeTokensCalculated: true,
}
}
if (!prevState.blacklistedTokensCalculated) {
const { blacklistedTokens } = nextProps
return {
blacklistedTokensAddresses: blacklistedTokens,
blacklistedTokensCalculated: true,
}
}
return null
}
componentWillUnmount() {
const { activeTokensAddresses } = this.state
const { updateActiveTokens, safeAddress } = this.props
const { activeTokensAddresses, blacklistedTokensAddresses } = this.state
const { updateActiveTokens, updateBlacklistedTokens, safeAddress } = this.props
updateActiveTokens(safeAddress, activeTokensAddresses)
updateBlacklistedTokens(safeAddress, blacklistedTokensAddresses)
}
onCancelSearch = () => {
@ -92,17 +104,20 @@ class Tokens extends React.Component<Props, State> {
}
onSwitch = (token: Token) => () => {
const { activeTokensAddresses } = this.state
this.setState((prevState) => {
const activeTokensAddresses = prevState.activeTokensAddresses.has(token.address)
? prevState.activeTokensAddresses.remove(token.address)
: prevState.activeTokensAddresses.add(token.address)
if (activeTokensAddresses.has(token.address)) {
this.setState({
activeTokensAddresses: activeTokensAddresses.remove(token.address),
})
} else {
this.setState({
activeTokensAddresses: activeTokensAddresses.add(token.address),
})
}
let { blacklistedTokensAddresses } = prevState
if (activeTokensAddresses.has(token.address)) {
blacklistedTokensAddresses = prevState.blacklistedTokensAddresses.remove(token.address)
} else if (prevState.initialActiveTokensAddresses.has(token.address)) {
blacklistedTokensAddresses = prevState.blacklistedTokensAddresses.add(token.address)
}
return ({ ...prevState, activeTokensAddresses, blacklistedTokensAddresses })
})
}
createItemData = (tokens, activeTokensAddresses) => ({

View File

@ -38,6 +38,9 @@ type Props = {
granted: boolean,
tokens: List<Token>,
activeTokens: List<Token>,
blacklistedTokens: List<Token>,
activateTokensByBalance: Function,
fetchTokens: Function,
safeAddress: string,
safeName: string,
ethBalance: string,
@ -57,6 +60,7 @@ class Balances extends React.Component<Props, State> {
},
showReceive: false,
}
props.fetchTokens()
}
onShow = (action: Action) => () => {
@ -85,6 +89,17 @@ class Balances extends React.Component<Props, State> {
})
}
handleChange = (e: SyntheticInputEvent<HTMLInputElement>) => {
const { checked } = e.target
this.setState(() => ({ hideZero: checked }))
}
componentDidMount(): void {
const { activateTokensByBalance, safeAddress } = this.props
activateTokensByBalance(safeAddress)
}
render() {
const {
showToken, showReceive, sendFunds,
@ -95,6 +110,7 @@ class Balances extends React.Component<Props, State> {
tokens,
safeAddress,
activeTokens,
blacklistedTokens,
safeName,
ethBalance,
createTransaction,
@ -110,10 +126,10 @@ class Balances extends React.Component<Props, State> {
<Row align="center" className={classes.message}>
<Col xs={12} end="sm">
<ButtonLink size="lg" onClick={this.onShow('Token')} testId="manage-tokens-btn">
Manage Tokens
Manage List
</ButtonLink>
<Modal
title="Manage Tokens"
title="Manage List"
description="Enable and disable tokens to be listed"
handleClose={this.onHide('Token')}
open={showToken}
@ -123,6 +139,7 @@ class Balances extends React.Component<Props, State> {
onClose={this.onHide('Token')}
safeAddress={safeAddress}
activeTokens={activeTokens}
blacklistedTokens={blacklistedTokens}
/>
</Modal>
</Col>

View File

@ -60,9 +60,12 @@ const Layout = (props: Props) => {
granted,
tokens,
activeTokens,
blacklistedTokens,
createTransaction,
processTransaction,
fetchTransactions,
activateTokensByBalance,
fetchTokens,
updateSafe,
transactions,
userAddress,
@ -156,8 +159,11 @@ const Layout = (props: Props) => {
ethBalance={ethBalance}
tokens={tokens}
activeTokens={activeTokens}
blacklistedTokens={blacklistedTokens}
granted={granted}
safeAddress={address}
activateTokensByBalance={activateTokensByBalance}
fetchTokens={fetchTokens}
safeName={name}
createTransaction={createTransaction}
/>

View File

@ -7,6 +7,7 @@ import processTransaction from '~/routes/safe/store/actions/processTransaction'
import fetchTransactions from '~/routes/safe/store/actions/fetchTransactions'
import updateSafe from '~/routes/safe/store/actions/updateSafe'
import fetchTokens from '~/logic/tokens/store/actions/fetchTokens'
import activateTokensByBalance from '~/logic/tokens/store/actions/activateTokensByBalance'
export type Actions = {
fetchSafe: typeof fetchSafe,
@ -17,6 +18,7 @@ export type Actions = {
fetchTokens: typeof fetchTokens,
processTransaction: typeof processTransaction,
fetchEtherBalance: typeof fetchEtherBalance,
activateTokensByBalance: typeof activateTokensByBalance
}
export default {
@ -26,6 +28,7 @@ export default {
processTransaction,
fetchTokens,
fetchTransactions,
activateTokensByBalance,
updateSafe,
fetchEtherBalance,
checkAndUpdateSafeOwners,

View File

@ -102,6 +102,7 @@ class SafeView extends React.Component<Props, State> {
safe,
provider,
activeTokens,
blacklistedTokens,
granted,
userAddress,
network,
@ -109,6 +110,8 @@ class SafeView extends React.Component<Props, State> {
createTransaction,
processTransaction,
fetchTransactions,
activateTokensByBalance,
fetchTokens,
updateSafe,
transactions,
} = this.props
@ -117,6 +120,7 @@ class SafeView extends React.Component<Props, State> {
<Page>
<Layout
activeTokens={activeTokens}
blacklistedTokens={blacklistedTokens}
tokens={tokens}
provider={provider}
safe={safe}
@ -126,6 +130,8 @@ class SafeView extends React.Component<Props, State> {
createTransaction={createTransaction}
processTransaction={processTransaction}
fetchTransactions={fetchTransactions}
activateTokensByBalance={activateTokensByBalance}
fetchTokens={fetchTokens}
updateSafe={updateSafe}
transactions={transactions}
sendFunds={sendFunds}

View File

@ -5,6 +5,7 @@ import {
safeSelector,
safeActiveTokensSelector,
safeBalancesSelector,
safeBlacklistedTokensSelector,
type RouterProps,
type SafeSelectorProps,
} from '~/routes/safe/store/selectors'
@ -25,6 +26,7 @@ export type SelectorProps = {
provider: string,
tokens: List<Token>,
activeTokens: List<Token>,
blacklistedTokens: List<Token>,
userAddress: string,
network: string,
safeUrl: string,
@ -135,6 +137,7 @@ export default createStructuredSelector<Object, *>({
provider: providerNameSelector,
tokens: orderedTokenListSelector,
activeTokens: extendedSafeTokensSelector,
blacklistedTokens: safeBlacklistedTokensSelector,
granted: grantedSelector,
userAddress: userAccountSelector,
network: networkSelector,

View File

@ -19,7 +19,7 @@ export const calculateBalanceOf = async (tokenAddress: string, safeAddress: stri
const token = await erc20Token.at(tokenAddress)
balance = await token.balanceOf(safeAddress)
} catch (err) {
console.error('Failed to fetch token balances: ', err)
console.error('Failed to fetch token balances: ', tokenAddress, err)
}
return new BigNumber(balance).div(10 ** decimals).toString()
@ -50,7 +50,6 @@ const fetchTokenBalances = (safeAddress: string, tokens: List<Token>) => async (
dispatch(updateSafe({ address: safeAddress, balances }))
} catch (err) {
// eslint-disable-next-line
console.error('Error when fetching token balances:', err)
}
}

View File

@ -0,0 +1,13 @@
// @flow
import { Set } from 'immutable'
import type { Dispatch as ReduxDispatch } from 'redux'
import { type GlobalState } from '~/store'
import updateSafe from './updateSafe'
const updateBlacklistedTokens = (safeAddress: string, blacklistedTokens: Set<string>) => async (
dispatch: ReduxDispatch<GlobalState>,
) => {
dispatch(updateSafe({ address: safeAddress, blacklistedTokens }))
}
export default updateBlacklistedTokens

View File

@ -1,5 +1,5 @@
// @flow
import type { Store, AnyAction } from 'redux'
import type { AnyAction, Store } from 'redux'
import { List } from 'immutable'
import { ADD_SAFE } from '~/routes/safe/store/actions/addSafe'
import { UPDATE_SAFE } from '~/routes/safe/store/actions/updateSafe'
@ -10,10 +10,12 @@ import { REPLACE_SAFE_OWNER } from '~/routes/safe/store/actions/replaceSafeOwner
import { EDIT_SAFE_OWNER } from '~/routes/safe/store/actions/editSafeOwner'
import { type GlobalState } from '~/store/'
import {
saveSafes, setOwners, removeOwners, saveDefaultSafe,
removeOwners,
saveDefaultSafe,
saveSafes,
setOwners,
} from '~/logic/safe/utils'
import { safesMapSelector, getActiveTokensAddressesForAllSafes } from '~/routes/safe/store/selectors'
import { getActiveTokensAddressesForAllSafes, safesMapSelector } from '~/routes/safe/store/selectors'
import { tokensSelector } from '~/logic/tokens/store/selectors'
import type { Token } from '~/logic/tokens/store/model/token'
import { makeOwner } from '~/routes/safe/store/models/owner'

View File

@ -12,6 +12,7 @@ export type SafeProps = {
owners: List<Owner>,
balances?: Map<string, string>,
activeTokens: Set<string>,
blacklistedTokens: Set<string>,
ethBalance?: string,
}
@ -22,6 +23,7 @@ const SafeRecord: RecordFactory<SafeProps> = Record({
ethBalance: 0,
owners: List([]),
activeTokens: new Set(),
blacklistedTokens: new Set(),
balances: Map({}),
})

View File

@ -22,6 +22,7 @@ export const buildSafe = (storedSafe: SafeProps) => {
const addresses = storedSafe.owners.map((owner: OwnerProps) => owner.address)
const owners = buildOwnersFrom(Array.from(names), Array.from(addresses))
const activeTokens = Set(storedSafe.activeTokens)
const blacklistedTokens = Set(storedSafe.blacklistedTokens)
const balances = Map(storedSafe.balances)
const safe: SafeProps = {
@ -29,6 +30,7 @@ export const buildSafe = (storedSafe: SafeProps) => {
owners,
balances,
activeTokens,
blacklistedTokens,
}
return safe

View File

@ -106,6 +106,21 @@ export const safeActiveTokensSelector: Selector<GlobalState, RouterProps, List<s
},
)
export const safeBlacklistedTokensSelector: Selector<GlobalState, RouterProps, List<string>> = createSelector(
safeSelector,
(safe: Safe) => {
if (!safe) {
return List()
}
return safe.blacklistedTokens
},
)
export const safeActiveTokensSelectorBySafe = (safeAddress: string, safes: Map<string, Safe>): List<string> => safes.get(safeAddress).get('activeTokens')
export const safeBlacklistedTokensSelectorBySafe = (safeAddress: string, safes: Map<string, Safe>): List<string> => safes.get(safeAddress).get('blacklistedTokens')
export const safeBalancesSelector: Selector<GlobalState, RouterProps, Map<string, string>> = createSelector(
safeSelector,
(safe: Safe) => {
@ -132,7 +147,23 @@ export const getActiveTokensAddressesForAllSafes: Selector<GlobalState, any, Set
},
)
export const getBlacklistedTokensAddressesForAllSafes: Selector<GlobalState, any, Set<string>> = createSelector(
safesListSelector,
(safes: List<Safe>) => {
const addresses = Set().withMutations((set) => {
safes.forEach((safe: Safe) => {
safe.blacklistedTokens.forEach((tokenAddress) => {
set.add(tokenAddress)
})
})
})
return addresses
},
)
export default createStructuredSelector<Object, *>({
safe: safeSelector,
tokens: safeActiveTokensSelector,
blacklistedTokens: safeBlacklistedTokensSelector,
})