import {
constants as sharedConstants,
EtherscanLinkSuffixes,
Networks,
Styles,
utils as sharedUtils,
} from '@0x/react-shared';
import { BigNumber, errorUtils, fetchAsync, logUtils } from '@0x/utils';
import { Web3Wrapper } from '@0x/web3-wrapper';
import * as _ from 'lodash';
import Dialog from 'material-ui/Dialog';
import Divider from 'material-ui/Divider';
import FlatButton from 'material-ui/FlatButton';
import FloatingActionButton from 'material-ui/FloatingActionButton';
import ContentAdd from 'material-ui/svg-icons/content/add';
import ContentRemove from 'material-ui/svg-icons/content/remove';
import { Table, TableBody, TableHeader, TableHeaderColumn, TableRow, TableRowColumn } from 'material-ui/Table';
import * as React from 'react';
import ReactTooltip from 'react-tooltip';
import firstBy from 'thenby';
import { Blockchain } from 'ts/blockchain';
import { AssetPicker } from 'ts/components/generate_order/asset_picker';
import { SendButton } from 'ts/components/send_button';
import { HelpTooltip } from 'ts/components/ui/help_tooltip';
import { LifeCycleRaisedButton } from 'ts/components/ui/lifecycle_raised_button';
import { TokenIcon } from 'ts/components/ui/token_icon';
import { AllowanceStateToggle } from 'ts/containers/inputs/allowance_state_toggle';
import { trackedTokenStorage } from 'ts/local_storage/tracked_token_storage';
import { Dispatcher } from 'ts/redux/dispatcher';
import {
BalanceErrs,
BlockchainCallErrs,
BlockchainErrs,
ScreenWidths,
Token,
TokenByAddress,
TokenStateByAddress,
TokenVisibility,
} from 'ts/types';
import { configs } from 'ts/utils/configs';
import { constants } from 'ts/utils/constants';
import { errorReporter } from 'ts/utils/error_reporter';
import { utils } from 'ts/utils/utils';
const ETHER_ICON_PATH = '/images/ether.png';
const ETHER_TOKEN_SYMBOL = 'WETH';
const ZRX_TOKEN_SYMBOL = 'ZRX';
const ICON_DIMENSION = 40;
const ARTIFICIAL_FAUCET_REQUEST_DELAY = 1000;
const TOKEN_TABLE_ROW_HEIGHT = 60;
const MAX_TOKEN_TABLE_HEIGHT = 420;
const TOKEN_COL_SPAN_LG = 2;
const TOKEN_COL_SPAN_SM = 1;
const styles: Styles = {
bgColor: {
backgroundColor: 'transparent',
},
};
interface TokenBalancesProps {
blockchain: Blockchain;
blockchainErr: BlockchainErrs;
blockchainIsLoaded: boolean;
dispatcher: Dispatcher;
screenWidth: ScreenWidths;
tokenByAddress: TokenByAddress;
trackedTokens: Token[];
userAddress: string;
userEtherBalanceInWei: BigNumber;
networkId: number;
lastForceTokenStateRefetch: number;
isFullWidth?: boolean;
}
interface TokenBalancesState {
errorType: BalanceErrs;
trackedTokenStateByAddress: TokenStateByAddress;
isBalanceSpinnerVisible: boolean;
isZRXSpinnerVisible: boolean;
isTokenPickerOpen: boolean;
isAddingToken: boolean;
}
export class TokenBalances extends React.Component<TokenBalancesProps, TokenBalancesState> {
public static defaultProps: Partial<TokenBalancesProps> = {
userEtherBalanceInWei: new BigNumber(0),
isFullWidth: false,
};
private _isUnmounted: boolean;
public constructor(props: TokenBalancesProps) {
super(props);
this._isUnmounted = false;
const initialTrackedTokenStateByAddress = this._getInitialTrackedTokenStateByAddress(props.trackedTokens);
this.state = {
errorType: undefined,
isBalanceSpinnerVisible: false,
isZRXSpinnerVisible: false,
isTokenPickerOpen: false,
isAddingToken: false,
trackedTokenStateByAddress: initialTrackedTokenStateByAddress,
};
}
public componentWillMount(): void {
const trackedTokenAddresses = _.keys(this.state.trackedTokenStateByAddress);
// tslint:disable-next-line:no-floating-promises
this._fetchBalancesAndAllowancesAsync(trackedTokenAddresses);
}
public componentWillUnmount(): void {
this._isUnmounted = true;
}
public componentWillReceiveProps(nextProps: TokenBalancesProps): void {
if (nextProps.userEtherBalanceInWei !== this.props.userEtherBalanceInWei) {
if (this.state.isBalanceSpinnerVisible) {
const receivedAmountInWei = nextProps.userEtherBalanceInWei.minus(this.props.userEtherBalanceInWei);
const receivedAmountInEth = Web3Wrapper.toUnitAmount(receivedAmountInWei, constants.DECIMAL_PLACES_ETH);
const networkName = sharedConstants.NETWORK_NAME_BY_ID[this.props.networkId];
this.props.dispatcher.showFlashMessage(
`Received ${receivedAmountInEth.toString(10)} ${networkName} Ether`,
);
}
this.setState({
isBalanceSpinnerVisible: false,
});
}
if (
nextProps.userAddress !== this.props.userAddress ||
nextProps.networkId !== this.props.networkId ||
nextProps.lastForceTokenStateRefetch !== this.props.lastForceTokenStateRefetch
) {
const trackedTokenAddresses = _.keys(this.state.trackedTokenStateByAddress);
// tslint:disable-next-line:no-floating-promises
this._fetchBalancesAndAllowancesAsync(trackedTokenAddresses);
}
if (!_.isEqual(nextProps.trackedTokens, this.props.trackedTokens)) {
const newTokens = _.difference(nextProps.trackedTokens, this.props.trackedTokens);
const newTokenAddresses = _.map(newTokens, token => token.address);
// Add placeholder entry for this token to the state, since fetching the
// balance/allowance is asynchronous
const trackedTokenStateByAddress = this.state.trackedTokenStateByAddress;
for (const tokenAddress of newTokenAddresses) {
trackedTokenStateByAddress[tokenAddress] = {
balance: new BigNumber(0),
allowance: new BigNumber(0),
isLoaded: false,
};
}
this.setState({
trackedTokenStateByAddress,
});
// Fetch the actual balance/allowance.
// tslint:disable-next-line:no-floating-promises
this._fetchBalancesAndAllowancesAsync(newTokenAddresses);
}
}
public componentDidMount(): void {
window.scrollTo(0, 0);
}
public render(): React.ReactNode {
const errorDialogActions = [
<FlatButton
key="errorOkBtn"
label="Ok"
primary={true}
onClick={this._onErrorDialogToggle.bind(this, false)}
/>,
];
const isTestNetwork = utils.isTestNetwork(this.props.networkId);
const stubColumnStyle = {
display: isTestNetwork ? 'none' : 'table-cell',
};
const allTokenRowHeight = _.size(this.props.tokenByAddress) * TOKEN_TABLE_ROW_HEIGHT;
const tokenTableHeight =
allTokenRowHeight < MAX_TOKEN_TABLE_HEIGHT ? allTokenRowHeight : MAX_TOKEN_TABLE_HEIGHT;
const isSmallScreen = this.props.screenWidth === ScreenWidths.Sm;
const tokenColSpan = isSmallScreen ? TOKEN_COL_SPAN_SM : TOKEN_COL_SPAN_LG;
const allowanceExplanation =
'0x smart contracts require access to your<br> \
token balances in order to execute trades.<br> \
Toggling sets an allowance for the<br> \
smart contract so you can start trading that token.';
const userEtherBalanceInEth = Web3Wrapper.toUnitAmount(
this.props.userEtherBalanceInWei,
constants.DECIMAL_PLACES_ETH,
);
const rootClassName = this.props.isFullWidth ? 'pb2' : 'lg-px4 md-px4 sm-px1 pb2';
return (
<div className={rootClassName}>
<h3>{isTestNetwork ? 'Test ether' : 'Ether'}</h3>
<Divider />
<div className="pt2 pb2">
{isTestNetwork
? 'In order to try out the 0x Portal Dapp, request some test ether to pay for \
gas costs. It might take a bit of time for the test ether to show up.'
: 'Ether must be converted to Ether Tokens in order to be tradable via 0x. \
You can convert between Ether and Ether Tokens from the "Wrap ETH" tab.'}
</div>
<Table selectable={false} style={styles.bgColor}>
<TableHeader displaySelectAll={false} adjustForCheckbox={false}>
<TableRow>
<TableHeaderColumn>Currency</TableHeaderColumn>
<TableHeaderColumn>Balance</TableHeaderColumn>
<TableRowColumn className="sm-hide xs-hide" style={stubColumnStyle} />
{isTestNetwork && <TableHeaderColumn style={{ paddingLeft: 3 }}>Action</TableHeaderColumn>}
<TableHeaderColumn>Send</TableHeaderColumn>
</TableRow>
</TableHeader>
<TableBody displayRowCheckbox={false}>
<TableRow key="ETH">
<TableRowColumn className="py1">
<img style={{ width: ICON_DIMENSION, height: ICON_DIMENSION }} src={ETHER_ICON_PATH} />
</TableRowColumn>
<TableRowColumn>
{userEtherBalanceInEth.toFixed(configs.AMOUNT_DISPLAY_PRECSION)} ETH
{this.state.isBalanceSpinnerVisible && (
<span className="pl1">
<i className="zmdi zmdi-spinner zmdi-hc-spin" />
</span>
)}
</TableRowColumn>
<TableRowColumn className="sm-hide xs-hide" style={stubColumnStyle} />
{isTestNetwork && (
<TableRowColumn style={{ paddingLeft: 3 }}>
<LifeCycleRaisedButton
labelReady="Request"
labelLoading="Sending..."
labelComplete="Sent!"
onClickAsyncFn={this._faucetRequestAsync.bind(this, true)}
/>
</TableRowColumn>
)}
<TableRowColumn>
<SendButton
userAddress={this.props.userAddress}
networkId={this.props.networkId}
blockchain={this.props.blockchain}
dispatcher={this.props.dispatcher}
asset="ETH"
onError={this._onSendFailed.bind(this)}
lastForceTokenStateRefetch={this.props.lastForceTokenStateRefetch}
// This is not necessary for ETH.
// tslint:disable:jsx-no-lambda
refetchTokenStateAsync={() => undefined}
/>
</TableRowColumn>
</TableRow>
</TableBody>
</Table>
<div className="clearfix" style={{ paddingBottom: 1 }}>
<div className="col col-10">
<h3 className="pt2">{isTestNetwork ? 'Test tokens' : 'Tokens'}</h3>
</div>
<div className="col col-1 pt3 align-right">
<FloatingActionButton mini={true} zDepth={0} onClick={this._onAddTokenClicked.bind(this)}>
<ContentAdd />
</FloatingActionButton>
</div>
<div className="col col-1 pt3 align-right">
<FloatingActionButton mini={true} zDepth={0} onClick={this._onRemoveTokenClicked.bind(this)}>
<ContentRemove />
</FloatingActionButton>
</div>
</div>
<Divider />
<div className="pt2 pb2">
{isTestNetwork
? "Mint some test tokens you'd like to use to generate or fill an order using 0x."
: "Set trading permissions for a token you'd like to start trading."}
</div>
<Table selectable={false} bodyStyle={{ height: tokenTableHeight }} style={styles.bgColor}>
<TableHeader displaySelectAll={false} adjustForCheckbox={false}>
<TableRow>
<TableHeaderColumn colSpan={tokenColSpan}>Token</TableHeaderColumn>
<TableHeaderColumn style={{ paddingLeft: 3 }}>Balance</TableHeaderColumn>
<TableHeaderColumn>
<div className="inline-block">Allowance</div>
<HelpTooltip style={{ paddingLeft: 4 }} explanation={allowanceExplanation} />
</TableHeaderColumn>
{isTestNetwork && <TableHeaderColumn>Action</TableHeaderColumn>}
{this.props.screenWidth !== ScreenWidths.Sm && <TableHeaderColumn>Send</TableHeaderColumn>}
</TableRow>
</TableHeader>
<TableBody displayRowCheckbox={false}>{this._renderTokenTableRows()}</TableBody>
</Table>
<Dialog
title="Oh oh"
titleStyle={{ fontWeight: 100 }}
actions={errorDialogActions}
open={!_.isUndefined(this.state.errorType)}
onRequestClose={this._onErrorDialogToggle.bind(this, false)}
>
{this._renderErrorDialogBody()}
</Dialog>
<AssetPicker
userAddress={this.props.userAddress}
networkId={this.props.networkId}
blockchain={this.props.blockchain}
dispatcher={this.props.dispatcher}
isOpen={this.state.isTokenPickerOpen}
currentTokenAddress={''}
onTokenChosen={this._onAssetTokenPicked.bind(this)}
tokenByAddress={this.props.tokenByAddress}
tokenVisibility={this.state.isAddingToken ? TokenVisibility.UNTRACKED : TokenVisibility.TRACKED}
/>
</div>
);
}
private _renderTokenTableRows(): React.ReactNode {
if (!this.props.blockchainIsLoaded || this.props.blockchainErr !== BlockchainErrs.NoError) {
return '';
}
const isSmallScreen = this.props.screenWidth === ScreenWidths.Sm;
const tokenColSpan = isSmallScreen ? TOKEN_COL_SPAN_SM : TOKEN_COL_SPAN_LG;
const actionPaddingX = isSmallScreen ? 2 : 24;
const trackedTokens = this.props.trackedTokens;
const trackedTokensStartingWithEtherToken = trackedTokens.sort(
firstBy((t: Token) => t.symbol !== ETHER_TOKEN_SYMBOL)
.thenBy((t: Token) => t.symbol !== ZRX_TOKEN_SYMBOL)
.thenBy('trackedTimestamp'),
);
const tableRows = _.map(
trackedTokensStartingWithEtherToken,
this._renderTokenRow.bind(this, tokenColSpan, actionPaddingX),
);
return tableRows;
}
private _renderTokenRow(tokenColSpan: number, actionPaddingX: number, token: Token): React.ReactNode {
const tokenState = this.state.trackedTokenStateByAddress[token.address];
const tokenLink = sharedUtils.getEtherScanLinkIfExists(
token.address,
this.props.networkId,
EtherscanLinkSuffixes.Address,
);
const isMintable =
(_.includes(configs.SYMBOLS_OF_MINTABLE_KOVAN_TOKENS, token.symbol) &&
this.props.networkId === sharedConstants.NETWORK_ID_BY_NAME[Networks.Kovan]) ||
(_.includes(configs.SYMBOLS_OF_MINTABLE_ROPSTEN_TOKENS, token.symbol) &&
this.props.networkId === sharedConstants.NETWORK_ID_BY_NAME[Networks.Ropsten]);
return (
<TableRow key={token.address} style={{ height: TOKEN_TABLE_ROW_HEIGHT }}>
<TableRowColumn colSpan={tokenColSpan}>
{_.isUndefined(tokenLink) ? (
this._renderTokenName(token)
) : (
<a href={tokenLink} target="_blank" style={{ textDecoration: 'none' }}>
{this._renderTokenName(token)}
</a>
)}
</TableRowColumn>
<TableRowColumn style={{ paddingRight: 3, paddingLeft: 3 }}>
{tokenState.isLoaded ? (
<span>
{this._renderAmount(tokenState.balance, token.decimals)} {token.symbol}
{this.state.isZRXSpinnerVisible &&
token.symbol === ZRX_TOKEN_SYMBOL && (
<span className="pl1">
<i className="zmdi zmdi-spinner zmdi-hc-spin" />
</span>
)}
</span>
) : (
<i className="zmdi zmdi-spinner zmdi-hc-spin" />
)}
</TableRowColumn>
<TableRowColumn>
<div className="flex justify-center">
<AllowanceStateToggle
blockchain={this.props.blockchain}
token={token}
tokenState={tokenState}
onErrorOccurred={this._onErrorOccurred.bind(this)}
refetchTokenStateAsync={this._refetchTokenStateAsync.bind(this, token.address)}
/>
</div>
</TableRowColumn>
{utils.isTestNetwork(this.props.networkId) && (
<TableRowColumn style={{ paddingLeft: actionPaddingX, paddingRight: actionPaddingX }}>
{isMintable && (
<LifeCycleRaisedButton
labelReady="Mint"
labelLoading={<span style={{ fontSize: 12 }}>Minting...</span>}
labelComplete="Minted!"
onClickAsyncFn={this._onMintTestTokensAsync.bind(this, token)}
/>
)}
</TableRowColumn>
)}
{this.props.screenWidth !== ScreenWidths.Sm && (
<TableRowColumn
style={{
paddingLeft: actionPaddingX,
paddingRight: actionPaddingX,
}}
>
<SendButton
userAddress={this.props.userAddress}
networkId={this.props.networkId}
blockchain={this.props.blockchain}
dispatcher={this.props.dispatcher}
asset={token}
onError={this._onSendFailed.bind(this)}
lastForceTokenStateRefetch={this.props.lastForceTokenStateRefetch}
refetchTokenStateAsync={this._refetchTokenStateAsync.bind(this, token.address)}
/>
</TableRowColumn>
)}
</TableRow>
);
}
private _onAssetTokenPicked(tokenAddress: string): void {
if (_.isEmpty(tokenAddress)) {
this.setState({
isTokenPickerOpen: false,
});
return;
}
const token = this.props.tokenByAddress[tokenAddress];
const isDefaultTrackedToken = _.includes(configs.DEFAULT_TRACKED_TOKEN_SYMBOLS, token.symbol);
if (!this.state.isAddingToken && !isDefaultTrackedToken) {
if (token.isRegistered) {
// Remove the token from tracked tokens
const newToken: Token = {
...token,
trackedTimestamp: undefined,
};
this.props.dispatcher.updateTokenByAddress([newToken]);
} else {
this.props.dispatcher.removeTokenToTokenByAddress(token);
}
trackedTokenStorage.removeTrackedToken(this.props.userAddress, this.props.networkId, tokenAddress);
} else if (isDefaultTrackedToken) {
this.props.dispatcher.showFlashMessage(`Cannot remove ${token.name} because it's a default token`);
}
this.setState({
isTokenPickerOpen: false,
});
}
private _onSendFailed(): void {
this.setState({
errorType: BalanceErrs.sendFailed,
});
}
private _renderAmount(amount: BigNumber, decimals: number): React.ReactNode {
const unitAmount = Web3Wrapper.toUnitAmount(amount, decimals);
return unitAmount.toNumber().toFixed(configs.AMOUNT_DISPLAY_PRECSION);
}
private _renderTokenName(token: Token): React.ReactNode {
const tooltipId = `tooltip-${token.address}`;
return (
<div className="flex">
<TokenIcon token={token} diameter={ICON_DIMENSION} />
<div data-tip={true} data-for={tooltipId} className="mt2 ml2 sm-hide xs-hide">
{token.name}
</div>
<ReactTooltip id={tooltipId}>{token.address}</ReactTooltip>
</div>
);
}
private _renderErrorDialogBody(): React.ReactNode {
switch (this.state.errorType) {
case BalanceErrs.incorrectNetworkForFaucet:
return (
<div>
Our faucet can only send test Ether to addresses on testnets. Please make sure you are connected
to a testnet and try requesting again.
</div>
);
case BalanceErrs.faucetRequestFailed:
return (
<div>
An unexpected error occurred while trying to request test Ether from our faucet. Please refresh
the page and try again.
</div>
);
case BalanceErrs.faucetQueueIsFull:
return <div>Our test Ether faucet queue is full. Please try requesting test Ether again later.</div>;
case BalanceErrs.mintingFailed:
return <div>Minting your test tokens failed unexpectedly. Please refresh the page and try again.</div>;
case BalanceErrs.allowanceSettingFailed:
return (
<div>
An unexpected error occurred while trying to set your test token allowance. Please refresh the
page and try again.
</div>
);
case undefined:
return null; // No error to show
default:
throw errorUtils.spawnSwitchErr('errorType', this.state.errorType);
}
}
private _onErrorOccurred(errorType: BalanceErrs): void {
this.setState({
errorType,
});
}
private async _onMintTestTokensAsync(token: Token): Promise<boolean> {
try {
await this.props.blockchain.mintTestTokensAsync(token);
await this._refetchTokenStateAsync(token.address);
const amount = Web3Wrapper.toUnitAmount(constants.MINT_AMOUNT, token.decimals);
this.props.dispatcher.showFlashMessage(`Successfully minted ${amount.toString(10)} ${token.symbol}`);
return true;
} catch (err) {
const errMsg = `${err}`;
if (_.includes(errMsg, BlockchainCallErrs.UserHasNoAssociatedAddresses)) {
this.props.dispatcher.updateShouldBlockchainErrDialogBeOpen(true);
return false;
}
if (utils.didUserDenyWeb3Request(errMsg)) {
return false;
}
logUtils.log(`Unexpected error encountered: ${err}`);
logUtils.log(err.stack);
this.setState({
errorType: BalanceErrs.mintingFailed,
});
errorReporter.report(err);
return false;
}
}
private async _faucetRequestAsync(isEtherRequest: boolean): Promise<boolean> {
if (this.props.userAddress === '') {
this.props.dispatcher.updateShouldBlockchainErrDialogBeOpen(true);
return false;
}
// If on another network other then the testnet our faucet serves test ether
// from, we must show user an error message
if (!utils.isTestNetwork(this.props.blockchain.networkId)) {
this.setState({
errorType: BalanceErrs.incorrectNetworkForFaucet,
});
return false;
}
await utils.sleepAsync(ARTIFICIAL_FAUCET_REQUEST_DELAY);
const segment = isEtherRequest ? 'ether' : 'zrx';
const response = await fetchAsync(
`${constants.URL_TESTNET_FAUCET}/${segment}/${this.props.userAddress}?networkId=${this.props.networkId}`,
);
const responseBody = await response.text();
if (response.status !== constants.SUCCESS_STATUS) {
logUtils.log(`Unexpected status code: ${response.status} -> ${responseBody}`);
const errorType =
response.status === constants.UNAVAILABLE_STATUS
? BalanceErrs.faucetQueueIsFull
: BalanceErrs.faucetRequestFailed;
this.setState({
errorType,
});
errorReporter.report(new Error(`Faucet returned non-200: ${JSON.stringify(response)}`));
return false;
}
if (isEtherRequest) {
this.setState({
isBalanceSpinnerVisible: true,
});
} else {
this.setState({
isZRXSpinnerVisible: true,
});
// tslint:disable-next-line:no-floating-promises
this._startPollingZrxBalanceAsync();
}
return true;
}
private _onErrorDialogToggle(_isOpen: boolean): void {
this.setState({
errorType: undefined,
});
}
private _onAddTokenClicked(): void {
this.setState({
isTokenPickerOpen: true,
isAddingToken: true,
});
}
private _onRemoveTokenClicked(): void {
this.setState({
isTokenPickerOpen: true,
isAddingToken: false,
});
}
private async _startPollingZrxBalanceAsync(): Promise<void> {
const tokens = _.values(this.props.tokenByAddress);
const zrxToken = _.find(tokens, t => t.symbol === ZRX_TOKEN_SYMBOL);
// tslint:disable-next-line:no-floating-promises
const balance = await this.props.blockchain.pollTokenBalanceAsync(zrxToken);
const trackedTokenStateByAddress = this.state.trackedTokenStateByAddress;
trackedTokenStateByAddress[zrxToken.address] = {
...trackedTokenStateByAddress[zrxToken.address],
balance,
};
this.setState({
isZRXSpinnerVisible: false,
});
}
private async _fetchBalancesAndAllowancesAsync(tokenAddresses: string[]): Promise<void> {
const trackedTokenStateByAddress = this.state.trackedTokenStateByAddress;
const userAddressIfExists = _.isEmpty(this.props.userAddress) ? undefined : this.props.userAddress;
for (const tokenAddress of tokenAddresses) {
const [balance, allowance] = await this.props.blockchain.getTokenBalanceAndAllowanceAsync(
userAddressIfExists,
tokenAddress,
);
trackedTokenStateByAddress[tokenAddress] = {
balance,
allowance,
isLoaded: true,
};
}
if (!this._isUnmounted) {
this.setState({
trackedTokenStateByAddress,
});
}
}
private _getInitialTrackedTokenStateByAddress(trackedTokens: Token[]): TokenStateByAddress {
const trackedTokenStateByAddress: TokenStateByAddress = {};
_.each(trackedTokens, token => {
trackedTokenStateByAddress[token.address] = {
balance: new BigNumber(0),
allowance: new BigNumber(0),
isLoaded: false,
};
});
return trackedTokenStateByAddress;
}
private async _refetchTokenStateAsync(tokenAddress: string): Promise<void> {
const userAddressIfExists = _.isEmpty(this.props.userAddress) ? undefined : this.props.userAddress;
const [balance, allowance] = await this.props.blockchain.getTokenBalanceAndAllowanceAsync(
userAddressIfExists,
tokenAddress,
);
this.setState({
trackedTokenStateByAddress: {
...this.state.trackedTokenStateByAddress,
[tokenAddress]: {
balance,
allowance,
isLoaded: true,
},
},
});
}
} // tslint:disable:max-file-line-count