diff options
Diffstat (limited to 'packages')
42 files changed, 810 insertions, 184 deletions
diff --git a/packages/instant/package.json b/packages/instant/package.json index d7ec85b2f..421802530 100644 --- a/packages/instant/package.json +++ b/packages/instant/package.json @@ -45,6 +45,7 @@ "homepage": "https://github.com/0xProject/0x-monorepo/packages/instant/README.md", "dependencies": { "@0x/asset-buyer": "^2.1.0", + "@0x/order-utils": "^2.0.0", "@0x/types": "^1.2.0", "@0x/typescript-typings": "^3.0.3", "@0x/utils": "^2.0.3", @@ -61,8 +62,8 @@ "ts-optchain": "^0.1.1" }, "devDependencies": { - "@static/discharge": "^1.2.2", "@0x/tslint-config": "^1.0.9", + "@static/discharge": "^1.2.2", "@types/enzyme": "^3.1.14", "@types/enzyme-adapter-react-16": "^1.0.3", "@types/jest": "^23.3.5", diff --git a/packages/instant/public/index.html b/packages/instant/public/index.html index fb041745e..14555fc64 100644 --- a/packages/instant/public/index.html +++ b/packages/instant/public/index.html @@ -25,7 +25,8 @@ <div id="zeroExInstantContainer"></div> <script> zeroExInstant.render({ - + liquiditySource: 'https://api.radarrelay.com/0x/v2/', + assetData: '0xf47261b0000000000000000000000000e41d2489571d322189246dafa5ebde1f4699f498', }); </script> </body> diff --git a/packages/instant/src/components/animations/full_rotation.tsx b/packages/instant/src/components/animations/full_rotation.tsx new file mode 100644 index 000000000..9adb565f9 --- /dev/null +++ b/packages/instant/src/components/animations/full_rotation.tsx @@ -0,0 +1,24 @@ +import { keyframes, styled } from '../../style/theme'; + +interface FullRotationProps { + height: string; + width: string; +} +const rotatingKeyframes = keyframes` +from { + transform: rotate(0deg); +} + +to { + transform: rotate(360deg); +} +`; + +export const FullRotation = + styled.div < + FullRotationProps > + ` + animation: ${rotatingKeyframes} 2s linear infinite; + height: ${props => props.height}; + width: ${props => props.width}; +`; diff --git a/packages/instant/src/components/asset_amount_input.tsx b/packages/instant/src/components/asset_amount_input.tsx index 730e6396f..c03ef1cf3 100644 --- a/packages/instant/src/components/asset_amount_input.tsx +++ b/packages/instant/src/components/asset_amount_input.tsx @@ -2,17 +2,18 @@ import { BigNumber } from '@0x/utils'; import * as _ from 'lodash'; import * as React from 'react'; -import { assetDataUtil } from '../util/asset_data'; - import { ColorOption } from '../style/theme'; +import { ERC20Asset } from '../types'; +import { assetUtils } from '../util/asset'; import { util } from '../util/util'; import { AmountInput, AmountInputProps } from './amount_input'; import { Container, Text } from './ui'; +// Asset amounts only apply to ERC20 assets export interface AssetAmountInputProps extends AmountInputProps { - assetData?: string; - onChange: (value?: BigNumber, assetData?: string) => void; + asset?: ERC20Asset; + onChange: (value?: BigNumber, asset?: ERC20Asset) => void; } export class AssetAmountInput extends React.Component<AssetAmountInputProps> { @@ -20,19 +21,19 @@ export class AssetAmountInput extends React.Component<AssetAmountInputProps> { onChange: util.boundNoop, }; public render(): React.ReactNode { - const { assetData, onChange, ...rest } = this.props; + const { asset, onChange, ...rest } = this.props; return ( <Container> <AmountInput {...rest} onChange={this._handleChange} /> <Container display="inline-block" marginLeft="10px"> <Text fontSize={rest.fontSize} fontColor={ColorOption.white} textTransform="uppercase"> - {assetDataUtil.bestNameForAsset(this.props.assetData, '???')} + {assetUtils.bestNameForAsset(asset)} </Text> </Container> </Container> ); } private readonly _handleChange = (value?: BigNumber): void => { - this.props.onChange(value, this.props.assetData); + this.props.onChange(value, this.props.asset); }; } diff --git a/packages/instant/src/components/buy_button.tsx b/packages/instant/src/components/buy_button.tsx index d2a8bd07a..0d35d36ca 100644 --- a/packages/instant/src/components/buy_button.tsx +++ b/packages/instant/src/components/buy_button.tsx @@ -1,20 +1,20 @@ -import { BuyQuote } from '@0x/asset-buyer'; +import { AssetBuyer, AssetBuyerError, BuyQuote } from '@0x/asset-buyer'; import * as _ from 'lodash'; import * as React from 'react'; import { ColorOption } from '../style/theme'; -import { assetBuyer } from '../util/asset_buyer'; import { util } from '../util/util'; import { web3Wrapper } from '../util/web3_wrapper'; -import { Button, Container, Text } from './ui'; +import { Button, Text } from './ui'; export interface BuyButtonProps { buyQuote?: BuyQuote; + assetBuyer?: AssetBuyer; onClick: (buyQuote: BuyQuote) => void; onBuySuccess: (buyQuote: BuyQuote, txnHash: string) => void; onBuyFailure: (buyQuote: BuyQuote, tnxHash?: string) => void; - text: string; + onBuyPrevented: (buyQuote: BuyQuote, preventedError: Error) => void; } export class BuyButton extends React.Component<BuyButtonProps> { @@ -24,29 +24,31 @@ export class BuyButton extends React.Component<BuyButtonProps> { onBuyFailure: util.boundNoop, }; public render(): React.ReactNode { - const shouldDisableButton = _.isUndefined(this.props.buyQuote); + const shouldDisableButton = _.isUndefined(this.props.buyQuote) || _.isUndefined(this.props.assetBuyer); return ( - <Container padding="20px" width="100%"> - <Button width="100%" onClick={this._handleClick} isDisabled={shouldDisableButton}> - <Text fontColor={ColorOption.white} fontWeight={600} fontSize="20px"> - {this.props.text} - </Text> - </Button> - </Container> + <Button width="100%" onClick={this._handleClick} isDisabled={shouldDisableButton}> + <Text fontColor={ColorOption.white} fontWeight={600} fontSize="20px"> + Buy + </Text> + </Button> ); } private readonly _handleClick = async () => { // The button is disabled when there is no buy quote anyway. - if (_.isUndefined(this.props.buyQuote)) { + if (_.isUndefined(this.props.buyQuote) || _.isUndefined(this.props.assetBuyer)) { return; } this.props.onClick(this.props.buyQuote); let txnHash; try { - txnHash = await assetBuyer.executeBuyQuoteAsync(this.props.buyQuote); - await web3Wrapper.awaitTransactionSuccessAsync(txnHash); - this.props.onBuySuccess(this.props.buyQuote, txnHash); - } catch { + txnHash = await this.props.assetBuyer.executeBuyQuoteAsync(this.props.buyQuote); + const txnReceipt = await web3Wrapper.awaitTransactionSuccessAsync(txnHash); + this.props.onBuySuccess(this.props.buyQuote, txnReceipt.transactionHash); + } catch (e) { + if (e instanceof Error && e.message === AssetBuyerError.SignatureRequestDenied) { + this.props.onBuyPrevented(this.props.buyQuote, e); + return; + } this.props.onBuyFailure(this.props.buyQuote, txnHash); } }; diff --git a/packages/instant/src/components/buy_order_state_button.tsx b/packages/instant/src/components/buy_order_state_button.tsx new file mode 100644 index 000000000..5bc965c7d --- /dev/null +++ b/packages/instant/src/components/buy_order_state_button.tsx @@ -0,0 +1,23 @@ +import * as React from 'react'; + +import { PlacingOrderButton } from '../components/placing_order_button'; +import { SelectedAssetBuyButton } from '../containers/selected_asset_buy_button'; +import { SelectedAssetRetryButton } from '../containers/selected_asset_retry_button'; +import { SelectedAssetViewTransactionButton } from '../containers/selected_asset_view_transaction_button'; +import { AsyncProcessState } from '../types'; + +export interface BuyOrderStateButtonProps { + buyOrderProcessingState: AsyncProcessState; +} + +export const BuyOrderStateButton: React.StatelessComponent<BuyOrderStateButtonProps> = props => { + if (props.buyOrderProcessingState === AsyncProcessState.FAILURE) { + return <SelectedAssetRetryButton />; + } else if (props.buyOrderProcessingState === AsyncProcessState.SUCCESS) { + return <SelectedAssetViewTransactionButton />; + } else if (props.buyOrderProcessingState === AsyncProcessState.PENDING) { + return <PlacingOrderButton />; + } + + return <SelectedAssetBuyButton />; +}; diff --git a/packages/instant/src/components/instant_heading.tsx b/packages/instant/src/components/instant_heading.tsx index 63d2138a5..ed753a3bd 100644 --- a/packages/instant/src/components/instant_heading.tsx +++ b/packages/instant/src/components/instant_heading.tsx @@ -4,22 +4,29 @@ import * as React from 'react'; import { SelectedAssetAmountInput } from '../containers/selected_asset_amount_input'; import { ColorOption } from '../style/theme'; -import { AsyncProcessState } from '../types'; +import { AsyncProcessState, OrderState } from '../types'; import { format } from '../util/format'; import { AmountPlaceholder } from './amount_placeholder'; import { Container, Flex, Text } from './ui'; +import { Icon } from './ui/icon'; export interface InstantHeadingProps { selectedAssetAmount?: BigNumber; totalEthBaseAmount?: BigNumber; ethUsdPrice?: BigNumber; quoteRequestState: AsyncProcessState; + buyOrderState: OrderState; } -const placeholderColor = ColorOption.white; +const PLACEHOLDER_COLOR = ColorOption.white; +const ICON_WIDTH = 34; +const ICON_HEIGHT = 34; +const ICON_COLOR = ColorOption.white; + export class InstantHeading extends React.Component<InstantHeadingProps, {}> { public render(): React.ReactNode { + const iconOrAmounts = this._renderIcon() || this._renderAmountsSection(); return ( <Container backgroundColor={ColorOption.primaryColor} @@ -36,26 +43,56 @@ export class InstantHeading extends React.Component<InstantHeadingProps, {}> { textTransform="uppercase" fontSize="12px" > - I want to buy + {this._renderTopText()} </Text> </Container> <Flex direction="row" justify="space-between"> <SelectedAssetAmountInput fontSize="45px" /> <Flex direction="column" justify="space-between"> - <Container marginBottom="5px">{this._placeholderOrAmount(this._ethAmount)}</Container> - <Container opacity={0.7}>{this._placeholderOrAmount(this._dollarAmount)}</Container> + {iconOrAmounts} </Flex> </Flex> </Container> ); } + private _renderAmountsSection(): React.ReactNode { + return ( + <Container> + <Container marginBottom="5px">{this._placeholderOrAmount(this._ethAmount)}</Container> + <Container opacity={0.7}>{this._placeholderOrAmount(this._dollarAmount)}</Container> + </Container> + ); + } + + private _renderIcon(): React.ReactNode { + const processState = this.props.buyOrderState.processState; + + if (processState === AsyncProcessState.FAILURE) { + return <Icon icon={'failed'} width={ICON_WIDTH} height={ICON_HEIGHT} color={ICON_COLOR} />; + } else if (processState === AsyncProcessState.SUCCESS) { + return <Icon icon={'success'} width={ICON_WIDTH} height={ICON_HEIGHT} color={ICON_COLOR} />; + } + return undefined; + } + + private _renderTopText(): React.ReactNode { + const processState = this.props.buyOrderState.processState; + if (processState === AsyncProcessState.FAILURE) { + return 'Order failed'; + } else if (processState === AsyncProcessState.SUCCESS) { + return 'Tokens received!'; + } + + return 'I want to buy'; + } + private _placeholderOrAmount(amountFunction: () => React.ReactNode): React.ReactNode { if (this.props.quoteRequestState === AsyncProcessState.PENDING) { - return <AmountPlaceholder isPulsating={true} color={placeholderColor} />; + return <AmountPlaceholder isPulsating={true} color={PLACEHOLDER_COLOR} />; } if (_.isUndefined(this.props.selectedAssetAmount)) { - return <AmountPlaceholder isPulsating={false} color={placeholderColor} />; + return <AmountPlaceholder isPulsating={false} color={PLACEHOLDER_COLOR} />; } return amountFunction(); } @@ -66,7 +103,7 @@ export class InstantHeading extends React.Component<InstantHeadingProps, {}> { {format.ethBaseAmount( this.props.totalEthBaseAmount, 4, - <AmountPlaceholder isPulsating={false} color={placeholderColor} />, + <AmountPlaceholder isPulsating={false} color={PLACEHOLDER_COLOR} />, )} </Text> ); diff --git a/packages/instant/src/components/placing_order_button.tsx b/packages/instant/src/components/placing_order_button.tsx new file mode 100644 index 000000000..4232e6c22 --- /dev/null +++ b/packages/instant/src/components/placing_order_button.tsx @@ -0,0 +1,19 @@ +import * as React from 'react'; + +import { ColorOption } from '../style/theme'; + +import { Button } from './ui/button'; +import { Container } from './ui/container'; +import { Spinner } from './ui/spinner'; +import { Text } from './ui/text'; + +export const PlacingOrderButton: React.StatelessComponent<{}> = props => ( + <Button isDisabled={true} width="100%"> + <Container display="inline-block" position="relative" top="3px" marginRight="8px"> + <Spinner widthPx={20} heightPx={20} /> + </Container> + <Text fontColor={ColorOption.white} fontWeight={600} fontSize="20px"> + Placing Order… + </Text> + </Button> +); diff --git a/packages/instant/src/components/retry_button.tsx b/packages/instant/src/components/retry_button.tsx new file mode 100644 index 000000000..0d6188e6a --- /dev/null +++ b/packages/instant/src/components/retry_button.tsx @@ -0,0 +1,11 @@ +import * as React from 'react'; + +import { SecondaryButton } from './secondary_button'; + +export interface RetryButtonProps { + onClick: () => void; +} + +export const RetryButton: React.StatelessComponent<RetryButtonProps> = props => { + return <SecondaryButton onClick={props.onClick}>Try Again</SecondaryButton>; +}; diff --git a/packages/instant/src/components/secondary_button.tsx b/packages/instant/src/components/secondary_button.tsx new file mode 100644 index 000000000..3c139a233 --- /dev/null +++ b/packages/instant/src/components/secondary_button.tsx @@ -0,0 +1,26 @@ +import * as _ from 'lodash'; +import * as React from 'react'; + +import { ColorOption } from '../style/theme'; + +import { Button, ButtonProps } from './ui/button'; +import { Text } from './ui/text'; + +export interface SecondaryButtonProps extends ButtonProps {} + +export const SecondaryButton: React.StatelessComponent<SecondaryButtonProps> = props => { + const buttonProps = _.omit(props, 'text'); + return ( + <Button + backgroundColor={ColorOption.white} + borderColor={ColorOption.lightGrey} + width="100%" + onClick={props.onClick} + {...buttonProps} + > + <Text fontColor={ColorOption.primaryColor} fontWeight={600} fontSize="16px"> + {props.children} + </Text> + </Button> + ); +}; diff --git a/packages/instant/src/components/ui/icon.tsx b/packages/instant/src/components/ui/icon.tsx new file mode 100644 index 000000000..7373c3acd --- /dev/null +++ b/packages/instant/src/components/ui/icon.tsx @@ -0,0 +1,58 @@ +import * as React from 'react'; + +import { ColorOption } from '../../style/theme'; + +type svgRule = 'evenodd' | 'nonzero' | 'inherit'; +interface IconInfo { + viewBox: string; + fillRule?: svgRule; + clipRule?: svgRule; + path: string; +} +interface IconInfoMapping { + failed: IconInfo; + success: IconInfo; +} +const ICONS: IconInfoMapping = { + failed: { + viewBox: '0 0 34 34', + fillRule: 'evenodd', + clipRule: 'evenodd', + path: + 'M6.65771 26.4362C9.21777 29.2406 12.9033 31 17 31C24.7319 31 31 24.7319 31 17C31 14.4468 30.3164 12.0531 29.1226 9.99219L6.65771 26.4362ZM4.88281 24.0173C3.68555 21.9542 3 19.5571 3 17C3 9.26807 9.26807 3 17 3C21.1006 3 24.7891 4.76294 27.3496 7.57214L4.88281 24.0173ZM0 17C0 26.3888 7.61133 34 17 34C26.3887 34 34 26.3888 34 17C34 7.61121 26.3887 0 17 0C7.61133 0 0 7.61121 0 17Z', + }, + success: { + viewBox: '0 0 34 34', + fillRule: 'evenodd', + clipRule: 'evenodd', + path: + 'M17 34C26.3887 34 34 26.3888 34 17C34 7.61121 26.3887 0 17 0C7.61133 0 0 7.61121 0 17C0 26.3888 7.61133 34 17 34ZM25.7539 13.0977C26.2969 12.4718 26.2295 11.5244 25.6035 10.9817C24.9775 10.439 24.0303 10.5063 23.4878 11.1323L15.731 20.0771L12.3936 16.7438C11.8071 16.1583 10.8574 16.1589 10.272 16.7451C9.68652 17.3313 9.6875 18.281 10.2734 18.8665L14.75 23.3373L15.8887 24.4746L16.9434 23.2587L25.7539 13.0977Z', + }, +}; + +export interface IconProps { + width: number; + height: number; + color: ColorOption; + icon: keyof IconInfoMapping; +} +export const Icon: React.SFC<IconProps> = props => { + const iconInfo = ICONS[props.icon]; + + return ( + <svg + width={props.width} + height={props.height} + viewBox={iconInfo.viewBox} + fill="none" + xmlns="http://www.w3.org/2000/svg" + > + <path + d={iconInfo.path} + fill={props.color} + fillRule={iconInfo.fillRule || 'nonzero'} + clipRule={iconInfo.clipRule || 'nonzero'} + /> + </svg> + ); +}; diff --git a/packages/instant/src/components/ui/spinner.tsx b/packages/instant/src/components/ui/spinner.tsx new file mode 100644 index 000000000..28ebc2598 --- /dev/null +++ b/packages/instant/src/components/ui/spinner.tsx @@ -0,0 +1,30 @@ +import * as React from 'react'; + +import { FullRotation } from '../animations/full_rotation'; + +export interface SpinnerProps { + widthPx: number; + heightPx: number; +} +export const Spinner: React.StatelessComponent<SpinnerProps> = props => { + return ( + <FullRotation width={`${props.widthPx}px`} height={`${props.heightPx}px`}> + <svg + width={props.widthPx} + height={props.heightPx} + viewBox="0 0 34 34" + fill="none" + xmlns="http://www.w3.org/2000/svg" + > + <circle cx="17" cy="17" r="15" stroke="white" strokeOpacity="0.2" strokeWidth="4" /> + <path + d="M17 32C25.2843 32 32 25.2843 32 17C32 8.71573 25.2843 2 17 2" + stroke="white" + strokeWidth="4" + strokeLinecap="round" + strokeLinejoin="round" + /> + </svg> + </FullRotation> + ); +}; diff --git a/packages/instant/src/components/view_transaction_button.tsx b/packages/instant/src/components/view_transaction_button.tsx new file mode 100644 index 000000000..7aa44e657 --- /dev/null +++ b/packages/instant/src/components/view_transaction_button.tsx @@ -0,0 +1,11 @@ +import * as React from 'react'; + +import { SecondaryButton } from './secondary_button'; + +export interface ViewTransactionButtonProps { + onClick: () => void; +} + +export const ViewTransactionButton: React.StatelessComponent<ViewTransactionButtonProps> = props => { + return <SecondaryButton onClick={props.onClick}>View Transaction</SecondaryButton>; +}; diff --git a/packages/instant/src/components/zero_ex_instant.tsx b/packages/instant/src/components/zero_ex_instant.tsx index f6472e811..ffa5a8250 100644 --- a/packages/instant/src/components/zero_ex_instant.tsx +++ b/packages/instant/src/components/zero_ex_instant.tsx @@ -1,23 +1,75 @@ +import { AssetBuyer } from '@0x/asset-buyer'; +import { ObjectMap } from '@0x/types'; import * as React from 'react'; import { Provider } from 'react-redux'; +import { SelectedAssetThemeProvider } from '../containers/selected_asset_theme_provider'; import { asyncData } from '../redux/async_data'; -import { store } from '../redux/store'; +import { INITIAL_STATE, State } from '../redux/reducer'; +import { store, Store } from '../redux/store'; import { fonts } from '../style/fonts'; -import { theme, ThemeProvider } from '../style/theme'; +import { AssetMetaData, Network } from '../types'; +import { assetUtils } from '../util/asset'; +import { getProvider } from '../util/provider'; import { ZeroExInstantContainer } from './zero_ex_instant_container'; fonts.include(); -// tslint:disable-next-line:no-floating-promises -asyncData.fetchAndDispatchToStore(); - -export interface ZeroExInstantProps {} - -export const ZeroExInstant: React.StatelessComponent<ZeroExInstantProps> = () => ( - <Provider store={store}> - <ThemeProvider theme={theme}> - <ZeroExInstantContainer /> - </ThemeProvider> - </Provider> -); + +export type ZeroExInstantProps = ZeroExInstantRequiredProps & Partial<ZeroExInstantOptionalProps>; + +export interface ZeroExInstantRequiredProps { + // TODO: Change API when we allow the selection of different assetDatas + assetData: string; + // TODO: Allow for a function that returns orders + liquiditySource: string; +} + +export interface ZeroExInstantOptionalProps { + additionalAssetMetaDataMap: ObjectMap<AssetMetaData>; + network: Network; +} + +export class ZeroExInstant extends React.Component<ZeroExInstantProps> { + private readonly _store: Store; + private static _mergeInitialStateWithProps(props: ZeroExInstantProps, state: State = INITIAL_STATE): State { + // Create merged object such that properties in props override default settings + const optionalPropsWithDefaults: ZeroExInstantOptionalProps = { + additionalAssetMetaDataMap: props.additionalAssetMetaDataMap || {}, + network: props.network || state.network, + }; + const { network } = optionalPropsWithDefaults; + // TODO: Provider needs to not be hard-coded to injected web3. + const assetBuyer = AssetBuyer.getAssetBuyerForStandardRelayerAPIUrl(getProvider(), props.liquiditySource, { + networkId: network, + }); + const completeAssetMetaDataMap = { + ...props.additionalAssetMetaDataMap, + ...state.assetMetaDataMap, + }; + const storeStateFromProps: State = { + ...state, + assetBuyer, + network, + selectedAsset: assetUtils.createAssetFromAssetData(props.assetData, completeAssetMetaDataMap, network), + assetMetaDataMap: completeAssetMetaDataMap, + }; + return storeStateFromProps; + } + constructor(props: ZeroExInstantProps) { + super(props); + this._store = store.create(ZeroExInstant._mergeInitialStateWithProps(this.props, INITIAL_STATE)); + // tslint:disable-next-line:no-floating-promises + asyncData.fetchAndDispatchToStore(this._store); + } + + public render(): React.ReactNode { + return ( + <Provider store={this._store}> + <SelectedAssetThemeProvider> + <ZeroExInstantContainer /> + </SelectedAssetThemeProvider> + </Provider> + ); + } +} diff --git a/packages/instant/src/components/zero_ex_instant_container.tsx b/packages/instant/src/components/zero_ex_instant_container.tsx index cf918d890..1d17ed12a 100644 --- a/packages/instant/src/components/zero_ex_instant_container.tsx +++ b/packages/instant/src/components/zero_ex_instant_container.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import { LatestBuyQuoteOrderDetails } from '../containers/latest_buy_quote_order_details'; import { LatestError } from '../containers/latest_error'; -import { SelectedAssetBuyButton } from '../containers/selected_asset_buy_button'; +import { SelectedAssetBuyOrderStateButton } from '../containers/selected_asset_buy_order_state_button'; import { SelectedAssetInstantHeading } from '../containers/selected_asset_instant_heading'; import { ColorOption } from '../style/theme'; @@ -26,7 +26,9 @@ export const ZeroExInstantContainer: React.StatelessComponent<ZeroExInstantConta <Flex direction="column" justify="flex-start"> <SelectedAssetInstantHeading /> <LatestBuyQuoteOrderDetails /> - <SelectedAssetBuyButton /> + <Container padding="20px" width="100%"> + <SelectedAssetBuyOrderStateButton /> + </Container> </Flex> </Container> </Container> diff --git a/packages/instant/src/constants.ts b/packages/instant/src/constants.ts index 5d5341f9c..31491c80a 100644 --- a/packages/instant/src/constants.ts +++ b/packages/instant/src/constants.ts @@ -1,6 +1,4 @@ import { BigNumber } from '@0x/utils'; export const BIG_NUMBER_ZERO = new BigNumber(0); -export const sraApiUrl = 'https://api.radarrelay.com/0x/v2/'; -export const zrxAssetData = '0xf47261b0000000000000000000000000e41d2489571d322189246dafa5ebde1f4699f498'; -export const zrxDecimals = 18; export const ethDecimals = 18; +export const DEFAULT_ZERO_EX_CONTAINER_SELECTOR = '#zeroExInstantContainer'; diff --git a/packages/instant/src/containers/latest_error.tsx b/packages/instant/src/containers/latest_error.tsx index 413cf16ad..b75ec00aa 100644 --- a/packages/instant/src/containers/latest_error.tsx +++ b/packages/instant/src/containers/latest_error.tsx @@ -4,11 +4,11 @@ import { connect } from 'react-redux'; import { SlidingError } from '../components/sliding_error'; import { State } from '../redux/reducer'; -import { DisplayStatus } from '../types'; +import { Asset, DisplayStatus } from '../types'; import { errorUtil } from '../util/error'; export interface LatestErrorComponentProps { - assetData?: string; + asset?: Asset; latestError?: any; slidingDirection: 'down' | 'up'; } @@ -17,18 +17,18 @@ export const LatestErrorComponent: React.StatelessComponent<LatestErrorComponent if (!props.latestError) { return <div />; } - const { icon, message } = errorUtil.errorDescription(props.latestError, props.assetData); + const { icon, message } = errorUtil.errorDescription(props.latestError, props.asset); return <SlidingError direction={props.slidingDirection} icon={icon} message={message} />; }; interface ConnectedState { - assetData?: string; + asset?: Asset; latestError?: any; slidingDirection: 'down' | 'up'; } export interface LatestErrorProps {} const mapStateToProps = (state: State, _ownProps: LatestErrorProps): ConnectedState => ({ - assetData: state.selectedAssetData, + asset: state.selectedAsset, latestError: state.latestError, slidingDirection: state.latestErrorDisplay === DisplayStatus.Present ? 'up' : 'down', }); diff --git a/packages/instant/src/containers/selected_asset_amount_input.ts b/packages/instant/src/containers/selected_asset_amount_input.ts index e55c8b991..f23b2010e 100644 --- a/packages/instant/src/containers/selected_asset_amount_input.ts +++ b/packages/instant/src/containers/selected_asset_amount_input.ts @@ -1,4 +1,5 @@ -import { BuyQuote } from '@0x/asset-buyer'; +import { AssetBuyer, BuyQuote } from '@0x/asset-buyer'; +import { AssetProxyId } from '@0x/types'; import { BigNumber } from '@0x/utils'; import { Web3Wrapper } from '@0x/web3-wrapper'; import * as _ from 'lodash'; @@ -6,12 +7,10 @@ import * as React from 'react'; import { connect } from 'react-redux'; import { Dispatch } from 'redux'; -import { zrxDecimals } from '../constants'; import { Action, actions } from '../redux/actions'; import { State } from '../redux/reducer'; import { ColorOption } from '../style/theme'; -import { AsyncProcessState } from '../types'; -import { assetBuyer } from '../util/asset_buyer'; +import { AsyncProcessState, ERC20Asset } from '../types'; import { errorUtil } from '../util/error'; import { AssetAmountInput } from '../components/asset_amount_input'; @@ -22,33 +21,52 @@ export interface SelectedAssetAmountInputProps { } interface ConnectedState { + assetBuyer?: AssetBuyer; value?: BigNumber; - assetData?: string; + asset?: ERC20Asset; } interface ConnectedDispatch { - onChange: (value?: BigNumber, assetData?: string) => void; + updateBuyQuote: (assetBuyer?: AssetBuyer, value?: BigNumber, asset?: ERC20Asset) => void; } -const mapStateToProps = (state: State, _ownProps: SelectedAssetAmountInputProps): ConnectedState => ({ - value: state.selectedAssetAmount, - assetData: state.selectedAssetData, -}); +interface ConnectedProps { + value?: BigNumber; + asset?: ERC20Asset; + onChange: (value?: BigNumber, asset?: ERC20Asset) => void; +} + +type FinalProps = ConnectedProps & SelectedAssetAmountInputProps; + +const mapStateToProps = (state: State, _ownProps: SelectedAssetAmountInputProps): ConnectedState => { + const selectedAsset = state.selectedAsset; + if (_.isUndefined(selectedAsset) || selectedAsset.metaData.assetProxyId !== AssetProxyId.ERC20) { + return { + value: state.selectedAssetAmount, + }; + } + return { + assetBuyer: state.assetBuyer, + value: state.selectedAssetAmount, + asset: selectedAsset as ERC20Asset, + }; +}; const updateBuyQuoteAsync = async ( + assetBuyer: AssetBuyer, dispatch: Dispatch<Action>, - assetData: string, + asset: ERC20Asset, assetAmount: BigNumber, ): Promise<void> => { // get a new buy quote. - const baseUnitValue = Web3Wrapper.toBaseUnitAmount(assetAmount, zrxDecimals); + const baseUnitValue = Web3Wrapper.toBaseUnitAmount(assetAmount, asset.metaData.decimals); // mark quote as pending dispatch(actions.setQuoteRequestStatePending()); let newBuyQuote: BuyQuote | undefined; try { - newBuyQuote = await assetBuyer.getBuyQuoteAsync(assetData, baseUnitValue); + newBuyQuote = await assetBuyer.getBuyQuoteAsync(asset.assetData, baseUnitValue); } catch (error) { dispatch(actions.setQuoteRequestStateFailure()); errorUtil.errorFlasher.flashNewError(dispatch, error); @@ -66,24 +84,40 @@ const mapDispatchToProps = ( dispatch: Dispatch<Action>, _ownProps: SelectedAssetAmountInputProps, ): ConnectedDispatch => ({ - onChange: (value, assetData) => { + updateBuyQuote: (assetBuyer, value, asset) => { // Update the input dispatch(actions.updateSelectedAssetAmount(value)); // invalidate the last buy quote. dispatch(actions.updateLatestBuyQuote(undefined)); // reset our buy state - dispatch(actions.updateBuyOrderState(AsyncProcessState.NONE)); + dispatch(actions.updateBuyOrderState({ processState: AsyncProcessState.NONE })); - if (!_.isUndefined(value) && !_.isUndefined(assetData)) { + if (!_.isUndefined(value) && !_.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(dispatch, assetData, value); + debouncedUpdateBuyQuoteAsync(assetBuyer, dispatch, asset, value); } }, }); +const mergeProps = ( + connectedState: ConnectedState, + connectedDispatch: ConnectedDispatch, + ownProps: SelectedAssetAmountInputProps, +): FinalProps => { + return { + ...ownProps, + asset: connectedState.asset, + value: connectedState.value, + onChange: (value, asset) => { + connectedDispatch.updateBuyQuote(connectedState.assetBuyer, value, asset); + }, + }; +}; + export const SelectedAssetAmountInput: React.ComponentClass<SelectedAssetAmountInputProps> = connect( mapStateToProps, mapDispatchToProps, + mergeProps, )(AssetAmountInput); diff --git a/packages/instant/src/containers/selected_asset_buy_button.ts b/packages/instant/src/containers/selected_asset_buy_button.ts index 4d3315b1a..428939e79 100644 --- a/packages/instant/src/containers/selected_asset_buy_button.ts +++ b/packages/instant/src/containers/selected_asset_buy_button.ts @@ -1,4 +1,4 @@ -import { BuyQuote } from '@0x/asset-buyer'; +import { AssetBuyer, BuyQuote } from '@0x/asset-buyer'; import * as _ from 'lodash'; import * as React from 'react'; import { connect } from 'react-redux'; @@ -13,40 +13,31 @@ import { BuyButton } from '../components/buy_button'; export interface SelectedAssetBuyButtonProps {} interface ConnectedState { - text: string; + assetBuyer?: AssetBuyer; buyQuote?: BuyQuote; } interface ConnectedDispatch { onClick: (buyQuote: BuyQuote) => void; - onBuySuccess: (buyQuote: BuyQuote) => void; + onBuySuccess: (buyQuote: BuyQuote, txnHash: string) => void; onBuyFailure: (buyQuote: BuyQuote) => void; + onBuyPrevented: (buyQuote: BuyQuote, error: Error) => void; } -const textForState = (state: AsyncProcessState): string => { - switch (state) { - case AsyncProcessState.NONE: - return 'Buy'; - case AsyncProcessState.PENDING: - return '...Loading'; - case AsyncProcessState.SUCCESS: - return 'Success!'; - case AsyncProcessState.FAILURE: - return 'Failed'; - default: - return 'Buy'; - } -}; - const mapStateToProps = (state: State, _ownProps: SelectedAssetBuyButtonProps): ConnectedState => ({ - text: textForState(state.buyOrderState), + assetBuyer: state.assetBuyer, buyQuote: state.latestBuyQuote, }); const mapDispatchToProps = (dispatch: Dispatch<Action>, ownProps: SelectedAssetBuyButtonProps): ConnectedDispatch => ({ - onClick: buyQuote => dispatch(actions.updateBuyOrderState(AsyncProcessState.PENDING)), - onBuySuccess: buyQuote => dispatch(actions.updateBuyOrderState(AsyncProcessState.SUCCESS)), - onBuyFailure: buyQuote => dispatch(actions.updateBuyOrderState(AsyncProcessState.FAILURE)), + onClick: buyQuote => dispatch(actions.updateBuyOrderState({ processState: AsyncProcessState.PENDING })), + onBuySuccess: (buyQuote: BuyQuote, txnHash: string) => + dispatch(actions.updateBuyOrderState({ processState: AsyncProcessState.SUCCESS, txnHash })), + onBuyFailure: buyQuote => dispatch(actions.updateBuyOrderState({ processState: AsyncProcessState.FAILURE })), + onBuyPrevented: (buyQuote, error) => { + dispatch(actions.resetAmount()); + dispatch(actions.setError(error)); + }, }); export const SelectedAssetBuyButton: React.ComponentClass<SelectedAssetBuyButtonProps> = connect( diff --git a/packages/instant/src/containers/selected_asset_buy_order_state_button.tsx b/packages/instant/src/containers/selected_asset_buy_order_state_button.tsx new file mode 100644 index 000000000..f3efbb5d2 --- /dev/null +++ b/packages/instant/src/containers/selected_asset_buy_order_state_button.tsx @@ -0,0 +1,20 @@ +import * as _ from 'lodash'; +import * as React from 'react'; +import { connect } from 'react-redux'; + +import { State } from '../redux/reducer'; +import { AsyncProcessState } from '../types'; + +import { BuyOrderStateButton } from '../components/buy_order_state_button'; + +interface ConnectedState { + buyOrderProcessingState: AsyncProcessState; +} +export interface SelectedAssetButtonProps {} +const mapStateToProps = (state: State, _ownProps: SelectedAssetButtonProps): ConnectedState => ({ + buyOrderProcessingState: state.buyOrderState.processState, +}); + +export const SelectedAssetBuyOrderStateButton: React.ComponentClass<SelectedAssetButtonProps> = connect( + mapStateToProps, +)(BuyOrderStateButton); diff --git a/packages/instant/src/containers/selected_asset_instant_heading.ts b/packages/instant/src/containers/selected_asset_instant_heading.ts index 43127582c..6b2a29b07 100644 --- a/packages/instant/src/containers/selected_asset_instant_heading.ts +++ b/packages/instant/src/containers/selected_asset_instant_heading.ts @@ -5,7 +5,7 @@ import { connect } from 'react-redux'; import { oc } from 'ts-optchain'; import { State } from '../redux/reducer'; -import { AsyncProcessState } from '../types'; +import { AsyncProcessState, OrderState } from '../types'; import { InstantHeading } from '../components/instant_heading'; @@ -16,6 +16,7 @@ interface ConnectedState { totalEthBaseAmount?: BigNumber; ethUsdPrice?: BigNumber; quoteRequestState: AsyncProcessState; + buyOrderState: OrderState; } const mapStateToProps = (state: State, _ownProps: InstantHeadingProps): ConnectedState => ({ @@ -23,6 +24,7 @@ const mapStateToProps = (state: State, _ownProps: InstantHeadingProps): Connecte totalEthBaseAmount: oc(state).latestBuyQuote.worstCaseQuoteInfo.totalEthAmount(), ethUsdPrice: state.ethUsdPrice, quoteRequestState: state.quoteRequestState, + buyOrderState: state.buyOrderState, }); export const SelectedAssetInstantHeading: React.ComponentClass<InstantHeadingProps> = connect(mapStateToProps)( diff --git a/packages/instant/src/containers/selected_asset_retry_button.tsx b/packages/instant/src/containers/selected_asset_retry_button.tsx new file mode 100644 index 000000000..b2b140be6 --- /dev/null +++ b/packages/instant/src/containers/selected_asset_retry_button.tsx @@ -0,0 +1,26 @@ +import * as _ from 'lodash'; +import * as React from 'react'; +import { connect } from 'react-redux'; +import { Dispatch } from 'redux'; + +import { Action, actions } from '../redux/actions'; + +import { RetryButton } from '../components/retry_button'; + +export interface SelectedAssetRetryButtonProps {} + +interface ConnectedDispatch { + onClick: () => void; +} + +const mapDispatchToProps = ( + dispatch: Dispatch<Action>, + _ownProps: SelectedAssetRetryButtonProps, +): ConnectedDispatch => ({ + onClick: () => dispatch(actions.resetAmount()), +}); + +export const SelectedAssetRetryButton: React.ComponentClass<SelectedAssetRetryButtonProps> = connect( + undefined, + mapDispatchToProps, +)(RetryButton); diff --git a/packages/instant/src/containers/selected_asset_theme_provider.ts b/packages/instant/src/containers/selected_asset_theme_provider.ts new file mode 100644 index 000000000..6e6b83d73 --- /dev/null +++ b/packages/instant/src/containers/selected_asset_theme_provider.ts @@ -0,0 +1,32 @@ +import * as _ from 'lodash'; +import * as React from 'react'; +import { connect } from 'react-redux'; + +import { State } from '../redux/reducer'; +import { Theme, theme as defaultTheme, ThemeProvider } from '../style/theme'; +import { Asset } from '../types'; + +export interface SelectedAssetThemeProviderProps {} + +interface ConnectedState { + theme: Theme; +} + +const getTheme = (asset?: Asset): Theme => { + if (!_.isUndefined(asset) && !_.isUndefined(asset.metaData.primaryColor)) { + return { + ...defaultTheme, + primaryColor: asset.metaData.primaryColor, + }; + } + return defaultTheme; +}; + +const mapStateToProps = (state: State, _ownProps: SelectedAssetThemeProviderProps): ConnectedState => { + const theme = getTheme(state.selectedAsset); + return { theme }; +}; + +export const SelectedAssetThemeProvider: React.ComponentClass<SelectedAssetThemeProviderProps> = connect( + mapStateToProps, +)(ThemeProvider); diff --git a/packages/instant/src/containers/selected_asset_view_transaction_button.tsx b/packages/instant/src/containers/selected_asset_view_transaction_button.tsx new file mode 100644 index 000000000..6f42b9f85 --- /dev/null +++ b/packages/instant/src/containers/selected_asset_view_transaction_button.tsx @@ -0,0 +1,34 @@ +import * as _ from 'lodash'; +import * as React from 'react'; +import { connect } from 'react-redux'; + +import { State } from '../redux/reducer'; + +import { ViewTransactionButton } from '../components/view_transaction_button'; +import { AsyncProcessState } from '../types'; +import { etherscanUtil } from '../util/etherscan'; + +export interface SelectedAssetViewTransactionButtonProps {} + +interface ConnectedState { + onClick: () => void; +} + +const mapStateToProps = (state: State, _ownProps: {}): ConnectedState => ({ + onClick: () => { + if (state.assetBuyer && state.buyOrderState.processState === AsyncProcessState.SUCCESS) { + const etherscanUrl = etherscanUtil.getEtherScanTxnAddressIfExists( + state.buyOrderState.txnHash, + state.assetBuyer.networkId, + ); + if (etherscanUrl) { + window.open(etherscanUrl, '_blank'); + return; + } + } + }, +}); + +export const SelectedAssetViewTransactionButton: React.ComponentClass< + SelectedAssetViewTransactionButtonProps +> = connect(mapStateToProps)(ViewTransactionButton); diff --git a/packages/instant/src/data/asset_data_network_mapping.ts b/packages/instant/src/data/asset_data_network_mapping.ts new file mode 100644 index 000000000..e8ccbf011 --- /dev/null +++ b/packages/instant/src/data/asset_data_network_mapping.ts @@ -0,0 +1,15 @@ +import * as _ from 'lodash'; + +import { Network } from '../types'; + +interface AssetDataByNetwork { + [Network.Kovan]?: string; + [Network.Mainnet]?: string; +} + +export const assetDataNetworkMapping: AssetDataByNetwork[] = [ + { + [Network.Mainnet]: '0xf47261b0000000000000000000000000e41d2489571d322189246dafa5ebde1f4699f498', + [Network.Kovan]: '0xf47261b00000000000000000000000002002d3812f58e35f0ea1ffbf80a75a38c32175fa', + }, +]; diff --git a/packages/instant/src/data/asset_meta_data.ts b/packages/instant/src/data/asset_meta_data_map.ts index ae0d32e4b..3a820a0c4 100644 --- a/packages/instant/src/data/asset_meta_data.ts +++ b/packages/instant/src/data/asset_meta_data_map.ts @@ -1,12 +1,11 @@ import { AssetProxyId, ObjectMap } from '@0x/types'; -import { zrxAssetData } from '../constants'; import { AssetMetaData } from '../types'; // Map from assetData string to AssetMetaData object // TODO: import this from somewhere else. -export const assetMetaData: ObjectMap<AssetMetaData> = { - [zrxAssetData]: { +export const assetMetaDataMap: ObjectMap<AssetMetaData> = { + '0xf47261b0000000000000000000000000e41d2489571d322189246dafa5ebde1f4699f498': { assetProxyId: AssetProxyId.ERC20, decimals: 18, primaryColor: 'rgb(54, 50, 60)', diff --git a/packages/instant/src/index.umd.ts b/packages/instant/src/index.umd.ts index d4eca177d..f648b37f2 100644 --- a/packages/instant/src/index.umd.ts +++ b/packages/instant/src/index.umd.ts @@ -1,10 +1,9 @@ import * as React from 'react'; import * as ReactDOM from 'react-dom'; -import { ZeroExInstant } from './index'; +import { DEFAULT_ZERO_EX_CONTAINER_SELECTOR } from './constants'; +import { ZeroExInstant, ZeroExInstantProps } from './index'; -export interface ZeroExInstantOptions {} - -export const render = (props: ZeroExInstantOptions, selector: string = '#zeroExInstantContainer') => { +export const render = (props: ZeroExInstantProps, selector: string = DEFAULT_ZERO_EX_CONTAINER_SELECTOR) => { ReactDOM.render(React.createElement(ZeroExInstant, props), document.querySelector(selector)); }; diff --git a/packages/instant/src/redux/actions.ts b/packages/instant/src/redux/actions.ts index e52a79e76..5a4099f15 100644 --- a/packages/instant/src/redux/actions.ts +++ b/packages/instant/src/redux/actions.ts @@ -2,7 +2,7 @@ import { BuyQuote } from '@0x/asset-buyer'; import { BigNumber } from '@0x/utils'; import * as _ from 'lodash'; -import { ActionsUnion, AsyncProcessState } from '../types'; +import { ActionsUnion, OrderState } from '../types'; export interface PlainAction<T extends string> { type: T; @@ -23,24 +23,27 @@ 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_SELECTED_ASSET_BUY_STATE = 'UPDATE_SELECTED_ASSET_BUY_STATE', + UPDATE_BUY_ORDER_STATE = 'UPDATE_BUY_ORDER_STATE', UPDATE_LATEST_BUY_QUOTE = 'UPDATE_LATEST_BUY_QUOTE', + UPDATE_SELECTED_ASSET = 'UPDATE_SELECTED_ASSET', SET_QUOTE_REQUEST_STATE_PENDING = 'SET_QUOTE_REQUEST_STATE_PENDING', SET_QUOTE_REQUEST_STATE_FAILURE = 'SET_QUOTE_REQUEST_STATE_FAILURE', SET_ERROR = 'SET_ERROR', HIDE_ERROR = 'HIDE_ERROR', CLEAR_ERROR = 'CLEAR_ERROR', + RESET_AMOUNT = 'RESET_AMOUNT', } export const actions = { updateEthUsdPrice: (price?: BigNumber) => createAction(ActionTypes.UPDATE_ETH_USD_PRICE, price), updateSelectedAssetAmount: (amount?: BigNumber) => createAction(ActionTypes.UPDATE_SELECTED_ASSET_AMOUNT, amount), - updateBuyOrderState: (buyState: AsyncProcessState) => - createAction(ActionTypes.UPDATE_SELECTED_ASSET_BUY_STATE, buyState), + updateBuyOrderState: (orderState: OrderState) => createAction(ActionTypes.UPDATE_BUY_ORDER_STATE, orderState), 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), setQuoteRequestStateFailure: () => createAction(ActionTypes.SET_QUOTE_REQUEST_STATE_FAILURE), setError: (error?: any) => createAction(ActionTypes.SET_ERROR, error), hideError: () => createAction(ActionTypes.HIDE_ERROR), clearError: () => createAction(ActionTypes.CLEAR_ERROR), + resetAmount: () => createAction(ActionTypes.RESET_AMOUNT), }; diff --git a/packages/instant/src/redux/async_data.ts b/packages/instant/src/redux/async_data.ts index 348838307..4ed89bdc3 100644 --- a/packages/instant/src/redux/async_data.ts +++ b/packages/instant/src/redux/async_data.ts @@ -3,10 +3,10 @@ import { coinbaseApi } from '../util/coinbase_api'; import { ActionTypes } from './actions'; -import { store } from './store'; +import { Store } from './store'; export const asyncData = { - fetchAndDispatchToStore: async () => { + fetchAndDispatchToStore: async (store: Store) => { let ethUsdPrice = BIG_NUMBER_ZERO; try { ethUsdPrice = await coinbaseApi.getEthUsdPrice(); diff --git a/packages/instant/src/redux/reducer.ts b/packages/instant/src/redux/reducer.ts index 2d50dd4b9..c6a05ac52 100644 --- a/packages/instant/src/redux/reducer.ts +++ b/packages/instant/src/redux/reducer.ts @@ -1,16 +1,21 @@ -import { BuyQuote } from '@0x/asset-buyer'; +import { AssetBuyer, BuyQuote } from '@0x/asset-buyer'; +import { ObjectMap } from '@0x/types'; import { BigNumber } from '@0x/utils'; import * as _ from 'lodash'; -import { zrxAssetData } from '../constants'; -import { AsyncProcessState, DisplayStatus } from '../types'; +import { assetMetaDataMap } from '../data/asset_meta_data_map'; +import { Asset, AssetMetaData, AsyncProcessState, DisplayStatus, Network, OrderState } from '../types'; +import { assetUtils } from '../util/asset'; import { Action, ActionTypes } from './actions'; export interface State { - selectedAssetData?: string; + network: Network; + assetBuyer?: AssetBuyer; + assetMetaDataMap: ObjectMap<AssetMetaData>; + selectedAsset?: Asset; selectedAssetAmount?: BigNumber; - buyOrderState: AsyncProcessState; + buyOrderState: OrderState; ethUsdPrice?: BigNumber; latestBuyQuote?: BuyQuote; quoteRequestState: AsyncProcessState; @@ -19,10 +24,10 @@ export interface State { } export const INITIAL_STATE: State = { - // TODO: Remove hardcoded zrxAssetData - selectedAssetData: zrxAssetData, + network: Network.Mainnet, selectedAssetAmount: undefined, - buyOrderState: AsyncProcessState.NONE, + assetMetaDataMap, + buyOrderState: { processState: AsyncProcessState.NONE }, ethUsdPrice: undefined, latestBuyQuote: undefined, latestError: undefined, @@ -60,7 +65,7 @@ export const reducer = (state: State = INITIAL_STATE, action: Action): State => latestBuyQuote: undefined, quoteRequestState: AsyncProcessState.FAILURE, }; - case ActionTypes.UPDATE_SELECTED_ASSET_BUY_STATE: + case ActionTypes.UPDATE_BUY_ORDER_STATE: return { ...state, buyOrderState: action.data, @@ -82,6 +87,28 @@ export const reducer = (state: State = INITIAL_STATE, action: Action): State => latestError: undefined, latestErrorDisplay: DisplayStatus.Hidden, }; + case ActionTypes.UPDATE_SELECTED_ASSET: + const newSelectedAssetData = action.data; + let newSelectedAsset: Asset | undefined; + if (!_.isUndefined(newSelectedAssetData)) { + newSelectedAsset = assetUtils.createAssetFromAssetData( + newSelectedAssetData, + state.assetMetaDataMap, + state.network, + ); + } + return { + ...state, + selectedAsset: newSelectedAsset, + }; + case ActionTypes.RESET_AMOUNT: + return { + ...state, + latestBuyQuote: undefined, + quoteRequestState: AsyncProcessState.NONE, + buyOrderState: { processState: AsyncProcessState.NONE }, + selectedAssetAmount: undefined, + }; default: return state; } diff --git a/packages/instant/src/redux/store.ts b/packages/instant/src/redux/store.ts index b9ce9c0c1..01deb8690 100644 --- a/packages/instant/src/redux/store.ts +++ b/packages/instant/src/redux/store.ts @@ -4,4 +4,10 @@ import { devToolsEnhancer } from 'redux-devtools-extension/developmentOnly'; import { reducer, State } from './reducer'; -export const store: ReduxStore<State> = createStore(reducer, devToolsEnhancer({})); +export type Store = ReduxStore<State>; + +export const store = { + create: (state: State): Store => { + return createStore(reducer, state, devToolsEnhancer({})); + }, +}; diff --git a/packages/instant/src/types.ts b/packages/instant/src/types.ts index 013ada27b..c5521c63c 100644 --- a/packages/instant/src/types.ts +++ b/packages/instant/src/types.ts @@ -7,6 +7,20 @@ export enum AsyncProcessState { SUCCESS = 'Success', FAILURE = 'Failure', } + +interface RegularOrderState { + processState: AsyncProcessState.NONE | AsyncProcessState.PENDING; +} +interface SuccessfulOrderState { + processState: AsyncProcessState.SUCCESS; + txnHash: string; +} +interface FailureOrderState { + processState: AsyncProcessState.FAILURE; + txnHash?: string; +} +export type OrderState = RegularOrderState | SuccessfulOrderState | FailureOrderState; + export enum DisplayStatus { Present, Hidden, @@ -26,12 +40,32 @@ export interface ERC20AssetMetaData { export interface ERC721AssetMetaData { assetProxyId: AssetProxyId.ERC721; name: string; + representationUrl?: string; primaryColor?: string; } export type AssetMetaData = ERC20AssetMetaData | ERC721AssetMetaData; +export interface ERC20Asset { + assetData: string; + metaData: ERC20AssetMetaData; +} + +export interface ERC721Asset { + assetData: string; + metaData: ERC721AssetMetaData; +} + +export interface Asset { + assetData: string; + metaData: AssetMetaData; +} + export enum Network { Kovan = 42, Mainnet = 1, } + +export enum ZeroExInstantError { + AssetMetaDataNotAvailable = 'ASSET_META_DATA_NOT_AVAILABLE', +} diff --git a/packages/instant/src/util/asset.ts b/packages/instant/src/util/asset.ts new file mode 100644 index 000000000..4e3b2b946 --- /dev/null +++ b/packages/instant/src/util/asset.ts @@ -0,0 +1,53 @@ +import { AssetProxyId, ObjectMap } from '@0x/types'; +import * as _ from 'lodash'; + +import { assetDataNetworkMapping } from '../data/asset_data_network_mapping'; +import { Asset, AssetMetaData, Network, ZeroExInstantError } from '../types'; + +export const assetUtils = { + createAssetFromAssetData: ( + assetData: string, + assetMetaDataMap: ObjectMap<AssetMetaData>, + network: Network, + ): Asset => { + return { + assetData, + metaData: assetUtils.getMetaDataOrThrow(assetData, assetMetaDataMap, network), + }; + }, + getMetaDataOrThrow: (assetData: string, metaDataMap: ObjectMap<AssetMetaData>, network: Network): AssetMetaData => { + let mainnetAssetData: string | undefined = assetData; + if (network !== Network.Mainnet) { + mainnetAssetData = assetUtils.getAssociatedAssetDataIfExists(assetData, network); + } + if (_.isUndefined(mainnetAssetData)) { + throw new Error(ZeroExInstantError.AssetMetaDataNotAvailable); + } + const metaData = metaDataMap[mainnetAssetData]; + if (_.isUndefined(metaData)) { + throw new Error(ZeroExInstantError.AssetMetaDataNotAvailable); + } + return metaData; + }, + bestNameForAsset: (asset?: Asset, defaultName: string = '???'): string => { + if (_.isUndefined(asset)) { + return defaultName; + } + const metaData = asset.metaData; + switch (metaData.assetProxyId) { + case AssetProxyId.ERC20: + return metaData.symbol.toUpperCase(); + case AssetProxyId.ERC721: + return metaData.name; + default: + return defaultName; + } + }, + getAssociatedAssetDataIfExists: (assetData: string, network: Network): string | undefined => { + const assetDataGroupIfExists = _.find(assetDataNetworkMapping, value => value[network] === assetData); + if (_.isUndefined(assetDataGroupIfExists)) { + return; + } + return assetDataGroupIfExists[Network.Mainnet]; + }, +}; diff --git a/packages/instant/src/util/asset_buyer.ts b/packages/instant/src/util/asset_buyer.ts deleted file mode 100644 index 6855fbcab..000000000 --- a/packages/instant/src/util/asset_buyer.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { AssetBuyer } from '@0x/asset-buyer'; - -import { sraApiUrl } from '../constants'; - -import { getProvider } from './provider'; - -const provider = getProvider(); - -export const assetBuyer = AssetBuyer.getAssetBuyerForStandardRelayerAPIUrl(provider, sraApiUrl); diff --git a/packages/instant/src/util/asset_data.ts b/packages/instant/src/util/asset_data.ts deleted file mode 100644 index fea2e2b19..000000000 --- a/packages/instant/src/util/asset_data.ts +++ /dev/null @@ -1,21 +0,0 @@ -import * as _ from 'lodash'; - -import { AssetProxyId } from '@0x/types'; - -import { assetMetaData } from '../data/asset_meta_data'; - -export const assetDataUtil = { - bestNameForAsset: (assetData: string | undefined, defaultString: string) => { - if (_.isUndefined(assetData)) { - return defaultString; - } - const metaData = assetMetaData[assetData]; - if (_.isUndefined(metaData)) { - return defaultString; - } - if (metaData.assetProxyId === AssetProxyId.ERC20) { - return metaData.symbol.toUpperCase(); - } - return defaultString; - }, -}; diff --git a/packages/instant/src/util/error.ts b/packages/instant/src/util/error.ts index c9b13ef83..64c1f4885 100644 --- a/packages/instant/src/util/error.ts +++ b/packages/instant/src/util/error.ts @@ -2,7 +2,9 @@ import { AssetBuyerError } from '@0x/asset-buyer'; import { Dispatch } from 'redux'; import { Action, actions } from '../redux/actions'; -import { assetDataUtil } from '../util/asset_data'; +import { Asset } from '../types'; + +import { assetUtils } from './asset'; class ErrorFlasher { private _timeoutId?: number; @@ -27,12 +29,12 @@ class ErrorFlasher { } } -const humanReadableMessageForError = (error: Error, assetData?: string): string | undefined => { +const humanReadableMessageForError = (error: Error, asset?: Asset): string | undefined => { const hasInsufficientLiquidity = error.message === AssetBuyerError.InsufficientAssetLiquidity || error.message === AssetBuyerError.InsufficientZrxLiquidity; if (hasInsufficientLiquidity) { - const assetName = assetDataUtil.bestNameForAsset(assetData, 'of this asset'); + const assetName = assetUtils.bestNameForAsset(asset, 'of this asset'); return `Not enough ${assetName} available`; } @@ -40,19 +42,23 @@ const humanReadableMessageForError = (error: Error, assetData?: string): string error.message === AssetBuyerError.StandardRelayerApiError || error.message.startsWith(AssetBuyerError.AssetUnavailable) ) { - const assetName = assetDataUtil.bestNameForAsset(assetData, 'This asset'); + const assetName = assetUtils.bestNameForAsset(asset, 'This asset'); return `${assetName} is currently unavailable`; } + if (error.message === AssetBuyerError.SignatureRequestDenied) { + return 'You denied this transaction'; + } + return undefined; }; export const errorUtil = { errorFlasher: new ErrorFlasher(), - errorDescription: (error?: any, assetData?: string): { icon: string; message: string } => { + errorDescription: (error?: any, asset?: Asset): { icon: string; message: string } => { let bestMessage: string | undefined; if (error instanceof Error) { - bestMessage = humanReadableMessageForError(error, assetData); + bestMessage = humanReadableMessageForError(error, asset); } return { icon: '😢', diff --git a/packages/instant/src/util/etherscan.ts b/packages/instant/src/util/etherscan.ts new file mode 100644 index 000000000..ffb08a382 --- /dev/null +++ b/packages/instant/src/util/etherscan.ts @@ -0,0 +1,24 @@ +import * as _ from 'lodash'; + +import { Network } from '../types'; + +const etherscanPrefix = (networkId: number): string | undefined => { + switch (networkId) { + case Network.Kovan: + return 'kovan.'; + case Network.Mainnet: + return ''; + default: + return undefined; + } +}; + +export const etherscanUtil = { + getEtherScanTxnAddressIfExists: (txnHash: string, networkId: number) => { + const prefix = etherscanPrefix(networkId); + if (_.isUndefined(prefix)) { + return; + } + return `https://${prefix}etherscan.io/tx/${txnHash}`; + }, +}; diff --git a/packages/instant/test/util/asset.test.ts b/packages/instant/test/util/asset.test.ts new file mode 100644 index 000000000..c7db7eba7 --- /dev/null +++ b/packages/instant/test/util/asset.test.ts @@ -0,0 +1,47 @@ +import { AssetProxyId, ObjectMap } from '@0x/types'; + +import { Asset, AssetMetaData, ERC20AssetMetaData, Network, ZeroExInstantError } from '../../src/types'; +import { assetUtils } from '../../src/util/asset'; + +const ZRX_ASSET_DATA = '0xf47261b0000000000000000000000000e41d2489571d322189246dafa5ebde1f4699f498'; +const ZRX_ASSET_DATA_KOVAN = '0xf47261b00000000000000000000000002002d3812f58e35f0ea1ffbf80a75a38c32175fa'; +const ZRX_META_DATA: ERC20AssetMetaData = { + assetProxyId: AssetProxyId.ERC20, + symbol: 'zrx', + decimals: 18, +}; +const ZRX_ASSET: Asset = { + assetData: ZRX_ASSET_DATA, + metaData: ZRX_META_DATA, +}; +const META_DATA_MAP: ObjectMap<AssetMetaData> = { + [ZRX_ASSET_DATA]: ZRX_META_DATA, +}; + +describe('assetDataUtil', () => { + describe('bestNameForAsset', () => { + it('should return default string if assetData is undefined', () => { + expect(assetUtils.bestNameForAsset(undefined, 'xyz')).toEqual('xyz'); + }); + it('should return ZRX for ZRX assetData', () => { + expect(assetUtils.bestNameForAsset(ZRX_ASSET, 'mah default')).toEqual('ZRX'); + }); + }); + describe('getMetaDataOrThrow', () => { + it('should return the metaData for the supplied mainnet asset data', () => { + expect(assetUtils.getMetaDataOrThrow(ZRX_ASSET_DATA, META_DATA_MAP, Network.Mainnet)).toEqual( + ZRX_META_DATA, + ); + }); + it('should return the metaData for the supplied non-mainnet asset data', () => { + expect(assetUtils.getMetaDataOrThrow(ZRX_ASSET_DATA_KOVAN, META_DATA_MAP, Network.Kovan)).toEqual( + ZRX_META_DATA, + ); + }); + it('should throw if the metaData for the asset is not available', () => { + expect(() => + assetUtils.getMetaDataOrThrow('asset data we dont have', META_DATA_MAP, Network.Mainnet), + ).toThrowError(ZeroExInstantError.AssetMetaDataNotAvailable); + }); + }); +}); diff --git a/packages/instant/test/util/asset_data.test.ts b/packages/instant/test/util/asset_data.test.ts deleted file mode 100644 index cf247142a..000000000 --- a/packages/instant/test/util/asset_data.test.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { assetDataUtil } from '../../src/util/asset_data'; - -const ZRX_ASSET_DATA = '0xf47261b0000000000000000000000000e41d2489571d322189246dafa5ebde1f4699f498'; - -describe('assetDataUtil', () => { - describe('bestNameForAsset', () => { - it('should return default string if assetData is undefined', () => { - expect(assetDataUtil.bestNameForAsset(undefined, 'xyz')).toEqual('xyz'); - }); - it('should return default string if assetData isnt found', () => { - expect(assetDataUtil.bestNameForAsset('fake', 'mah default')).toEqual('mah default'); - }); - it('should return ZRX for ZRX assetData', () => { - expect(assetDataUtil.bestNameForAsset(ZRX_ASSET_DATA, 'mah default')).toEqual('ZRX'); - }); - }); -}); diff --git a/packages/instant/test/util/error.test.ts b/packages/instant/test/util/error.test.ts index 78b742f06..90e9c5fb4 100644 --- a/packages/instant/test/util/error.test.ts +++ b/packages/instant/test/util/error.test.ts @@ -1,14 +1,24 @@ import { AssetBuyerError } from '@0x/asset-buyer'; +import { AssetProxyId } from '@0x/types'; +import { Asset } from '../../src/types'; import { errorUtil } from '../../src/util/error'; const ZRX_ASSET_DATA = '0xf47261b0000000000000000000000000e41d2489571d322189246dafa5ebde1f4699f498'; +const ZRX_ASSET: Asset = { + assetData: ZRX_ASSET_DATA, + metaData: { + assetProxyId: AssetProxyId.ERC20, + symbol: 'zrx', + decimals: 18, + }, +}; describe('errorUtil', () => { describe('errorFlasher', () => { it('should return error and asset name for InsufficientAssetLiquidity', () => { const insufficientAssetError = new Error(AssetBuyerError.InsufficientAssetLiquidity); - expect(errorUtil.errorDescription(insufficientAssetError, ZRX_ASSET_DATA).message).toEqual( + expect(errorUtil.errorDescription(insufficientAssetError, ZRX_ASSET).message).toEqual( 'Not enough ZRX available', ); }); @@ -20,27 +30,25 @@ describe('errorUtil', () => { }); it('should return asset name for InsufficientAssetLiquidity', () => { const insufficientZrxError = new Error(AssetBuyerError.InsufficientZrxLiquidity); - expect(errorUtil.errorDescription(insufficientZrxError, ZRX_ASSET_DATA).message).toEqual( + expect(errorUtil.errorDescription(insufficientZrxError, ZRX_ASSET).message).toEqual( 'Not enough ZRX available', ); }); it('should return unavailable error and asset name for StandardRelayerApiError', () => { const standardRelayerError = new Error(AssetBuyerError.StandardRelayerApiError); - expect(errorUtil.errorDescription(standardRelayerError, ZRX_ASSET_DATA).message).toEqual( + expect(errorUtil.errorDescription(standardRelayerError, ZRX_ASSET).message).toEqual( 'ZRX is currently unavailable', ); }); it('should return error for AssetUnavailable error', () => { - const assetUnavailableError = new Error( - `${AssetBuyerError.AssetUnavailable}: For assetData ${ZRX_ASSET_DATA}`, - ); - expect(errorUtil.errorDescription(assetUnavailableError, ZRX_ASSET_DATA).message).toEqual( + const assetUnavailableError = new Error(`${AssetBuyerError.AssetUnavailable}: For assetData ${ZRX_ASSET}`); + expect(errorUtil.errorDescription(assetUnavailableError, ZRX_ASSET).message).toEqual( 'ZRX is currently unavailable', ); }); it('should return default for AssetUnavailable error', () => { const assetUnavailableError = new Error(`${AssetBuyerError.AssetUnavailable}: For assetData xyz`); - expect(errorUtil.errorDescription(assetUnavailableError, 'xyz').message).toEqual( + expect(errorUtil.errorDescription(assetUnavailableError, undefined).message).toEqual( 'This asset is currently unavailable', ); }); diff --git a/packages/web3-wrapper/CHANGELOG.json b/packages/web3-wrapper/CHANGELOG.json index b8d06eac1..6b554110f 100644 --- a/packages/web3-wrapper/CHANGELOG.json +++ b/packages/web3-wrapper/CHANGELOG.json @@ -1,5 +1,15 @@ [ { + "version": "3.1.1", + "changes": [ + { + "note": + "Fix bug in `getTransactionByHashAsync` which was causing the return value to have the wrong type (raw fields instead of unmarshalled fields).", + "pr": 1177 + } + ] + }, + { "version": "3.1.0", "changes": [ { diff --git a/packages/web3-wrapper/src/web3_wrapper.ts b/packages/web3-wrapper/src/web3_wrapper.ts index 3ba153680..56877fef3 100644 --- a/packages/web3-wrapper/src/web3_wrapper.ts +++ b/packages/web3-wrapper/src/web3_wrapper.ts @@ -23,7 +23,13 @@ import { import * as _ from 'lodash'; import { marshaller } from './marshaller'; -import { BlockWithoutTransactionDataRPC, BlockWithTransactionDataRPC, NodeType, Web3WrapperErrors } from './types'; +import { + BlockWithoutTransactionDataRPC, + BlockWithTransactionDataRPC, + NodeType, + TransactionRPC, + Web3WrapperErrors, +} from './types'; import { utils } from './utils'; const BASE_TEN = 10; @@ -228,10 +234,11 @@ export class Web3Wrapper { */ public async getTransactionByHashAsync(txHash: string): Promise<Transaction> { assert.isHexString('txHash', txHash); - const transaction = await this.sendRawPayloadAsync<Transaction>({ + const transactionRpc = await this.sendRawPayloadAsync<TransactionRPC>({ method: 'eth_getTransactionByHash', params: [txHash], }); + const transaction = marshaller.unmarshalTransaction(transactionRpc); return transaction; } /** |