import { ContractWrappersError } from '@0xproject/contract-wrappers'; import { assetDataUtils, OrderError } from '@0xproject/order-utils'; import { constants as sharedConstants, Networks } from '@0xproject/react-shared'; import { ExchangeContractErrs } from '@0xproject/types'; import { BigNumber } from '@0xproject/utils'; import { Web3Wrapper } from '@0xproject/web3-wrapper'; import * as bowser from 'bowser'; import deepEqual = require('deep-equal'); import * as _ from 'lodash'; import * as moment from 'moment'; import * as numeral from 'numeral'; import { Provider } from 'ethereum-types'; import { AccountState, BlockchainCallErrs, BrowserType, Environments, OperatingSystemType, PortalOrder, Providers, ProviderType, ScreenWidths, Side, SideToAssetToken, Token, TokenByAddress, TokenState, } from 'ts/types'; import { configs } from 'ts/utils/configs'; import { constants } from 'ts/utils/constants'; import * as u2f from 'ts/vendor/u2f_api'; export const utils = { assert(condition: boolean, message: string): void { if (!condition) { throw new Error(message); } }, isNumeric(n: string): boolean { return !isNaN(parseFloat(n)) && isFinite(Number(n)); }, // This default unix timestamp is used for orders where the user does not specify an expiry date. // It is a fixed constant so that both the redux store's INITIAL_STATE and components can check for // whether a user has set an expiry date or not. It is set unrealistically high so as not to collide // with actual values a user would select. initialOrderExpiryUnixTimestampSec(): BigNumber { const m = moment('2050-01-01'); return new BigNumber(m.unix()); }, convertToUnixTimestampSeconds(date: moment.Moment, time?: moment.Moment): BigNumber { const finalMoment = date; if (!_.isUndefined(time)) { finalMoment.hours(time.hours()); finalMoment.minutes(time.minutes()); } return new BigNumber(finalMoment.unix()); }, convertToMomentFromUnixTimestamp(unixTimestampSec: BigNumber): moment.Moment { return moment.unix(unixTimestampSec.toNumber()); }, convertToReadableDateTimeFromUnixTimestamp(unixTimestampSec: BigNumber): string { const m = utils.convertToMomentFromUnixTimestamp(unixTimestampSec); const formattedDate: string = m.format('h:MMa MMMM D YYYY'); return formattedDate; }, generateOrder( exchangeAddress: string, sideToAssetToken: SideToAssetToken, expirationTimeSeconds: BigNumber, orderTakerAddress: string, orderMakerAddress: string, makerFee: BigNumber, takerFee: BigNumber, feeRecipientAddress: string, signature: string, tokenByAddress: TokenByAddress, orderSalt: BigNumber, ): PortalOrder { const makerToken = tokenByAddress[sideToAssetToken[Side.Deposit].address]; const takerToken = tokenByAddress[sideToAssetToken[Side.Receive].address]; const order = { signedOrder: { senderAddress: constants.NULL_ADDRESS, makerAddress: orderMakerAddress, takerAddress: orderTakerAddress, makerFee, takerFee, makerAssetAmount: sideToAssetToken[Side.Deposit].amount, takerAssetAmount: sideToAssetToken[Side.Receive].amount, makerAssetData: assetDataUtils.encodeERC20AssetData(makerToken.address), takerAssetData: assetDataUtils.encodeERC20AssetData(takerToken.address), expirationTimeSeconds, feeRecipientAddress, salt: orderSalt, signature, exchangeAddress, }, metadata: { makerToken: { name: makerToken.name, symbol: makerToken.symbol, decimals: makerToken.decimals, }, takerToken: { name: takerToken.name, symbol: takerToken.symbol, decimals: takerToken.decimals, }, }, }; return order; }, async sleepAsync(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)); }, deepEqual(actual: any, expected: any, opts?: { strict: boolean }): boolean { return deepEqual(actual, expected, opts); }, getColSize(items: number): number { const bassCssGridSize = 12; // Source: http://basscss.com/#basscss-grid const colSize = bassCssGridSize / items; if (!_.isInteger(colSize)) { throw new Error(`Number of cols must be divisible by ${bassCssGridSize}`); } return colSize; }, getScreenWidth(): ScreenWidths { const documentEl = document.documentElement; const body = document.getElementsByTagName('body')[0]; const widthInPx = window.innerWidth || documentEl.clientWidth || body.clientWidth; const bodyStyles: any = window.getComputedStyle(document.querySelector('body')); const widthInEm = widthInPx / parseFloat(bodyStyles['font-size']); // This logic mirrors the CSS media queries in BassCSS for the `lg-`, `md-` and `sm-` CSS // class prefixes. Do not edit these. if (widthInEm > ScreenWidths.Lg) { return ScreenWidths.Lg; } else if (widthInEm > ScreenWidths.Md) { return ScreenWidths.Md; } else { return ScreenWidths.Sm; } }, async isU2FSupportedAsync(): Promise { const w = window as any; return new Promise((resolve: (isSupported: boolean) => void) => { if (w.u2f && !w.u2f.getApiVersion) { // u2f object was found (Firefox with extension) resolve(true); } else { // u2f object was not found. Using Google polyfill // HACK: u2f.getApiVersion will simply not return a version if the // U2F call fails for any reason. Because of this, we set a hard 3sec // timeout to the request on our end. const getApiVersionTimeoutMs = 3000; const intervalId = setTimeout(() => { resolve(false); }, getApiVersionTimeoutMs); u2f.getApiVersion((_version: number) => { clearTimeout(intervalId); resolve(true); }); } }); }, // This checks the error message returned from an injected Web3 instance on the page // after a user was prompted to sign a message or send a transaction and decided to // reject the request. didUserDenyWeb3Request(errMsg: string): boolean { const metamaskDenialErrMsg = 'User denied'; const paritySignerDenialErrMsg = 'Request has been rejected'; const ledgerDenialErrMsg = 'Invalid status 6985'; const isUserDeniedErrMsg = _.includes(errMsg, metamaskDenialErrMsg) || _.includes(errMsg, paritySignerDenialErrMsg) || _.includes(errMsg, ledgerDenialErrMsg); return isUserDeniedErrMsg; }, getAddressBeginAndEnd(address: string): string { const truncatedAddress = `${address.substring(0, 6)}...${address.substr(-4)}`; // 0x3d5a...b287 return truncatedAddress; }, getReadableAccountState(accountState: AccountState, userAddress: string): string { switch (accountState) { case AccountState.Loading: return 'Loading...'; case AccountState.Ready: return utils.getAddressBeginAndEnd(userAddress); case AccountState.Locked: return 'Please Unlock'; case AccountState.Disconnected: return 'Connect a Wallet'; default: return ''; } }, getAccountState( isBlockchainReady: boolean, providerType: ProviderType, injectedProviderName: string, userAddress?: string, ): AccountState { const isAddressAvailable = !_.isUndefined(userAddress) && !_.isEmpty(userAddress); const isExternallyInjectedProvider = utils.isExternallyInjected(providerType, injectedProviderName); if (!isBlockchainReady) { return AccountState.Loading; } else if (isAddressAvailable) { return AccountState.Ready; // tslint:disable-next-line: prefer-conditional-expression } else if (isExternallyInjectedProvider) { return AccountState.Locked; } else { return AccountState.Disconnected; } }, hasUniqueNameAndSymbol(tokens: Token[], token: Token): boolean { if (token.isRegistered) { return true; // Since it's registered, it is the canonical token } const registeredTokens = _.filter(tokens, t => t.isRegistered); const tokenWithSameNameIfExists = _.find(registeredTokens, { name: token.name, }); const isUniqueName = _.isUndefined(tokenWithSameNameIfExists); const tokenWithSameSymbolIfExists = _.find(registeredTokens, { name: token.symbol, }); const isUniqueSymbol = _.isUndefined(tokenWithSameSymbolIfExists); return isUniqueName && isUniqueSymbol; }, zeroExErrToHumanReadableErrMsg(error: ContractWrappersError | ExchangeContractErrs, takerAddress: string): string { const ContractWrappersErrorToHumanReadableError: { [error: string]: string } = { [ContractWrappersError.ExchangeContractDoesNotExist]: 'Exchange contract does not exist', [ContractWrappersError.EtherTokenContractDoesNotExist]: 'EtherToken contract does not exist', [ContractWrappersError.ERC20ProxyContractDoesNotExist]: 'ERC20 proxy contract des not exist', [ContractWrappersError.ERC721ProxyContractDoesNotExist]: 'ERC721 proxy contract des not exist', [ContractWrappersError.ERC20TokenContractDoesNotExist]: 'ERC20 token contract does not exist', [ContractWrappersError.ERC721TokenContractDoesNotExist]: 'ERC721 token contract does not exist', [ContractWrappersError.ZRXContractDoesNotExist]: 'ZRX contract does not exist', [BlockchainCallErrs.UserHasNoAssociatedAddresses]: 'User has no addresses available', [OrderError.InvalidSignature]: 'Order signature is not valid', [ContractWrappersError.ContractNotDeployedOnNetwork]: 'Contract is not deployed on the detected network', [ContractWrappersError.InvalidJump]: 'Invalid jump occured while executing the transaction', [ContractWrappersError.OutOfGas]: 'Transaction ran out of gas', }; const exchangeContractErrorToHumanReadableError: { [error: string]: string; } = { [ExchangeContractErrs.OrderFillExpired]: 'This order has expired', [ExchangeContractErrs.OrderCancelExpired]: 'This order has expired', [ExchangeContractErrs.OrderCancelled]: 'This order has been cancelled', [ExchangeContractErrs.OrderFillAmountZero]: "Order fill amount can't be 0", [ExchangeContractErrs.OrderRemainingFillAmountZero]: 'This order has already been completely filled', [ExchangeContractErrs.OrderFillRoundingError]: 'Rounding error will occur when filling this order. Please try filling a different amount.', [ExchangeContractErrs.InsufficientTakerBalance]: 'Taker no longer has a sufficient balance to complete this order', [ExchangeContractErrs.InsufficientTakerAllowance]: 'Taker no longer has a sufficient allowance to complete this order', [ExchangeContractErrs.InsufficientMakerBalance]: 'Maker no longer has a sufficient balance to complete this order', [ExchangeContractErrs.InsufficientMakerAllowance]: 'Maker no longer has a sufficient allowance to complete this order', [ExchangeContractErrs.InsufficientTakerFeeBalance]: 'Taker no longer has a sufficient balance to pay fees', [ExchangeContractErrs.InsufficientTakerFeeAllowance]: 'Taker no longer has a sufficient allowance to pay fees', [ExchangeContractErrs.InsufficientMakerFeeBalance]: 'Maker no longer has a sufficient balance to pay fees', [ExchangeContractErrs.InsufficientMakerFeeAllowance]: 'Maker no longer has a sufficient allowance to pay fees', [ExchangeContractErrs.TransactionSenderIsNotFillOrderTaker]: `This order can only be filled by ${takerAddress}`, [ExchangeContractErrs.InsufficientRemainingFillAmount]: 'Insufficient remaining fill amount', }; const humanReadableErrorMsg = exchangeContractErrorToHumanReadableError[error] || ContractWrappersErrorToHumanReadableError[error]; return humanReadableErrorMsg; }, isParityNode(nodeVersion: string): boolean { return _.includes(nodeVersion, 'Parity'); }, isTestRpc(nodeVersion: string): boolean { return _.includes(nodeVersion, 'TestRPC'); }, isTestNetwork(networkId: number): boolean { const isTestNetwork = _.includes( [ sharedConstants.NETWORK_ID_BY_NAME[Networks.Kovan], sharedConstants.NETWORK_ID_BY_NAME[Networks.Rinkeby], sharedConstants.NETWORK_ID_BY_NAME[Networks.Ropsten], ], networkId, ); return isTestNetwork; }, getCurrentBaseUrl(): string { const port = window.location.port; const hasPort = !_.isUndefined(port); const baseUrl = `https://${window.location.hostname}${hasPort ? `:${port}` : ''}`; return baseUrl; }, onPageLoadPromise: new Promise((resolve, _reject) => { if (document.readyState === 'complete') { resolve(); return; } window.onload = () => resolve(); }), getProviderType(provider: Provider): Providers | string { const constructorName = provider.constructor.name; let parsedProviderName = constructorName; // https://ethereum.stackexchange.com/questions/24266/elegant-way-to-detect-current-provider-int-web3-js switch (constructorName) { case 'EthereumProvider': parsedProviderName = Providers.Mist; break; default: parsedProviderName = constructorName; break; } if ((provider as any).isParity) { parsedProviderName = Providers.Parity; } else if ((provider as any).isMetaMask) { parsedProviderName = Providers.Metamask; } else if (!_.isUndefined(_.get(window, 'SOFA'))) { parsedProviderName = Providers.Toshi; } else if (!_.isUndefined(_.get(window, '__CIPHER__'))) { parsedProviderName = Providers.Cipher; } return parsedProviderName; }, getBackendBaseUrl(): string { return utils.isDogfood() ? configs.BACKEND_BASE_STAGING_URL : configs.BACKEND_BASE_PROD_URL; }, isDevelopment(): boolean { return _.includes(configs.DOMAINS_DEVELOPMENT, window.location.host); }, isStaging(): boolean { return _.includes(window.location.href, configs.DOMAIN_STAGING); }, isExternallyInjected(providerType: ProviderType, injectedProviderName: string): boolean { return providerType === ProviderType.Injected && injectedProviderName !== constants.PROVIDER_NAME_PUBLIC; }, isDogfood(): boolean { return _.includes(window.location.href, configs.DOMAIN_DOGFOOD); }, isProduction(): boolean { return _.includes(window.location.href, configs.DOMAIN_PRODUCTION); }, getEnvironment(): Environments { if (utils.isDogfood()) { return Environments.DOGFOOD; } if (utils.isDevelopment()) { return Environments.DEVELOPMENT; } if (utils.isStaging()) { return Environments.STAGING; } if (utils.isProduction()) { return Environments.PRODUCTION; } return Environments.UNKNOWN; }, getEthToken(tokenByAddress: TokenByAddress): Token { return utils.getTokenBySymbol(constants.ETHER_TOKEN_SYMBOL, tokenByAddress); }, getZrxToken(tokenByAddress: TokenByAddress): Token { return utils.getTokenBySymbol(constants.ZRX_TOKEN_SYMBOL, tokenByAddress); }, getTokenBySymbol(symbol: string, tokenByAddress: TokenByAddress): Token { const tokens = _.values(tokenByAddress); const token = _.find(tokens, { symbol }); return token; }, getTrackedTokens(tokenByAddress: TokenByAddress): Token[] { const allTokens = _.values(tokenByAddress); const trackedTokens = _.filter(allTokens, t => utils.isTokenTracked(t)); return trackedTokens; }, getFormattedAmountFromToken(token: Token, tokenState: TokenState): string { return utils.getFormattedAmount(tokenState.balance, token.decimals); }, format(value: BigNumber, format: string): string { const formattedAmount = numeral(value).format(format); if (_.isNaN(formattedAmount)) { // https://github.com/adamwdraper/Numeral-js/issues/596 return numeral(new BigNumber(0)).format(format); } return formattedAmount; }, getFormattedAmount(amount: BigNumber, decimals: number): string { const unitAmount = Web3Wrapper.toUnitAmount(amount, decimals); // if the unit amount is less than 1, show the natural number of decimal places with a max of 4 // if the unit amount is greater than or equal to 1, show only 2 decimal places const lessThanOnePrecision = Math.min(constants.TOKEN_AMOUNT_DISPLAY_PRECISION, unitAmount.decimalPlaces()); const greaterThanOnePrecision = 2; const precision = unitAmount.lt(1) ? lessThanOnePrecision : greaterThanOnePrecision; const format = `0,0.${_.repeat('0', precision)}`; return utils.format(unitAmount, format); }, getUsdValueFormattedAmount(amount: BigNumber, decimals: number, price: BigNumber): string { const unitAmount = Web3Wrapper.toUnitAmount(amount, decimals); const value = unitAmount.mul(price); return utils.format(value, constants.NUMERAL_USD_FORMAT); }, openUrl(url: string): void { window.open(url, '_blank'); }, isMobileWidth(screenWidth: ScreenWidths): boolean { return screenWidth === ScreenWidths.Sm; }, isMobileOperatingSystem(): boolean { return bowser.mobile; }, getBrowserType(): BrowserType { if (bowser.chrome) { return BrowserType.Chrome; } else if (bowser.firefox) { return BrowserType.Firefox; } else if (bowser.opera) { return BrowserType.Opera; } else { return BrowserType.Other; } }, getOperatingSystem(): OperatingSystemType { if (bowser.android) { return OperatingSystemType.Android; } else if (bowser.ios) { return OperatingSystemType.iOS; } else if (bowser.mac) { return OperatingSystemType.Mac; } else if (bowser.windows) { return OperatingSystemType.Windows; } else if (bowser.windowsphone) { return OperatingSystemType.WindowsPhone; } else if (bowser.linux) { return OperatingSystemType.Linux; } else { return OperatingSystemType.Other; } }, isTokenTracked(token: Token): boolean { return !_.isUndefined(token.trackedTimestamp); }, // Returns a [downloadLink, isOnMobile] tuple. getBestWalletDownloadLinkAndIsMobile(): [string, boolean] { const browserType = utils.getBrowserType(); const isOnMobile = utils.isMobileOperatingSystem(); const operatingSystem = utils.getOperatingSystem(); let downloadLink; if (isOnMobile) { switch (operatingSystem) { case OperatingSystemType.Android: downloadLink = constants.URL_TOSHI_ANDROID_APP_STORE; break; case OperatingSystemType.iOS: downloadLink = constants.URL_TOSHI_IOS_APP_STORE; break; default: // Toshi is only supported on these mobile OSes - just default to iOS downloadLink = constants.URL_TOSHI_IOS_APP_STORE; } } else { switch (browserType) { case BrowserType.Chrome: downloadLink = constants.URL_METAMASK_CHROME_STORE; break; case BrowserType.Firefox: downloadLink = constants.URL_METAMASK_FIREFOX_STORE; break; case BrowserType.Opera: downloadLink = constants.URL_METAMASK_OPERA_STORE; break; default: downloadLink = constants.URL_METAMASK_HOMEPAGE; } } return [downloadLink, isOnMobile]; }, getTokenIconUrl(symbol: string): string { const result = `/images/token_icons/${symbol}.png`; return result; }, };