diff options
Diffstat (limited to 'packages/instant/src/redux')
-rw-r--r-- | packages/instant/src/redux/actions.ts | 75 | ||||
-rw-r--r-- | packages/instant/src/redux/async_data.ts | 104 | ||||
-rw-r--r-- | packages/instant/src/redux/reducer.ts | 288 | ||||
-rw-r--r-- | packages/instant/src/redux/store.ts | 14 |
4 files changed, 481 insertions, 0 deletions
diff --git a/packages/instant/src/redux/actions.ts b/packages/instant/src/redux/actions.ts new file mode 100644 index 000000000..77e3dec12 --- /dev/null +++ b/packages/instant/src/redux/actions.ts @@ -0,0 +1,75 @@ +import { BuyQuote } from '@0x/asset-buyer'; +import { BigNumber } from '@0x/utils'; +import * as _ from 'lodash'; + +import { ActionsUnion, AddressAndEthBalanceInWei, Asset, StandardSlidingPanelContent } from '../types'; + +export interface PlainAction<T extends string> { + type: T; +} + +export interface ActionWithPayload<T extends string, P> extends PlainAction<T> { + data: P; +} + +export type Action = ActionsUnion<typeof actions>; + +function createAction<T extends string>(type: T): PlainAction<T>; +function createAction<T extends string, P>(type: T, data: P): ActionWithPayload<T, P>; +function createAction<T extends string, P>(type: T, data?: P): PlainAction<T> | ActionWithPayload<T, P> { + return _.isUndefined(data) ? { type } : { type, data }; +} + +export enum ActionTypes { + SET_ACCOUNT_STATE_LOADING = 'SET_ACCOUNT_STATE_LOADING', + SET_ACCOUNT_STATE_LOCKED = 'SET_ACCOUNT_STATE_LOCKED', + SET_ACCOUNT_STATE_READY = 'SET_ACCOUNT_STATE_READY', + UPDATE_ACCOUNT_ETH_BALANCE = 'UPDATE_ACCOUNT_ETH_BALANCE', + UPDATE_ETH_USD_PRICE = 'UPDATE_ETH_USD_PRICE', + UPDATE_SELECTED_ASSET_UNIT_AMOUNT = 'UPDATE_SELECTED_ASSET_UNIT_AMOUNT', + 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', + SET_QUOTE_REQUEST_STATE_PENDING = 'SET_QUOTE_REQUEST_STATE_PENDING', + SET_QUOTE_REQUEST_STATE_FAILURE = 'SET_QUOTE_REQUEST_STATE_FAILURE', + SET_ERROR_MESSAGE = 'SET_ERROR_MESSAGE', + HIDE_ERROR = 'HIDE_ERROR', + CLEAR_ERROR = 'CLEAR_ERROR', + RESET_AMOUNT = 'RESET_AMOUNT', + OPEN_STANDARD_SLIDING_PANEL = 'OPEN_STANDARD_SLIDING_PANEL', + CLOSE_STANDARD_SLIDING_PANEL = 'CLOSE_STANDARD_SLIDING_PANEL', +} + +export const actions = { + setAccountStateLoading: () => createAction(ActionTypes.SET_ACCOUNT_STATE_LOADING), + setAccountStateLocked: () => createAction(ActionTypes.SET_ACCOUNT_STATE_LOCKED), + setAccountStateReady: (address: string) => createAction(ActionTypes.SET_ACCOUNT_STATE_READY, address), + updateAccountEthBalance: (addressAndBalance: AddressAndEthBalanceInWei) => + createAction(ActionTypes.UPDATE_ACCOUNT_ETH_BALANCE, addressAndBalance), + updateEthUsdPrice: (price?: BigNumber) => createAction(ActionTypes.UPDATE_ETH_USD_PRICE, price), + updateSelectedAssetAmount: (amount?: BigNumber) => + createAction(ActionTypes.UPDATE_SELECTED_ASSET_UNIT_AMOUNT, amount), + 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), + setQuoteRequestStatePending: () => createAction(ActionTypes.SET_QUOTE_REQUEST_STATE_PENDING), + setQuoteRequestStateFailure: () => createAction(ActionTypes.SET_QUOTE_REQUEST_STATE_FAILURE), + setErrorMessage: (errorMessage: string) => createAction(ActionTypes.SET_ERROR_MESSAGE, errorMessage), + hideError: () => createAction(ActionTypes.HIDE_ERROR), + clearError: () => createAction(ActionTypes.CLEAR_ERROR), + resetAmount: () => createAction(ActionTypes.RESET_AMOUNT), + openStandardSlidingPanel: (content: StandardSlidingPanelContent) => + createAction(ActionTypes.OPEN_STANDARD_SLIDING_PANEL, content), + closeStandardSlidingPanel: () => createAction(ActionTypes.CLOSE_STANDARD_SLIDING_PANEL), +}; diff --git a/packages/instant/src/redux/async_data.ts b/packages/instant/src/redux/async_data.ts new file mode 100644 index 000000000..5d30388b8 --- /dev/null +++ b/packages/instant/src/redux/async_data.ts @@ -0,0 +1,104 @@ +import { AssetProxyId } from '@0x/types'; +import { Web3Wrapper } from '@0x/web3-wrapper'; +import * as _ from 'lodash'; +import { Dispatch } from 'redux'; + +import { BIG_NUMBER_ZERO } from '../constants'; +import { AccountState, ERC20Asset, OrderProcessState, ProviderState } from '../types'; +import { assetUtils } from '../util/asset'; +import { buyQuoteUpdater } from '../util/buy_quote_updater'; +import { coinbaseApi } from '../util/coinbase_api'; +import { errorFlasher } from '../util/error_flasher'; + +import { actions } from './actions'; +import { State } from './reducer'; + +export const asyncData = { + fetchEthPriceAndDispatchToStore: async (dispatch: Dispatch) => { + try { + const ethUsdPrice = await coinbaseApi.getEthUsdPrice(); + dispatch(actions.updateEthUsdPrice(ethUsdPrice)); + } catch (e) { + const errorMessage = 'Error fetching ETH/USD price'; + errorFlasher.flashNewErrorMessage(dispatch, errorMessage); + dispatch(actions.updateEthUsdPrice(BIG_NUMBER_ZERO)); + } + }, + fetchAvailableAssetDatasAndDispatchToStore: async (state: State, dispatch: Dispatch) => { + const { providerState, assetMetaDataMap, network } = state; + const assetBuyer = providerState.assetBuyer; + try { + const assetDatas = await assetBuyer.getAvailableAssetDatasAsync(); + const assets = assetUtils.createAssetsFromAssetDatas(assetDatas, assetMetaDataMap, network); + dispatch(actions.setAvailableAssets(assets)); + } catch (e) { + const errorMessage = 'Could not find any assets'; + errorFlasher.flashNewErrorMessage(dispatch, errorMessage); + // On error, just specify that none are available + dispatch(actions.setAvailableAssets([])); + } + }, + fetchAccountInfoAndDispatchToStore: async ( + providerState: ProviderState, + dispatch: Dispatch, + shouldAttemptUnlock: boolean = false, + shouldSetToLoading: boolean = false, + ) => { + const web3Wrapper = providerState.web3Wrapper; + const provider = providerState.provider; + if (shouldSetToLoading && providerState.account.state !== AccountState.Loading) { + dispatch(actions.setAccountStateLoading()); + } + let availableAddresses: string[]; + try { + // TODO(bmillman): Add support at the web3Wrapper level for calling `eth_requestAccounts` instead of calling enable here + const isPrivacyModeEnabled = !_.isUndefined((provider as any).enable); + availableAddresses = + isPrivacyModeEnabled && shouldAttemptUnlock + ? await (provider as any).enable() + : await web3Wrapper.getAvailableAddressesAsync(); + } catch (e) { + dispatch(actions.setAccountStateLocked()); + return; + } + if (!_.isEmpty(availableAddresses)) { + const activeAddress = availableAddresses[0]; + dispatch(actions.setAccountStateReady(activeAddress)); + // tslint:disable-next-line:no-floating-promises + asyncData.fetchAccountBalanceAndDispatchToStore(activeAddress, providerState.web3Wrapper, dispatch); + } else { + dispatch(actions.setAccountStateLocked()); + } + }, + fetchAccountBalanceAndDispatchToStore: async (address: string, web3Wrapper: Web3Wrapper, dispatch: Dispatch) => { + try { + const ethBalanceInWei = await web3Wrapper.getBalanceInWeiAsync(address); + dispatch(actions.updateAccountEthBalance({ address, ethBalanceInWei })); + } catch (e) { + // leave balance as is + return; + } + }, + fetchCurrentBuyQuoteAndDispatchToStore: async ( + state: State, + dispatch: Dispatch, + options: { updateSilently: boolean }, + ) => { + const { buyOrderState, providerState, selectedAsset, selectedAssetUnitAmount, affiliateInfo } = state; + const assetBuyer = providerState.assetBuyer; + if ( + !_.isUndefined(selectedAssetUnitAmount) && + !_.isUndefined(selectedAsset) && + buyOrderState.processState === OrderProcessState.None && + selectedAsset.metaData.assetProxyId === AssetProxyId.ERC20 + ) { + await buyQuoteUpdater.updateBuyQuoteAsync( + assetBuyer, + dispatch, + selectedAsset as ERC20Asset, + selectedAssetUnitAmount, + { setPending: !options.updateSilently, dispatchErrors: !options.updateSilently, affiliateInfo }, + ); + } + }, +}; diff --git a/packages/instant/src/redux/reducer.ts b/packages/instant/src/redux/reducer.ts new file mode 100644 index 000000000..dfc2b89f3 --- /dev/null +++ b/packages/instant/src/redux/reducer.ts @@ -0,0 +1,288 @@ +import { BuyQuote } from '@0x/asset-buyer'; +import { AssetProxyId, ObjectMap } from '@0x/types'; +import { BigNumber } from '@0x/utils'; +import { Web3Wrapper } from '@0x/web3-wrapper'; +import * as _ from 'lodash'; + +import { LOADING_ACCOUNT, LOCKED_ACCOUNT } from '../constants'; +import { assetMetaDataMap } from '../data/asset_meta_data_map'; +import { + Account, + AccountReady, + AccountState, + AffiliateInfo, + Asset, + AssetMetaData, + AsyncProcessState, + DisplayStatus, + Network, + OrderProcessState, + OrderState, + ProviderState, + StandardSlidingPanelContent, + StandardSlidingPanelSettings, +} from '../types'; + +import { Action, ActionTypes } from './actions'; + +// State that is required and we have defaults for, before props are passed in +export interface DefaultState { + network: Network; + assetMetaDataMap: ObjectMap<AssetMetaData>; + buyOrderState: OrderState; + latestErrorDisplayStatus: DisplayStatus; + quoteRequestState: AsyncProcessState; + standardSlidingPanelSettings: StandardSlidingPanelSettings; +} + +// State that is required but needs to be derived from the props +interface PropsDerivedState { + providerState: ProviderState; +} + +// State that is optional +interface OptionalState { + selectedAsset: Asset; + availableAssets: Asset[]; + selectedAssetUnitAmount: BigNumber; + ethUsdPrice: BigNumber; + latestBuyQuote: BuyQuote; + latestErrorMessage: string; + affiliateInfo: AffiliateInfo; +} + +export type State = DefaultState & PropsDerivedState & Partial<OptionalState>; + +export const DEFAULT_STATE: DefaultState = { + network: Network.Mainnet, + assetMetaDataMap, + buyOrderState: { processState: OrderProcessState.None }, + latestErrorDisplayStatus: DisplayStatus.Hidden, + quoteRequestState: AsyncProcessState.None, + standardSlidingPanelSettings: { + animationState: 'none', + content: StandardSlidingPanelContent.None, + }, +}; + +export const createReducer = (initialState: State) => { + const reducer = (state: State = initialState, action: Action): State => { + switch (action.type) { + case ActionTypes.SET_ACCOUNT_STATE_LOADING: + return reduceStateWithAccount(state, LOADING_ACCOUNT); + case ActionTypes.SET_ACCOUNT_STATE_LOCKED: + return reduceStateWithAccount(state, LOCKED_ACCOUNT); + case ActionTypes.SET_ACCOUNT_STATE_READY: { + const address = action.data; + let newAccount: AccountReady = { + state: AccountState.Ready, + address, + }; + const currentAccount = state.providerState.account; + if (currentAccount.state === AccountState.Ready && currentAccount.address === address) { + newAccount = { + ...newAccount, + ethBalanceInWei: currentAccount.ethBalanceInWei, + }; + } + return reduceStateWithAccount(state, newAccount); + } + case ActionTypes.UPDATE_ACCOUNT_ETH_BALANCE: { + const { address, ethBalanceInWei } = action.data; + const currentAccount = state.providerState.account; + if (currentAccount.state !== AccountState.Ready || currentAccount.address !== address) { + return state; + } else { + const newAccount: AccountReady = { + ...currentAccount, + ethBalanceInWei, + }; + return reduceStateWithAccount(state, newAccount); + } + } + case ActionTypes.UPDATE_ETH_USD_PRICE: + return { + ...state, + ethUsdPrice: action.data, + }; + case ActionTypes.UPDATE_SELECTED_ASSET_UNIT_AMOUNT: + return { + ...state, + selectedAssetUnitAmount: action.data, + }; + case ActionTypes.UPDATE_LATEST_BUY_QUOTE: + const newBuyQuoteIfExists = action.data; + const shouldUpdate = + _.isUndefined(newBuyQuoteIfExists) || doesBuyQuoteMatchState(newBuyQuoteIfExists, state); + if (shouldUpdate) { + return { + ...state, + latestBuyQuote: newBuyQuoteIfExists, + quoteRequestState: AsyncProcessState.Success, + }; + } else { + return state; + } + case ActionTypes.SET_QUOTE_REQUEST_STATE_PENDING: + return { + ...state, + latestBuyQuote: undefined, + quoteRequestState: AsyncProcessState.Pending, + }; + case ActionTypes.SET_QUOTE_REQUEST_STATE_FAILURE: + return { + ...state, + latestBuyQuote: undefined, + quoteRequestState: AsyncProcessState.Failure, + }; + case ActionTypes.SET_BUY_ORDER_STATE_NONE: + return { + ...state, + 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, + latestErrorMessage: action.data, + latestErrorDisplayStatus: DisplayStatus.Present, + }; + case ActionTypes.HIDE_ERROR: + return { + ...state, + latestErrorDisplayStatus: DisplayStatus.Hidden, + }; + case ActionTypes.CLEAR_ERROR: + return { + ...state, + latestErrorMessage: undefined, + latestErrorDisplayStatus: DisplayStatus.Hidden, + }; + case ActionTypes.UPDATE_SELECTED_ASSET: + return { + ...state, + selectedAsset: action.data, + }; + case ActionTypes.RESET_AMOUNT: + return { + ...state, + latestBuyQuote: undefined, + quoteRequestState: AsyncProcessState.None, + buyOrderState: { processState: OrderProcessState.None }, + selectedAssetUnitAmount: undefined, + }; + case ActionTypes.SET_AVAILABLE_ASSETS: + return { + ...state, + availableAssets: action.data, + }; + case ActionTypes.OPEN_STANDARD_SLIDING_PANEL: + return { + ...state, + standardSlidingPanelSettings: { + content: action.data, + animationState: 'slidIn', + }, + }; + case ActionTypes.CLOSE_STANDARD_SLIDING_PANEL: + return { + ...state, + standardSlidingPanelSettings: { + content: state.standardSlidingPanelSettings.content, + animationState: 'slidOut', + }, + }; + default: + return state; + } + }; + return reducer; +}; + +const reduceStateWithAccount = (state: State, account: Account) => { + const oldProviderState = state.providerState; + const newProviderState: ProviderState = { + ...oldProviderState, + account, + }; + return { + ...state, + providerState: newProviderState, + }; +}; + +const doesBuyQuoteMatchState = (buyQuote: BuyQuote, state: State): boolean => { + const selectedAssetIfExists = state.selectedAsset; + const selectedAssetUnitAmountIfExists = state.selectedAssetUnitAmount; + // if no selectedAsset or selectedAssetAmount exists on the current state, return false + if (_.isUndefined(selectedAssetIfExists) || _.isUndefined(selectedAssetUnitAmountIfExists)) { + return false; + } + // if buyQuote's assetData does not match that of the current selected asset, return false + if (selectedAssetIfExists.assetData !== buyQuote.assetData) { + return false; + } + // if ERC20 and buyQuote's assetBuyAmount does not match selectedAssetAmount, return false + // if ERC721, return true + const selectedAssetMetaData = selectedAssetIfExists.metaData; + if (selectedAssetMetaData.assetProxyId === AssetProxyId.ERC20) { + const selectedAssetAmountBaseUnits = Web3Wrapper.toBaseUnitAmount( + selectedAssetUnitAmountIfExists, + selectedAssetMetaData.decimals, + ); + const doesAssetAmountMatch = selectedAssetAmountBaseUnits.eq(buyQuote.assetBuyAmount); + return doesAssetAmountMatch; + } else { + return true; + } +}; diff --git a/packages/instant/src/redux/store.ts b/packages/instant/src/redux/store.ts new file mode 100644 index 000000000..20710765d --- /dev/null +++ b/packages/instant/src/redux/store.ts @@ -0,0 +1,14 @@ +import * as _ from 'lodash'; +import { createStore, Store as ReduxStore } from 'redux'; +import { devToolsEnhancer } from 'redux-devtools-extension/developmentOnly'; + +import { createReducer, State } from './reducer'; + +export type Store = ReduxStore<State>; + +export const store = { + create: (initialState: State): Store => { + const reducer = createReducer(initialState); + return createStore(reducer, initialState, devToolsEnhancer({})); + }, +}; |