Merge branch 'development' of https://github.com/0xProject/0x-monorepo into feature/instant/maker-asset-datas-interface

This commit is contained in:
fragosti
2018-11-02 14:38:18 -07:00
23 changed files with 515 additions and 59 deletions

View File

@@ -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
}
]
},

View File

@@ -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;

View File

@@ -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

View File

@@ -77,12 +77,22 @@
}
const orderSourceOverride = queryParams.getQueryParamValue('orderSource');
const availableAssetDatasString = queryParams.getQueryParamValue('availableAssetDatas');
const feeRecipientOverride = queryParams.getQueryParamValue('feeRecipient');
const feePercentageOverride = +queryParams.getQueryParamValue('feePercentage');
let affiliateInfoOverride;
if (feeRecipientOverride !== undefined && feePercentageOverride !== undefined) {
affiliateInfoOverride = {
feeRecipient: feeRecipientOverride,
feePercentage: feePercentageOverride
};
}
const renderOptionsOverrides = {
orderSource: orderSourceOverride === 'provided' ? [providedOrder] : orderSourceOverride,
networkId: +queryParams.getQueryParamValue('networkId') || undefined,
defaultAssetBuyAmount: +queryParams.getQueryParamValue('defaultAssetBuyAmount') || undefined,
availableAssetDatas: availableAssetDatasString ? JSON.parse(availableAssetDatasString) : undefined,
defaultSelectedAssetData: queryParams.getQueryParamValue('defaultSelectedAssetData'),
affiliateInfo: affiliateInfoOverride,
}
const renderOptions = Object.assign({}, renderOptionsDefaults, removeUndefined(renderOptionsOverrides));
zeroExInstant.render(renderOptions);

View File

@@ -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);
};
}

View File

@@ -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;
};

View File

@@ -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}

View File

@@ -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);
}
}
}

View File

@@ -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;
`;

View File

@@ -5,13 +5,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;
@@ -37,6 +39,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 />

View File

@@ -9,7 +9,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 { BigNumberInput } from '../util/big_number_input';
import { errorFlasher } from '../util/error_flasher';
@@ -32,6 +32,7 @@ export interface ZeroExInstantProviderOptionalProps {
defaultSelectedAssetData: string;
additionalAssetMetaDataMap: ObjectMap<AssetMetaData>;
networkId: Network;
affiliateInfo: AffiliateInfo;
}
export class ZeroExInstantProvider extends React.Component<ZeroExInstantProviderProps> {
@@ -76,6 +77,7 @@ export class ZeroExInstantProvider extends React.Component<ZeroExInstantProvider
? undefined
: assetUtils.createAssetsFromAssetDatas(props.availableAssetDatas, completeAssetMetaDataMap, networkId),
assetMetaDataMap: completeAssetMetaDataMap,
affiliateInfo: props.affiliateInfo,
};
return storeStateFromProps;
}
@@ -98,7 +100,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();

View File

@@ -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;

View File

@@ -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);

View File

@@ -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);

View File

@@ -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 { BigNumberInput } from '../util/big_number_input';
import { errorFlasher } from '../util/error_flasher';
@@ -28,10 +29,16 @@ interface ConnectedState {
asset?: ERC20Asset;
isDisabled: boolean;
numberOfAssetsAvailable?: number;
affiliateInfo?: AffiliateInfo;
}
interface ConnectedDispatch {
updateBuyQuote: (assetBuyer?: AssetBuyer, value?: BigNumberInput, asset?: ERC20Asset) => void;
updateBuyQuote: (
assetBuyer?: AssetBuyer,
value?: BigNumberInput,
asset?: ERC20Asset,
affiliateInfo?: AffiliateInfo,
) => void;
}
interface ConnectedProps {
@@ -59,6 +66,7 @@ const mapStateToProps = (state: State, _ownProps: SelectedERC20AssetAmountInputP
asset: selectedAsset,
isDisabled,
numberOfAssetsAvailable,
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,
numberOfAssetsAvailable: connectedState.numberOfAssetsAvailable,

View File

@@ -29,6 +29,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;

View File

@@ -4,7 +4,7 @@ import * as _ from 'lodash';
import { BigNumberInput } from '../util/big_number_input';
import { ActionsUnion, Asset, OrderState } from '../types';
import { ActionsUnion, Asset } 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_AVAILABLE_ASSETS = 'SET_AVAILABLE_ASSETS',
@@ -41,7 +45,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: (asset: Asset) => createAction(ActionTypes.UPDATE_SELECTED_ASSET, asset),
setAvailableAssets: (availableAssets: Asset[]) => createAction(ActionTypes.SET_AVAILABLE_ASSETS, availableAssets),

View File

@@ -6,6 +6,7 @@ import * as _ from 'lodash';
import { assetMetaDataMap } from '../data/asset_meta_data_map';
import {
AffiliateInfo,
Asset,
AssetMetaData,
AsyncProcessState,
@@ -31,6 +32,7 @@ export interface State {
quoteRequestState: AsyncProcessState;
latestErrorMessage?: string;
latestErrorDisplayStatus: DisplayStatus;
affiliateInfo?: AffiliateInfo;
}
export const INITIAL_STATE: State = {
@@ -44,6 +46,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 => {
@@ -84,11 +87,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,

View File

@@ -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;
@@ -77,3 +83,8 @@ export enum ZeroExInstantError {
}
export type SimpleHandler = () => void;
export interface AffiliateInfo {
feeRecipient: string;
feePercentage: number;
}

View File

@@ -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,
@@ -44,4 +44,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}`,
);
},
};

View File

@@ -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();

View File

@@ -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)}`;
},
};

View File

@@ -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);
});
});
});
});