import { constants as sharedConstants, EtherscanLinkSuffixes, Networks, Styles, utils as sharedUtils, } from '@0xproject/react-shared'; import { BigNumber, errorUtils, fetchAsync, logUtils } from '@0xproject/utils'; import { Web3Wrapper } from '@0xproject/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 = require('react-tooltip'); import firstBy = require('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_RINKEBY_ROPSTEN_TOKENS, token.symbol) && _.includes( [ sharedConstants.NETWORK_ID_BY_NAME[Networks.Rinkeby], sharedConstants.NETWORK_ID_BY_NAME[Networks.Ropsten], ], this.props.networkId, )); 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)} /> )} {token.symbol === ZRX_TOKEN_SYMBOL && ( <LifeCycleRaisedButton labelReady="Request" labelLoading="Sending..." labelComplete="Sent!" onClickAsyncFn={this._faucetRequestAsync.bind(this, false)} /> )} </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