diff options
17 files changed, 414 insertions, 33 deletions
diff --git a/packages/instant/src/components/buy_button.tsx b/packages/instant/src/components/buy_button.tsx index 93bd8e635..c00b1678d 100644 --- a/packages/instant/src/components/buy_button.tsx +++ b/packages/instant/src/components/buy_button.tsx @@ -19,7 +19,7 @@ export interface BuyButtonProps { onValidationPending: (buyQuote: BuyQuote) => void; onValidationFail: (buyQuote: BuyQuote, errorMessage: AssetBuyerError | ZeroExInstantError) => void; onSignatureDenied: (buyQuote: BuyQuote) => void; - onBuyProcessing: (buyQuote: BuyQuote, txHash: string) => void; + onBuyProcessing: (buyQuote: BuyQuote, txHash: string, startTimeUnix: number, expectedEndTimeUnix: number) => void; onBuySuccess: (buyQuote: BuyQuote, txHash: string) => void; onBuyFailure: (buyQuote: BuyQuote, txHash: string) => void; } @@ -57,9 +57,9 @@ export class BuyButton extends React.Component<BuyButtonProps> { } let txHash: string | undefined; - const gasPrice = await gasPriceEstimator.getFastAmountInWeiAsync(); + const gasInfo = await gasPriceEstimator.getGasInfoAsync(); try { - txHash = await assetBuyer.executeBuyQuoteAsync(buyQuote, { takerAddress, gasPrice }); + txHash = await assetBuyer.executeBuyQuoteAsync(buyQuote, { takerAddress, gasPrice: gasInfo.gasPriceInWei }); } catch (e) { if (e instanceof Error) { if (e.message === AssetBuyerError.SignatureRequestDenied) { @@ -73,7 +73,9 @@ export class BuyButton extends React.Component<BuyButtonProps> { throw e; } - this.props.onBuyProcessing(buyQuote, txHash); + const startTimeUnix = new Date().getTime(); + const expectedEndTimeUnix = startTimeUnix + gasInfo.estimatedTimeMs; + this.props.onBuyProcessing(buyQuote, txHash, startTimeUnix, expectedEndTimeUnix); try { await web3Wrapper.awaitTransactionSuccessAsync(txHash); } catch (e) { @@ -83,6 +85,7 @@ export class BuyButton extends React.Component<BuyButtonProps> { } throw e; } + this.props.onBuySuccess(buyQuote, txHash); }; } diff --git a/packages/instant/src/components/buy_order_progress.tsx b/packages/instant/src/components/buy_order_progress.tsx new file mode 100644 index 000000000..e259e5606 --- /dev/null +++ b/packages/instant/src/components/buy_order_progress.tsx @@ -0,0 +1,35 @@ +import * as React from 'react'; + +import { TimedProgressBar } from '../components/timed_progress_bar'; + +import { TimeCounter } from '../components/time_counter'; +import { Container } from '../components/ui'; +import { OrderProcessState, OrderState } from '../types'; + +export interface BuyOrderProgressProps { + buyOrderState: OrderState; +} + +export const BuyOrderProgress: React.StatelessComponent<BuyOrderProgressProps> = props => { + const { buyOrderState } = props; + + if ( + buyOrderState.processState === OrderProcessState.PROCESSING || + buyOrderState.processState === OrderProcessState.SUCCESS || + buyOrderState.processState === OrderProcessState.FAILURE + ) { + const progress = buyOrderState.progress; + const hasEnded = buyOrderState.processState !== OrderProcessState.PROCESSING; + const expectedTimeMs = progress.expectedEndTimeUnix - progress.startTimeUnix; + return ( + <Container padding="20px 20px 0px 20px" width="100%"> + <Container marginBottom="5px"> + <TimeCounter estimatedTimeMs={expectedTimeMs} hasEnded={hasEnded} key={progress.startTimeUnix} /> + </Container> + <TimedProgressBar expectedTimeMs={expectedTimeMs} hasEnded={hasEnded} key={progress.startTimeUnix} /> + </Container> + ); + } + + return null; +}; diff --git a/packages/instant/src/components/buy_order_state_buttons.tsx b/packages/instant/src/components/buy_order_state_buttons.tsx index 51e06a284..1d02f8cd9 100644 --- a/packages/instant/src/components/buy_order_state_buttons.tsx +++ b/packages/instant/src/components/buy_order_state_buttons.tsx @@ -17,13 +17,12 @@ export interface BuyOrderStateButtonProps { onValidationPending: (buyQuote: BuyQuote) => void; onValidationFail: (buyQuote: BuyQuote, errorMessage: AssetBuyerError | ZeroExInstantError) => void; onSignatureDenied: (buyQuote: BuyQuote) => void; - onBuyProcessing: (buyQuote: BuyQuote, txHash: string) => void; + onBuyProcessing: (buyQuote: BuyQuote, txHash: string, startTimeUnix: number, expectedEndTimeUnix: number) => void; onBuySuccess: (buyQuote: BuyQuote, txHash: string) => void; onBuyFailure: (buyQuote: BuyQuote, txHash: string) => void; onRetry: () => void; } -// TODO: rename to buttons export const BuyOrderStateButtons: React.StatelessComponent<BuyOrderStateButtonProps> = props => { if (props.buyOrderProcessingState === OrderProcessState.FAILURE) { return ( diff --git a/packages/instant/src/components/time_counter.tsx b/packages/instant/src/components/time_counter.tsx new file mode 100644 index 000000000..f9b68163c --- /dev/null +++ b/packages/instant/src/components/time_counter.tsx @@ -0,0 +1,78 @@ +import * as React from 'react'; + +import { ONE_SECOND_MS } from '../constants'; +import { ColorOption } from '../style/theme'; +import { timeUtil } from '../util/time'; + +import { Container } from './ui/container'; +import { Flex } from './ui/flex'; +import { Text } from './ui/text'; + +export interface TimeCounterProps { + estimatedTimeMs: number; + hasEnded: boolean; +} +interface TimeCounterState { + elapsedSeconds: number; +} + +export class TimeCounter extends React.Component<TimeCounterProps, TimeCounterState> { + public state = { + elapsedSeconds: 0, + }; + private _timerId?: number; + + public componentDidMount(): void { + this._setupTimerBasedOnProps(); + } + + public componentWillUnmount(): void { + this._clearTimer(); + } + + public componentDidUpdate(prevProps: TimeCounterProps): void { + if (prevProps.hasEnded !== this.props.hasEnded) { + this._setupTimerBasedOnProps(); + } + } + + public render(): React.ReactNode { + const estimatedTimeSeconds = this.props.estimatedTimeMs / ONE_SECOND_MS; + return ( + <Flex justify="space-between"> + <Container> + <Container marginRight="5px" display="inline"> + <Text fontWeight={600} fontColor={ColorOption.grey}> + Est. Time + </Text> + </Container> + <Text fontColor={ColorOption.grey}> + ({timeUtil.secondsToHumanDescription(estimatedTimeSeconds)}) + </Text> + </Container> + <Text fontColor={ColorOption.grey}> + Time: {timeUtil.secondsToStopwatchTime(this.state.elapsedSeconds)} + </Text> + </Flex> + ); + } + + private _setupTimerBasedOnProps(): void { + this.props.hasEnded ? this._clearTimer() : this._newTimer(); + } + + private _newTimer(): void { + this._clearTimer(); + this._timerId = window.setInterval(() => { + this.setState({ + elapsedSeconds: this.state.elapsedSeconds + 1, + }); + }, ONE_SECOND_MS); + } + + private _clearTimer(): void { + if (this._timerId) { + window.clearInterval(this._timerId); + } + } +} diff --git a/packages/instant/src/components/timed_progress_bar.tsx b/packages/instant/src/components/timed_progress_bar.tsx new file mode 100644 index 000000000..f2a6f5745 --- /dev/null +++ b/packages/instant/src/components/timed_progress_bar.tsx @@ -0,0 +1,78 @@ +import * as _ from 'lodash'; +import * as React from 'react'; + +import { PROGRESS_FINISH_ANIMATION_TIME_MS, PROGRESS_STALL_AT_WIDTH } from '../constants'; +import { ColorOption, keyframes, styled } from '../style/theme'; + +import { Container } from './ui/container'; + +export interface TimedProgressBarProps { + expectedTimeMs: number; + hasEnded: boolean; +} + +/** + * Timed Progress Bar + * Goes from 0% -> PROGRESS_STALL_AT_WIDTH over time of expectedTimeMs + * When hasEnded set to true, goes to 100% through animation of PROGRESS_FINISH_ANIMATION_TIME_MS length of time + */ +export class TimedProgressBar extends React.Component<TimedProgressBarProps, {}> { + private readonly _barRef = React.createRef<HTMLDivElement>(); + + public render(): React.ReactNode { + const timedProgressProps = this._calculateTimedProgressProps(); + return ( + <Container width="100%" backgroundColor={ColorOption.lightGrey} borderRadius="6px"> + <TimedProgress {...timedProgressProps} ref={this._barRef as any} /> + </Container> + ); + } + + private _calculateTimedProgressProps(): TimedProgressProps { + if (this.props.hasEnded) { + if (!this._barRef.current) { + throw new Error('ended but no reference'); + } + const fromWidth = `${this._barRef.current.offsetWidth}px`; + return { + timeMs: PROGRESS_FINISH_ANIMATION_TIME_MS, + fromWidth, + toWidth: '100%', + }; + } + + return { + timeMs: this.props.expectedTimeMs, + fromWidth: '0px', + toWidth: PROGRESS_STALL_AT_WIDTH, + }; + } +} + +const expandingWidthKeyframes = (fromWidth: string, toWidth: string) => { + return keyframes` + from { + width: ${fromWidth}; + } + to { + width: ${toWidth}; + } + `; +}; + +interface TimedProgressProps { + timeMs: number; + fromWidth: string; + toWidth: string; +} + +export const TimedProgress = + styled.div < + TimedProgressProps > + ` + background-color: ${props => props.theme[ColorOption.primaryColor]}; + border-radius: 6px; + height: 6px; + animation: ${props => expandingWidthKeyframes(props.fromWidth, props.toWidth)} + ${props => props.timeMs}ms linear 1 forwards; + `; diff --git a/packages/instant/src/components/zero_ex_instant.tsx b/packages/instant/src/components/zero_ex_instant.tsx index d54dfc153..365c1610f 100644 --- a/packages/instant/src/components/zero_ex_instant.tsx +++ b/packages/instant/src/components/zero_ex_instant.tsx @@ -83,7 +83,7 @@ export class ZeroExInstant extends React.Component<ZeroExInstantProps> { // warm up the gas price estimator cache just in case we can't // grab the gas price estimate when submitting the transaction // tslint:disable-next-line:no-floating-promises - gasPriceEstimator.getFastAmountInWeiAsync(); + gasPriceEstimator.getGasInfoAsync(); // tslint:disable-next-line:no-floating-promises this._flashErrorIfWrongNetwork(); diff --git a/packages/instant/src/components/zero_ex_instant_container.tsx b/packages/instant/src/components/zero_ex_instant_container.tsx index 009ad9b2a..f8e3935fb 100644 --- a/packages/instant/src/components/zero_ex_instant_container.tsx +++ b/packages/instant/src/components/zero_ex_instant_container.tsx @@ -4,13 +4,15 @@ import { LatestBuyQuoteOrderDetails } from '../containers/latest_buy_quote_order import { LatestError } from '../containers/latest_error'; import { SelectedAssetBuyOrderStateButtons } from '../containers/selected_asset_buy_order_state_buttons'; import { SelectedAssetInstantHeading } from '../containers/selected_asset_instant_heading'; + +import { SelectedAssetBuyOrderProgress } from '../containers/selected_asset_buy_order_progress'; + import { ColorOption } from '../style/theme'; import { zIndex } from '../style/z_index'; import { SlideAnimationState } from './animations/slide_animation'; import { SlidingPanel } from './sliding_panel'; import { Container, Flex } from './ui'; - export interface ZeroExInstantContainerProps {} export interface ZeroExInstantContainerState { tokenSelectionPanelAnimationState: SlideAnimationState; @@ -36,6 +38,7 @@ export class ZeroExInstantContainer extends React.Component<ZeroExInstantContain > <Flex direction="column" justify="flex-start"> <SelectedAssetInstantHeading onSelectAssetClick={this._handleSymbolClick} /> + <SelectedAssetBuyOrderProgress /> <LatestBuyQuoteOrderDetails /> <Container padding="20px" width="100%"> <SelectedAssetBuyOrderStateButtons /> diff --git a/packages/instant/src/constants.ts b/packages/instant/src/constants.ts index 424f35ecb..e811d3d13 100644 --- a/packages/instant/src/constants.ts +++ b/packages/instant/src/constants.ts @@ -4,6 +4,11 @@ export const ETH_DECIMALS = 18; export const DEFAULT_ZERO_EX_CONTAINER_SELECTOR = '#zeroExInstantContainer'; export const WEB_3_WRAPPER_TRANSACTION_FAILED_ERROR_MSG_PREFIX = 'Transaction failed'; export const GWEI_IN_WEI = new BigNumber(1000000000); +export const ONE_SECOND_MS = 1000; +export const ONE_MINUTE_MS = ONE_SECOND_MS * 60; export const DEFAULT_GAS_PRICE = GWEI_IN_WEI.mul(6); +export const DEFAULT_ESTIMATED_TRANSACTION_TIME_MS = ONE_MINUTE_MS * 2; export const ETH_GAS_STATION_API_BASE_URL = 'https://ethgasstation.info'; export const COINBASE_API_BASE_URL = 'https://api.coinbase.com/v2'; +export const PROGRESS_STALL_AT_WIDTH = '95%'; +export const PROGRESS_FINISH_ANIMATION_TIME_MS = 200; diff --git a/packages/instant/src/containers/selected_asset_buy_order_progress.ts b/packages/instant/src/containers/selected_asset_buy_order_progress.ts new file mode 100644 index 000000000..7c8c24676 --- /dev/null +++ b/packages/instant/src/containers/selected_asset_buy_order_progress.ts @@ -0,0 +1,13 @@ +import { connect } from 'react-redux'; + +import { BuyOrderProgress } from '../components/buy_order_progress'; +import { State } from '../redux/reducer'; +import { OrderState } from '../types'; + +interface ConnectedState { + buyOrderState: OrderState; +} +const mapStateToProps = (state: State, _ownProps: {}): ConnectedState => ({ + buyOrderState: state.buyOrderState, +}); +export const SelectedAssetBuyOrderProgress = connect(mapStateToProps)(BuyOrderProgress); diff --git a/packages/instant/src/containers/selected_asset_buy_order_state_buttons.ts b/packages/instant/src/containers/selected_asset_buy_order_state_buttons.ts index 500d6b88a..7c36fa4d0 100644 --- a/packages/instant/src/containers/selected_asset_buy_order_state_buttons.ts +++ b/packages/instant/src/containers/selected_asset_buy_order_state_buttons.ts @@ -7,7 +7,7 @@ import { Dispatch } from 'redux'; import { BuyOrderStateButtons } from '../components/buy_order_state_buttons'; import { Action, actions } from '../redux/actions'; import { State } from '../redux/reducer'; -import { OrderProcessState, OrderState, ZeroExInstantError } from '../types'; +import { OrderProcessState, ZeroExInstantError } from '../types'; import { errorFlasher } from '../util/error_flasher'; import { etherscanUtil } from '../util/etherscan'; @@ -21,7 +21,7 @@ interface ConnectedState { interface ConnectedDispatch { onValidationPending: (buyQuote: BuyQuote) => void; onSignatureDenied: (buyQuote: BuyQuote) => void; - onBuyProcessing: (buyQuote: BuyQuote, txHash: string) => void; + onBuyProcessing: (buyQuote: BuyQuote, txHash: string, startTimeUnix: number, expectedEndTimeUnix: number) => void; onBuySuccess: (buyQuote: BuyQuote, txHash: string) => void; onBuyFailure: (buyQuote: BuyQuote, txHash: string) => void; onRetry: () => void; @@ -56,24 +56,20 @@ const mapDispatchToProps = ( ownProps: SelectedAssetBuyOrderStateButtons, ): ConnectedDispatch => ({ onValidationPending: (buyQuote: BuyQuote) => { - const newOrderState: OrderState = { processState: OrderProcessState.VALIDATING }; - dispatch(actions.updateBuyOrderState(newOrderState)); + dispatch(actions.setBuyOrderStateValidating()); }, - onBuyProcessing: (buyQuote: BuyQuote, txHash: string) => { - const newOrderState: OrderState = { processState: OrderProcessState.PROCESSING, txHash }; - dispatch(actions.updateBuyOrderState(newOrderState)); + onBuyProcessing: (buyQuote: BuyQuote, txHash: string, startTimeUnix: number, expectedEndTimeUnix: number) => { + dispatch(actions.setBuyOrderStateProcessing(txHash, startTimeUnix, expectedEndTimeUnix)); }, - onBuySuccess: (buyQuote: BuyQuote, txHash: string) => - dispatch(actions.updateBuyOrderState({ processState: OrderProcessState.SUCCESS, txHash })), - onBuyFailure: (buyQuote: BuyQuote, txHash: string) => - dispatch(actions.updateBuyOrderState({ processState: OrderProcessState.FAILURE, txHash })), + onBuySuccess: (buyQuote: BuyQuote, txHash: string) => dispatch(actions.setBuyOrderStateSuccess(txHash)), + onBuyFailure: (buyQuote: BuyQuote, txHash: string) => dispatch(actions.setBuyOrderStateFailure(txHash)), onSignatureDenied: () => { dispatch(actions.resetAmount()); const errorMessage = 'You denied this transaction'; errorFlasher.flashNewErrorMessage(dispatch, errorMessage); }, onValidationFail: (buyQuote, error) => { - dispatch(actions.updateBuyOrderState({ processState: OrderProcessState.NONE })); + dispatch(actions.setBuyOrderStateNone()); if (error === ZeroExInstantError.InsufficientETH) { const errorMessage = "You don't have enough ETH"; errorFlasher.flashNewErrorMessage(dispatch, errorMessage); diff --git a/packages/instant/src/containers/selected_erc20_asset_amount_input.ts b/packages/instant/src/containers/selected_erc20_asset_amount_input.ts index 217d603d2..f0e792e2f 100644 --- a/packages/instant/src/containers/selected_erc20_asset_amount_input.ts +++ b/packages/instant/src/containers/selected_erc20_asset_amount_input.ts @@ -114,7 +114,7 @@ const mapDispatchToProps = ( // invalidate the last buy quote. dispatch(actions.updateLatestBuyQuote(undefined)); // reset our buy state - dispatch(actions.updateBuyOrderState({ processState: OrderProcessState.NONE })); + dispatch(actions.setBuyOrderStateNone()); if (!_.isUndefined(value) && !_.isUndefined(asset) && !_.isUndefined(assetBuyer)) { // even if it's debounced, give them the illusion it's loading diff --git a/packages/instant/src/redux/actions.ts b/packages/instant/src/redux/actions.ts index bfae68e2b..885f09c7c 100644 --- a/packages/instant/src/redux/actions.ts +++ b/packages/instant/src/redux/actions.ts @@ -4,7 +4,7 @@ import * as _ from 'lodash'; import { BigNumberInput } from '../util/big_number_input'; -import { ActionsUnion, OrderState } from '../types'; +import { ActionsUnion } from '../types'; export interface PlainAction<T extends string> { type: T; @@ -25,7 +25,11 @@ function createAction<T extends string, P>(type: T, data?: P): PlainAction<T> | export enum ActionTypes { UPDATE_ETH_USD_PRICE = 'UPDATE_ETH_USD_PRICE', UPDATE_SELECTED_ASSET_AMOUNT = 'UPDATE_SELECTED_ASSET_AMOUNT', - UPDATE_BUY_ORDER_STATE = 'UPDATE_BUY_ORDER_STATE', + SET_BUY_ORDER_STATE_NONE = 'SET_BUY_ORDER_STATE_NONE', + SET_BUY_ORDER_STATE_VALIDATING = 'SET_BUY_ORDER_STATE_VALIDATING', + SET_BUY_ORDER_STATE_PROCESSING = 'SET_BUY_ORDER_STATE_PROCESSING', + SET_BUY_ORDER_STATE_FAILURE = 'SET_BUY_ORDER_STATE_FAILURE', + SET_BUY_ORDER_STATE_SUCCESS = 'SET_BUY_ORDER_STATE_SUCCESS', UPDATE_LATEST_BUY_QUOTE = 'UPDATE_LATEST_BUY_QUOTE', UPDATE_SELECTED_ASSET = 'UPDATE_SELECTED_ASSET', SET_QUOTE_REQUEST_STATE_PENDING = 'SET_QUOTE_REQUEST_STATE_PENDING', @@ -40,7 +44,12 @@ export const actions = { updateEthUsdPrice: (price?: BigNumber) => createAction(ActionTypes.UPDATE_ETH_USD_PRICE, price), updateSelectedAssetAmount: (amount?: BigNumberInput) => createAction(ActionTypes.UPDATE_SELECTED_ASSET_AMOUNT, amount), - updateBuyOrderState: (orderState: OrderState) => createAction(ActionTypes.UPDATE_BUY_ORDER_STATE, orderState), + setBuyOrderStateNone: () => createAction(ActionTypes.SET_BUY_ORDER_STATE_NONE), + setBuyOrderStateValidating: () => createAction(ActionTypes.SET_BUY_ORDER_STATE_VALIDATING), + setBuyOrderStateProcessing: (txHash: string, startTimeUnix: number, expectedEndTimeUnix: number) => + createAction(ActionTypes.SET_BUY_ORDER_STATE_PROCESSING, { txHash, startTimeUnix, expectedEndTimeUnix }), + setBuyOrderStateFailure: (txHash: string) => createAction(ActionTypes.SET_BUY_ORDER_STATE_FAILURE, txHash), + setBuyOrderStateSuccess: (txHash: string) => createAction(ActionTypes.SET_BUY_ORDER_STATE_SUCCESS, txHash), updateLatestBuyQuote: (buyQuote?: BuyQuote) => createAction(ActionTypes.UPDATE_LATEST_BUY_QUOTE, buyQuote), updateSelectedAsset: (assetData?: string) => createAction(ActionTypes.UPDATE_SELECTED_ASSET, assetData), setQuoteRequestStatePending: () => createAction(ActionTypes.SET_QUOTE_REQUEST_STATE_PENDING), diff --git a/packages/instant/src/redux/reducer.ts b/packages/instant/src/redux/reducer.ts index dd9403052..cf24f8488 100644 --- a/packages/instant/src/redux/reducer.ts +++ b/packages/instant/src/redux/reducer.ts @@ -83,11 +83,62 @@ export const reducer = (state: State = INITIAL_STATE, action: Action): State => latestBuyQuote: undefined, quoteRequestState: AsyncProcessState.FAILURE, }; - case ActionTypes.UPDATE_BUY_ORDER_STATE: + case ActionTypes.SET_BUY_ORDER_STATE_NONE: return { ...state, - buyOrderState: action.data, + buyOrderState: { processState: OrderProcessState.NONE }, + }; + case ActionTypes.SET_BUY_ORDER_STATE_VALIDATING: + return { + ...state, + buyOrderState: { processState: OrderProcessState.VALIDATING }, }; + case ActionTypes.SET_BUY_ORDER_STATE_PROCESSING: + const processingData = action.data; + const { startTimeUnix, expectedEndTimeUnix } = processingData; + return { + ...state, + buyOrderState: { + processState: OrderProcessState.PROCESSING, + txHash: processingData.txHash, + progress: { + startTimeUnix, + expectedEndTimeUnix, + }, + }, + }; + case ActionTypes.SET_BUY_ORDER_STATE_FAILURE: + const failureTxHash = action.data; + if ('txHash' in state.buyOrderState) { + if (state.buyOrderState.txHash === failureTxHash) { + const { txHash, progress } = state.buyOrderState; + return { + ...state, + buyOrderState: { + processState: OrderProcessState.FAILURE, + txHash, + progress, + }, + }; + } + } + return state; + case ActionTypes.SET_BUY_ORDER_STATE_SUCCESS: + const successTxHash = action.data; + if ('txHash' in state.buyOrderState) { + if (state.buyOrderState.txHash === successTxHash) { + const { txHash, progress } = state.buyOrderState; + return { + ...state, + buyOrderState: { + processState: OrderProcessState.SUCCESS, + txHash, + progress, + }, + }; + } + } + return state; case ActionTypes.SET_ERROR_MESSAGE: return { ...state, diff --git a/packages/instant/src/types.ts b/packages/instant/src/types.ts index 336465e43..288a6d111 100644 --- a/packages/instant/src/types.ts +++ b/packages/instant/src/types.ts @@ -16,12 +16,18 @@ export enum OrderProcessState { FAILURE = 'Failure', } +export interface SimulatedProgress { + startTimeUnix: number; + expectedEndTimeUnix: number; +} + interface OrderStatePreTx { processState: OrderProcessState.NONE | OrderProcessState.VALIDATING; } interface OrderStatePostTx { processState: OrderProcessState.PROCESSING | OrderProcessState.SUCCESS | OrderProcessState.FAILURE; txHash: string; + progress: SimulatedProgress; } export type OrderState = OrderStatePreTx | OrderStatePostTx; diff --git a/packages/instant/src/util/gas_price_estimator.ts b/packages/instant/src/util/gas_price_estimator.ts index 336c4a3fa..6b15809a3 100644 --- a/packages/instant/src/util/gas_price_estimator.ts +++ b/packages/instant/src/util/gas_price_estimator.ts @@ -1,6 +1,11 @@ import { BigNumber, fetchAsync } from '@0x/utils'; -import { DEFAULT_GAS_PRICE, ETH_GAS_STATION_API_BASE_URL, GWEI_IN_WEI } from '../constants'; +import { + DEFAULT_ESTIMATED_TRANSACTION_TIME_MS, + DEFAULT_GAS_PRICE, + ETH_GAS_STATION_API_BASE_URL, + GWEI_IN_WEI, +} from '../constants'; interface EthGasStationResult { average: number; @@ -16,18 +21,25 @@ interface EthGasStationResult { safeLow: number; } -const fetchFastAmountInWeiAsync = async () => { +interface GasInfo { + gasPriceInWei: BigNumber; + estimatedTimeMs: number; +} + +const fetchFastAmountInWeiAsync = async (): Promise<GasInfo> => { const res = await fetchAsync(`${ETH_GAS_STATION_API_BASE_URL}/json/ethgasAPI.json`); const gasInfo = (await res.json()) as EthGasStationResult; // Eth Gas Station result is gwei * 10 const gasPriceInGwei = new BigNumber(gasInfo.fast / 10); - return gasPriceInGwei.mul(GWEI_IN_WEI); + // Time is in minutes + const estimatedTimeMs = gasInfo.fastWait * 60 * 1000; // Minutes to MS + return { gasPriceInWei: gasPriceInGwei.mul(GWEI_IN_WEI), estimatedTimeMs }; }; export class GasPriceEstimator { - private _lastFetched?: BigNumber; - public async getFastAmountInWeiAsync(): Promise<BigNumber> { - let fetchedAmount: BigNumber | undefined; + private _lastFetched?: GasInfo; + public async getGasInfoAsync(): Promise<GasInfo> { + let fetchedAmount: GasInfo | undefined; try { fetchedAmount = await fetchFastAmountInWeiAsync(); } catch { @@ -38,7 +50,13 @@ export class GasPriceEstimator { this._lastFetched = fetchedAmount; } - return fetchedAmount || this._lastFetched || DEFAULT_GAS_PRICE; + return ( + fetchedAmount || + this._lastFetched || { + gasPriceInWei: DEFAULT_GAS_PRICE, + estimatedTimeMs: DEFAULT_ESTIMATED_TRANSACTION_TIME_MS, + } + ); } } export const gasPriceEstimator = new GasPriceEstimator(); diff --git a/packages/instant/src/util/time.ts b/packages/instant/src/util/time.ts new file mode 100644 index 000000000..bfe69cad5 --- /dev/null +++ b/packages/instant/src/util/time.ts @@ -0,0 +1,39 @@ +const secondsToMinutesAndRemainingSeconds = (seconds: number): { minutes: number; remainingSeconds: number } => { + const minutes = Math.floor(seconds / 60); + const remainingSeconds = seconds - minutes * 60; + + return { + minutes, + remainingSeconds, + }; +}; + +const padZero = (aNumber: number): string => { + return aNumber < 10 ? `0${aNumber}` : aNumber.toString(); +}; + +export const timeUtil = { + // converts seconds to human readable version of seconds or minutes + secondsToHumanDescription: (seconds: number): string => { + const { minutes, remainingSeconds } = secondsToMinutesAndRemainingSeconds(seconds); + + if (minutes === 0) { + const suffix = seconds > 1 ? 's' : ''; + return `${seconds} second${suffix}`; + } + + const minuteSuffix = minutes > 1 ? 's' : ''; + const minuteText = `${minutes} minute${minuteSuffix}`; + + const secondsSuffix = remainingSeconds > 1 ? 's' : ''; + const secondsText = remainingSeconds === 0 ? '' : ` ${remainingSeconds} second${secondsSuffix}`; + + return `${minuteText}${secondsText}`; + }, + // converts seconds to stopwatch time (i.e. 05:30 and 00:30) + // only goes up to minutes, not hours + secondsToStopwatchTime: (seconds: number): string => { + const { minutes, remainingSeconds } = secondsToMinutesAndRemainingSeconds(seconds); + return `${padZero(minutes)}:${padZero(remainingSeconds)}`; + }, +}; diff --git a/packages/instant/test/util/time.test.ts b/packages/instant/test/util/time.test.ts new file mode 100644 index 000000000..fcb4e1875 --- /dev/null +++ b/packages/instant/test/util/time.test.ts @@ -0,0 +1,48 @@ +import { timeUtil } from '../../src/util/time'; + +describe('timeUtil', () => { + describe('secondsToHumanDescription', () => { + const numsToResults: { + [aNumber: number]: string; + } = { + 1: '1 second', + 59: '59 seconds', + 60: '1 minute', + 119: '1 minute 59 seconds', + 120: '2 minutes', + 121: '2 minutes 1 second', + 122: '2 minutes 2 seconds', + }; + + const nums = Object.keys(numsToResults); + nums.forEach(aNum => { + const numInt = parseInt(aNum, 10); + it(`should work for ${aNum} seconds`, () => { + const expectedResult = numsToResults[numInt]; + expect(timeUtil.secondsToHumanDescription(numInt)).toEqual(expectedResult); + }); + }); + }); + describe('secondsToStopwatchTime', () => { + const numsToResults: { + [aNumber: number]: string; + } = { + 1: '00:01', + 59: '00:59', + 60: '01:00', + 119: '01:59', + 120: '02:00', + 121: '02:01', + 2701: '45:01', + }; + + const nums = Object.keys(numsToResults); + nums.forEach(aNum => { + const numInt = parseInt(aNum, 10); + it(`should work for ${aNum} seconds`, () => { + const expectedResult = numsToResults[numInt]; + expect(timeUtil.secondsToStopwatchTime(numInt)).toEqual(expectedResult); + }); + }); + }); +}); |