diff options
23 files changed, 515 insertions, 59 deletions
diff --git a/packages/asset-buyer/CHANGELOG.json b/packages/asset-buyer/CHANGELOG.json index 6ba2a0fd9..0d71bb84d 100644 --- a/packages/asset-buyer/CHANGELOG.json +++ b/packages/asset-buyer/CHANGELOG.json @@ -14,6 +14,16 @@ { "note": "No longer require that provided orders all have the same maker and taker asset data", "pr": 1197 + }, + { + "note": + "Fix bug where `BuyQuoteInfo` objects could return `totalEthAmount` and `feeEthAmount` that were not whole numbers", + "pr": 1207 + }, + { + "note": + "Fix bug where default values for `AssetBuyer` public facing methods could get overriden by `undefined` values", + "pr": 1207 } ] }, diff --git a/packages/asset-buyer/src/asset_buyer.ts b/packages/asset-buyer/src/asset_buyer.ts index 49743404f..934410c55 100644 --- a/packages/asset-buyer/src/asset_buyer.ts +++ b/packages/asset-buyer/src/asset_buyer.ts @@ -90,10 +90,11 @@ export class AssetBuyer { * @return An instance of AssetBuyer */ constructor(provider: Provider, orderProvider: OrderProvider, options: Partial<AssetBuyerOpts> = {}) { - const { networkId, orderRefreshIntervalMs, expiryBufferSeconds } = { - ...constants.DEFAULT_ASSET_BUYER_OPTS, - ...options, - }; + const { networkId, orderRefreshIntervalMs, expiryBufferSeconds } = _.merge( + {}, + constants.DEFAULT_ASSET_BUYER_OPTS, + options, + ); assert.isWeb3Provider('provider', provider); assert.isValidOrderProvider('orderProvider', orderProvider); assert.isNumber('networkId', networkId); @@ -122,10 +123,11 @@ export class AssetBuyer { assetBuyAmount: BigNumber, options: Partial<BuyQuoteRequestOpts> = {}, ): Promise<BuyQuote> { - const { feePercentage, shouldForceOrderRefresh, slippagePercentage } = { - ...constants.DEFAULT_BUY_QUOTE_REQUEST_OPTS, - ...options, - }; + const { feePercentage, shouldForceOrderRefresh, slippagePercentage } = _.merge( + {}, + constants.DEFAULT_BUY_QUOTE_REQUEST_OPTS, + options, + ); assert.isString('assetData', assetData); assert.isBigNumber('assetBuyAmount', assetBuyAmount); assert.isValidPercentage('feePercentage', feePercentage); @@ -186,10 +188,11 @@ export class AssetBuyer { buyQuote: BuyQuote, options: Partial<BuyQuoteExecutionOpts> = {}, ): Promise<string> { - const { ethAmount, takerAddress, feeRecipient, gasLimit, gasPrice } = { - ...constants.DEFAULT_BUY_QUOTE_EXECUTION_OPTS, - ...options, - }; + const { ethAmount, takerAddress, feeRecipient, gasLimit, gasPrice } = _.merge( + {}, + constants.DEFAULT_BUY_QUOTE_EXECUTION_OPTS, + options, + ); assert.isValidBuyQuote('buyQuote', buyQuote); if (!_.isUndefined(ethAmount)) { assert.isBigNumber('ethAmount', ethAmount); @@ -198,6 +201,12 @@ export class AssetBuyer { assert.isETHAddressHex('takerAddress', takerAddress); } assert.isETHAddressHex('feeRecipient', feeRecipient); + if (!_.isUndefined(gasLimit)) { + assert.isNumber('gasLimit', gasLimit); + } + if (!_.isUndefined(gasPrice)) { + assert.isBigNumber('gasPrice', gasPrice); + } const { orders, feeOrders, feePercentage, assetBuyAmount, worstCaseQuoteInfo } = buyQuote; // if no takerAddress is provided, try to get one from the provider let finalTakerAddress; diff --git a/packages/asset-buyer/src/utils/buy_quote_calculator.ts b/packages/asset-buyer/src/utils/buy_quote_calculator.ts index f94ab3fa4..6a67ed1ed 100644 --- a/packages/asset-buyer/src/utils/buy_quote_calculator.ts +++ b/packages/asset-buyer/src/utils/buy_quote_calculator.ts @@ -119,7 +119,7 @@ function calculateQuoteInfo( ethAmountToBuyZrx = findEthAmountNeededToBuyZrx(feeOrdersAndFillableAmounts, zrxAmountToBuyAsset); } /// find the eth amount needed to buy the affiliate fee - const ethAmountToBuyAffiliateFee = ethAmountToBuyAsset.mul(feePercentage); + const ethAmountToBuyAffiliateFee = ethAmountToBuyAsset.mul(feePercentage).ceil(); const totalEthAmountWithoutAffiliateFee = ethAmountToBuyAsset.plus(ethAmountToBuyZrx); const ethAmountTotal = totalEthAmountWithoutAffiliateFee.plus(ethAmountToBuyAffiliateFee); // divide into the assetBuyAmount in order to find rate of makerAsset / WETH diff --git a/packages/instant/public/index.html b/packages/instant/public/index.html index 7580ab132..96a703224 100644 --- a/packages/instant/public/index.html +++ b/packages/instant/public/index.html @@ -77,11 +77,21 @@ onClose: () => { console.log('0x Instant Closed') } } const liquiditySourceOverride = queryParams.getQueryParamValue('liquiditySource'); + const feeRecipientOverride = queryParams.getQueryParamValue('feeRecipient'); + const feePercentageOverride = +queryParams.getQueryParamValue('feePercentage'); + let affiliateInfoOverride; + if (feeRecipientOverride !== undefined && feePercentageOverride !== undefined) { + affiliateInfoOverride = { + feeRecipient: feeRecipientOverride, + feePercentage: feePercentageOverride + }; + } const renderOptionsOverrides = { liquiditySource: liquiditySourceOverride === 'provided' ? providedOrders : liquiditySourceOverride, assetData: queryParams.getQueryParamValue('assetData'), networkId: +queryParams.getQueryParamValue('networkId') || undefined, defaultAssetBuyAmount: +queryParams.getQueryParamValue('defaultAssetBuyAmount') || undefined, + affiliateInfo: affiliateInfoOverride, } const renderOptions = Object.assign({}, renderOptionsDefaults, removeUndefined(renderOptionsOverrides)); zeroExInstant.render(renderOptions); diff --git a/packages/instant/src/components/buy_button.tsx b/packages/instant/src/components/buy_button.tsx index 93bd8e635..12ac62601 100644 --- a/packages/instant/src/components/buy_button.tsx +++ b/packages/instant/src/components/buy_button.tsx @@ -1,10 +1,11 @@ import { AssetBuyer, AssetBuyerError, BuyQuote } from '@0x/asset-buyer'; import * as _ from 'lodash'; import * as React from 'react'; +import { oc } from 'ts-optchain'; import { WEB_3_WRAPPER_TRANSACTION_FAILED_ERROR_MSG_PREFIX } from '../constants'; import { ColorOption } from '../style/theme'; -import { ZeroExInstantError } from '../types'; +import { AffiliateInfo, ZeroExInstantError } from '../types'; import { getBestAddress } from '../util/address'; import { balanceUtil } from '../util/balance'; import { gasPriceEstimator } from '../util/gas_price_estimator'; @@ -16,10 +17,11 @@ import { Button, Text } from './ui'; export interface BuyButtonProps { buyQuote?: BuyQuote; assetBuyer?: AssetBuyer; + affiliateInfo?: AffiliateInfo; 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; } @@ -42,7 +44,7 @@ export class BuyButton extends React.Component<BuyButtonProps> { } private readonly _handleClick = async () => { // The button is disabled when there is no buy quote anyway. - const { buyQuote, assetBuyer } = this.props; + const { buyQuote, assetBuyer, affiliateInfo } = this.props; if (_.isUndefined(buyQuote) || _.isUndefined(assetBuyer)) { return; } @@ -57,9 +59,14 @@ export class BuyButton extends React.Component<BuyButtonProps> { } let txHash: string | undefined; - const gasPrice = await gasPriceEstimator.getFastAmountInWeiAsync(); + const gasInfo = await gasPriceEstimator.getGasInfoAsync(); + const feeRecipient = oc(affiliateInfo).feeRecipient(); try { - txHash = await assetBuyer.executeBuyQuoteAsync(buyQuote, { takerAddress, gasPrice }); + txHash = await assetBuyer.executeBuyQuoteAsync(buyQuote, { + feeRecipient, + takerAddress, + gasPrice: gasInfo.gasPriceInWei, + }); } catch (e) { if (e instanceof Error) { if (e.message === AssetBuyerError.SignatureRequestDenied) { @@ -73,7 +80,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 +92,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..5c074a67a 100644 --- a/packages/instant/src/components/buy_order_state_buttons.tsx +++ b/packages/instant/src/components/buy_order_state_buttons.tsx @@ -2,7 +2,7 @@ import { AssetBuyer, AssetBuyerError, BuyQuote } from '@0x/asset-buyer'; import * as React from 'react'; import { ColorOption } from '../style/theme'; -import { OrderProcessState, ZeroExInstantError } from '../types'; +import { AffiliateInfo, OrderProcessState, ZeroExInstantError } from '../types'; import { BuyButton } from './buy_button'; import { PlacingOrderButton } from './placing_order_button'; @@ -13,17 +13,17 @@ export interface BuyOrderStateButtonProps { buyQuote?: BuyQuote; buyOrderProcessingState: OrderProcessState; assetBuyer?: AssetBuyer; + affiliateInfo?: AffiliateInfo; onViewTransaction: () => void; 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 ( @@ -51,6 +51,7 @@ export const BuyOrderStateButtons: React.StatelessComponent<BuyOrderStateButtonP <BuyButton buyQuote={props.buyQuote} assetBuyer={props.assetBuyer} + affiliateInfo={props.affiliateInfo} onValidationPending={props.onValidationPending} onValidationFail={props.onValidationFail} onSignatureDenied={props.onSignatureDenied} 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_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/components/zero_ex_instant_provider.tsx b/packages/instant/src/components/zero_ex_instant_provider.tsx index 9391c03f7..a09ab68dc 100644 --- a/packages/instant/src/components/zero_ex_instant_provider.tsx +++ b/packages/instant/src/components/zero_ex_instant_provider.tsx @@ -10,7 +10,7 @@ import { asyncData } from '../redux/async_data'; import { INITIAL_STATE, State } from '../redux/reducer'; import { store, Store } from '../redux/store'; import { fonts } from '../style/fonts'; -import { AssetMetaData, Network } from '../types'; +import { AffiliateInfo, AssetMetaData, Network } from '../types'; import { assetUtils } from '../util/asset'; import { errorFlasher } from '../util/error_flasher'; import { gasPriceEstimator } from '../util/gas_price_estimator'; @@ -29,9 +29,10 @@ export interface ZeroExInstantProviderRequiredProps { } export interface ZeroExInstantProviderOptionalProps { - defaultAssetBuyAmount?: number; + defaultAssetBuyAmount: number; additionalAssetMetaDataMap: ObjectMap<AssetMetaData>; networkId: Network; + affiliateInfo: AffiliateInfo; } export class ZeroExInstantProvider extends React.Component<ZeroExInstantProviderProps> { @@ -66,6 +67,7 @@ export class ZeroExInstantProvider extends React.Component<ZeroExInstantProvider ? state.selectedAssetAmount : new BigNumber(props.defaultAssetBuyAmount), assetMetaDataMap: completeAssetMetaDataMap, + affiliateInfo: props.affiliateInfo, }; return storeStateFromProps; } @@ -82,7 +84,7 @@ export class ZeroExInstantProvider extends React.Component<ZeroExInstantProvider // 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/constants.ts b/packages/instant/src/constants.ts index 3e830ba00..15105b65c 100644 --- a/packages/instant/src/constants.ts +++ b/packages/instant/src/constants.ts @@ -5,6 +5,11 @@ export const DEFAULT_ZERO_EX_CONTAINER_SELECTOR = '#zeroExInstantContainer'; export const INJECTED_DIV_ID = 'zeroExInstant'; 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..72d99f844 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 { AffiliateInfo, OrderProcessState, ZeroExInstantError } from '../types'; import { errorFlasher } from '../util/error_flasher'; import { etherscanUtil } from '../util/etherscan'; @@ -15,13 +15,14 @@ interface ConnectedState { buyQuote?: BuyQuote; buyOrderProcessingState: OrderProcessState; assetBuyer?: AssetBuyer; + affiliateInfo?: AffiliateInfo; onViewTransaction: () => void; } 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; @@ -32,6 +33,7 @@ const mapStateToProps = (state: State, _ownProps: SelectedAssetBuyOrderStateButt buyOrderProcessingState: state.buyOrderState.processState, assetBuyer: state.assetBuyer, buyQuote: state.latestBuyQuote, + affiliateInfo: state.affiliateInfo, onViewTransaction: () => { if ( state.assetBuyer && @@ -56,24 +58,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 1a5609b12..12bc7040e 100644 --- a/packages/instant/src/containers/selected_erc20_asset_amount_input.ts +++ b/packages/instant/src/containers/selected_erc20_asset_amount_input.ts @@ -6,12 +6,13 @@ import * as _ from 'lodash'; import * as React from 'react'; import { connect } from 'react-redux'; import { Dispatch } from 'redux'; +import { oc } from 'ts-optchain'; import { ERC20AssetAmountInput } from '../components/erc20_asset_amount_input'; import { Action, actions } from '../redux/actions'; import { State } from '../redux/reducer'; import { ColorOption } from '../style/theme'; -import { ERC20Asset, OrderProcessState } from '../types'; +import { AffiliateInfo, ERC20Asset, OrderProcessState } from '../types'; import { assetUtils } from '../util/asset'; import { errorFlasher } from '../util/error_flasher'; @@ -26,10 +27,16 @@ interface ConnectedState { value?: BigNumber; asset?: ERC20Asset; isDisabled: boolean; + affiliateInfo?: AffiliateInfo; } interface ConnectedDispatch { - updateBuyQuote: (assetBuyer?: AssetBuyer, value?: BigNumber, asset?: ERC20Asset) => void; + updateBuyQuote: ( + assetBuyer?: AssetBuyer, + value?: BigNumber, + asset?: ERC20Asset, + affiliateInfo?: AffiliateInfo, + ) => void; } interface ConnectedProps { @@ -59,6 +66,7 @@ const mapStateToProps = (state: State, _ownProps: SelectedERC20AssetAmountInputP value: state.selectedAssetAmount, asset: selectedAsset as ERC20Asset, isDisabled, + affiliateInfo: state.affiliateInfo, }; }; @@ -67,6 +75,7 @@ const updateBuyQuoteAsync = async ( dispatch: Dispatch<Action>, asset: ERC20Asset, assetAmount: BigNumber, + affiliateInfo?: AffiliateInfo, ): Promise<void> => { // get a new buy quote. const baseUnitValue = Web3Wrapper.toBaseUnitAmount(assetAmount, asset.metaData.decimals); @@ -74,9 +83,10 @@ const updateBuyQuoteAsync = async ( // mark quote as pending dispatch(actions.setQuoteRequestStatePending()); + const feePercentage = oc(affiliateInfo).feePercentage(); let newBuyQuote: BuyQuote | undefined; try { - newBuyQuote = await assetBuyer.getBuyQuoteAsync(asset.assetData, baseUnitValue); + newBuyQuote = await assetBuyer.getBuyQuoteAsync(asset.assetData, baseUnitValue, { feePercentage }); } catch (error) { dispatch(actions.setQuoteRequestStateFailure()); let errorMessage; @@ -92,7 +102,11 @@ const updateBuyQuoteAsync = async ( const assetName = assetUtils.bestNameForAsset(asset, 'This asset'); errorMessage = `${assetName} is currently unavailable`; } - errorFlasher.flashNewErrorMessage(dispatch, errorMessage); + if (!_.isUndefined(errorMessage)) { + errorFlasher.flashNewErrorMessage(dispatch, errorMessage); + } else { + throw error; + } return; } // We have a successful new buy quote @@ -107,19 +121,19 @@ const mapDispatchToProps = ( dispatch: Dispatch<Action>, _ownProps: SelectedERC20AssetAmountInputProps, ): ConnectedDispatch => ({ - updateBuyQuote: (assetBuyer, value, asset) => { + updateBuyQuote: (assetBuyer, value, asset, affiliateInfo) => { // Update the input dispatch(actions.updateSelectedAssetAmount(value)); // 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)) { + if (!_.isUndefined(value) && value.greaterThan(0) && !_.isUndefined(asset) && !_.isUndefined(assetBuyer)) { // even if it's debounced, give them the illusion it's loading dispatch(actions.setQuoteRequestStatePending()); // tslint:disable-next-line:no-floating-promises - debouncedUpdateBuyQuoteAsync(assetBuyer, dispatch, asset, value); + debouncedUpdateBuyQuoteAsync(assetBuyer, dispatch, asset, value, affiliateInfo); } }, }); @@ -134,7 +148,7 @@ const mergeProps = ( asset: connectedState.asset, value: connectedState.value, onChange: (value, asset) => { - connectedDispatch.updateBuyQuote(connectedState.assetBuyer, value, asset); + connectedDispatch.updateBuyQuote(connectedState.assetBuyer, value, asset, connectedState.affiliateInfo); }, isDisabled: connectedState.isDisabled, }; diff --git a/packages/instant/src/index.umd.ts b/packages/instant/src/index.umd.ts index b12e65485..806187a16 100644 --- a/packages/instant/src/index.umd.ts +++ b/packages/instant/src/index.umd.ts @@ -24,6 +24,9 @@ export const render = (props: ZeroExInstantOverlayProps, selector: string = DEFA if (!_.isUndefined(props.zIndex)) { assert.isNumber('props.zIndex', props.zIndex); } + if (!_.isUndefined(props.affiliateInfo)) { + assert.isValidaffiliateInfo('props.affiliateInfo', props.affiliateInfo); + } const appendToIfExists = document.querySelector(selector); assert.assert(!_.isNull(appendToIfExists), `Could not find div with selector: ${selector}`); const appendTo = appendToIfExists as Element; diff --git a/packages/instant/src/redux/actions.ts b/packages/instant/src/redux/actions.ts index a0781db19..16bda0208 100644 --- a/packages/instant/src/redux/actions.ts +++ b/packages/instant/src/redux/actions.ts @@ -23,7 +23,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', @@ -37,7 +41,12 @@ export enum ActionTypes { export const actions = { updateEthUsdPrice: (price?: BigNumber) => createAction(ActionTypes.UPDATE_ETH_USD_PRICE, price), updateSelectedAssetAmount: (amount?: BigNumber) => 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 20f682787..1b4fdabbc 100644 --- a/packages/instant/src/redux/reducer.ts +++ b/packages/instant/src/redux/reducer.ts @@ -6,6 +6,7 @@ import * as _ from 'lodash'; import { assetMetaDataMap } from '../data/asset_meta_data_map'; import { + AffiliateInfo, Asset, AssetMetaData, AsyncProcessState, @@ -30,6 +31,7 @@ export interface State { quoteRequestState: AsyncProcessState; latestErrorMessage?: string; latestErrorDisplayStatus: DisplayStatus; + affiliateInfo?: AffiliateInfo; } export const INITIAL_STATE: State = { @@ -42,6 +44,7 @@ export const INITIAL_STATE: State = { latestErrorMessage: undefined, latestErrorDisplayStatus: DisplayStatus.Hidden, quoteRequestState: AsyncProcessState.NONE, + affiliateInfo: undefined, }; export const reducer = (state: State = INITIAL_STATE, action: Action): State => { @@ -82,11 +85,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 e92e96e9a..739556801 100644 --- a/packages/instant/src/types.ts +++ b/packages/instant/src/types.ts @@ -18,12 +18,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; @@ -76,3 +82,8 @@ export enum ZeroExInstantError { AssetMetaDataNotAvailable = 'ASSET_META_DATA_NOT_AVAILABLE', InsufficientETH = 'INSUFFICIENT_ETH', } + +export interface AffiliateInfo { + feeRecipient: string; + feePercentage: number; +} diff --git a/packages/instant/src/util/assert.ts b/packages/instant/src/util/assert.ts index 584d3d4b1..20f8ddaee 100644 --- a/packages/instant/src/util/assert.ts +++ b/packages/instant/src/util/assert.ts @@ -4,7 +4,7 @@ import { assetDataUtils } from '@0x/order-utils'; import { AssetProxyId, ObjectMap, SignedOrder } from '@0x/types'; import * as _ from 'lodash'; -import { AssetMetaData } from '../types'; +import { AffiliateInfo, AssetMetaData } from '../types'; export const assert = { ...sharedAssert, @@ -41,4 +41,12 @@ export const assert = { assert.isUri(`${variableName}.imageUrl`, metaData.imageUrl); } }, + isValidaffiliateInfo(variableName: string, affiliateInfo: AffiliateInfo): void { + assert.isETHAddressHex(`${variableName}.recipientAddress`, affiliateInfo.feeRecipient); + assert.isNumber(`${variableName}.percentage`, affiliateInfo.feePercentage); + assert.assert( + affiliateInfo.feePercentage >= 0 && affiliateInfo.feePercentage <= 0.05, + `Expected ${variableName}.percentage to be between 0 and 0.05, but is ${affiliateInfo.feePercentage}`, + ); + }, }; 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); + }); + }); + }); +}); |