From c5f453d695be9a3c7f138578d604e4c979c0d5af Mon Sep 17 00:00:00 2001 From: Evgeniia Vakarina <27793901+EvgeniiaVak@users.noreply.github.com> Date: Mon, 9 Oct 2023 20:53:37 +0400 Subject: [PATCH] functionality from figma for limit orders --- src/app/common/input.tsx | 41 +- src/app/common/tokenWithSymbol.tsx | 2 +- .../components/order_input/AmountInput.tsx | 241 ++++----- .../order_input/LimitOrderInput.tsx | 55 +- .../order_input/MarketOrderInput.tsx | 58 ++- src/app/components/order_input/OrderInput.tsx | 93 +--- src/app/redux/orderInputSlice.ts | 477 +++++++++--------- src/app/redux/pairSelectorSlice.ts | 6 +- src/app/subscriptions.ts | 2 + 9 files changed, 431 insertions(+), 544 deletions(-) diff --git a/src/app/common/input.tsx b/src/app/common/input.tsx index 38b85251..16076a08 100644 --- a/src/app/common/input.tsx +++ b/src/app/common/input.tsx @@ -1,36 +1,43 @@ import React, { ReactNode } from "react"; +import { ValidationResult } from "redux/orderInputSlice"; type Props = Omit & { parentClasses?: string; inputClasses?: string; endAdornmentClasses?: string; endAdornment?: ReactNode; - isError?: boolean; + validation?: ValidationResult; }; export const Input = ({ parentClasses = "", inputClasses = "", endAdornment, - isError = false, endAdornmentClasses = "", + validation, ...inputProps }: Props) => { + const isError = validation?.valid === false; return ( -
- - {endAdornment && ( -
{endAdornment}
- )} -
+ <> +
+ + {endAdornment && ( +
{endAdornment}
+ )} +
+ + ); }; diff --git a/src/app/common/tokenWithSymbol.tsx b/src/app/common/tokenWithSymbol.tsx index 2c994bec..e56e7675 100644 --- a/src/app/common/tokenWithSymbol.tsx +++ b/src/app/common/tokenWithSymbol.tsx @@ -8,7 +8,7 @@ type Props = { export const TokenWithSymbol = ({ logoUrl, symbol }: Props) => { return ( -
+
{symbol}
diff --git a/src/app/components/order_input/AmountInput.tsx b/src/app/components/order_input/AmountInput.tsx index c302feb3..962eb70a 100644 --- a/src/app/components/order_input/AmountInput.tsx +++ b/src/app/components/order_input/AmountInput.tsx @@ -1,152 +1,117 @@ -import React, { useMemo, useRef } from "react"; -import { RxChevronDown } from "react-icons/rx"; +import React from "react"; -import { Input } from "common/input"; -import { TokenAvatar } from "common/tokenAvatar"; -import { TokenWithSymbol } from "common/tokenWithSymbol"; import { useAppDispatch, useAppSelector } from "hooks"; -import { - OrderSide, - getSelectedToken, - getUnselectedToken, - orderInputSlice, - validatePositionSize, -} from "redux/orderInputSlice"; +import { OrderSide, TokenInput, orderInputSlice } from "redux/orderInputSlice"; -export function AmountInput() { - const { size, quote, side, token1Selected } = useAppSelector( - (state) => state.orderInput - ); - // const { - // token1: { balance: balance1 }, - // token2: { balance: balance2 }, - // } = useAppSelector((state) => state.pairSelector); - const validationResult = useAppSelector(validatePositionSize); - const selectedToken = useAppSelector(getSelectedToken); - const unSelectedToken = useAppSelector(getUnselectedToken); - const dispatch = useAppDispatch(); - // const [customPercentage, setCustomPercentage] = useState(0); +interface TokenInputFiledProps extends TokenInput { + onFocus: () => void; + onChange: (event: React.ChangeEvent) => void; +} - const customPercentInputRef = useRef(null); +function nullableNumberInput(event: React.ChangeEvent) { + let amount: number | ""; + if (event.target.value === "") { + amount = ""; + } else { + amount = Number(event.target.value); + } + return amount; +} - const handleOnChange = (event: React.ChangeEvent) => { - if (customPercentInputRef.current) { - customPercentInputRef.current.value = ""; - } - const size = Number(event.target.value); - dispatch(orderInputSlice.actions.setSize(size)); - }; +function TokenInputFiled(props: TokenInputFiledProps) { + const { + symbol, + iconUrl, + valid, + message, + amount, + balance, + onChange, + onFocus, + } = props; + return ( +
+ {/* balance */} +
+ Current balance: + {balance || "unknown"} +
- // const handleOnPercentChange = ( - // event: React.ChangeEvent - // ) => { - // if (Number(event.target.value) > 100) return; - // const size = Number(event.target.value); - // setCustomPercentage(size); - // dispatch(setSizePercent(size)); - // }; + {/* input */} +
+ {symbol} + {symbol} + +
- const isBuy = useMemo(() => side === OrderSide.BUY, [side]); - const isSell = useMemo(() => side === OrderSide.SELL, [side]); + {/* error message */} + +
+ ); +} - const handleTokenSwitch = () => { - dispatch(orderInputSlice.actions.setToken1Selected(!token1Selected)); - }; +export function AmountInput() { + const { token1, token2, quote, description } = useAppSelector( + (state) => state.orderInput + ); + + const dispatch = useAppDispatch(); return ( -
-
-

- {side === OrderSide.BUY ? "Buy" : "Sell"} Amount: -

- {/* {isSell && ( -

- Balance: {balance1} -

- )} */} -
- -
- - -
-
    -
  • { - e.stopPropagation(); - handleTokenSwitch(); - }} - > - -
  • -
-
- //
- // - // {selectedToken.symbol} - //
- } +
+ { + dispatch(orderInputSlice.actions.setSide(OrderSide.SELL)); + }} + onChange={(event) => { + dispatch( + orderInputSlice.actions.setAmountToken1(nullableNumberInput(event)) + ); + }} /> - -
-

- {isBuy ? "You pay" : "You receive:"} -

- {/* {isBuy && ( -

- Balance: {balance2} -

- )} */} -
- - - - {unSelectedToken.symbol} - -
- } + { + dispatch(orderInputSlice.actions.swapTokens()); + }} + > + + + + { + dispatch(orderInputSlice.actions.setSide(OrderSide.BUY)); + }} + onChange={(event) => { + dispatch( + orderInputSlice.actions.setAmountToken2(nullableNumberInput(event)) + ); + }} /> -
@@ -172,6 +137,10 @@ export function AmountInput() { {quote?.liquidityFees} {quote?.toToken.symbol}
+
+
Description:
+
{description}
+
diff --git a/src/app/components/order_input/LimitOrderInput.tsx b/src/app/components/order_input/LimitOrderInput.tsx index 817b5518..b14a3e3f 100644 --- a/src/app/components/order_input/LimitOrderInput.tsx +++ b/src/app/components/order_input/LimitOrderInput.tsx @@ -26,39 +26,36 @@ export function LimitOrderInput() { return ( <>
-
-

- {side === OrderSide.BUY ? "Buy" : "Sell"} Price -

-

- dispatch( - orderInputSlice.actions.setPrice( - side === OrderSide.BUY - ? bestBuyPrice || 0 - : bestSellPrice || 0 - ) + +

+ {side === OrderSide.BUY ? "Buy" : "Sell"} Price +

+

+ dispatch( + orderInputSlice.actions.setPrice( + side === OrderSide.BUY ? bestBuyPrice || 0 : bestSellPrice || 0 ) + ) + } + > + Best Price:{" "} + - Best Price:{" "} - - {side === OrderSide.BUY ? bestBuyPrice : bestSellPrice}{" "} - {priceToken.symbol} - -

-
+ {side === OrderSide.BUY ? bestBuyPrice : bestSellPrice}{" "} + {priceToken.symbol} + +

} /> -
- ); } diff --git a/src/app/components/order_input/MarketOrderInput.tsx b/src/app/components/order_input/MarketOrderInput.tsx index 71546c25..7d5d9718 100644 --- a/src/app/components/order_input/MarketOrderInput.tsx +++ b/src/app/components/order_input/MarketOrderInput.tsx @@ -1,34 +1,42 @@ +import { useAppDispatch, useAppSelector } from "hooks"; import { AmountInput } from "./AmountInput"; +import { orderInputSlice, validateSlippageInput } from "redux/orderInputSlice"; +import { Input } from "common/input"; + +function uiSlippageToSlippage(slippage: number) { + return slippage / 100; +} + +function slippageToUiSlippage(slippage: number) { + return slippage * 100; +} export function MarketOrderInput() { - // const slippage = useAppSelector((state) => state.orderInput.slippage); - // const validationResult = useAppSelector(validateSlippageInput); - // const dispatch = useAppDispatch(); + const slippage = useAppSelector((state) => state.orderInput.slippage); + const validationResult = useAppSelector(validateSlippageInput); + const dispatch = useAppDispatch(); - // const handleOnChange = (event: React.ChangeEvent) => { - // const slippage = slippageFromPercentage(event.target.value); - // dispatch(orderInputSlice.actions.setSlippage(slippage)); - // }; return ( <> - {/*
- - %} - isError={!validationResult.valid} - /> - -
*/} - +
+ + + { + dispatch( + orderInputSlice.actions.setSlippage( + uiSlippageToSlippage(event.target.valueAsNumber) + ) + ); + }} + endAdornment={%} + validation={validationResult} + /> +
); } diff --git a/src/app/components/order_input/OrderInput.tsx b/src/app/components/order_input/OrderInput.tsx index 6bb51051..568a745c 100644 --- a/src/app/components/order_input/OrderInput.tsx +++ b/src/app/components/order_input/OrderInput.tsx @@ -1,13 +1,11 @@ import { useEffect } from "react"; -import { TokenAvatar } from "common/tokenAvatar"; import { useAppDispatch, useAppSelector } from "hooks"; import { - OrderSide, OrderTab, fetchQuote, - getSelectedToken, orderInputSlice, + selectTargetToken, submitOrder, validateOrderInput, } from "redux/orderInputSlice"; @@ -16,69 +14,8 @@ import { OrderTypeTabs } from "./OrderTypeTabs"; import { MarketOrderInput } from "./MarketOrderInput"; import { LimitOrderInput } from "./LimitOrderInput"; -function SingleGroupButton({ - isActive, - onClick, - avatarUrl, - text, - wrapperClass, -}: { - isActive: boolean; - onClick: () => void; - avatarUrl?: string; - text: string; - wrapperClass?: string; -}) { - return ( -
- {avatarUrl && } -

- {text} -

-
- ); -} - -function DirectionToggle() { - const activeSide = useAppSelector((state) => state.orderInput.side); - const dispatch = useAppDispatch(); - const isBuyActive = activeSide === OrderSide.BUY; - const isSellActive = activeSide === OrderSide.SELL; - return ( -
- { - dispatch(orderInputSlice.actions.setSide(OrderSide.BUY)); - }} - wrapperClass={ - "w-1/2 max-w-none border-none " + (isBuyActive ? "!bg-neutral" : "") - } - /> - { - dispatch(orderInputSlice.actions.setSide(OrderSide.SELL)); - }} - wrapperClass={ - "w-1/2 max-w-none border-none " + (isSellActive ? "!bg-neutral" : "") - } - /> -
- ); -} - function SubmitButton() { - const symbol = useAppSelector(getSelectedToken).symbol; + const symbol = useAppSelector(selectTargetToken).symbol; const tab = useAppSelector((state) => state.orderInput.tab); const side = useAppSelector((state) => state.orderInput.side); const transactionInProgress = useAppSelector( @@ -89,10 +26,7 @@ function SubmitButton() { ); const validationResult = useAppSelector(validateOrderInput); const dispatch = useAppDispatch(); - const submitString = - (tab === OrderTab.LIMIT ? "LIMIT " : "") + - (side === OrderSide.BUY ? "Buy " : "Sell ") + - symbol; + const submitString = tab.toString() + " " + side.toString() + " " + symbol; return (
@@ -125,14 +59,15 @@ function SubmitButton() { export function OrderInput() { const dispatch = useAppDispatch(); const { - token1Selected, + token1, + token2, side, - size, price, preventImmediateExecution, slippage, tab, } = useAppSelector((state) => state.orderInput); + const tartgetToken = useAppSelector(selectTargetToken); const pairAddress = useAppSelector((state) => state.pairSelector.address); const validationResult = useAppSelector(validateOrderInput); @@ -142,31 +77,29 @@ export function OrderInput() { }, [dispatch, pairAddress]); useEffect(() => { - if (validationResult.valid) { + if (validationResult.valid && tartgetToken.amount !== "") { dispatch(fetchQuote()); } }, [ dispatch, pairAddress, + token1, + token2, side, - size, price, slippage, tab, - token1Selected, preventImmediateExecution, validationResult, + tartgetToken, ]); return ( <> -
- -
- {tab === OrderTab.MARKET ? : } - -
+
+ {tab === OrderTab.MARKET ? : } +
); diff --git a/src/app/redux/orderInputSlice.ts b/src/app/redux/orderInputSlice.ts index 863de447..8cfc04b9 100644 --- a/src/app/redux/orderInputSlice.ts +++ b/src/app/redux/orderInputSlice.ts @@ -1,19 +1,21 @@ -import { createAsyncThunk, createSlice } from "@reduxjs/toolkit"; import type { PayloadAction } from "@reduxjs/toolkit"; +import { + createAsyncThunk, + createSelector, + createSlice, +} from "@reduxjs/toolkit"; import * as adex from "alphadex-sdk-js"; -import { RootState } from "./store"; -import { createSelector } from "@reduxjs/toolkit"; -import { getRdt, RDT } from "../subscriptions"; -import { AMOUNT_MAX_DECIMALS, fetchBalances } from "./pairSelectorSlice"; import { SdkResult } from "alphadex-sdk-js/lib/models/sdk-result"; -import * as utils from "../utils"; +import { RDT, getRdt } from "../subscriptions"; +import { displayAmount } from "../utils"; import { fetchAccountHistory } from "./accountHistorySlice"; import { selectBestBuy, selectBestSell } from "./orderBookSlice"; -import { displayAmount } from "../utils"; +import { TokenInfo, fetchBalances } from "./pairSelectorSlice"; +import { RootState } from "./store"; export enum OrderTab { - MARKET, - LIMIT, + MARKET = "MARKET", + LIMIT = "LIMIT", } export const PLATFORM_BADGE_ID = 1; //TODO: Get this data from the platform badge @@ -23,19 +25,33 @@ export const OrderSide = adex.OrderSide; export type OrderSide = adex.OrderSide; export type Quote = adex.Quote; +export interface ValidationResult { + valid: boolean; + message: string; +} + +export interface TokenInput { + address: string; + symbol: string; + iconUrl: string; + valid: boolean; + message: string; + amount: number | ""; + balance?: number; +} + export interface OrderInputState { - token1Selected: boolean; + token1: TokenInput; + token2: TokenInput; tab: OrderTab; preventImmediateExecution: boolean; side: OrderSide; - size: number; price: number; slippage: number; quote?: Quote; description?: string; transactionInProgress: boolean; transactionResult?: string; - fromSize?: number; } function adexOrderType(state: OrderInputState): adex.OrderType { @@ -53,12 +69,21 @@ function adexOrderType(state: OrderInputState): adex.OrderType { throw new Error("Invalid order type"); } +const initialTokenInput = { + address: "", + symbol: "", + iconUrl: "", + amount: 0, + valid: true, + message: "", +}; + const initialState: OrderInputState = { - token1Selected: true, + token1: initialTokenInput, + token2: initialTokenInput, tab: OrderTab.MARKET, preventImmediateExecution: false, side: adex.OrderSide.BUY, - size: 0, price: 0, slippage: 0.01, transactionInProgress: false, @@ -82,111 +107,25 @@ export const fetchQuote = createAsyncThunk< } else { slippageToSend = state.orderInput.slippage; } - if (!state.orderInput.size) { - return; + const targetToken = selectTargetToken(state); + + if (!targetToken?.amount) { + throw new Error("No amount specified when fetching quote."); } + const response = await adex.getExchangeOrderQuote( state.pairSelector.address, adexOrderType(state.orderInput), state.orderInput.side, - getSelectedToken(state).address, - state.orderInput.size, + targetToken.address, + targetToken.amount, PLATFORM_FEE, priceToSend, slippageToSend ); const quote = JSON.parse(JSON.stringify(response.data)); - return { ...quote }; -}); -export const setSizePercent = createAsyncThunk< - undefined, - number, - { state: RootState } ->("orderInput/setSizePercent", async (percentage, thunkAPI) => { - //Depending on the combination of input settings, the position size - //is set to x% of the tokens that will leave the user wallet - const state = thunkAPI.getState(); - const dispatch = thunkAPI.dispatch; - const side = state.orderInput.side; - const proportion = percentage / 100; - if (proportion <= 0) { - dispatch(orderInputSlice.actions.setSize(0)); - return; - } - if (percentage > 100) { - percentage = Math.floor(percentage / 10); - dispatch(setSizePercent(percentage)); - return; - } - let balance; - // TODO: add tests for this - if (side === OrderSide.BUY) { - const unselectedBalance = utils.roundTo( - proportion * (getUnselectedToken(state).balance || 0), - AMOUNT_MAX_DECIMALS - 1, - utils.RoundType.DOWN - ); - if (state.orderInput.tab === OrderTab.MARKET) { - //Market buy needs to get a quote to work out what will be returned - const quote = await adex.getExchangeOrderQuote( - state.pairSelector.address, - adexOrderType(state.orderInput), - adex.OrderSide.SELL, - getUnselectedToken(state).address, - unselectedBalance, - PLATFORM_FEE, - -1, - state.orderInput.slippage - ); - balance = quote.data.toAmount; - if (quote.data.fromAmount < unselectedBalance) { - //TODO: Display this message properly - console.error( - "Insufficient liquidity to execute full market order. Increase slippage or reduce position" - ); - } - } else { - // for limit buy orders we can just calculate based on balance and price - if (selectToken1Selected(state)) { - balance = unselectedBalance / state.orderInput.price; - } else { - balance = unselectedBalance * state.orderInput.price; - } - } - } else { - //for sell orders the calculation is very simple - balance = getSelectedToken(state).balance || 0; - balance = balance * proportion; - //for market sell orders the order quote is retrieved to check liquidity. - if (state.orderInput.tab === OrderTab.MARKET) { - const quote = await adex.getExchangeOrderQuote( - state.pairSelector.address, - adexOrderType(state.orderInput), - adex.OrderSide.SELL, - getSelectedToken(state).address, - balance || 0, - PLATFORM_FEE, - -1, - state.orderInput.slippage - ); - if (quote.data.fromAmount < balance) { - balance = quote.data.fromAmount; - //TODO: Display this message properly - console.error( - "Insufficient liquidity to execute full market order. Increase slippage or reduce position" - ); - } - } - } - const newSize = utils.roundTo( - balance || 0, - adex.AMOUNT_MAX_DECIMALS, - utils.RoundType.DOWN - ); - dispatch(orderInputSlice.actions.setSize(newSize)); - - return undefined; + return { ...quote }; }); export const submitOrder = createAsyncThunk< @@ -210,32 +149,21 @@ export const submitOrder = createAsyncThunk< return result; }); -const selectToken1 = (state: RootState) => state.pairSelector.token1; -const selectToken2 = (state: RootState) => state.pairSelector.token2; -const selectToken1Selected = (state: RootState) => - state.orderInput.token1Selected; - -export const getSelectedToken = createSelector( - [selectToken1, selectToken2, selectToken1Selected], - (token1, token2, token1Selected) => { - if (token1Selected) { - return token1; - } else { - return token2; - } - } -); - -export const getUnselectedToken = createSelector( - [selectToken1, selectToken2, selectToken1Selected], - (token1, token2, token1Selected) => { - if (token1Selected) { - return token2; - } else { - return token1; - } +export const selectTargetToken = (state: RootState) => { + if (state.orderInput.side === OrderSide.SELL) { + return state.orderInput.token1; + } else { + return state.orderInput.token2; } -); +}; +const selectSlippage = (state: RootState) => state.orderInput.slippage; +const selectPrice = (state: RootState) => state.orderInput.price; +const selectSide = (state: RootState) => state.orderInput.side; +const selectPriceMaxDecimals = (state: RootState) => { + return state.pairSelector.priceMaxDecimals; +}; +const selectToken1 = (state: RootState) => state.orderInput.token1; +const selectToken2 = (state: RootState) => state.orderInput.token2; export const orderInputSlice = createSlice({ name: "orderInput", @@ -246,17 +174,84 @@ export const orderInputSlice = createSlice({ setActiveTab(state, action: PayloadAction) { state.tab = action.payload; }, - setToken1Selected(state, action: PayloadAction) { - state.token1Selected = action.payload; - setFromSize(state); + updateAdex(state, action: PayloadAction) { + const serializedState: adex.StaticState = JSON.parse( + JSON.stringify(action.payload) + ); + const adexToken1 = serializedState.currentPairInfo.token1; + const adexToken2 = serializedState.currentPairInfo.token2; + if (state.token1.address !== adexToken1.address) { + state.token1 = { + address: adexToken1.address, + symbol: adexToken1.symbol, + iconUrl: adexToken1.iconUrl, + amount: "", + valid: true, + message: "", + }; + } + if (state.token2.address !== adexToken2.address) { + state.token2 = { + address: adexToken2.address, + symbol: adexToken2.symbol, + iconUrl: adexToken2.iconUrl, + amount: "", + valid: true, + message: "", + }; + } + + // set up a valid default price + if (state.price === 0) { + state.price = + serializedState.currentPairOrderbook.buys?.[0]?.price || 0; + } }, - setSize(state, action: PayloadAction) { - state.size = action.payload; - setFromSize(state); + updateBalance( + state, + action: PayloadAction<{ balance: number; token: TokenInfo }> + ) { + const { token, balance } = action.payload; + if (token.address === state.token1.address) { + state.token1 = { ...state.token1, balance }; + } else if (token.address === state.token2.address) { + state.token2 = { ...state.token2, balance }; + } + }, + setAmountToken1(state, action: PayloadAction) { + const amount = action.payload; + let token1 = { + ...state.token1, + amount, + }; + token1 = validateAmountToken1(token1); + state.token1 = token1; + + if (amount === "") { + state.token2.amount = ""; + } + }, + setAmountToken2(state, action: PayloadAction) { + const amount = action.payload; + let token2 = { + ...state.token2, + amount, + }; + + token2 = validateAmount(token2); + state.token2 = token2; + + if (amount === "") { + state.token1.amount = ""; + } + }, + swapTokens(state) { + const temp = state.token1; + state.token1 = state.token2; + state.token2 = temp; }, setSide(state, action: PayloadAction) { state.side = action.payload; - setFromSize(state); }, setPrice(state, action: PayloadAction) { state.price = action.payload; @@ -286,12 +281,44 @@ export const orderInputSlice = createSlice({ } state.quote = quote; - state.fromSize = quote.fromAmount; state.description = toDescription(quote); + if (state.tab === OrderTab.MARKET) { + if (state.side === OrderSide.SELL) { + state.token2.amount = quote.toAmount; + } else { + state.token1.amount = quote.fromAmount; + } + + if (quote.message.startsWith("Not enough liquidity")) { + if (state.side === OrderSide.SELL) { + state.token1.amount = quote.fromAmount; + state.token1.message = quote.message; + } else { + state.token2.amount = quote.toAmount; + state.token2.message = quote.message; + } + } + } else { + // limit order + if (state.side === OrderSide.SELL) { + state.token2.amount = Number(state.token1.amount) * state.price; + } else { + state.token1.amount = Number(state.token2.amount) / state.price; + } + } } ); - builder.addCase(fetchQuote.rejected, (_state, action) => { + builder.addCase(fetchQuote.rejected, (state, action) => { + if (state.side === OrderSide.SELL) { + state.token2.amount = ""; + state.token2.valid = false; + state.token2.message = "Could not get quote"; + } else { + state.token1.amount = ""; + state.token1.valid = false; + state.token1.message = "Could not get quote"; + } console.error("fetchQuote rejected:", action.error.message); }); @@ -311,21 +338,6 @@ export const orderInputSlice = createSlice({ }, }); -function setFromSize(state: OrderInputState) { - //Sets the amount of token leaving the user wallet - if (state.side === OrderSide.SELL) { - state.fromSize = state.size; - } else if (state.tab === OrderTab.LIMIT) { - if (state.token1Selected) { - state.fromSize = state.size * state.price; - } else { - state.fromSize = state.size / state.price; - } - } else { - state.fromSize = 0; //will update when quote is requested - } -} - function toDescription(quote: Quote): string { let description = ""; @@ -351,12 +363,17 @@ async function createTx(state: RootState, rdt: RDT) { const orderPrice = tab === OrderTab.LIMIT ? price : -1; const orderSlippage = tab === OrderTab.MARKET ? slippage : -1; //Adex creates the transaction manifest + const targetToken = selectTargetToken(state); + + if (!targetToken?.amount) { + throw new Error("No amount specified when creating transaction."); + } const createOrderResponse = await adex.createExchangeOrderTx( state.pairSelector.address, adexOrderType(state.orderInput), state.orderInput.side, - getSelectedToken(state).address, - state.orderInput.size, + targetToken.address, + targetToken.amount, orderPrice, orderSlippage, PLATFORM_BADGE_ID, @@ -376,20 +393,6 @@ async function createTx(state: RootState, rdt: RDT) { return submitTransactionResponse; } -export interface ValidationResult { - valid: boolean; - message: string; -} - -const selectSlippage = (state: RootState) => state.orderInput.slippage; -const selectPrice = (state: RootState) => state.orderInput.price; -const selectSize = (state: RootState) => state.orderInput.size; -const selectSide = (state: RootState) => state.orderInput.side; -const selectFromSize = (state: RootState) => state.orderInput.fromSize; -const selectPriceMaxDecimals = (state: RootState) => { - return state.pairSelector.priceMaxDecimals; -}; - export const validateSlippageInput = createSelector( [selectSlippage], (slippage) => { @@ -412,9 +415,8 @@ export const validatePriceInput = createSelector( selectBestBuy, selectBestSell, selectSide, - selectToken1Selected, ], - (price, priceMaxDecimals, bestBuy, bestSell, side, token1Selected) => { + (price, priceMaxDecimals, bestBuy, bestSell, side) => { if (price <= 0) { return { valid: false, message: "Price must be greater than 0" }; } @@ -423,97 +425,68 @@ export const validatePriceInput = createSelector( return { valid: false, message: "Too many decimal places" }; } - if ( - ((side === OrderSide.BUY && token1Selected) || - (side === OrderSide.SELL && !token1Selected)) && - bestSell - ? price > bestSell * 1.05 - : false - ) { - return { - valid: true, - message: "Price is significantly higher than best sell", - }; + if (bestSell) { + if (side === OrderSide.BUY && price > bestSell * 1.05) { + return { + valid: true, + message: "Price is significantly higher than best sell", + }; + } } - if ( - ((side === OrderSide.SELL && token1Selected) || - (side === OrderSide.BUY && !token1Selected)) && - bestBuy - ? price < bestBuy * 0.95 - : false - ) { - return { - valid: true, - message: "Price is significantly lower than best buy", - }; + if (bestBuy) { + if (side === OrderSide.SELL && price < bestBuy * 0.95) { + return { + valid: true, + message: "Price is significantly lower than best buy", + }; + } } return { valid: true, message: "" }; } ); -export const validatePositionSize = createSelector( - [ - selectSide, - selectSize, - getSelectedToken, - getUnselectedToken, - selectFromSize, - ], - (side, size, selectedToken, unSelectedToken, fromSize) => { - if (size.toString().split(".")[1]?.length > adex.AMOUNT_MAX_DECIMALS) { - return { valid: false, message: "Too many decimal places" }; - } +function validateAmount(token: TokenInput): TokenInput { + const amount = token.amount; + let valid = true; + let message = ""; + if (amount.toString().split(".")[1]?.length > adex.AMOUNT_MAX_DECIMALS) { + valid = false; + message = "Too many decimal places"; + } - if (size <= 0) { - return { valid: false, message: "Order size must be greater than 0" }; - } - if ( - (side === OrderSide.SELL && - selectedToken.balance && - size > selectedToken.balance) || - (side === OrderSide.BUY && - unSelectedToken.balance && - fromSize && - fromSize > unSelectedToken.balance) - ) { - return { valid: false, message: "Insufficient funds" }; - } + if (amount !== "" && amount <= 0) { + valid = false; + message = "Amount must be greater than 0"; + } - //Checks user isn't using all their xrd. maybe excessive - const MIN_XRD_BALANCE = 25; - if ( - (side === OrderSide.SELL && - selectedToken.symbol === "XRD" && - selectedToken.balance && - size > selectedToken.balance - MIN_XRD_BALANCE) || - (side === OrderSide.BUY && - unSelectedToken.balance && - unSelectedToken.symbol === "XRD" && - fromSize && - fromSize > unSelectedToken.balance - MIN_XRD_BALANCE) - ) { - return { - valid: true, - message: "WARNING: Leaves XRD balance dangerously low", - }; - } + return { ...token, valid, message }; +} - return { valid: true, message: "" }; +function validateAmountToken1(token1: TokenInput): TokenInput { + if ((token1.balance || 0) < (token1.amount || 0)) { + return { ...token1, valid: false, message: "Insufficient funds" }; + } else { + return validateAmount(token1); } -); +} const selectTab = (state: RootState) => state.orderInput.tab; export const validateOrderInput = createSelector( - [validatePositionSize, validatePriceInput, validateSlippageInput, selectTab], - ( - sizeValidationResult, - priceValidationResult, - slippageValidationResult, - tab - ) => { - if (!sizeValidationResult.valid) { - return sizeValidationResult; + [ + selectToken1, + selectToken2, + validatePriceInput, + validateSlippageInput, + selectTab, + ], + (token1, token2, priceValidationResult, slippageValidationResult, tab) => { + if (!token1.valid || token1.amount === undefined) { + return { valid: false, message: token1.message }; + } + + if (!token2.valid || token2.amount === undefined) { + return { valid: false, message: token2.message }; } if (tab === OrderTab.LIMIT && !priceValidationResult.valid) { diff --git a/src/app/redux/pairSelectorSlice.ts b/src/app/redux/pairSelectorSlice.ts index 12455b17..eb0d9364 100644 --- a/src/app/redux/pairSelectorSlice.ts +++ b/src/app/redux/pairSelectorSlice.ts @@ -2,6 +2,7 @@ import * as adex from "alphadex-sdk-js"; import { PayloadAction, createSlice, createAsyncThunk } from "@reduxjs/toolkit"; import { RootState } from "./store"; import { getRdt } from "../subscriptions"; +import { orderInputSlice } from "./orderInputSlice"; export const AMOUNT_MAX_DECIMALS = adex.AMOUNT_MAX_DECIMALS; @@ -40,7 +41,7 @@ export const fetchBalances = createAsyncThunk< { state: RootState; } ->("pairSelector/fetchToken1Balance", async (_arg, thunkAPI) => { +>("pairSelector/fetchBalances", async (_arg, thunkAPI) => { const dispatch = thunkAPI.dispatch; const state = thunkAPI.getState(); @@ -68,8 +69,11 @@ export const fetchBalances = createAsyncThunk< // if there are no items in response, set the balance to 0 const balance = parseFloat(response?.items[0]?.amount || "0"); dispatch(pairSelectorSlice.actions.setBalance({ balance, token })); + // TODO: store balances in one place only? + dispatch(orderInputSlice.actions.updateBalance({ balance, token })); } catch (error) { dispatch(pairSelectorSlice.actions.setBalance({ balance: 0, token })); + dispatch(orderInputSlice.actions.updateBalance({ balance: 0, token })); throw new Error("Error getting data from Radix gateway"); } } diff --git a/src/app/subscriptions.ts b/src/app/subscriptions.ts index 06e182fe..56e9e5ba 100644 --- a/src/app/subscriptions.ts +++ b/src/app/subscriptions.ts @@ -12,6 +12,7 @@ import { orderBookSlice } from "./redux/orderBookSlice"; import { updateCandles } from "./redux/priceChartSlice"; import { updatePriceInfo } from "./redux/priceInfoSlice"; import { accountHistorySlice } from "./redux/accountHistorySlice"; +import { orderInputSlice } from "redux/orderInputSlice"; import { AppStore } from "./redux/store"; export type RDT = ReturnType; @@ -59,6 +60,7 @@ export function initializeSubscriptions(store: AppStore) { store.dispatch(updateCandles(serializedState.currentPairCandlesList)); store.dispatch(updatePriceInfo(serializedState)); store.dispatch(accountHistorySlice.actions.updateAdex(serializedState)); + store.dispatch(orderInputSlice.actions.updateAdex(serializedState)); }) ); }