diff --git a/apps/kyberswap-interface/.env b/apps/kyberswap-interface/.env index 3127fa437b..018ed50a62 100644 --- a/apps/kyberswap-interface/.env +++ b/apps/kyberswap-interface/.env @@ -48,6 +48,7 @@ VITE_KYBER_AI_API_URL=https://kd-api.kyberswap.com/api VITE_AFFILIATE_SERVICE=https://affiliate-service.kyberswap.com/api VITE_SOLANA_RPC=https://solana-rpc.kyberswap.com -VITE_SMART_EXIT_API_URL=https://conditional-order.kyberswap.com/api +VITE_CONDITIONAL_SERVICE_URL=https://pre-conditional-order.kyberengineering.io/api +# VITE_CONDITIONAL_SERVICE_URL=https://conditional-order.kyberswap.com/api VITE_CROSSCHAIN_AGGREGATOR_API=https://crosschain-aggregator.kyberswap.com # VITE_CROSSCHAIN_AGGREGATOR_API=https://pre-crosschain-aggregator.kyberengineering.io diff --git a/apps/kyberswap-interface/src/assets/svg/ic_stoploss_recreate.svg b/apps/kyberswap-interface/src/assets/svg/ic_stoploss_recreate.svg new file mode 100644 index 0000000000..9b81fdf8da --- /dev/null +++ b/apps/kyberswap-interface/src/assets/svg/ic_stoploss_recreate.svg @@ -0,0 +1,10 @@ + + + + + \ No newline at end of file diff --git a/apps/kyberswap-interface/src/components/CurrencyInputPanel/index.tsx b/apps/kyberswap-interface/src/components/CurrencyInputPanel/index.tsx index 2b341301d8..f57fb71c01 100644 --- a/apps/kyberswap-interface/src/components/CurrencyInputPanel/index.tsx +++ b/apps/kyberswap-interface/src/components/CurrencyInputPanel/index.tsx @@ -321,6 +321,8 @@ interface CurrencyInputPanelProps { onClickSelect?: () => void positionMax?: 'inline' | 'top' label?: ReactNode + /** Rendered inside the panel, under the amount row — for a note that belongs to this field. */ + footer?: ReactNode positionLabel?: 'in' | 'out' onCurrencySelect?: (currency: Currency) => void onSwitchCurrency?: () => void @@ -363,6 +365,7 @@ export default function CurrencyInputPanel({ onHalf, positionMax = 'inline', label = '', + footer, positionLabel = 'out', onCurrencySelect, onSwitchCurrency, @@ -488,6 +491,8 @@ export default function CurrencyInputPanel({ )} + + {footer} {!disableCurrencySelect && !isSwitchMode && onCurrencySelect && ( new Date(date.getFullYear(), date.getMonth(), 1) + +/** + * The calendar is the control here, not the text field: the CSS collapses the input and shows the + * calendar inline. `isOpen` therefore drives it rather than focus — the picker renders no calendar at + * all until it has been opened, so leaving that to focus hides it the moment focus moves elsewhere. + */ export default function DatePicker({ onChange, value }: { value: Date; onChange: (date: Date) => void }) { const today = new Date() const minDate = new Date(today.getFullYear(), today.getMonth(), today.getDate()) - const dateKey = `${value.getFullYear()}-${value.getMonth()}-${value.getDate()}` + + /** + * Which month is on screen. The calendar keeps its own copy the moment the user pages through + * months, and that copy then outranks `value` — so a date chosen elsewhere (a preset, say) would + * change the selection while leaving the user looking at an unrelated month. Following `value` + * here re-anchors the view on every change; paging within an unchanged `value` is untouched. + */ + const valueTime = value.getTime() + const [activeStartDate, setActiveStartDate] = useState(() => startOfMonth(value)) + useEffect(() => { + setActiveStartDate(startOfMonth(new Date(valueTime))) + }, [valueTime]) return (
next && setActiveStartDate(next), + }} className="custom-date-picker" value={value} closeCalendar={false} diff --git a/apps/kyberswap-interface/src/components/DropdownMenu/index.tsx b/apps/kyberswap-interface/src/components/DropdownMenu/index.tsx index 493858c83b..eb0790829b 100644 --- a/apps/kyberswap-interface/src/components/DropdownMenu/index.tsx +++ b/apps/kyberswap-interface/src/components/DropdownMenu/index.tsx @@ -34,6 +34,8 @@ type DropdownMenuProps = { mobileFullWidth?: boolean mobileHalfWidth?: boolean usePortal?: boolean + /** Names the trigger; each option becomes `${dataTestId}-option-${value}`. */ + dataTestId?: string onChange: (value: string | number) => void } @@ -49,6 +51,7 @@ const DropdownMenu = ({ mobileFullWidth = false, mobileHalfWidth = false, usePortal = false, + dataTestId, onChange, }: DropdownMenuProps) => { const [open, setOpen] = useState(false) @@ -130,7 +133,13 @@ const DropdownMenu = ({ } const dropdownContent = ( - + handleScrollClick('up')}> @@ -139,6 +148,7 @@ const DropdownMenu = ({ handleSelectItem(option.value)} + data-testid={dataTestId && `${dataTestId}-option-${option.value}`} className={option.value === value ? 'selected' : ''} > {option.icon && } @@ -165,6 +175,7 @@ const DropdownMenu = ({ background={background} highlight={flatten && open} onClick={handleOpenChange} + data-testid={dataTestId} > {optionValue?.icon && } diff --git a/apps/kyberswap-interface/src/components/ErrorWarning.tsx b/apps/kyberswap-interface/src/components/ErrorWarning.tsx index 2ac533dc90..a352555ec0 100644 --- a/apps/kyberswap-interface/src/components/ErrorWarning.tsx +++ b/apps/kyberswap-interface/src/components/ErrorWarning.tsx @@ -18,9 +18,18 @@ type ErrorWarningProps = { style?: CSSProperties className?: string action?: ReactNode + dataTestId?: string } -export const ErrorWarning = ({ title, type, desc, style: customStyle = {}, className, action }: ErrorWarningProps) => { +export const ErrorWarning = ({ + title, + type, + desc, + style: customStyle = {}, + className, + action, + dataTestId, +}: ErrorWarningProps) => { const detailsId = useId() const { backgroundClass, colorClass, Icon } = WARNING_STYLES[type] const [expanded, setExpanded] = useState(false) @@ -30,6 +39,8 @@ export const ErrorWarning = ({ title, type, desc, style: customStyle = {}, class
{title}
@@ -39,7 +50,12 @@ export const ErrorWarning = ({ title, type, desc, style: customStyle = {}, class } return ( - + { diff --git a/apps/kyberswap-interface/src/components/LimitOrder/Form/LimitOrderExpirySection.tsx b/apps/kyberswap-interface/src/components/LimitOrder/Form/LimitOrderExpirySection.tsx index 52f8cb500b..805d208e5c 100644 --- a/apps/kyberswap-interface/src/components/LimitOrder/Form/LimitOrderExpirySection.tsx +++ b/apps/kyberswap-interface/src/components/LimitOrder/Form/LimitOrderExpirySection.tsx @@ -3,7 +3,7 @@ import { ButtonHTMLAttributes } from 'react' import { Calendar, ChevronDown } from 'react-feather' import { HStack, Stack } from 'components/Stack' -import { DropdownIcon } from 'components/SwapForm/SlippageSetting' +import { DropdownIcon, type SettingGridCells } from 'components/SwapForm/SlippageSetting' import { TextDashed } from 'components/Text' import { MouseoverTooltip } from 'components/Tooltip' import { TIMES_IN_SECS } from 'constants/index' @@ -32,6 +32,8 @@ const ExpireOptionButton = ({ /> ) +export type ExpiryPresetOption = { value: number; label: string } + type Props = { expiry?: { expire?: number @@ -44,13 +46,21 @@ type Props = { onOpenDatePicker?: () => void onExpireChange?: (val: Date | number) => void } + /** Durations in seconds offered above the Custom Date entry. */ + presetOptions?: ExpiryPresetOption[] + tooltip?: string + /** See `SettingGridCells`: splits the header and the panel across the caller's grid. */ + gridCells?: SettingGridCells } const LimitOrderExpirySection = ({ expiry: { expire, expanded, customDateExpire, displayTime } = {}, events = {}, + presetOptions, + tooltip, + gridCells, }: Props) => { - const expirePresetOptions = [ + const expirePresetOptions = presetOptions ?? [ { value: TIMES_IN_SECS.ONE_HOUR, label: t`1 Hour` }, { value: TIMES_IN_SECS.ONE_DAY, label: t`1 Day` }, { value: 7 * TIMES_IN_SECS.ONE_DAY, label: t`7 Days` }, @@ -66,13 +76,15 @@ const LimitOrderExpirySection = ({ : expirePresetOptions.find(item => item.value === expire)?.label || displayTime return ( - - + + Expires In: @@ -81,8 +93,11 @@ const LimitOrderExpirySection = ({ className="cursor-pointer items-center gap-1 hover:brightness-75" role="button" onClick={events.onToggleExpanded} + data-testid="expiry-setting-toggle" > - {fullDisplayTime} + + {fullDisplayTime} + @@ -94,11 +109,17 @@ const LimitOrderExpirySection = ({ className={cn( 'grid transition-[grid-template-rows,opacity] duration-200 ease-in-out', expanded ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0', + // As a grid item the panel's automatic minimum size would hold the collapsed 0fr row open. + gridCells && 'min-h-0', + gridCells?.panel, )} >
-
+
{expireOptions.map(item => { const active = customDateExpire ? item.custom : item.value === expire @@ -111,6 +132,7 @@ const LimitOrderExpirySection = ({ }} active={active} custom={item.custom} + data-testid={item.custom ? 'expiry-option-custom' : `expiry-option-${item.value}`} > {item.custom ? ( diff --git a/apps/kyberswap-interface/src/components/LimitOrder/Form/LimitOrderForm.tsx b/apps/kyberswap-interface/src/components/LimitOrder/Form/LimitOrderForm.tsx index 0846acec52..82b895c83c 100644 --- a/apps/kyberswap-interface/src/components/LimitOrder/Form/LimitOrderForm.tsx +++ b/apps/kyberswap-interface/src/components/LimitOrder/Form/LimitOrderForm.tsx @@ -18,6 +18,7 @@ import MarketPrice from 'components/LimitOrder/Form/MarketPrice' import { useLimitOrderFormState } from 'components/LimitOrder/Form/useLimitOrderFormState' import { NetworkSelector } from 'components/NetworkSelector' import { HStack, Stack } from 'components/Stack' +import OrderTypeSubTabs from 'components/StopLoss/OrderTypeSubTabs' import ReverseTokenSelectionButton from 'components/SwapForm/ReverseTokenSelectionButton' import { useActiveWeb3React } from 'hooks' import { NETWORKS_INFO } from 'hooks/useChainsConfig' @@ -172,6 +173,7 @@ const LimitOrderForm = ({ currencyIn: currencyInProp, currencyOut: currencyOutPr return ( <> + {!isEmbeddedSwap && } {isEmbeddedSwap && } diff --git a/apps/kyberswap-interface/src/components/LimitOrder/Form/LimitOrderTokenSection.tsx b/apps/kyberswap-interface/src/components/LimitOrder/Form/LimitOrderTokenSection.tsx index e502970084..409215e174 100644 --- a/apps/kyberswap-interface/src/components/LimitOrder/Form/LimitOrderTokenSection.tsx +++ b/apps/kyberswap-interface/src/components/LimitOrder/Form/LimitOrderTokenSection.tsx @@ -13,6 +13,8 @@ export type LimitOrderTokenPanelProps = { tokens?: TokenSectionTokens estimateUsd?: TokenSectionEstimateUsd events?: TokenSectionEvents + /** Rendered inside the panel, under the amount row — for a note about the token chosen here. */ + footer?: ReactNode } type TokenSectionTokens = { @@ -45,11 +47,13 @@ export const LimitOrderInputTokenPanel = ({ tokens = {}, estimateUsd = DEFAULT_ESTIMATE_USD, events = {}, + footer, }: LimitOrderTokenPanelProps) => { const { currencyIn, currencyOut, inputAmount = '' } = tokens return ( { value={orderType} width={130} mobileHalfWidth + // The panel is clipped by the order list's rounded `overflow-hidden` shell, which cuts the + // last options off whenever the table is short. A portalled menu escapes that box. + usePortal onChange={onSelectOrderType} /> My Order(s) }, + { + id: LimitOrderTab.STOP_LOSS, + label: ( + <> + + Stop Loss Order(s) + + + Stop Loss + + + ), + }, { id: LimitOrderTab.PRICE, label: Price }, ] as const @@ -52,7 +71,7 @@ const TabSelector = ({ activeTab, setActiveTab, tabs }: TabSelectorProps) => { return ( -
+
{tabs.map((tab, index) => { const active = tab.id === activeTab const isLast = index === tabs.length - 1 @@ -63,6 +82,7 @@ const TabSelector = ({ activeTab, setActiveTab, tabs }: TabSelectorProps) => { onClick={() => setActiveTab(tab.id)} role="tab" type="button" + data-testid={`order-list-tab-${tab.id}`} className={cn( 'relative flex min-h-11 shrink-0 cursor-pointer items-center gap-1 border-0 px-4 py-3 text-sm font-medium', !isLast && 'border-r border-darkBorder', @@ -83,7 +103,10 @@ const TabSelector = ({ activeTab, setActiveTab, tabs }: TabSelectorProps) => { } > - + {numberOfInsufficientFundOrders} @@ -99,28 +122,72 @@ const TabSelector = ({ activeTab, setActiveTab, tabs }: TabSelectorProps) => { const OrderList = () => { const { chainId, syncOrderListTabWithQuery } = useLimitOrderContext() const { currencyIn, currencyOut } = useLimitState() + const navigate = useNavigate() + const { pathname, search } = useLocation() + const { network, currency } = useParams<{ network: string; currency?: string }>() + + const isStopLossPage = getTradeProductPath(pathname) === APP_PATHS.STOP_LOSS + // Partner embeds have no :network segment and no stop-loss route, so offering the tab there would + // navigate the iframe to a malformed path in the host app. + const { isEmbeddedSwap } = usePageLocation() + const stopLossSupported = isSupportStopLoss(chainId) && !isEmbeddedSwap const hasSupportedTokenPriceChart = Boolean(PRICE_CHART_QUOTES[chainId]) const tabs = useMemo( - () => ORDER_LIST_TABS.filter(tab => hasSupportedTokenPriceChart || tab.id !== LimitOrderTab.PRICE), - [hasSupportedTokenPriceChart], + () => + ORDER_LIST_TABS.filter( + tab => + (hasSupportedTokenPriceChart || tab.id !== LimitOrderTab.PRICE) && + (stopLossSupported || tab.id !== LimitOrderTab.STOP_LOSS), + ), + [hasSupportedTokenPriceChart, stopLossSupported], ) const tabIds = useMemo(() => tabs.map(tab => tab.id), [tabs]) const { activeTab, setActiveTab } = useTab({ tabs: tabIds, - defaultTab: LimitOrderTab.ORDER_BOOK, + defaultTab: isStopLossPage ? LimitOrderTab.STOP_LOSS : LimitOrderTab.ORDER_BOOK, syncQuery: syncOrderListTabWithQuery, }) - const currentTab = activeTab || LimitOrderTab.ORDER_BOOK + const currentTab = activeTab || (isStopLossPage ? LimitOrderTab.STOP_LOSS : LimitOrderTab.ORDER_BOOK) + + /** + * Each order type owns a route, so picking the other product's tab has to move the page as well as + * the panel — otherwise the card and the list below it would be showing different products. + */ + const onSelectTab = (tab: LimitOrderTab) => { + // Price is product-neutral, so selecting it must not drag the user off the route they are on and + // discard the form they were filling in. + if (tab === LimitOrderTab.PRICE) { + setActiveTab(tab) + return + } + + const wantsStopLoss = tab === LimitOrderTab.STOP_LOSS + if (wantsStopLoss !== isStopLossPage) { + const nextProduct = wantsStopLoss ? APP_PATHS.STOP_LOSS : APP_PATHS.LIMIT + const nextSearch = new URLSearchParams(search) + nextSearch.set('tab', tab) + // Keeps the current panel on screen while the destination chunk loads — see OrderTypeSubTabs. + startTransition(() => { + navigate({ + pathname: `${nextProduct}/${network || ''}${currency ? `/${currency}` : ''}`, + search: nextSearch.toString(), + }) + }) + return + } + setActiveTab(tab) + } return ( - + {currentTab === LimitOrderTab.ORDER_BOOK && } {currentTab === LimitOrderTab.MY_ORDER && } + {currentTab === LimitOrderTab.STOP_LOSS && } {currentTab === LimitOrderTab.PRICE && } diff --git a/apps/kyberswap-interface/src/components/LimitOrder/ProcessingOrder/ProcessingOrderModal.tsx b/apps/kyberswap-interface/src/components/LimitOrder/ProcessingOrder/ProcessingOrderModal.tsx index b178e86fd8..79621cecdb 100644 --- a/apps/kyberswap-interface/src/components/LimitOrder/ProcessingOrder/ProcessingOrderModal.tsx +++ b/apps/kyberswap-interface/src/components/LimitOrder/ProcessingOrder/ProcessingOrderModal.tsx @@ -1,6 +1,6 @@ import { ChainId, Currency } from '@kyberswap/ks-sdk-core' import { Trans, t } from '@lingui/macro' -import { useEffect, useRef } from 'react' +import { ReactNode, useEffect, useRef } from 'react' import { AlertCircle, RotateCw } from 'react-feather' import { ButtonLight, ButtonOutlined, ButtonPrimary } from 'components/Button' @@ -30,12 +30,18 @@ type ProcessingController = { retryStep?: (step: Step) => void } +/** Per-status copy for the signing step, which names the kind of order being placed. */ +export type FinalStepLabels = { idle: string; active: string; success: string } + type ProcessingOrderModalProps = { processing: ProcessingController chainId?: ChainId currencyIn?: Currency onUserDismiss?: () => void onViewOrder?: () => void + title?: string + finalStepLabels?: FinalStepLabels + viewOrderLabel?: ReactNode } const getStepStatus = ({ @@ -71,11 +77,13 @@ const getStepLabel = ({ status, chainId, currencyIn, + finalStepLabels, }: { step: ProcessingOrderStep status: ProcessingStepStatus chainId: ChainId | undefined currencyIn: Currency | undefined + finalStepLabels?: FinalStepLabels }) => { if (step === 'wrap') { const nativeSymbol = chainId ? NativeCurrencies[chainId].symbol : t`token` @@ -92,9 +100,9 @@ const getStepLabel = ({ } if (step === 'create') { - if (status === 'active') return t`Signing order` - if (status === 'success') return t`Order successfully listed` - return t`Sign order` + if (status === 'active') return finalStepLabels?.active ?? t`Signing order` + if (status === 'success') return finalStepLabels?.success ?? t`Order successfully listed` + return finalStepLabels?.idle ?? t`Sign order` } if (status === 'active') return t`Filling order` @@ -109,6 +117,7 @@ const ProcessingStepRow = ({ chainId, currencyIn, onRetryStep, + finalStepLabels, }: { index: number step: Step @@ -116,8 +125,9 @@ const ProcessingStepRow = ({ chainId: ChainId | undefined currencyIn: Currency | undefined onRetryStep?: (step: Step) => void + finalStepLabels?: FinalStepLabels }) => ( - + ({ status === 'error' && 'text-red', )} > - {getStepLabel({ step, status, chainId, currencyIn })} + {getStepLabel({ step, status, chainId, currencyIn, finalStepLabels })} {status === 'error' && ( - onRetryStep?.(step)} width="auto" className="gap-1 px-2 py-1 text-xs"> + onRetryStep?.(step)} + width="auto" + className="gap-1 px-2 py-1 text-xs" + data-testid="processing-step-retry" + > {t`Retry`} @@ -145,6 +160,9 @@ const ProcessingOrderModal = ({ currencyIn, onUserDismiss, onViewOrder, + title, + finalStepLabels, + viewOrderLabel, }: ProcessingOrderModalProps) => { const { state, dismiss, retryStep } = processing const { account } = useActiveWeb3React() @@ -179,10 +197,12 @@ const ProcessingOrderModal = ({ return ( - + -
{t`Processing Order`}
- +
+ {title ?? t`Processing Order`} +
+
@@ -203,18 +223,19 @@ const ProcessingOrderModal = ({ chainId={chainId} currencyIn={currencyIn} onRetryStep={retryStep} + finalStepLabels={finalStepLabels} /> ) })} {orderComplete && ( - - + + Close - - My Orders + + {viewOrderLabel ?? My Orders} )} diff --git a/apps/kyberswap-interface/src/components/LimitOrder/types.ts b/apps/kyberswap-interface/src/components/LimitOrder/types.ts index aab456203e..00d60c642f 100644 --- a/apps/kyberswap-interface/src/components/LimitOrder/types.ts +++ b/apps/kyberswap-interface/src/components/LimitOrder/types.ts @@ -5,6 +5,7 @@ import { isSupportedChainId } from 'constants/networks' import type { BaseTradeInfo } from 'hooks/useBaseTradeInfo' export enum LimitOrderTab { + STOP_LOSS = 'stop_loss', PRICE = 'price', ORDER_BOOK = 'order_book', MY_ORDER = 'my_order', diff --git a/apps/kyberswap-interface/src/components/RouteFallback/index.tsx b/apps/kyberswap-interface/src/components/RouteFallback/index.tsx index 9380a0ef08..2ff39e5d53 100644 --- a/apps/kyberswap-interface/src/components/RouteFallback/index.tsx +++ b/apps/kyberswap-interface/src/components/RouteFallback/index.tsx @@ -16,7 +16,10 @@ const matchesAnyRoute = (pathname: string, paths: string[]) => paths.some(path = const pickSkeleton = (rawPathname: string) => { const pathname = rawPathname.length > 1 ? rawPathname.replace(/\/+$/, '') : rawPathname - if (isSwapLikePath(pathname) || matchesAnyRoute(pathname, [APP_PATHS.LIMIT, APP_PATHS.CROSS_CHAIN])) { + if ( + isSwapLikePath(pathname) || + matchesAnyRoute(pathname, [APP_PATHS.LIMIT, APP_PATHS.STOP_LOSS, APP_PATHS.CROSS_CHAIN]) + ) { return } if (matchesAnyRoute(pathname, [APP_PATHS.PARTNER_SWAP, APP_PATHS.USER_SWAP])) { diff --git a/apps/kyberswap-interface/src/components/SearchInput.tsx b/apps/kyberswap-interface/src/components/SearchInput.tsx index 334badd49c..976167cb99 100644 --- a/apps/kyberswap-interface/src/components/SearchInput.tsx +++ b/apps/kyberswap-interface/src/components/SearchInput.tsx @@ -10,6 +10,7 @@ export default function SearchInput({ placeholder, style, className, + dataTestId, }: { maxLength?: number placeholder: string @@ -17,6 +18,7 @@ export default function SearchInput({ onChange: (val: string) => void style?: CSSProperties className?: string + dataTestId?: string }) { return (
onChange(e.target.value)} + data-testid={dataTestId} className="max-w-[calc(100%-20px)] flex-1 truncate border-none bg-inherit text-[13.3px] text-text outline-none placeholder:text-text placeholder:opacity-40" /> {value ? ( - onChange('')} /> + onChange('')} + data-testid={dataTestId && `${dataTestId}-clear`} + /> ) : ( )} diff --git a/apps/kyberswap-interface/src/components/Seo/routeMetadata.ts b/apps/kyberswap-interface/src/components/Seo/routeMetadata.ts index bac3be542f..e56c805a46 100644 --- a/apps/kyberswap-interface/src/components/Seo/routeMetadata.ts +++ b/apps/kyberswap-interface/src/components/Seo/routeMetadata.ts @@ -8,7 +8,7 @@ import { KYBER_NETWORK_TELEGRAM_URL, KYBER_NETWORK_TWITTER_URL, } from 'constants/index' -import { MAINNET_NETWORKS, NETWORKS_INFO, isSupportLimitOrder } from 'constants/networks' +import { MAINNET_NETWORKS, NETWORKS_INFO, isSupportLimitOrder, isSupportStopLoss } from 'constants/networks' import { SwapIntent } from 'utils/routes' // Pure route metadata shared by client-side and static trade shells. @@ -24,7 +24,7 @@ export type RouteSeoMetadata = { title: string } -export type TradeProduct = 'limit' | 'swap' +export type TradeProduct = 'limit' | 'stop-loss' | 'swap' type SeoCopy = Pick @@ -51,6 +51,12 @@ const LIMIT_DESCRIPTION = 'Set a target price and your order settles on-chain automatically when the market reaches it. Gasless submission, no slippage, zero fee for placing order.' const DEFAULT_LIMIT_SEO_COPY: SeoCopy = { title: LIMIT_TITLE, description: LIMIT_DESCRIPTION } +// Sitemap: Stop Loss - per chain +const STOP_LOSS_TITLE = 'Stop Loss Orders | KyberSwap' +const STOP_LOSS_DESCRIPTION = + 'Set a trigger price and KyberSwap sells automatically at the best available market price when the oracle price reaches it. Free to place, tokens stay in your wallet.' +const DEFAULT_STOP_LOSS_SEO_COPY: SeoCopy = { title: STOP_LOSS_TITLE, description: STOP_LOSS_DESCRIPTION } + // Sitemap: Cross-chain and Earn const CROSS_CHAIN_DESCRIPTION = 'Swap tokens between EVMs, Bitcoin, Solana, and Near chains in one step - no manual bridging. Quotes from multiple providers, best rate picked automatically.' @@ -101,6 +107,12 @@ const supportsLimitOrder = (networkRoute: string) => { return isSupportLimitOrder(chainId) } +const supportsStopLoss = (networkRoute: string) => { + const chainId = getMainnetChainIdByRoute(networkRoute) + if (chainId === undefined) return false + return isSupportStopLoss(chainId) +} + const getSingleSearchParam = (searchParams: URLSearchParams, key: string) => { const values = searchParams.getAll(key) return values.length === 1 ? values[0] : undefined @@ -136,6 +148,16 @@ const getLimitSeoCopy = (networkRoute: string): SeoCopy => { : DEFAULT_LIMIT_SEO_COPY } +const getStopLossSeoCopy = (networkRoute: string): SeoCopy => { + const networkName = getNetworkNameByRoute(networkRoute) + return networkName + ? { + title: `Stop Loss Orders - Protect Your Positions on ${networkName} | KyberSwap`, + description: STOP_LOSS_DESCRIPTION, + } + : DEFAULT_STOP_LOSS_SEO_COPY +} + const getLegacyPairCanonicalPath = (productPath: string, networkRoute: string, searchParams: URLSearchParams) => { const tokenIn = getSingleSearchParam(searchParams, 'inputCurrency')?.trim().toLowerCase() const tokenOut = getSingleSearchParam(searchParams, 'outputCurrency')?.trim().toLowerCase() @@ -278,6 +300,33 @@ export const resolveRouteMetadata = (pathname: string, search: string): RouteSeo } } + // Sitemap: Stop Loss - per chain. Same pair-route policy as Limit Orders. + const stopLossPairMatch = matchPath(`${APP_PATHS.STOP_LOSS}/:network/:currency`, normalizedPath) + if (stopLossPairMatch) { + const networkRoute = stopLossPairMatch.params.network || 'ethereum' + return { + ...getStopLossSeoCopy(networkRoute), + canonicalPath: normalizedPath, + jsonLd: buildSiteJsonLd(normalizedPath), + robots: NOINDEX_ROBOTS, + } + } + + const stopLossMatch = matchPath(`${APP_PATHS.STOP_LOSS}/:network`, normalizedPath) + if (stopLossMatch) { + const networkRoute = (stopLossMatch.params.network || 'ethereum').toLowerCase() + const isSupportedStopLossRoute = supportsStopLoss(networkRoute) + const canonicalPath = + (isSupportedStopLossRoute && getLegacyPairCanonicalPath(APP_PATHS.STOP_LOSS, networkRoute, searchParams)) || + `${APP_PATHS.STOP_LOSS}/${networkRoute}` + return { + ...getStopLossSeoCopy(networkRoute), + canonicalPath, + jsonLd: buildSiteJsonLd(canonicalPath), + robots: !isSupportedStopLossRoute || hasQueryParams ? NOINDEX_ROBOTS : INDEX_ROBOTS, + } + } + // Sitemap: Cross-chain and Earn if (normalizedPath === APP_PATHS.CROSS_CHAIN) { const canonicalPath = getCrossChainCanonicalPath(searchParams) || normalizedPath @@ -446,10 +495,15 @@ export const resolveRouteMetadata = (pathname: string, search: string): RouteSeo * RouteSeo replaces it after the browser app mounts. */ export const resolveTradeShellMetadata = (product: TradeProduct): RouteSeoMetadata => { - const isSwap = product === 'swap' + const shell = { + limit: { copy: DEFAULT_LIMIT_SEO_COPY, canonicalPath: APP_PATHS.LIMIT }, + 'stop-loss': { copy: DEFAULT_STOP_LOSS_SEO_COPY, canonicalPath: APP_PATHS.STOP_LOSS }, + swap: { copy: DEFAULT_SWAP_SEO_COPY, canonicalPath: APP_PATHS.SWAP }, + }[product] + return { - ...(isSwap ? DEFAULT_SWAP_SEO_COPY : DEFAULT_LIMIT_SEO_COPY), - canonicalPath: isSwap ? APP_PATHS.SWAP : APP_PATHS.LIMIT, + ...shell.copy, + canonicalPath: shell.canonicalPath, robots: NOINDEX_ROBOTS, } } diff --git a/apps/kyberswap-interface/src/components/Seo/seoContract.test.ts b/apps/kyberswap-interface/src/components/Seo/seoContract.test.ts index eaf90770bc..bc1fd3da9e 100644 --- a/apps/kyberswap-interface/src/components/Seo/seoContract.test.ts +++ b/apps/kyberswap-interface/src/components/Seo/seoContract.test.ts @@ -308,6 +308,7 @@ describe('SEO contract', () => { it.each([ ['swap', '/swap'], ['limit', '/limit'], + ['stop-loss', '/stop-loss'], ] as const)('keeps the shared %s shell safely noindex until OG or RouteSeo replaces it', (product, canonical) => { const head = renderTradeShellHeadHtml(product) diff --git a/apps/kyberswap-interface/src/components/Seo/sitemapRoutes.ts b/apps/kyberswap-interface/src/components/Seo/sitemapRoutes.ts index 92b0b99bf9..b8b9145e7f 100644 --- a/apps/kyberswap-interface/src/components/Seo/sitemapRoutes.ts +++ b/apps/kyberswap-interface/src/components/Seo/sitemapRoutes.ts @@ -47,6 +47,8 @@ export const SITEMAP_PAGE_ROUTES = [ '/', ...SITEMAP_SWAP_CHAIN_SLUGS.map(chain => `/swap/${chain}`), ...SITEMAP_LIMIT_CHAIN_SLUGS.map(chain => `/limit/${chain}`), + // Stop-loss is built but not launched, so its routes resolve without being advertised for indexing. + // Add them here when the feature goes live. '/cross-chain', '/earn', '/earn/pools', diff --git a/apps/kyberswap-interface/src/components/SlippageControl/CustomSlippageInput.tsx b/apps/kyberswap-interface/src/components/SlippageControl/CustomSlippageInput.tsx index 79a5cab80f..581450ffab 100644 --- a/apps/kyberswap-interface/src/components/SlippageControl/CustomSlippageInput.tsx +++ b/apps/kyberswap-interface/src/components/SlippageControl/CustomSlippageInput.tsx @@ -145,6 +145,7 @@ const CustomSlippageInput: React.FC = ({ onFocusChange?.(true) onActiveChange(true) }} + data-testid="slippage-custom-input" className="w-14 min-w-0 border-0 bg-transparent p-0 text-right text-[13px] font-medium text-inherit outline-none placeholder:text-inherit [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none" /> % diff --git a/apps/kyberswap-interface/src/components/SlippageControl/index.tsx b/apps/kyberswap-interface/src/components/SlippageControl/index.tsx index 528bd122a6..48386ed324 100644 --- a/apps/kyberswap-interface/src/components/SlippageControl/index.tsx +++ b/apps/kyberswap-interface/src/components/SlippageControl/index.tsx @@ -51,13 +51,17 @@ const SlippageControl: React.FC = props => { }, [isCustomInputFocused, options, rawSlippage]) return ( -
+
{options.map(slp => { const isActive = rawSlippage === slp && !isCustomActive return ( { trackingHandler(TRACKING_EVENT_TYPE.SLIPPAGE_CHANGED, { new_slippage: slp / 100, diff --git a/apps/kyberswap-interface/src/components/StopLoss/CancelOrder/CancelStopLossModal.tsx b/apps/kyberswap-interface/src/components/StopLoss/CancelOrder/CancelStopLossModal.tsx new file mode 100644 index 0000000000..4ccae68061 --- /dev/null +++ b/apps/kyberswap-interface/src/components/StopLoss/CancelOrder/CancelStopLossModal.tsx @@ -0,0 +1,181 @@ +import { Trans, t } from '@lingui/macro' +import { useState } from 'react' +import { + useBatchCancelStopLossOrdersMutation, + useCancelStopLossOrderMutation, + useGetStopLossBatchCancelSignMessageMutation, + useGetStopLossCancelSignMessageMutation, +} from 'services/stopLoss' + +import { NotificationType } from 'components/Announcement/type' +import { ButtonOutlined, ButtonPrimary } from 'components/Button' +import Modal from 'components/Modal' +import { HStack, Stack } from 'components/Stack' +import { useStopLossTracking } from 'components/StopLoss/hooks/useStopLossTracking' +import { StopLossOrder } from 'components/StopLoss/types' +import { stripEmptyEip712Salt } from 'components/StopLoss/utils' +import { useActiveWeb3React } from 'hooks' +import { useNotify } from 'state/application/hooks' +import { CloseIcon } from 'theme' +import { friendlyError } from 'utils/errorMessage' +import { formatSignature } from 'utils/transaction' +import { Address } from 'utils/viem' +import { signTypedDataRaw } from 'utils/walletClient' + +type Props = { + /** One order for a row cancel, or the whole active set for Cancel All. Empty closes the modal. */ + orders: StopLossOrder[] + /** Named in the batch copy, because one signature can only reach one chain. */ + chainName?: string + /** True when open orders on other chains are on screen but outside this batch. */ + hasOtherChains?: boolean + onDismiss: () => void + onCancelled: (orderIds: number[]) => void +} + +const CancelStopLossModal = ({ orders, chainName, hasOtherChains, onDismiss, onCancelled }: Props) => { + const { account } = useActiveWeb3React() + const notify = useNotify() + const tracking = useStopLossTracking() + + const [getCancelSignMessage] = useGetStopLossCancelSignMessageMutation() + const [cancelOrder] = useCancelStopLossOrderMutation() + const [getBatchSignMessage] = useGetStopLossBatchCancelSignMessageMutation() + const [batchCancel] = useBatchCancelStopLossOrdersMutation() + + const [isCancelling, setIsCancelling] = useState(false) + const [error, setError] = useState('') + + const isBatch = orders.length > 1 + const order = orders[0] + + const handleDismiss = () => { + setError('') + onDismiss() + } + + const onConfirm = async () => { + if (!order || !account) return + setIsCancelling(true) + setError('') + + try { + // A batch covers one chain and one order type, so it is keyed off the first order's chain. + const chainId = order.chainId + let cancelledIds: number[] + + if (isBatch) { + const orderIds = orders.map(o => o.id) + const params = { chainId, userWallet: account, orderIds } + const typedData = await getBatchSignMessage(params).unwrap() + const rawSignature = await signTypedDataRaw({ + chainId, + account: account as Address, + typedData: stripEmptyEip712Salt(typedData), + }) + const results = await batchCancel({ ...params, signature: formatSignature(rawSignature) }).unwrap() + + // A verified signature still returns success, so a per-order failure is only visible here. + cancelledIds = results.filter(result => result.success).map(result => result.orderId) + const failed = results.filter(result => !result.success) + if (failed.length) { + const count = failed.length + notify( + { + type: NotificationType.WARNING, + title: t`Some orders were not cancelled`, + summary: t`${count} of the selected orders could not be cancelled — they may have executed already.`, + }, + 10000, + ) + } + } else { + const params = { chainId, userWallet: account, orderId: order.id } + const typedData = await getCancelSignMessage(params).unwrap() + const rawSignature = await signTypedDataRaw({ + chainId, + account: account as Address, + typedData: stripEmptyEip712Salt(typedData), + }) + await cancelOrder({ ...params, signature: formatSignature(rawSignature) }).unwrap() + cancelledIds = [order.id] + } + + if (cancelledIds.length) { + const count = cancelledIds.length + notify( + { + type: NotificationType.SUCCESS, + title: t`Order cancelled`, + summary: isBatch ? t`${count} stop-loss orders were cancelled.` : t`Your stop-loss order was cancelled.`, + }, + 10000, + ) + orders.filter(o => cancelledIds.includes(o.id)).forEach(tracking.trackOrderCancelled) + onCancelled(cancelledIds) + } + handleDismiss() + } catch (cancelError) { + setError(friendlyError(cancelError)) + } finally { + setIsCancelling(false) + } + } + + const orderCount = orders.length + + return ( + 0} onDismiss={handleDismiss} maxWidth={420} borderRadius={16}> + + +
+ {isBatch ? Cancel All Stop-Loss Orders : Cancel Stop-Loss Order} +
+ +
+ + + {isBatch ? ( + + Cancel {orderCount} stop-loss orders on {chainName} with a single signature? You can recreate them + anytime, and cancelling costs no gas. + + ) : ( + Cancel this stop-loss order? You can recreate it anytime, and cancelling costs no gas. + )} + + + {isBatch && hasOtherChains && ( + + Orders on other chains are not included — one signature covers a single chain. + + )} + + + {error} + + + + + Keep Order + + + {isCancelling ? Cancelling... : Gas-less Cancel} + + +
+
+ ) +} + +export default CancelStopLossModal diff --git a/apps/kyberswap-interface/src/components/StopLoss/CreateOrder/StopLossConfirmModal.tsx b/apps/kyberswap-interface/src/components/StopLoss/CreateOrder/StopLossConfirmModal.tsx new file mode 100644 index 0000000000..1058479783 --- /dev/null +++ b/apps/kyberswap-interface/src/components/StopLoss/CreateOrder/StopLossConfirmModal.tsx @@ -0,0 +1,219 @@ +import { Currency } from '@kyberswap/ks-sdk-core' +import { Trans } from '@lingui/macro' +import dayjs from 'dayjs' +import { AlertTriangle, ArrowRight, Info } from 'react-feather' + +import { ButtonPrimary } from 'components/Button' +import CurrencyLogo from 'components/CurrencyLogo' +import Modal from 'components/Modal' +import { Center, HStack, Stack } from 'components/Stack' +import { StopLossWarning } from 'components/StopLoss/Form/useStopLossWarnings' +import { StopLossFee } from 'components/StopLoss/types' +import { clampStopLossDeadline } from 'components/StopLoss/utils' +import { CloseIcon } from 'theme' +import { cn } from 'utils/cn' +import { formatDisplayNumber } from 'utils/numbers' +import { formatSlippage } from 'utils/slippage' + +type Props = { + isOpen: boolean + currencyIn?: Currency + currencyOut?: Currency + inputAmount: string + estimatedOutput: string + estimatedUsdIn?: string + estimatedUsdOut?: string + triggerPrice: string + triggerPercent?: number + slippage: number + expiredAt: number + fee?: StopLossFee + /** Sell side priced in USD, which is what turns the fee percentage into an amount. */ + notionalUsd?: number + needsWrap?: boolean + warnings?: StopLossWarning[] + onDismiss?: () => void + onSubmit?: () => void +} + +const SummaryRow = ({ + label, + dataTestId, + children, +}: { + label: React.ReactNode + dataTestId: string + children: React.ReactNode +}) => ( + + {label} +
+ {children} +
+
+) + +const WARNING_STYLES = { + info: { className: 'bg-primary-10 text-primary', Icon: Info }, + warn: { className: 'bg-warning-10 text-warning', Icon: AlertTriangle }, +} as const + +const ReviewWarning = ({ warning }: { warning: StopLossWarning }) => { + const { className, Icon } = WARNING_STYLES[warning.type] + return ( + + +
{warning.message}
+
+ ) +} + +const StopLossConfirmModal = ({ + isOpen, + currencyIn, + currencyOut, + inputAmount, + estimatedOutput, + estimatedUsdIn, + estimatedUsdOut, + triggerPrice, + triggerPercent, + slippage, + expiredAt, + fee, + notionalUsd, + needsWrap, + warnings = [], + onDismiss, + onSubmit, +}: Props) => { + const feePercentage = fee?.protocol?.percentage + const feeUsd = feePercentage !== undefined && notionalUsd ? (notionalUsd * feePercentage) / 100 : undefined + // The order stays reviewable so the warning can be read; only signing it is refused. + const isBlocked = warnings.some(warning => warning.blocking) + + return ( + + + +
+ Review Stop-Loss Order +
+ +
+ + + + + + You Sell + + + + + {formatDisplayNumber(inputAmount, { significantDigits: 6 })} {currencyIn?.symbol} + + + {estimatedUsdIn && ( + + ~{estimatedUsdIn} + + )} + + +
+ +
+ + + + Est. Receive + + + + + ~{formatDisplayNumber(estimatedOutput, { significantDigits: 6 })} {currencyOut?.symbol} + + + {estimatedUsdOut && ( + + ~{estimatedUsdOut} + + )} + +
+ + + Trigger Price} dataTestId="stop-loss-confirm-trigger-price"> +
+ 1 {currencyIn?.symbol} = {triggerPrice} {currencyOut?.symbol} +
+ {triggerPercent !== undefined && triggerPercent < 0 && ( +
+ + ↓ {formatDisplayNumber(Math.abs(triggerPercent), { fractionDigits: 1 })}% + {' '} + + below current oracle price + +
+ )} +
+ + Max Slippage} dataTestId="stop-loss-confirm-slippage"> + {formatSlippage(slippage)} + + + Fee} dataTestId="stop-loss-confirm-fee"> + + + {feePercentage === undefined ? '--' : `${formatDisplayNumber(feePercentage, { fractionDigits: 2 })}%`} + + {/* The percentage applied to the sell side, so the figure is an estimate like the rest. */} + {feeUsd !== undefined && ( + + ~{formatDisplayNumber(feeUsd, { style: 'currency', significantDigits: 4 })} + + )} + + + + {/* Show the deadline that actually gets signed, not the pre-clamp value an + "expires never" choice produces. */} + Expires} dataTestId="stop-loss-confirm-expiry"> + {dayjs.unix(clampStopLossDeadline(expiredAt / 1000)).format('DD/MM/YYYY HH:mm')} + +
+ +
+ + When triggered, KyberSwap will swap your {currencyIn?.symbol} at the best available market price. + +
+ + {warnings.map((warning, index) => ( + + ))} + + + + {needsWrap ? Wrap & Confirm Stop-Loss : Confirm Stop-Loss} + + + No gas to sign · Fee on execution + + +
+
+
+ ) +} + +export default StopLossConfirmModal diff --git a/apps/kyberswap-interface/src/components/StopLoss/CreateOrder/StopLossOrderFlow.tsx b/apps/kyberswap-interface/src/components/StopLoss/CreateOrder/StopLossOrderFlow.tsx new file mode 100644 index 0000000000..aac8264d0f --- /dev/null +++ b/apps/kyberswap-interface/src/components/StopLoss/CreateOrder/StopLossOrderFlow.tsx @@ -0,0 +1,111 @@ +import { Currency } from '@kyberswap/ks-sdk-core' +import { Trans, t } from '@lingui/macro' +import { useEffect, useState } from 'react' +import { useNavigate } from 'react-router-dom' + +import ProcessingOrderModal from 'components/LimitOrder/ProcessingOrder/ProcessingOrderModal' +import { DEFAULT_PROCESSING_ORDER, useProcessingOrder } from 'components/LimitOrder/ProcessingOrder/useProcessingOrder' +import StopLossConfirmModal from 'components/StopLoss/CreateOrder/StopLossConfirmModal' +import { useCreateStopLossOrder } from 'components/StopLoss/CreateOrder/useCreateStopLossOrder' +import { StopLossWarning } from 'components/StopLoss/Form/useStopLossWarnings' +import { APP_PATHS } from 'constants/index' +import { NETWORKS_INFO } from 'hooks/useChainsConfig' + +type Props = { + isOpen: boolean + currencyIn?: Currency + currencyOut?: Currency + inputAmount: string + estimatedOutput: string + estimatedUsdIn?: string + estimatedUsdOut?: string + triggerPrice: string + triggerPercent?: number + slippage: number + expiredAt: number + notionalUsd?: number + warnings?: StopLossWarning[] + onDismiss: () => void + onResetForm?: () => void + createOrder: ReturnType +} + +const StopLossOrderFlow = ({ + isOpen, + currencyIn, + currencyOut, + inputAmount, + estimatedOutput, + estimatedUsdIn, + estimatedUsdOut, + triggerPrice, + triggerPercent, + slippage, + expiredAt, + notionalUsd, + warnings, + onDismiss, + createOrder, +}: Props) => { + const navigate = useNavigate() + const [processingOrder, setProcessingOrder] = useState(DEFAULT_PROCESSING_ORDER) + + const { fee, refreshFee, needsWrap } = createOrder + + // The fee decides the cap carried in the signed intent, so it is fetched as soon as review opens. + useEffect(() => { + if (isOpen) refreshFee() + }, [isOpen, refreshFee]) + + const processing = useProcessingOrder({ + processingOrder, + setProcessingOrder, + ...createOrder.processing, + onStart: onDismiss, + }) + + const viewOrders = () => { + const chainId = createOrder.processing.chainId + navigate(`${APP_PATHS.STOP_LOSS}/${NETWORKS_INFO[chainId].route}`) + } + + return ( + <> + + + View order} + /> + + ) +} + +export default StopLossOrderFlow diff --git a/apps/kyberswap-interface/src/components/StopLoss/CreateOrder/useCreateStopLossOrder.tsx b/apps/kyberswap-interface/src/components/StopLoss/CreateOrder/useCreateStopLossOrder.tsx new file mode 100644 index 0000000000..bdb4aead2e --- /dev/null +++ b/apps/kyberswap-interface/src/components/StopLoss/CreateOrder/useCreateStopLossOrder.tsx @@ -0,0 +1,267 @@ +import { Currency, CurrencyAmount, TokenAmount, WETH } from '@kyberswap/ks-sdk-core' +import { t } from '@lingui/macro' +import { useCallback, useMemo, useState } from 'react' +import { useGetTotalActiveMakingAmountQuery } from 'services/limitOrder' +import { + useCreateStopLossOrderMutation, + useEstimateStopLossFeeMutation, + useGetStopLossConfigQuery, + useGetStopLossOrdersQuery, + useGetStopLossSignMessageMutation, +} from 'services/stopLoss' + +import { NotificationType } from 'components/Announcement/type' +import { ProcessingOrderStep } from 'components/LimitOrder/ProcessingOrder/useProcessingOrder' +import { useLimitOrderApproval } from 'components/LimitOrder/hooks/useLimitOrderApproval' +import { useLimitOrderWrapStep } from 'components/LimitOrder/hooks/useLimitOrderWrapStep' +import { DEFAULT_MAX_FEES_PERCENTAGE, DEFAULT_MAX_GAS_PERCENTAGE } from 'components/StopLoss/constants' +import { useStopLossTracking } from 'components/StopLoss/hooks/useStopLossTracking' +import { StopLossFee, StopLossOrder, StopLossOrderStatus } from 'components/StopLoss/types' +import { buildStopLossPayload, stripEmptyEip712Salt } from 'components/StopLoss/utils' +import { useActiveWeb3React } from 'hooks' +import { useApproveCallback } from 'hooks/useApproveCallback' +import { useNotify } from 'state/application/hooks' +import { tryParseAmount } from 'state/swap/hooks' +import { useCurrencyBalance } from 'state/wallet/hooks' +import { formatSignature } from 'utils/transaction' +import { Address } from 'utils/viem' +import { signTypedDataRaw } from 'utils/walletClient' + +const EMPTY_OPEN_ORDERS: StopLossOrder[] = [] + +type Props = { + currencyIn?: Currency + currencyOut?: Currency + inputAmount: string + triggerPrice: string + slippage: number + expiredAt: number + onResetForm?: () => void +} + +export const useCreateStopLossOrder = ({ + currencyIn, + currencyOut, + inputAmount, + triggerPrice, + slippage, + expiredAt, + onResetForm, +}: Props) => { + const { account, chainId } = useActiveWeb3React() + const notify = useNotify() + const tracking = useStopLossTracking() + + const [fee, setFee] = useState() + + const [estimateFee] = useEstimateStopLossFeeMutation() + const [getSignMessage] = useGetStopLossSignMessageMutation() + const [submitOrder] = useCreateStopLossOrderMutation() + + // Open orders and the limit-order commitment both eat into the same ERC-20 approval. + const { data: openOrdersData } = useGetStopLossOrdersQuery( + { userWallet: account || '', chainIds: [chainId], status: StopLossOrderStatus.OPEN, page: 1, pageSize: 100 }, + { skip: !account }, + ) + const openOrders = openOrdersData?.orders ?? EMPTY_OPEN_ORDERS + const { data: activeLimitOrderAmount } = useGetTotalActiveMakingAmountQuery( + { chainId, makerAsset: currencyIn?.wrapped.address, account }, + { skip: !currencyIn || !account }, + ) + + const { data: config } = useGetStopLossConfigQuery(chainId) + // Both the contract that pulls tokenIn and the EIP-712 verifying contract. + const smartIntentAddress = config?.smartIntentAddress + + const parsedInputAmount = useMemo(() => tryParseAmount(inputAmount, currencyIn), [inputAmount, currencyIn]) + const balance = useCurrencyBalance(currencyIn, chainId) + + const nativeWrapAmount = useMemo(() => { + if (!currencyIn?.isNative || !parsedInputAmount || !balance?.currency.equals(currencyIn)) return undefined + return balance.lessThan(parsedInputAmount) ? undefined : parsedInputAmount + }, [balance, currencyIn, parsedInputAmount]) + + const { insufficientBalance, onWrap, wrapAmount } = useLimitOrderWrapStep({ + chainId, + amount: parsedInputAmount, + balance, + wrapAmount: nativeWrapAmount, + }) + + // The order sells the wrapped token, so that is what gets approved. + const approvalCurrency = useMemo( + () => (currencyIn ? (currencyIn.isNative ? WETH[chainId] : currencyIn.wrapped) : undefined), + [chainId, currencyIn], + ) + + const parsedApprovalAmount = useMemo(() => { + if (!approvalCurrency || !parsedInputAmount) return undefined + return CurrencyAmount.fromRawAmount(approvalCurrency, parsedInputAmount.quotient) + }, [approvalCurrency, parsedInputAmount]) + + const [approval, approveCallback] = useApproveCallback({ + amount: parsedApprovalAmount, + spender: smartIntentAddress || undefined, + forceApprove: true, + }) + + /** + * Existing open orders on the same token already lay claim to part of the allowance, and both order + * types draw on the same approval. Ignoring that lets a second order skip approval and leaves the two + * competing for an allowance only one can spend. + */ + const committedAmount = useMemo(() => { + if (!approvalCurrency) return undefined + const openSameToken = openOrders.filter( + order => order.tokenIn.toLowerCase() === approvalCurrency.address.toLowerCase(), + ) + const total = openSameToken.reduce((sum, order) => sum + BigInt(order.amountIn), 0n) + const limitOrderTotal = activeLimitOrderAmount ? BigInt(activeLimitOrderAmount) : 0n + const combined = total + limitOrderTotal + return combined > 0n ? CurrencyAmount.fromRawAmount(approvalCurrency, combined.toString()) : undefined + }, [approvalCurrency, openOrders, activeLimitOrderAmount]) + + const hasEnoughAllowance = useCallback( + (allowance: TokenAmount) => { + if (!parsedApprovalAmount) return true + try { + const available = committedAmount ? allowance.subtract(committedAmount) : allowance + return !available.lessThan(parsedApprovalAmount) + } catch (error) { + return false + } + }, + [parsedApprovalAmount, committedAmount], + ) + + const checkApprovalManually = useLimitOrderApproval({ + account, + amount: parsedApprovalAmount, + chainId, + currency: approvalCurrency, + spender: smartIntentAddress, + isAllowanceEnough: hasEnoughAllowance, + }) + + const processingSteps = useMemo(() => { + const steps: ProcessingOrderStep[] = [] + if (wrapAmount) steps.push('wrap') + steps.push('approve') + steps.push('create') + return steps + }, [wrapAmount]) + + const buildPayload = useCallback( + (maxFeesPercentage: number[]) => { + if (!currencyIn || !currencyOut || !account) return undefined + return buildStopLossPayload({ + chainId, + account, + currencyIn, + currencyOut, + inputAmount, + triggerPrice, + slippage, + expiredAt, + maxFeesPercentage, + maxGasPercentage: DEFAULT_MAX_GAS_PERCENTAGE, + }) + }, + [account, chainId, currencyIn, currencyOut, inputAmount, triggerPrice, slippage, expiredAt], + ) + + /** + * The protocol fee has to be known before signing, because the cap is signed into the intent and + * the BE doc requires it to be at least the live fee. + */ + const refreshFee = useCallback(async () => { + const payload = buildPayload(DEFAULT_MAX_FEES_PERCENTAGE) + if (!payload) return undefined + try { + const result = await estimateFee(payload).unwrap() + setFee(result) + return result + } catch (error) { + setFee(undefined) + return undefined + } + }, [buildPayload, estimateFee]) + + const submit = useCallback(async () => { + if (!account || !currencyIn || !currencyOut) return false + + const latestFee = (await refreshFee()) ?? fee + const protocolPercentage = latestFee?.protocol?.percentage ?? 0 + // Keep the cap at or above the live fee, whichever is larger. + const maxFeesPercentage = DEFAULT_MAX_FEES_PERCENTAGE.map(value => Math.max(value, protocolPercentage)) + + const payload = buildPayload(maxFeesPercentage) + if (!payload) return false + + const typedData = await getSignMessage(payload).unwrap() + const rawSignature = await signTypedDataRaw({ + chainId, + account: account as Address, + typedData: stripEmptyEip712Salt(typedData), + }) + + await submitOrder({ ...payload, signature: formatSignature(rawSignature) }).unwrap() + + tracking.trackOrderPlaced({ + currencyIn, + currencyOut, + chainId, + inputAmount, + triggerPrice, + slippage, + expiredAt, + }) + + const sellSymbol = currencyIn.symbol + const receiveSymbol = currencyOut.symbol + notify( + { + type: NotificationType.SUCCESS, + title: t`Stop-loss order placed`, + summary: t`Selling ${inputAmount} ${sellSymbol} when the price drops to ${triggerPrice} ${receiveSymbol}.`, + }, + 10000, + ) + onResetForm?.() + return true + }, [ + account, + chainId, + currencyIn, + currencyOut, + fee, + inputAmount, + triggerPrice, + slippage, + expiredAt, + buildPayload, + refreshFee, + getSignMessage, + submitOrder, + notify, + tracking, + onResetForm, + ]) + + return { + fee, + refreshFee, + insufficientBalance, + needsWrap: !!wrapAmount, + processing: { + chainId, + approval, + approveCallback, + checkApprovalManually, + onWrap, + finalStep: 'create' as const, + onFinalStep: submit, + steps: processingSteps, + }, + } +} diff --git a/apps/kyberswap-interface/src/components/StopLoss/Form/StopLossForm.tsx b/apps/kyberswap-interface/src/components/StopLoss/Form/StopLossForm.tsx new file mode 100644 index 0000000000..a3b3b08ad9 --- /dev/null +++ b/apps/kyberswap-interface/src/components/StopLoss/Form/StopLossForm.tsx @@ -0,0 +1,317 @@ +import { Currency } from '@kyberswap/ks-sdk-core' +import { Trans, t } from '@lingui/macro' +import { ReactNode, memo, useEffect, useMemo, useRef, useState } from 'react' +import { AlertTriangle } from 'react-feather' + +import { ButtonLight, ButtonPrimary } from 'components/Button' +import DateTimePicker from 'components/DateTimePicker' +import { ErrorWarning } from 'components/ErrorWarning' +import LimitOrderExpirySection from 'components/LimitOrder/Form/LimitOrderExpirySection' +import { LimitOrderInputTokenPanel } from 'components/LimitOrder/Form/LimitOrderTokenSection' +import { calcUsdPrices } from 'components/LimitOrder/utils' +import { HStack, Stack } from 'components/Stack' +import StopLossOrderFlow from 'components/StopLoss/CreateOrder/StopLossOrderFlow' +import { useCreateStopLossOrder } from 'components/StopLoss/CreateOrder/useCreateStopLossOrder' +import StopLossReceiveSection from 'components/StopLoss/Form/StopLossReceiveSection' +import TriggerPriceSection from 'components/StopLoss/Form/TriggerPriceSection' +import { useStopLossFormState } from 'components/StopLoss/Form/useStopLossFormState' +import { useStopLossWarnings } from 'components/StopLoss/Form/useStopLossWarnings' +import OrderTypeSubTabs from 'components/StopLoss/OrderTypeSubTabs' +import { + STOP_LOSS_SLIPPAGE_HIGH_THRESHOLD, + STOP_LOSS_SLIPPAGE_LOW_THRESHOLD, + STOP_LOSS_SLIPPAGE_PRESETS, + getStopLossExpiryPresets, +} from 'components/StopLoss/constants' +import { useStopLossTracking } from 'components/StopLoss/hooks/useStopLossTracking' +import SlippageSetting from 'components/SwapForm/SlippageSetting' +import { useActiveWeb3React } from 'hooks' +import { useActiveLocale } from 'hooks/useActiveLocale' +import { restrictedTokenMessage, useIsTokenRestricted } from 'hooks/useRestrictedTokens' +import { useWalletModalToggle } from 'state/application/hooks' +import { useLimitState } from 'state/limit/hooks' +import { useCurrencyBalance } from 'state/wallet/hooks' +import { halfAmountSpend, maxAmountSpend } from 'utils/maxAmountSpend' +import { formatSlippage } from 'utils/slippage' + +type StopLossFormProps = { + currencyIn?: Currency + currencyOut?: Currency +} + +/** A note about the field it sits in, so it reads as part of that input rather than of the form. */ +const FieldWarning = ({ children }: { children: ReactNode }) => ( + + + {children} + +) + +const StopLossForm = ({ currencyIn: currencyInProp, currencyOut: currencyOutProp }: StopLossFormProps) => { + const toggleWalletModal = useWalletModalToggle() + const { account } = useActiveWeb3React() + const limitState = useLimitState() + + const currencyIn = currencyInProp || limitState.currencyIn + const currencyOut = currencyOutProp || limitState.currencyOut + + const [showReview, setShowReview] = useState(false) + const tracking = useStopLossTracking() + const trackedPageView = useRef(false) + + const form = useStopLossFormState({ currencyIn, currencyOut }) + const validation = useStopLossWarnings({ + chainId: form.chainId, + currencyIn, + currencyOut, + triggerPercent: form.triggerPercent, + triggerAtOrAboveMarket: form.triggerAtOrAboveMarket, + }) + + const createOrder = useCreateStopLossOrder({ + currencyIn, + currencyOut, + inputAmount: form.inputAmount, + triggerPrice: form.triggerPrice, + slippage: form.slippage, + expiredAt: form.expiredAt, + onResetForm: form.onResetForm, + }) + + const isTokenRestricted = useIsTokenRestricted() + const restrictedCurrency = isTokenRestricted(currencyIn) + ? currencyIn + : isTokenRestricted(currencyOut) + ? currencyOut + : undefined + + const balance = useCurrencyBalance(currencyIn ?? undefined, form.chainId) + const insufficientBalance = createOrder.insufficientBalance + + const estimateUsd = calcUsdPrices({ + inputAmount: form.inputAmount, + outputAmount: form.estimatedOutput, + priceUsdIn: form.tradeInfo?.priceUsdIn, + priceUsdOut: form.tradeInfo?.priceUsdOut, + currencyIn, + currencyOut, + }) + + useEffect(() => { + if (trackedPageView.current) return + trackedPageView.current = true + tracking.trackPageViewed(form.chainId, 'nav') + }, [tracking, form.chainId]) + + // Fires once per token the user lands on without a feed, not on every re-render of that state. + const trackedIneligibleToken = useRef(undefined) + useEffect(() => { + if (!currencyIn || !validation.hasIneligibleToken) return + const key = currencyIn.wrapped.address + if (trackedIneligibleToken.current === key) return + trackedIneligibleToken.current = key + tracking.trackIneligibleToken(currencyIn) + }, [currencyIn, validation.hasIneligibleToken, tracking]) + + // Stable identity per locale: DateTimePicker keys an effect off this list, so a fresh array every + // render would re-seed its date every render. The labels are read from the active catalogue when + // the helper runs, which the linter cannot see — hence the explicit locale dependency. + const locale = useActiveLocale() + // eslint-disable-next-line react-hooks/exhaustive-deps + const expiryPresets = useMemo(() => getStopLossExpiryPresets(), [locale]) + + // Both sides report to the same funnel event the tracking spec defines. + const onSelectSellToken = (currency: Currency) => { + tracking.trackTokenSelected(currency) + form.onSelectCurrencyIn(currency) + } + const onSelectReceiveToken = (currency: Currency) => { + tracking.trackTokenSelected(currency) + form.onSelectCurrencyOut(currency) + } + + const lowSlippageThreshold = formatSlippage(STOP_LOSS_SLIPPAGE_LOW_THRESHOLD) + const highSlippageThreshold = formatSlippage(STOP_LOSS_SLIPPAGE_HIGH_THRESHOLD) + + const isMissingAmount = !form.inputAmount || Number(form.inputAmount) === 0 + const isMissingTrigger = !form.triggerPrice || Number(form.triggerPrice) === 0 + const disableAction = + !!restrictedCurrency || isMissingAmount || isMissingTrigger || insufficientBalance || validation.shouldDisableAction + + const actionLabel = restrictedCurrency ? ( + restrictedTokenMessage(restrictedCurrency.symbol) + ) : isMissingAmount ? ( + Enter an amount + ) : isMissingTrigger ? ( + Set a trigger price + ) : insufficientBalance ? ( + Insufficient {currencyIn?.symbol} balance + ) : ( + Place Stop-Loss Order + ) + + return ( + <> + + + + { + const max = maxAmountSpend(balance) + if (max) form.setInputValue(max.toExact()) + }, + onHalfInput: () => { + const half = halfAmountSpend(balance) + if (half) form.setInputValue(half.toExact()) + }, + onInputTokenSelect: onSelectSellToken, + }} + footer={validation.sellTokenWarning ? {validation.sellTokenWarning} : undefined} + /> + + + + {validation.receiveTokenWarning} : undefined + } + /> + + {/* The two headers sit side by side, but an open panel spans the whole row: half the form is + 189px, where the slippage presets shrink under their own labels and the expiry pills wrap + out of their border. Each control places its own header and panel into this grid, so + opening one never moves the other's header. Below xxs the row is a single column. */} +
+ STOP_LOSS_SLIPPAGE_HIGH_THRESHOLD, + message: + form.slippage < STOP_LOSS_SLIPPAGE_LOW_THRESHOLD + ? t`Slippage below ${lowSlippageThreshold} may cause your stop-loss to fail during volatile markets — the conditions it exists for.` + : form.slippage > STOP_LOSS_SLIPPAGE_HIGH_THRESHOLD + ? t`Slippage above ${highSlippageThreshold} means your order could fill meaningfully below the market price at the moment it executes.` + : '', + }} + /> + + form.setExpiryExpanded(expanded => !expanded), + onOpenDatePicker: form.toggleDatePicker, + onExpireChange: form.onChangeExpire, + }} + presetOptions={expiryPresets} + tooltip={t`You can cancel anytime before expiry at no cost.`} + /> +
+ + {validation.formWarnings.length > 0 && ( + + {validation.formWarnings.map((warning, index) => ( + + ))} + + )} + + {!account ? ( + + Connect + + ) : ( + { + tracking.trackReviewOpened({ + currencyIn, + currencyOut, + chainId: form.chainId, + inputAmount: form.inputAmount, + triggerPrice: form.triggerPrice, + triggerPercent: form.triggerPercent, + slippage: form.slippage, + expiredAt: form.expiredAt, + }) + setShowReview(true) + }} + > + {actionLabel} + + )} +
+ + setShowReview(false)} + createOrder={createOrder} + /> + + + + ) +} + +export default memo(StopLossForm) diff --git a/apps/kyberswap-interface/src/components/StopLoss/Form/StopLossReceiveSection.tsx b/apps/kyberswap-interface/src/components/StopLoss/Form/StopLossReceiveSection.tsx new file mode 100644 index 0000000000..b335f96e25 --- /dev/null +++ b/apps/kyberswap-interface/src/components/StopLoss/Form/StopLossReceiveSection.tsx @@ -0,0 +1,72 @@ +import { Currency } from '@kyberswap/ks-sdk-core' +import { Trans } from '@lingui/macro' +import { ReactNode } from 'react' + +import CurrencyInputPanel from 'components/CurrencyInputPanel' +import { Stack } from 'components/Stack' + +type Props = { + sellCurrency?: Currency + receiveCurrency?: Currency + estimatedOutput: string + estimatedUsd?: string + triggerPrice: string + onSelectCurrency?: (currency: Currency) => void + /** Shown above the estimate note when the receive token itself cannot be monitored. */ + warning?: ReactNode +} + +/** + * Output is decided at execution, so the amount is read-only: it shows what the trigger price would + * yield, not a floor the order guarantees. + */ +const StopLossReceiveSection = ({ + sellCurrency, + receiveCurrency, + estimatedOutput, + estimatedUsd, + triggerPrice, + onSelectCurrency, + warning, +}: Props) => ( +
+ + You Receive +
+ } + // The note qualifies this figure, so it belongs in the same box rather than floating under it. + footer={ + + {warning} + + {triggerPrice ? ( + + Estimated at your {triggerPrice} {receiveCurrency?.symbol} trigger. Actual amount depends on market + conditions at trigger time. + + ) : ( + Set a trigger price to see your estimated output. + )} + + + } + /> +
+) + +export default StopLossReceiveSection diff --git a/apps/kyberswap-interface/src/components/StopLoss/Form/TriggerPriceSection.tsx b/apps/kyberswap-interface/src/components/StopLoss/Form/TriggerPriceSection.tsx new file mode 100644 index 0000000000..73c794af06 --- /dev/null +++ b/apps/kyberswap-interface/src/components/StopLoss/Form/TriggerPriceSection.tsx @@ -0,0 +1,163 @@ +import { Currency } from '@kyberswap/ks-sdk-core' +import { Trans } from '@lingui/macro' +import { useEffect, useState } from 'react' + +import CurrencyLogo from 'components/CurrencyLogo' +import NumericalInput from 'components/NumericalInput' +import { HStack, Stack } from 'components/Stack' +import { TRIGGER_PERCENT_PRESETS } from 'components/StopLoss/constants' +import { cn } from 'utils/cn' +import { formatDisplayNumber } from 'utils/numbers' + +type Props = { + receiveCurrency?: Currency + triggerPrice: string + /** Signed distance from the market price; negative once the trigger is a valid stop-loss. */ + triggerPercent?: number + marketPrice?: number + isLoadingPrice?: boolean + onChangeTriggerPrice: (value: string) => void + onChangeTriggerPercent: (percent: string) => void + onSetMarketPrice: () => void +} + +const PERCENT_CHIP_CLASSES = 'h-6 rounded-lg border px-2 text-xs font-medium transition-colors' + +/** Takes a magnitude — the arrow beside it carries the direction. */ +const formatPercentMagnitude = (percent: number) => `${formatDisplayNumber(percent, { fractionDigits: 1 })}%` + +/** Editable percent-below-market chip. It and the price field are the same value from two sides. */ +const PercentInputChip = ({ percent, onChange }: { percent?: number; onChange: (value: string) => void }) => { + const [draft, setDraft] = useState('') + const [isEditing, setIsEditing] = useState(false) + // A plain numeric string: the field has to round-trip, and formatDisplayNumber blanks negatives. + const displayValue = isEditing ? draft : percent === undefined ? '' : percent.toFixed(1) + + // Seeded with what is on screen, not the raw float: a preset lands on values like -19.999999999999996, + // and focusing the chip would otherwise swap the clean label for that. + useEffect(() => { + if (!isEditing && percent !== undefined) setDraft(percent.toFixed(1)) + }, [isEditing, percent]) + + return ( +
+ { + setDraft(value) + if (value && value !== '-' && !value.endsWith('.')) onChange(value) + }} + onFocus={() => setIsEditing(true)} + onBlur={() => setIsEditing(false)} + /> + % +
+ ) +} + +const TriggerPriceSection = ({ + receiveCurrency, + triggerPrice, + triggerPercent, + marketPrice, + isLoadingPrice, + onChangeTriggerPrice, + onChangeTriggerPercent, + onSetMarketPrice, +}: Props) => { + const isBelowMarket = triggerPercent !== undefined && triggerPercent < 0 + + return ( + + + + Sell when price drop to + + {marketPrice ? ( + + ) : null} + + + +
+ +
+ {/* The trigger is quoted in the receive token, so that token names the unit — the same shape + the limit-order rate row uses. */} + {receiveCurrency && ( + + + + {receiveCurrency.symbol} + + + )} +
+ +
+ + {TRIGGER_PERCENT_PRESETS.map(percent => ( + + ))} +
+ + {marketPrice ? ( + + + {/* Quoted in the receive token, so it carries that symbol rather than a currency sign. */} + Current oracle price: {formatDisplayNumber(marketPrice, { significantDigits: 6 })}{' '} + {receiveCurrency?.symbol} + + {isBelowMarket && ( + + ↓ {formatPercentMagnitude(Math.abs(triggerPercent))} below + + )} + + ) : isLoadingPrice ? ( + + Loading the current oracle price… + + ) : null} +
+ ) +} + +export default TriggerPriceSection diff --git a/apps/kyberswap-interface/src/components/StopLoss/Form/useStopLossFormState.ts b/apps/kyberswap-interface/src/components/StopLoss/Form/useStopLossFormState.ts new file mode 100644 index 0000000000..24d19a946a --- /dev/null +++ b/apps/kyberswap-interface/src/components/StopLoss/Form/useStopLossFormState.ts @@ -0,0 +1,188 @@ +import { Currency } from '@kyberswap/ks-sdk-core' +import dayjs from 'dayjs' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' + +import { calcOutput, formatPriceInputValue } from 'components/LimitOrder/utils' +import { useStopLossOraclePrice } from 'components/StopLoss/hooks/useStopLossOraclePrice' +import { useActiveWeb3React } from 'hooks' +import { useBaseTradeInfoLimitOrder } from 'hooks/useBaseTradeInfo' +import { useAppDispatch, useAppSelector } from 'state/hooks' +import { useLimitActionHandlers, useLimitState } from 'state/limit/hooks' +import { resetStopLossForm, updateStopLossForm } from 'state/stopLoss/reducer' +import { formatTimeDuration } from 'utils/time' + +export type UseStopLossFormStateProps = { + currencyIn: Currency | undefined + currencyOut: Currency | undefined +} + +/** + * Owns everything on the stop-loss card except the tokens and sell amount, which come from the shared + * swap state so switching between Swap, Limit and Stop Loss keeps them. The card's own inputs live in + * the store for the same reason — see `state/stopLoss/reducer`. + */ +export const useStopLossFormState = ({ currencyIn, currencyOut }: UseStopLossFormStateProps) => { + const { chainId } = useActiveWeb3React() + const { inputAmount } = useLimitState() + const { setCurrencyIn, setCurrencyOut, setInputValue } = useLimitActionHandlers() + + const dispatch = useAppDispatch() + const { triggerPrice, slippage, expire, customDateExpire: customDateExpireMs } = useAppSelector(s => s.stopLoss) + const customDateExpire = useMemo( + () => (customDateExpireMs === undefined ? undefined : new Date(customDateExpireMs)), + [customDateExpireMs], + ) + + const setTriggerPrice = useCallback( + (value: string) => dispatch(updateStopLossForm({ triggerPrice: value })), + [dispatch], + ) + const setSlippage = useCallback((value: number) => dispatch(updateStopLossForm({ slippage: value })), [dispatch]) + + // Panel open/closed is presentation, not part of the order, so it stays with the component. + const [expiryExpanded, setExpiryExpanded] = useState(false) + const [showDatePicker, setShowDatePicker] = useState(false) + + // USD prices still back the "≈ $" figures, but the trigger is compared against the oracle feed the + // service evaluates, so the two must not be conflated. + const { tradeInfo } = useBaseTradeInfoLimitOrder(currencyIn, currencyOut, chainId) + const { priceNumber: marketPrice, isLoading: loadingMarketPrice } = useStopLossOraclePrice( + currencyIn, + currencyOut, + chainId, + ) + + /** How far the trigger sits below the market price, negative while it is a valid stop-loss. */ + const triggerPercent = useMemo(() => { + const price = Number(triggerPrice) + if (!marketPrice || !price || !Number.isFinite(price)) return undefined + return ((price - marketPrice) / marketPrice) * 100 + }, [triggerPrice, marketPrice]) + + /** + * The seed and the Market button both fill the field with the price rounded to the input's own + * precision, which lands a hair either side of the live price. Comparing against the lower of the + * two keeps a trigger set to market on the blocked side of the rule whichever way it rounded. + */ + const triggerAtOrAboveMarket = useMemo(() => { + const price = Number(triggerPrice) + if (!marketPrice || !price || !Number.isFinite(price)) return false + return price >= Math.min(marketPrice, Number(formatPriceInputValue(marketPrice))) + }, [triggerPrice, marketPrice]) + + const onChangeTriggerPrice = setTriggerPrice + + /** The percent chip is the same value from the other side, so typing in it drives the price. */ + const onChangeTriggerPercent = useCallback( + (percent: string) => { + const parsed = Number(percent) + if (!marketPrice || !Number.isFinite(parsed)) { + setTriggerPrice('') + return + } + setTriggerPrice(formatPriceInputValue(marketPrice * (1 + parsed / 100))) + }, + [marketPrice, setTriggerPrice], + ) + + const onSetMarketPrice = useCallback(() => { + if (marketPrice) setTriggerPrice(formatPriceInputValue(marketPrice)) + }, [marketPrice, setTriggerPrice]) + + /** + * Seeds the trigger with the market price once the feed has resolved, so the field opens with a + * usable figure instead of blank. Skipped whenever a price is already held: the poll must not + * overwrite what the user typed, and Recreate stages a past order's trigger before this runs. + */ + const autoFilledTrigger = useRef(false) + useEffect(() => { + if (!marketPrice || loadingMarketPrice || autoFilledTrigger.current) return + autoFilledTrigger.current = true + if (!triggerPrice) setTriggerPrice(formatPriceInputValue(marketPrice)) + }, [marketPrice, loadingMarketPrice, triggerPrice, setTriggerPrice]) + + const onChangeExpire = useCallback( + (value: Date | number) => { + dispatch( + value instanceof Date + ? updateStopLossForm({ customDateExpire: value.getTime() }) + : updateStopLossForm({ customDateExpire: undefined, expire: value }), + ) + }, + [dispatch], + ) + + const onSelectCurrencyIn = useCallback( + (currency: Currency) => { + // Picking the token already on the other side swaps them rather than leaving a same-token pair. + if (currencyOut && currency.equals(currencyOut)) setCurrencyOut(currencyIn) + setCurrencyIn(currency) + setTriggerPrice('') + // The new pair prices differently, so it gets its own seed once its feed resolves. + autoFilledTrigger.current = false + }, + [currencyIn, currencyOut, setCurrencyIn, setCurrencyOut, setTriggerPrice], + ) + + const onSelectCurrencyOut = useCallback( + (currency: Currency) => { + if (currencyIn && currency.equals(currencyIn)) setCurrencyIn(currencyOut) + setCurrencyOut(currency) + setTriggerPrice('') + autoFilledTrigger.current = false + }, + [currencyIn, currencyOut, setCurrencyIn, setCurrencyOut, setTriggerPrice], + ) + + const onResetForm = useCallback(() => { + setInputValue('') + dispatch(resetStopLossForm()) + // A cleared trigger is eligible for the market seed again on the next feed tick. + autoFilledTrigger.current = false + }, [setInputValue, dispatch]) + + /** + * Output at the trigger price, before fees. Not a floor: the fill tracks the oracle price at + * execution, so a market that gaps through the trigger settles lower than this. + */ + const estimatedOutput = useMemo( + () => + inputAmount && triggerPrice && currencyOut ? calcOutput(inputAmount, triggerPrice, currencyOut.decimals) : '', + [inputAmount, triggerPrice, currencyOut], + ) + + // Anchored to the moment the expiry was chosen. Reading the clock on every render would give this a + // new value each time, which cascades into anything memoised on it. + const expiredAt = useMemo(() => customDateExpire?.getTime() || Date.now() + expire * 1000, [customDateExpire, expire]) + const displayTime = customDateExpire ? dayjs(customDateExpire).format('DD/MM/YYYY HH:mm') : formatTimeDuration(expire) + + return { + chainId, + inputAmount, + triggerPrice, + triggerPercent, + triggerAtOrAboveMarket, + marketPrice, + loadingMarketPrice, + tradeInfo, + estimatedOutput, + slippage, + expire, + customDateExpire, + expiryExpanded, + showDatePicker, + expiredAt, + displayTime, + setSlippage, + setInputValue, + setExpiryExpanded, + toggleDatePicker: useCallback(() => setShowDatePicker(value => !value), []), + onChangeTriggerPrice, + onChangeTriggerPercent, + onSetMarketPrice, + onChangeExpire, + onSelectCurrencyIn, + onSelectCurrencyOut, + onResetForm, + } +} diff --git a/apps/kyberswap-interface/src/components/StopLoss/Form/useStopLossWarnings.tsx b/apps/kyberswap-interface/src/components/StopLoss/Form/useStopLossWarnings.tsx new file mode 100644 index 0000000000..8f956cab27 --- /dev/null +++ b/apps/kyberswap-interface/src/components/StopLoss/Form/useStopLossWarnings.tsx @@ -0,0 +1,129 @@ +import { ChainId, Currency } from '@kyberswap/ks-sdk-core' +import { Trans } from '@lingui/macro' +import { ReactNode, useMemo } from 'react' + +import { TRIGGER_CLOSE_TO_MARKET_PERCENT } from 'components/StopLoss/constants' +import { + useIsStopLossEligibleToken, + useStopLossSupportedTokens, +} from 'components/StopLoss/hooks/useStopLossSupportedTokens' +import { NETWORKS_INFO } from 'hooks/useChainsConfig' + +export type StopLossWarning = { + type: 'info' | 'warn' + message: ReactNode + /** Only meaningful on a review warning: it disables the confirm button in the modal. */ + blocking?: boolean +} + +type Props = { + chainId: ChainId + currencyIn?: Currency + currencyOut?: Currency + triggerPercent?: number + /** Judged at the trigger input's precision, so a trigger set to market counts as at market. */ + triggerAtOrAboveMarket?: boolean +} + +/** + * Blocking rules and soft warnings for the creation form, in the shape the form's warning stack and + * CTA already expect. + */ +export const useStopLossWarnings = ({ + chainId, + currencyIn, + currencyOut, + triggerPercent, + triggerAtOrAboveMarket, +}: Props) => { + const { hasEligibleTokens, isLoading: loadingSupportedTokens } = useStopLossSupportedTokens(chainId) + const { isEligible, isLoading: loadingEligibility } = useIsStopLossEligibleToken(currencyIn) + // The trigger prices tokenIn *in* tokenOut, so the oracle needs a feed for both sides. + const { isEligible: isReceiveEligible, isLoading: loadingReceiveEligibility } = + useIsStopLossEligibleToken(currencyOut) + + return useMemo(() => { + /** + * Two destinations, because they answer different questions. + * + * `formWarnings` say the pair or chain cannot take a stop-loss at all — nothing the user types + * fixes them, and they block the CTA, so the review modal that would otherwise carry them can + * never be opened. `reviewWarnings` belong to the review step, right before signing; one marked + * `blocking` also disables the confirm button there. + */ + const formWarnings: StopLossWarning[] = [] + const reviewWarnings: StopLossWarning[] = [] + // A token that cannot be monitored is a fact about one field, so it is reported in that field's + // own box rather than in a notice at the bottom of the form. + let sellTokenWarning: ReactNode + let receiveTokenWarning: ReactNode + let shouldDisableAction = false + let shouldWarningAction = false + + if (!loadingSupportedTokens && !hasEligibleTokens) { + formWarnings.push({ + type: 'warn', + message: Stop-loss is not available on {NETWORKS_INFO[chainId].name} yet., + }) + shouldDisableAction = true + } else if (currencyIn && !loadingEligibility && !isEligible) { + sellTokenWarning = Stop-loss is not available for {currencyIn.symbol} — no oracle price feed. + shouldDisableAction = true + } else if (currencyOut && !loadingReceiveEligibility && !isReceiveEligible) { + receiveTokenWarning = ( + Cannot receive {currencyOut.symbol} — no oracle price feed for the receive token. + ) + shouldDisableAction = true + } + + /** + * A trigger at or above the market both makes this a sell-above order and would fire at once. + * The form stays usable so the user can open the review and read why; the block sits there. + */ + if (triggerAtOrAboveMarket) { + reviewWarnings.push({ + type: 'warn', + blocking: true, + message: ( + + Trigger price must be below the current oracle price. To sell above the current price, use Limit Order + instead. + + ), + }) + } else if (triggerPercent !== undefined && Math.abs(triggerPercent) < TRIGGER_CLOSE_TO_MARKET_PERCENT) { + reviewWarnings.push({ + type: 'warn', + message: ( + + Your trigger is close to the current price. This order may trigger quickly, including from normal price + fluctuations. + + ), + }) + shouldWarningAction = true + } + + return { + formWarnings, + reviewWarnings, + sellTokenWarning, + receiveTokenWarning, + shouldDisableAction, + shouldWarningAction, + hasIneligibleToken: !!currencyIn && !loadingEligibility && !isEligible && hasEligibleTokens, + } + }, [ + chainId, + currencyIn, + currencyOut, + triggerPercent, + triggerAtOrAboveMarket, + hasEligibleTokens, + isEligible, + isReceiveEligible, + loadingSupportedTokens, + loadingEligibility, + loadingReceiveEligibility, + ]) +} diff --git a/apps/kyberswap-interface/src/components/StopLoss/MyOrders/StopLossOrderRow.tsx b/apps/kyberswap-interface/src/components/StopLoss/MyOrders/StopLossOrderRow.tsx new file mode 100644 index 0000000000..17a0f6a1fb --- /dev/null +++ b/apps/kyberswap-interface/src/components/StopLoss/MyOrders/StopLossOrderRow.tsx @@ -0,0 +1,203 @@ +import { CurrencyAmount } from '@kyberswap/ks-sdk-core' +import { t } from '@lingui/macro' +import { useState } from 'react' +import { ExternalLink as LinkIcon, Trash } from 'react-feather' + +import { ReactComponent as RecreateIcon } from 'assets/svg/ic_stoploss_recreate.svg' +import IconButton from 'components/Button/IconButton' +import { StopLossRowLayout, StopLossRowWrapper } from 'components/StopLoss/MyOrders/TableHeader' +import { + AmountCell, + DistanceCell, + PairCell, + StatusCell, + StopLossFailureDetail, + formatExpiry, +} from 'components/StopLoss/MyOrders/components' +import { useStopLossOraclePrice } from 'components/StopLoss/hooks/useStopLossOraclePrice' +import { StopLossDisplayStatus, StopLossOrder } from 'components/StopLoss/types' +import { + getLatestExecution, + getStopLossDisplayStatus, + getStopLossExecutionTxHash, + getStopLossFailureReason, + getStopLossTriggerPrice, + resolveExecutionAmountOut, +} from 'components/StopLoss/utils' +import { MouseoverTooltip } from 'components/Tooltip' +import { useCurrencyV2 } from 'hooks/useTokens' +import { ExternalLink } from 'theme' +import { getEtherscanLink } from 'utils/explorer' +import { formatDisplayNumber } from 'utils/numbers' + +type Props = { + order: StopLossOrder + isActiveTab: boolean + /** USD prices for the sell-amount sub-line; absent when the row sits outside the priced chain. */ + priceUsd?: Record + isCancelling: boolean + onCancel: (order: StopLossOrder) => void + /** `sellAmount` is exact, not the rounded display figure — it goes straight back into the form. */ + onRecreate: (order: StopLossOrder, sellAmount: string) => void +} + +/** + * Prices here are tokenOut per tokenIn, not USD, so they carry the quote symbol instead of a currency + * sign — a pair like WETH/WBTC has no dollar meaning at all. + */ +const formatPairPrice = (value: number | string | undefined, quoteSymbol?: string) => { + if (value === undefined || value === '' || Number.isNaN(Number(value))) return '--' + const amount = formatDisplayNumber(value, { significantDigits: 6 }) + return quoteSymbol ? `${amount} ${quoteSymbol}` : amount +} + +const StopLossOrderRow = ({ order, isActiveTab, priceUsd, isCancelling, onCancel, onRecreate }: Props) => { + const sellCurrency = useCurrencyV2(order.tokenIn, order.chainId) + const receiveCurrency = useCurrencyV2(order.tokenOut, order.chainId) + + const status = getStopLossDisplayStatus(order) + const triggerPrice = getStopLossTriggerPrice(order) + + // Without the token's decimals `amountIn` is an unreadable integer, and pricing it would be wrong by + // 10^decimals — so an unresolved token shows no amount rather than a misleading one. + const sellCurrencyAmount = sellCurrency ? CurrencyAmount.fromRawAmount(sellCurrency, order.amountIn) : undefined + const sellAmount = sellCurrencyAmount ? `${sellCurrencyAmount.toSignificant(6)} ${sellCurrency?.symbol ?? ''}` : '--' + + const sellPriceUsd = priceUsd?.[order.tokenIn.toLowerCase()] + const sellAmountUsd = + sellCurrencyAmount && sellPriceUsd ? Number(sellCurrencyAmount.toExact()) * sellPriceUsd : undefined + + // The same feed the trigger is evaluated against, queried per pair so rows on any chain are right. + // History rows never render a live price, so they do not open a subscription for it. + const { priceNumber: currentPrice } = useStopLossOraclePrice( + isActiveTab ? sellCurrency ?? undefined : undefined, + isActiveTab ? receiveCurrency ?? undefined : undefined, + order.chainId, + ) + const distancePercent = + currentPrice && Number(triggerPrice) ? ((Number(triggerPrice) - currentPrice) / currentPrice) * 100 : undefined + + const execution = getLatestExecution(order) + const executionPrice = execution?.extraData?.oraclePrice + const receivedAmount = resolveExecutionAmountOut(execution, order.tokenOut, receiveCurrency?.decimals) + + const txHash = getStopLossExecutionTxHash(order) + const showTxLink = status === StopLossDisplayStatus.EXECUTED && !!txHash + + const isFailed = status === StopLossDisplayStatus.FAILED + const [showFailureDetail, setShowFailureDetail] = useState(false) + const recreate = () => onRecreate(order, sellCurrencyAmount?.toExact() ?? '') + + return ( + + + + + + + {formatPairPrice(triggerPrice, receiveCurrency?.symbol)} + + + {isActiveTab ? ( + <> + + {formatPairPrice(currentPrice, receiveCurrency?.symbol)} + +
+ +
+ + {formatExpiry(order.deadline)} + + + ) : ( + <> + + {formatPairPrice(executionPrice, receiveCurrency?.symbol)} + + + {receivedAmount === undefined + ? '--' + : `${formatDisplayNumber(receivedAmount, { significantDigits: 6 })} ${receiveCurrency?.symbol ?? ''}`} + +
+ setShowFailureDetail(open => !open) : undefined} + /> +
+ + )} + +
+ {isActiveTab ? ( + + onCancel(order)} + data-testid="stop-loss-order-cancel-button" + className="p-0 text-subText hover:bg-white/10 hover:text-red disabled:text-subText-40 disabled:opacity-100" + > + + + + ) : showTxLink ? ( + + + + ) : ( + + + + + + )} +
+ + {isFailed && showFailureDetail && ( + + )} +
+ ) +} + +export default StopLossOrderRow diff --git a/apps/kyberswap-interface/src/components/StopLoss/MyOrders/TableHeader.tsx b/apps/kyberswap-interface/src/components/StopLoss/MyOrders/TableHeader.tsx new file mode 100644 index 0000000000..ed6a498184 --- /dev/null +++ b/apps/kyberswap-interface/src/components/StopLoss/MyOrders/TableHeader.tsx @@ -0,0 +1,86 @@ +import { Trans } from '@lingui/macro' +import { HTMLAttributes, ReactNode } from 'react' + +import { cn } from 'utils/cn' + +export enum StopLossRowLayout { + ACTIVE = 'active', + HISTORY = 'history', +} + +type RowWrapperProps = { + children: ReactNode + className?: string + layout?: StopLossRowLayout +} & Omit, 'children' | 'className'> + +/** + * Owns the column template for both the header and every data row, so they can never drift apart. + * Mobile keeps the pair, one number and the action, matching how the limit-order table sheds columns. + */ +export const StopLossRowWrapper = ({ + children, + className, + layout = StopLossRowLayout.ACTIVE, + ...rest +}: RowWrapperProps) => ( +
+ {children} +
+) + +const StopLossTableHeader = ({ isActiveTab }: { isActiveTab?: boolean }) => ( + + + Pair + + + Sell Amount + + + Trigger Price + + {isActiveTab ? ( + <> + + Current Price + + + Distance + + + Expires + + + ) : ( + <> + + Execution Price + + + Received + + + Status + + + )} + + +) + +export default StopLossTableHeader diff --git a/apps/kyberswap-interface/src/components/StopLoss/MyOrders/components.test.ts b/apps/kyberswap-interface/src/components/StopLoss/MyOrders/components.test.ts new file mode 100644 index 0000000000..5e551b7f1d --- /dev/null +++ b/apps/kyberswap-interface/src/components/StopLoss/MyOrders/components.test.ts @@ -0,0 +1,49 @@ +import dayjs from 'dayjs' +import duration from 'dayjs/plugin/duration' +import relativeTime from 'dayjs/plugin/relativeTime' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { formatExpiry } from 'components/StopLoss/MyOrders/components' + +// The app entry extends these before any route renders; mirror that so the helper behaves the same. +dayjs.extend(duration) +dayjs.extend(relativeTime) + +const NOW = new Date('2026-08-06T12:00:00Z') +const at = (offsetSeconds: number) => Math.floor(NOW.getTime() / 1000) + offsetSeconds + +const HOUR = 3600 +const DAY = 24 * HOUR + +describe('formatExpiry', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(NOW) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it.each([ + ['in an hour', 1 * HOUR], + ['in 3 days', 3 * DAY], + ['a day short of the cutoff', 6 * DAY], + ])('uses relative wording %s', (_label, offset) => { + const result = formatExpiry(at(offset)) + expect(result).toMatch(/^in /) + }) + + it.each([ + ['exactly a week out', 7 * DAY], + ['a month out', 30 * DAY], + ])('switches to an absolute date %s', (_label, offset) => { + const expected = dayjs.unix(at(offset)).format('DD/MM/YYYY HH:mm') + expect(formatExpiry(at(offset))).toBe(expected) + }) + + it('reports an elapsed deadline as expired rather than a negative duration', () => { + expect(formatExpiry(at(-HOUR))).toBe('Expired') + expect(formatExpiry(at(0))).toBe('Expired') + }) +}) diff --git a/apps/kyberswap-interface/src/components/StopLoss/MyOrders/components.tsx b/apps/kyberswap-interface/src/components/StopLoss/MyOrders/components.tsx new file mode 100644 index 0000000000..36df5eea2e --- /dev/null +++ b/apps/kyberswap-interface/src/components/StopLoss/MyOrders/components.tsx @@ -0,0 +1,302 @@ +import { Currency } from '@kyberswap/ks-sdk-core' +import { Trans, t } from '@lingui/macro' +import { cva } from 'class-variance-authority' +import dayjs from 'dayjs' +import { ReactNode } from 'react' +import { AlertTriangle, ChevronDown, Trash } from 'react-feather' + +import { ReactComponent as RecreateIcon } from 'assets/svg/ic_stoploss_recreate.svg' +import { ReactComponent as NoDataIcon } from 'assets/svg/no_data.svg' +import { ButtonOutlined } from 'components/Button' +import CurrencyLogo from 'components/CurrencyLogo' +import { HStack, Stack } from 'components/Stack' +import { StopLossDisplayStatus } from 'components/StopLoss/types' +import { cn } from 'utils/cn' +import { formatDisplayNumber } from 'utils/numbers' +import { formatTimeDuration } from 'utils/time' + +export const StopLossTabSelector = ({ + isActiveTab, + onChange, +}: { + isActiveTab: boolean + onChange: (isActive: boolean) => void +}) => ( + + + + +) + +/** + * The confirmation modal owns the cancelling state and covers this button while a batch runs, so the + * button itself has no busy state to show. Orders drop out of the cancellable set as they settle, + * which is what takes it off screen. + */ +export const CancelAllButton = ({ onClick }: { onClick: () => void }) => ( + + + Cancel All + +) + +export const StopLossEmptyOrders = ({ + isActiveTab, + keyword, + isError, +}: { + isActiveTab: boolean + keyword: string + /** A failed request must not read as "you have no orders" — that hides outages behind a normal state. */ + isError?: boolean +}) => ( + + {isError ? : } + + {isError ? ( + Could not load your stop-loss orders. Retrying… + ) : keyword ? ( + No orders found. + ) : isActiveTab ? ( + No active stop-loss orders. Place your first order above. + ) : ( + No order history yet. + )} + + +) + +/** + * Logo and symbol both come from the resolved currency. Looking the logo up by the order's raw + * address instead would miss: the service returns addresses lower-cased, while the whitelist map is + * keyed by checksummed ones. + */ +export const PairCell = ({ + sellCurrency, + receiveCurrency, +}: { + sellCurrency?: Currency + receiveCurrency?: Currency +}) => ( + + + {sellCurrency?.symbol || '--'} + + + {receiveCurrency?.symbol || '--'} + +) + +/** A primary value with its USD equivalent underneath, the shape every amount column uses. */ +export const AmountCell = ({ + value, + subValue, + className, + dataTestId, +}: { + value: ReactNode + subValue?: ReactNode + className?: string + dataTestId?: string +}) => ( + + + {value} + + {subValue !== undefined && ( + + {subValue} + + )} + +) + +const distanceStyles = cva('text-sm font-medium', { + variants: { + proximity: { + far: 'text-primary', + near: 'text-warning', + imminent: 'text-red', + unknown: 'text-subText', + }, + }, + defaultVariants: { proximity: 'unknown' }, +}) + +/** + * How much room is left before the trigger fires. The arrow follows the sign: a trigger that has risen + * above the market is about to fire, which is the opposite of the same magnitude below it. + */ +export const DistanceCell = ({ percent }: { percent?: number }) => { + if (percent === undefined) + return ( + + -- + + ) + + const magnitude = Math.abs(percent) + // A trigger at or above the market is imminent no matter how far past it has gone. + const proximity = percent >= 0 ? 'imminent' : magnitude > 10 ? 'far' : magnitude >= 5 ? 'near' : 'imminent' + + return ( + + {percent >= 0 ? '↑' : '↓'} {formatDisplayNumber(magnitude, { fractionDigits: 1 })}% + + ) +} + +const statusStyles = cva('block text-sm font-medium', { + variants: { + status: { + [StopLossDisplayStatus.ACTIVE]: 'text-primary', + [StopLossDisplayStatus.TRIGGERED]: 'text-warning', + [StopLossDisplayStatus.EXECUTED]: 'text-primary', + [StopLossDisplayStatus.FAILED]: 'text-red', + [StopLossDisplayStatus.CANCELLED]: 'text-subText', + [StopLossDisplayStatus.EXPIRED]: 'text-subText', + }, + }, +}) + +export const formatStopLossStatus = (status: StopLossDisplayStatus) => { + switch (status) { + case StopLossDisplayStatus.ACTIVE: + return t`Active` + case StopLossDisplayStatus.TRIGGERED: + return t`Triggered` + case StopLossDisplayStatus.EXECUTED: + return t`Executed` + case StopLossDisplayStatus.FAILED: + return t`Failed` + case StopLossDisplayStatus.CANCELLED: + return t`Cancelled` + default: + return t`Expired` + } +} + +/** + * Only a failure carries anything to expand — the other statuses say everything they have in one word. + */ +export const StatusCell = ({ + status, + expanded, + onToggle, +}: { + status: StopLossDisplayStatus + expanded?: boolean + onToggle?: () => void +}) => { + const label = formatStopLossStatus(status) + if (!onToggle) + return ( + + {label} + + ) + + return ( + + ) +} + +/** The failure detail that drops out of a Failed row, spanning the whole table width. */ +export const StopLossFailureDetail = ({ + sellSymbol, + reason, + onRecreate, +}: { + sellSymbol: string + reason: string + onRecreate: () => void +}) => ( + + + + + Your stop-loss triggered but the swap could not complete. Your {sellSymbol}{' '} + is still in your wallet. + + + + Reason: {reason} + + + + + Recreate + + +) + +const SEVEN_DAYS_IN_SECONDS = 7 * 24 * 60 * 60 + +/** Relative wording inside a week, an absolute date beyond it. */ +export const formatExpiry = (deadlineInSeconds: number) => { + const expiry = dayjs.unix(deadlineInSeconds) + const secondsLeft = expiry.diff(dayjs(), 'second') + + if (secondsLeft <= 0) return t`Expired` + if (secondsLeft >= SEVEN_DAYS_IN_SECONDS) return expiry.format('DD/MM/YYYY HH:mm') + + const remaining = formatTimeDuration(secondsLeft) + return t`in ${remaining}` +} diff --git a/apps/kyberswap-interface/src/components/StopLoss/MyOrders/index.tsx b/apps/kyberswap-interface/src/components/StopLoss/MyOrders/index.tsx new file mode 100644 index 0000000000..8bbf06b250 --- /dev/null +++ b/apps/kyberswap-interface/src/components/StopLoss/MyOrders/index.tsx @@ -0,0 +1,309 @@ +import { ChainId } from '@kyberswap/ks-sdk-core' +import { t } from '@lingui/macro' +import { useEffect, useMemo, useRef, useState } from 'react' +import { useNavigate, useSearchParams } from 'react-router-dom' +import { useGetStopLossOrdersQuery } from 'services/stopLoss' + +import DropdownMenu, { MenuOption } from 'components/DropdownMenu' +import Pagination from 'components/Pagination' +import RefetchIndicator from 'components/RefetchIndicator' +import SearchInput from 'components/SearchInput' +import CancelStopLossModal from 'components/StopLoss/CancelOrder/CancelStopLossModal' +import StopLossOrderRow from 'components/StopLoss/MyOrders/StopLossOrderRow' +import StopLossTableHeader from 'components/StopLoss/MyOrders/TableHeader' +import { CancelAllButton, StopLossEmptyOrders, StopLossTabSelector } from 'components/StopLoss/MyOrders/components' +import { STOP_LOSS_DEFAULT_EXPIRE } from 'components/StopLoss/constants' +import { useStopLossOrderNotifications } from 'components/StopLoss/hooks/useStopLossOrderNotifications' +import { useStopLossTracking } from 'components/StopLoss/hooks/useStopLossTracking' +import { StopLossOrder, StopLossOrderStatus } from 'components/StopLoss/types' +import { getStopLossDisplayStatus, getStopLossRecreateDraft, isActiveStopLossStatus } from 'components/StopLoss/utils' +import { APP_PATHS } from 'constants/index' +import { isSupportStopLoss } from 'constants/networks' +import { useActiveWeb3React } from 'hooks' +import useChainsConfig, { NETWORKS_INFO } from 'hooks/useChainsConfig' +import { useAppDispatch } from 'state/hooks' +import { useLimitActionHandlers } from 'state/limit/hooks' +import { updateStopLossForm } from 'state/stopLoss/reducer' +import { useTokenPricesWithLoading } from 'state/tokenPrices/hooks' + +const PAGE_SIZE = 10 +/** + * The service exposes one status at a time and no "closed" bucket, so the Active/History split, the + * status sub-filter and paging are all resolved here over a single bounded fetch. Splitting them + * server-side would return pages that are mostly filtered away, leaving short pages under a total + * count that disagrees with them. + */ +const FETCH_SIZE = 100 +/** The service signs at most 100 ids into one CancelBatchOrders message. */ +const BATCH_CANCEL_LIMIT = 100 +const ALL_CLOSED_VALUE = 'all_closed' +const ALL_CHAINS_VALUE = 'all' +const EMPTY_ORDERS: StopLossOrder[] = [] + +const getActiveStatusOptions = (): MenuOption[] => [{ label: t`All Active Orders`, value: ALL_CLOSED_VALUE }] + +const getClosedStatusOptions = (): MenuOption[] => [ + { label: t`All Closed Orders`, value: ALL_CLOSED_VALUE }, + { label: t`Executed Orders`, value: StopLossOrderStatus.DONE }, + { label: t`Cancelled Orders`, value: StopLossOrderStatus.CANCELLED }, + { label: t`Expired Orders`, value: StopLossOrderStatus.EXPIRED }, +] + +const StopLossOrders = () => { + const { account, chainId } = useActiveWeb3React() + const navigate = useNavigate() + const { setInputValue } = useLimitActionHandlers() + const dispatch = useAppDispatch() + const tracking = useStopLossTracking() + const [searchParams, setSearchParams] = useSearchParams() + + const [isActiveTab, setIsActiveTab] = useState(true) + const [closedFilter, setClosedFilter] = useState(ALL_CLOSED_VALUE) + // Starts on the wallet's chain, which is where the form above places orders. + const [selectedChainValue, setSelectedChainValue] = useState(String(chainId)) + const [curPage, setCurPage] = useState(1) + const [cancelTargets, setCancelTargets] = useState([]) + const [cancellingIds, setCancellingIds] = useState([]) + + // Follow the wallet when the user switches network, or the list keeps showing the previous chain + // while the form above creates orders on the new one. + const previousChainId = useRef(chainId) + useEffect(() => { + if (previousChainId.current === chainId) return + previousChainId.current = chainId + setSelectedChainValue(String(chainId)) + setCurPage(1) + }, [chainId]) + + const keyword = searchParams.get('search') || '' + + const { supportedChains } = useChainsConfig() + const stopLossChainOptions = useMemo( + () => + supportedChains + .filter(chain => isSupportStopLoss(chain.chainId)) + .map(chain => ({ label: chain.name, value: chain.chainId.toString(), icon: chain.icon })), + [supportedChains], + ) + const chainOptions = useMemo( + () => [{ label: t`All Chains`, value: ALL_CHAINS_VALUE }, ...stopLossChainOptions], + [stopLossChainOptions], + ) + const isAllChains = selectedChainValue === ALL_CHAINS_VALUE + // All Chains sends no `chainIds` at all rather than listing them: an absent filter already means + // every chain, and enumerating them makes the request fail outright the moment our list names one + // the service does not know. + const queriedChainIds = useMemo( + () => (isAllChains ? undefined : [Number(selectedChainValue) as ChainId]), + [isAllChains, selectedChainValue], + ) + + const { + data, + isFetching, + isError, + isSuccess: isLoaded, + } = useGetStopLossOrdersQuery( + { + userWallet: account || '', + chainIds: queriedChainIds, + page: 1, + pageSize: FETCH_SIZE, + }, + { skip: !account, pollingInterval: 10_000, refetchOnFocus: true }, + ) + + const allOrders = data?.orders ?? EMPTY_ORDERS + + // Fed the unfiltered set so a transition out of Open is still observed on the Active tab. + useStopLossOrderNotifications(allOrders) + + const filteredOrders = useMemo(() => { + const scoped = allOrders.filter(order => { + const isActive = isActiveStopLossStatus(getStopLossDisplayStatus(order)) + if (isActiveTab) return isActive + // A failed order is still `Open` to the service, so it matches none of the closed sub-statuses + // and surfaces only under the unfiltered view. + return !isActive && (closedFilter === ALL_CLOSED_VALUE || order.status === closedFilter) + }) + if (!keyword) return scoped + + const needle = keyword.trim().toLowerCase() + return scoped.filter( + order => order.tokenIn.toLowerCase().includes(needle) || order.tokenOut.toLowerCase().includes(needle), + ) + }, [allOrders, isActiveTab, closedFilter, keyword]) + + const totalItems = filteredOrders.length + // Orders leave the list as they settle, so a page the user is sitting on can vanish underneath them. + const pageCount = Math.max(1, Math.ceil(totalItems / PAGE_SIZE)) + const page = Math.min(curPage, pageCount) + const orders = useMemo(() => filteredOrders.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE), [filteredOrders, page]) + + // USD sub-lines come from the per-chain price slice, and a batch cancel is bound to one chain; both + // fall back to the wallet's chain while the All Chains filter is on. + const priceChainId = isAllChains ? chainId : (Number(selectedChainValue) as ChainId) + const batchChainId = priceChainId + const priceAddresses = useMemo( + () => + Array.from(new Set(orders.filter(order => order.chainId === priceChainId).flatMap(o => [o.tokenIn, o.tokenOut]))), + [orders, priceChainId], + ) + const { data: rawPrices } = useTokenPricesWithLoading(priceAddresses, priceChainId) + const priceUsd = useMemo( + () => Object.fromEntries(Object.entries(rawPrices ?? {}).map(([address, price]) => [address.toLowerCase(), price])), + [rawPrices], + ) + + const hasOrders = orders.length > 0 + const showNoOrders = !hasOrders && (isLoaded || isError || !account) + + /** + * One signature covers one chain and at most 100 ids, so Cancel All can only ever reach the open + * orders of a single chain. Under the All Chains filter that is a subset of what is on screen, which + * the modal has to say out loud rather than silently leaving the rest behind. + */ + const cancellableOrders = useMemo(() => { + if (!isActiveTab) return EMPTY_ORDERS + return allOrders + .filter( + order => + // Matches what the Active tab lists, not the raw `Open` set: a failed order keeps that + // status until its deadline, and counting it here would offer to cancel rows the user is + // not looking at. + isActiveStopLossStatus(getStopLossDisplayStatus(order)) && + order.chainId === batchChainId && + !cancellingIds.includes(order.id), + ) + .slice(0, BATCH_CANCEL_LIMIT) + }, [allOrders, isActiveTab, batchChainId, cancellingIds]) + + const onChangeKeyword = (value: string) => { + const next = new URLSearchParams(searchParams) + if (value) next.set('search', value) + else next.delete('search') + setSearchParams(next, { replace: true }) + setCurPage(1) + } + + /** + * Puts the whole order back on the card, not just its pair: the amount rides the shared swap state + * and the rest is staged in the stop-loss store, which the form reads on mount. `sellAmount` comes + * from the row because only it has resolved tokenIn's decimals. + */ + const onRecreate = (order: StopLossOrder, sellAmount: string) => { + tracking.trackRecreateClicked(order, getStopLossDisplayStatus(order)) + setInputValue(sellAmount) + dispatch( + updateStopLossForm({ + ...getStopLossRecreateDraft(order, STOP_LOSS_DEFAULT_EXPIRE), + // The staged expiry is a duration, so any custom date left over from a previous draft must go. + customDateExpire: undefined, + }), + ) + navigate(`${APP_PATHS.STOP_LOSS}/${NETWORKS_INFO[order.chainId].route}/${order.tokenIn}-to-${order.tokenOut}`) + } + + return ( +
+
+ { + setIsActiveTab(next) + setCurPage(1) + }} + /> + {cancellableOrders.length > 0 && ( +
+ setCancelTargets(cancellableOrders)} /> +
+ )} +
+ +
+
+ { + if (isActiveTab) return + setClosedFilter(String(value)) + setCurPage(1) + }} + /> + { + setSelectedChainValue(String(value)) + setCurPage(1) + }} + /> +
+ +
+ + +
+ + {orders.map(order => ( + setCancelTargets([target])} + onRecreate={onRecreate} + /> + ))} + {showNoOrders && } +
+ + {totalItems > PAGE_SIZE && ( +
+ +
+ )} + + isActiveStopLossStatus(getStopLossDisplayStatus(o)) && o.chainId !== batchChainId) + } + onDismiss={() => setCancelTargets([])} + onCancelled={orderIds => setCancellingIds(ids => [...ids, ...orderIds])} + /> +
+ ) +} + +export default StopLossOrders diff --git a/apps/kyberswap-interface/src/components/StopLoss/OrderTypeSubTabs.tsx b/apps/kyberswap-interface/src/components/StopLoss/OrderTypeSubTabs.tsx new file mode 100644 index 0000000000..60df6cfd5f --- /dev/null +++ b/apps/kyberswap-interface/src/components/StopLoss/OrderTypeSubTabs.tsx @@ -0,0 +1,69 @@ +import { Trans } from '@lingui/macro' +import { startTransition } from 'react' +import { useLocation, useNavigate, useParams } from 'react-router-dom' + +import { HStack } from 'components/Stack' +import { APP_PATHS } from 'constants/index' +import { isSupportStopLoss } from 'constants/networks' +import { useActiveWeb3React } from 'hooks' +import { cn } from 'utils/cn' +import { getTradeProductPath } from 'utils/routes' + +const TABS = [ + { path: APP_PATHS.LIMIT, key: 'limit', label: Limit Orders }, + { path: APP_PATHS.STOP_LOSS, key: 'stop-loss', label: Stop Loss }, +] as const + +/** + * Switches the trading card between limit and stop-loss. Each order type owns a route, so this + * navigates rather than toggling local state; the pair stays in the path and the sell amount survives + * because both forms read it from the shared swap state. + */ +const OrderTypeSubTabs = () => { + const navigate = useNavigate() + const { pathname, search } = useLocation() + const { network, currency } = useParams<{ network: string; currency?: string }>() + const { chainId, networkInfo } = useActiveWeb3React() + + // Offering the tab on a chain without stop-loss would only bounce the user to Swap. + if (!isSupportStopLoss(chainId)) return null + + const activePath = getTradeProductPath(pathname) + + return ( + + {TABS.map(({ path, key, label }, index) => ( + + ))} + + ) +} + +export default OrderTypeSubTabs diff --git a/apps/kyberswap-interface/src/components/StopLoss/SetExitPriceButton.tsx b/apps/kyberswap-interface/src/components/StopLoss/SetExitPriceButton.tsx new file mode 100644 index 0000000000..ab907d427d --- /dev/null +++ b/apps/kyberswap-interface/src/components/StopLoss/SetExitPriceButton.tsx @@ -0,0 +1,62 @@ +import { Currency, CurrencyAmount } from '@kyberswap/ks-sdk-core' +import { Trans } from '@lingui/macro' +import { useNavigate } from 'react-router-dom' + +import { ButtonOutlined } from 'components/Button' +import { useIsStopLossEligibleToken } from 'components/StopLoss/hooks/useStopLossSupportedTokens' +import { useStopLossTracking } from 'components/StopLoss/hooks/useStopLossTracking' +import { APP_PATHS } from 'constants/index' +import { isSupportStopLoss } from 'constants/networks' +import { NativeCurrencies, STABLE_TOKENS } from 'constants/tokens' +import { NETWORKS_INFO } from 'hooks/useChainsConfig' +import { useLimitActionHandlers } from 'state/limit/hooks' +import { currencyId } from 'utils/currencyId' + +type Props = { + /** The token the user now holds and might want to protect. */ + currency?: Currency + amount?: CurrencyAmount + source: 'post_swap' | 'portfolio' + className?: string + onNavigate?: () => void +} + +/** + * Sends the user to a stop-loss pre-filled with what they hold. The amount rides along through the + * shared swap state rather than the URL, which only carries the pair. + */ +const SetExitPriceButton = ({ currency, amount, source, className, onNavigate }: Props) => { + const navigate = useNavigate() + const { setInputValue } = useLimitActionHandlers() + const { trackExitPriceEntryClicked } = useStopLossTracking() + const { isEligible } = useIsStopLossEligibleToken(currency) + + const chainId = currency?.chainId + if (!currency || !chainId || !isSupportStopLoss(chainId) || !isEligible) return null + + // Sell into the chain's stable, unless that is the token being protected. + const stable = STABLE_TOKENS[chainId] + const counter = + stable && !currency.wrapped.equals(stable) ? stable : (NativeCurrencies[chainId] as Currency | undefined) + if (!counter) return null + + const onClick = () => { + trackExitPriceEntryClicked({ currency, source }) + if (amount) setInputValue(amount.toExact()) + navigate( + `${APP_PATHS.STOP_LOSS}/${NETWORKS_INFO[chainId].route}/${currencyId(currency, chainId)}-to-${currencyId( + counter, + chainId, + )}`, + ) + onNavigate?.() + } + + return ( + + Set exit price + + ) +} + +export default SetExitPriceButton diff --git a/apps/kyberswap-interface/src/components/StopLoss/constants.ts b/apps/kyberswap-interface/src/components/StopLoss/constants.ts new file mode 100644 index 0000000000..d52c4bfab0 --- /dev/null +++ b/apps/kyberswap-interface/src/components/StopLoss/constants.ts @@ -0,0 +1,42 @@ +import { t } from '@lingui/macro' + +import { TIMES_IN_SECS } from 'constants/index' + +/** + * A stop-loss fires while the market is moving against the user, so it is provisioned like a + * high-volatility pair rather than a normal swap: a tighter setting reverts the settlement exactly + * when the order is most needed. + */ +export const DEFAULT_STOP_LOSS_SLIPPAGE = 50 +export const STOP_LOSS_SLIPPAGE_PRESETS = [50, 150, 300, 500] +export const STOP_LOSS_SLIPPAGE_LOW_THRESHOLD = 50 +export const STOP_LOSS_SLIPPAGE_HIGH_THRESHOLD = 500 + +export const STOP_LOSS_DEFAULT_EXPIRE = 30 * TIMES_IN_SECS.ONE_DAY + +/** + * The expiry durations the card offers. Shared by the inline control and the custom-date modal: the + * modal treats a duration it does not recognise as an absolute epoch timestamp, so a list only one of + * them knows about turns that duration into a 1970 date. + */ +export const getStopLossExpiryPresets = () => [ + { value: 7 * TIMES_IN_SECS.ONE_DAY, label: t`7 Days` }, + { value: STOP_LOSS_DEFAULT_EXPIRE, label: t`30 Days` }, + { value: 90 * TIMES_IN_SECS.ONE_DAY, label: t`90 Days` }, + { value: 36500 * TIMES_IN_SECS.ONE_DAY, label: t`Forever` }, +] + +/** Below this distance the trigger is close enough to fire on ordinary price noise. */ +export const TRIGGER_CLOSE_TO_MARKET_PERCENT = 2 + +/** Percent-below-market shortcuts offered next to the editable percent chip. */ +export const TRIGGER_PERCENT_PRESETS = [-20, -50] + +/** + * Starting fee cap carried in the signed intent. Raised to the live protocol fee from `estimate-fee` + * before signing — the BE doc requires each entry to be at least that percentage. + */ +export const DEFAULT_MAX_FEES_PERCENTAGE = [1, 1] + +/** Ceiling on what the operator may spend on gas, as a percentage of the trade. */ +export const DEFAULT_MAX_GAS_PERCENTAGE = 50 diff --git a/apps/kyberswap-interface/src/components/StopLoss/hooks/useStopLossOraclePrice.test.ts b/apps/kyberswap-interface/src/components/StopLoss/hooks/useStopLossOraclePrice.test.ts new file mode 100644 index 0000000000..566aa5495c --- /dev/null +++ b/apps/kyberswap-interface/src/components/StopLoss/hooks/useStopLossOraclePrice.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest' + +import { oraclePriceToNumber } from 'components/StopLoss/hooks/useStopLossOraclePrice' + +// What the service actually returned for WETH/USDC on Base. +const FULL_PRECISION = '1910.54005140881556398545612944142014185027628295174124686246227000829135405475' + +describe('oraclePriceToNumber', () => { + it('reads a price carrying far more digits than a double can hold', () => { + expect(oraclePriceToNumber(FULL_PRECISION)).toBeCloseTo(1910.54, 2) + }) + + it('handles the inverted pair, where the price is a small fraction', () => { + expect( + oraclePriceToNumber('0.00052341351767953090584730954391404738264091920821295605679614049325553777933625'), + ).toBeCloseTo(0.000523413, 8) + }) + + it.each([ + ['a missing price', undefined], + ['an empty string', ''], + ['a non-numeric payload', 'not-a-price'], + ['zero, which no live feed should report', '0'], + ['a negative value', '-5'], + ])('returns nothing for %s', (_label, input) => { + expect(oraclePriceToNumber(input)).toBeUndefined() + }) + + it('never returns Infinity for an absurdly large exponent', () => { + expect(oraclePriceToNumber('1e999')).toBeUndefined() + }) +}) diff --git a/apps/kyberswap-interface/src/components/StopLoss/hooks/useStopLossOraclePrice.ts b/apps/kyberswap-interface/src/components/StopLoss/hooks/useStopLossOraclePrice.ts new file mode 100644 index 0000000000..3c224234cc --- /dev/null +++ b/apps/kyberswap-interface/src/components/StopLoss/hooks/useStopLossOraclePrice.ts @@ -0,0 +1,54 @@ +import { ChainId, Currency } from '@kyberswap/ks-sdk-core' +import { useMemo } from 'react' +import { useGetStopLossOraclePriceQuery } from 'services/stopLoss' + +import { isSupportStopLoss } from 'constants/networks' + +const POLL_INTERVAL = 15_000 + +/** + * The oracle carries far more precision than a double. Anything used as an amount must stay a string; + * this is only for the ratio maths behind a percentage, where a double is already precise enough. + */ +export const oraclePriceToNumber = (price?: string) => { + if (!price) return undefined + const value = Number(price) + return Number.isFinite(value) && value > 0 ? value : undefined +} + +/** + * The cross-rate the trigger is actually evaluated against — quote per base, matching the units the + * trigger is typed in. Deliberately not the app's USD-ratio price, which can disagree with the oracle. + */ +export const useStopLossOraclePrice = (base?: Currency, quote?: Currency, chainId?: ChainId) => { + const resolvedChainId = chainId ?? (base?.chainId as ChainId | undefined) + const baseAddress = base?.wrapped.address + const quoteAddress = quote?.wrapped.address + + const canQuery = + !!resolvedChainId && + !!baseAddress && + !!quoteAddress && + baseAddress.toLowerCase() !== quoteAddress.toLowerCase() && + isSupportStopLoss(resolvedChainId) + + const { data, isLoading, isError } = useGetStopLossOraclePriceQuery( + { chainId: resolvedChainId as ChainId, base: baseAddress ?? '', quote: quoteAddress ?? '' }, + { skip: !canQuery, pollingInterval: POLL_INTERVAL }, + ) + + return useMemo( + () => ({ + /** Full-precision decimal string, safe to display or hand back to the service. */ + price: data?.price, + /** Lossy convenience value for percentage maths only. */ + priceNumber: oraclePriceToNumber(data?.price), + updatedAt: data?.updatedAt, + source: data?.source, + isLoading: canQuery && isLoading, + /** True once the pair is known to have no configured feed. */ + hasNoFeed: canQuery && isError, + }), + [data, isLoading, isError, canQuery], + ) +} diff --git a/apps/kyberswap-interface/src/components/StopLoss/hooks/useStopLossOrderNotifications.tsx b/apps/kyberswap-interface/src/components/StopLoss/hooks/useStopLossOrderNotifications.tsx new file mode 100644 index 0000000000..e4a5993412 --- /dev/null +++ b/apps/kyberswap-interface/src/components/StopLoss/hooks/useStopLossOrderNotifications.tsx @@ -0,0 +1,63 @@ +import { t } from '@lingui/macro' +import { useEffect, useRef } from 'react' + +import { NotificationType } from 'components/Announcement/type' +import { StopLossDisplayStatus, StopLossOrder } from 'components/StopLoss/types' +import { getStopLossDisplayStatus } from 'components/StopLoss/utils' +import { useNotify } from 'state/application/hooks' + +const NOTIFIED_STATUSES = [ + StopLossDisplayStatus.TRIGGERED, + StopLossDisplayStatus.EXECUTED, + StopLossDisplayStatus.FAILED, + StopLossDisplayStatus.EXPIRED, +] + +const describe = (status: StopLossDisplayStatus) => { + switch (status) { + case StopLossDisplayStatus.TRIGGERED: + return { type: NotificationType.WARNING, title: t`Stop-loss triggered`, summary: t`Executing the swap...` } + case StopLossDisplayStatus.EXECUTED: + return { type: NotificationType.SUCCESS, title: t`Stop-loss executed`, summary: t`Your order has been filled.` } + case StopLossDisplayStatus.FAILED: + return { + type: NotificationType.ERROR, + title: t`Stop-loss failed`, + summary: t`The swap could not complete. Your tokens are still in your wallet.`, + } + default: + return { + type: NotificationType.WARNING, + title: t`Stop-loss expired`, + summary: t`The order expired without triggering.`, + } + } +} + +/** + * Raises a toast when a polled order changes state. It only covers the period the order list is on + * screen — order lifecycle events reaching the user elsewhere need the backend notification channel + * the limit-order feature subscribes to, which stop-loss does not have yet. + */ +export const useStopLossOrderNotifications = (orders: StopLossOrder[]) => { + const notify = useNotify() + const previousStatuses = useRef | undefined>(undefined) + + useEffect(() => { + const current = new Map(orders.map(order => [order.id, getStopLossDisplayStatus(order)])) + + // The first poll establishes the baseline; without it every open order would announce itself. + if (!previousStatuses.current) { + previousStatuses.current = current + return + } + + current.forEach((status, id) => { + const previous = previousStatuses.current?.get(id) + if (previous === undefined || previous === status || !NOTIFIED_STATUSES.includes(status)) return + notify(describe(status), 10000) + }) + + previousStatuses.current = current + }, [orders, notify]) +} diff --git a/apps/kyberswap-interface/src/components/StopLoss/hooks/useStopLossSupportedTokens.ts b/apps/kyberswap-interface/src/components/StopLoss/hooks/useStopLossSupportedTokens.ts new file mode 100644 index 0000000000..413a79e5d8 --- /dev/null +++ b/apps/kyberswap-interface/src/components/StopLoss/hooks/useStopLossSupportedTokens.ts @@ -0,0 +1,56 @@ +import { ChainId, Currency } from '@kyberswap/ks-sdk-core' +import { useMemo } from 'react' +import { useGetStopLossSupportedTokensQuery } from 'services/stopLoss' + +import { StopLossSupportedToken } from 'components/StopLoss/types' +import { isSupportStopLoss } from 'constants/networks' + +const EMPTY_TOKENS: StopLossSupportedToken[] = [] + +/** + * Tokens that carry an oracle feed on a chain — the set a stop-loss can monitor. The response holds + * addresses only, so symbols and logos still come from the app token list. + */ +export const useStopLossSupportedTokens = (chainId: ChainId, options?: { skip?: boolean }) => { + const chainSupportsStopLoss = isSupportStopLoss(chainId) + const { data, isLoading, isError } = useGetStopLossSupportedTokensQuery(chainId, { + skip: !chainSupportsStopLoss || options?.skip, + }) + + const tokens = data ?? EMPTY_TOKENS + + const addresses = useMemo(() => new Set(tokens.map(token => token.address.toLowerCase())), [tokens]) + + return { + tokens, + addresses, + isLoading, + isError, + /** + * False only when the chain genuinely has no feeds. A failed request leaves the list empty too, + * and reporting that as "not available on this chain" would blame the chain for an outage and + * block order placement with no way back. + */ + hasEligibleTokens: chainSupportsStopLoss && (isLoading || isError || addresses.size > 0), + } +} + +/** + * Whether a token can be monitored. Native currency resolves to its wrapped address because that is + * what the signed order sells. + */ +export const useIsStopLossEligibleToken = (currency?: Currency) => { + // Without a currency there is no chain to ask about, so the placeholder must not reach the network. + const { addresses, isLoading, isError } = useStopLossSupportedTokens( + (currency?.chainId as ChainId) ?? ChainId.MAINNET, + { skip: !currency }, + ) + + return useMemo(() => { + if (!currency) return { isEligible: false, isLoading: false } + if (isLoading) return { isEligible: false, isLoading: true } + // An unanswered request is not evidence the token lacks a feed, so it must not read as ineligible. + if (isError) return { isEligible: true, isLoading: false } + return { isEligible: addresses.has(currency.wrapped.address.toLowerCase()), isLoading: false } + }, [currency, addresses, isLoading, isError]) +} diff --git a/apps/kyberswap-interface/src/components/StopLoss/hooks/useStopLossTracking.ts b/apps/kyberswap-interface/src/components/StopLoss/hooks/useStopLossTracking.ts new file mode 100644 index 0000000000..80ad4545b8 --- /dev/null +++ b/apps/kyberswap-interface/src/components/StopLoss/hooks/useStopLossTracking.ts @@ -0,0 +1,83 @@ +import { ChainId, Currency } from '@kyberswap/ks-sdk-core' +import { useMemo } from 'react' + +import { StopLossOrder } from 'components/StopLoss/types' +import { getStopLossTriggerPrice } from 'components/StopLoss/utils' +import { NETWORKS_INFO } from 'hooks/useChainsConfig' +import useTracking, { TRACKING_EVENT_TYPE } from 'hooks/useTracking' + +type OrderContext = { + currencyIn?: Currency + currencyOut?: Currency + chainId: number + inputAmount: string + triggerPrice: string + triggerPercent?: number + slippage: number + expiredAt: number +} + +const pairOf = (currencyIn?: Currency, currencyOut?: Currency) => + `${currencyIn?.symbol ?? ''}/${currencyOut?.symbol ?? ''}` + +/** Feeds the funnel described in the spec: entry point → review → placed, split by source. */ +export const useStopLossTracking = () => { + const { trackingHandler } = useTracking() + + return useMemo( + () => ({ + trackPageViewed: (chainId: number, source: string) => + trackingHandler(TRACKING_EVENT_TYPE.SL_PAGE_VIEWED, { chain: NETWORKS_INFO[chainId as ChainId]?.name, source }), + + trackTokenSelected: (currency: Currency) => + trackingHandler(TRACKING_EVENT_TYPE.SL_TOKEN_SELECTED, { + token: currency.symbol, + chain: NETWORKS_INFO[currency.chainId]?.name, + }), + + trackReviewOpened: (context: OrderContext) => + trackingHandler(TRACKING_EVENT_TYPE.SL_REVIEW_OPENED, { + pair: pairOf(context.currencyIn, context.currencyOut), + amount: context.inputAmount, + trigger_pct_below: context.triggerPercent, + }), + + trackOrderPlaced: (context: OrderContext) => + trackingHandler(TRACKING_EVENT_TYPE.SL_ORDER_PLACED, { + pair: pairOf(context.currencyIn, context.currencyOut), + chain: NETWORKS_INFO[context.chainId as ChainId]?.name, + amount: context.inputAmount, + trigger_price: context.triggerPrice, + trigger_pct_below: context.triggerPercent, + slippage: context.slippage, + expiry: context.expiredAt, + order_type: 'stoploss', + }), + + trackOrderCancelled: (order: StopLossOrder) => + trackingHandler(TRACKING_EVENT_TYPE.SL_ORDER_CANCELLED, { + order_id: order.id, + chain: NETWORKS_INFO[order.chainId]?.name, + trigger_price: getStopLossTriggerPrice(order), + order_age_hours: Math.round((Date.now() / 1000 - order.createdAt) / 3600), + }), + + trackRecreateClicked: (order: StopLossOrder, sourceStatus: string) => + trackingHandler(TRACKING_EVENT_TYPE.SL_RECREATE_CLICKED, { order_id: order.id, source: sourceStatus }), + + trackIneligibleToken: (currency: Currency) => + trackingHandler(TRACKING_EVENT_TYPE.SL_INELIGIBLE_TOKEN, { + token: currency.symbol, + chain: NETWORKS_INFO[currency.chainId]?.name, + }), + + trackExitPriceEntryClicked: ({ currency, source }: { currency: Currency; source: string }) => + trackingHandler(TRACKING_EVENT_TYPE.SL_EXIT_PRICE_ENTRY_CLICKED, { + token: currency.symbol, + chain: NETWORKS_INFO[currency.chainId]?.name, + source, + }), + }), + [trackingHandler], + ) +} diff --git a/apps/kyberswap-interface/src/components/StopLoss/types.ts b/apps/kyberswap-interface/src/components/StopLoss/types.ts new file mode 100644 index 0000000000..7252943fad --- /dev/null +++ b/apps/kyberswap-interface/src/components/StopLoss/types.ts @@ -0,0 +1,152 @@ +import { ChainId } from '@kyberswap/ks-sdk-core' + +/** Order status as reported by the conditional-order service. */ +export enum StopLossOrderStatus { + OPEN = 'OrderStatusOpen', + DONE = 'OrderStatusDone', + CANCELLED = 'OrderStatusCancelled', + EXPIRED = 'OrderStatusExpired', +} + +/** Status of one settlement attempt. An order accumulates one entry per attempt. */ +export enum StopLossExecutionStatus { + CREATED = 'OrderExecutionStatusCreated', + PENDING = 'OrderExecutionStatusPending', + SUCCESS = 'OrderExecutionStatusSuccess', + FAILED = 'OrderExecutionStatusFailed', + NOT_MINED = 'OrderExecutionStatusNotMined', +} + +/** + * What an order row displays. The service reports only the four order statuses, so the two states a + * user cares about mid-flight — the trigger fired, and the swap could not complete — are derived from + * the latest execution instead. See `getStopLossDisplayStatus`. + */ +export enum StopLossDisplayStatus { + ACTIVE = 'active', + TRIGGERED = 'triggered', + EXECUTED = 'executed', + FAILED = 'failed', + CANCELLED = 'cancelled', + EXPIRED = 'expired', +} + +export type StopLossCondition = { + field: { + type: 'oracle_price' + value: { + /** Trigger price as a human decimal string, tokenOut per tokenIn. Fires when the oracle is at or below it. */ + lte: string + /** Optional lower bound, making the trigger a band. Unused by a plain stop-loss. */ + gte?: string + /** How stale the oracle reading may be, in seconds. 0 falls back to the chain default. */ + maxStaleness?: number + } + } +} + +export type StopLossExecutionAmount = { + amountWei?: number + amount?: string + amountUsd?: string +} + +export type StopLossExecution = { + /** Settlement transaction hash. This is the one to link on a block explorer. */ + hash: string + executionNum: number + operatorWallet: string + status: StopLossExecutionStatus + extraData?: { + amountIn?: StopLossExecutionAmount + amountOut?: StopLossExecutionAmount + /** tokenIn priced in tokenOut at the moment of execution. */ + oraclePrice?: string + tokensInfo?: Array<{ address: string; priceUsd: string; decimal: number }> + } +} + +export type StopLossOrder = { + id: number + chainId: ChainId + status: StopLossOrderStatus + userWallet: string + receiver: string + tokenIn: string + tokenOut: string + /** Raw integer amount in tokenIn's own decimals. */ + amountIn: string + /** Max slippage in basis points, 1..10000. */ + slippage: number + condition: StopLossCondition + /** Unix seconds. */ + deadline: number + /** Intent hash — the order's identity, not a transaction hash. */ + hash: string + signature: string + protocolFeePercentage: number + category: string + maxFeesPercentage: number[] + maxGasPercentage: number + source: string + /** Unix seconds. */ + createdAt: number + executions?: StopLossExecution[] +} + +/** The payload shared by estimate-fee, sign-message and create. Build it once and reuse it. */ +export type StopLossCorePayload = { + chainId: number + userWallet: string + receiver?: string + tokenIn: string + tokenOut: string + amountIn: string + slippage: number + deadline: number + /** The BE doc requires each entry to be at least the protocol percentage returned by estimate-fee. */ + maxFeesPercentage: number[] + maxGasPercentage: number + permitData?: string + feeAddress?: string + source?: string + condition: StopLossCondition +} + +export type StopLossFee = { + protocol: { percentage: number; category: string } + gas: { percentage: number; usd: number; wei: string | number } +} + +export type StopLossSupportedToken = { + address: string + decimals: number + source: string + pythPriceId?: string +} + +export type StopLossOraclePrice = { + chainId: ChainId + base: string + quote: string + /** + * Quote per base at human scale, as a decimal string carrying far more precision than a double can + * hold — keep it a string for anything that feeds an amount. + */ + price: string + /** The oracle's own publish time in unix seconds, not when the request was served. */ + updatedAt: number + source: string +} + +/** Contract the user approves tokenIn to, and the EIP-712 verifying contract. */ +export type StopLossConfig = { + smartIntentAddress: string +} + +export type StopLossTypedData = { + domain: Record + types: Record + message: Record + primaryType: string +} diff --git a/apps/kyberswap-interface/src/components/StopLoss/utils.test.ts b/apps/kyberswap-interface/src/components/StopLoss/utils.test.ts new file mode 100644 index 0000000000..b58658745b --- /dev/null +++ b/apps/kyberswap-interface/src/components/StopLoss/utils.test.ts @@ -0,0 +1,314 @@ +import { ChainId, Currency, Token, WETH } from '@kyberswap/ks-sdk-core' +import { describe, expect, it, vi } from 'vitest' + +import { + StopLossDisplayStatus, + StopLossExecution, + StopLossExecutionStatus, + StopLossOrder, + StopLossOrderStatus, +} from 'components/StopLoss/types' +import { + MAX_STOP_LOSS_DEADLINE, + buildStopLossPayload, + clampStopLossDeadline, + getStopLossDisplayStatus, + getStopLossRecreateDraft, + isActiveStopLossStatus, + parseStopLossOrder, + parseStopLossOrders, + resolveExecutionAmountOut, + stripEmptyEip712Salt, +} from 'components/StopLoss/utils' +import { NativeCurrencies } from 'constants/tokens' + +const ORDER: StopLossOrder = { + id: 4, + chainId: ChainId.BASE, + status: StopLossOrderStatus.OPEN, + userWallet: '0x9965507D1a55bcC2695C58ba16FB37d819B0A4dc', + receiver: '0x9965507D1a55bcC2695C58ba16FB37d819B0A4dc', + tokenIn: '0x4200000000000000000000000000000000000006', + tokenOut: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', + amountIn: '100000000000000000', + slippage: 50, + condition: { field: { type: 'oracle_price', value: { lte: '2400', maxStaleness: 60 } } }, + deadline: 1783344543, + hash: '9d47cd41', + signature: '808d0da6', + protocolFeePercentage: 0.15, + category: 'commonPair', + maxFeesPercentage: [1, 1], + maxGasPercentage: 50, + source: '', + createdAt: 1783258149, +} + +const execution = (status: StopLossExecutionStatus, executionNum = 0): StopLossExecution => ({ + hash: '0xb73e5fd3', + executionNum, + operatorWallet: '0x9965', + status, +}) + +describe('getStopLossDisplayStatus', () => { + it.each([ + [StopLossOrderStatus.DONE, StopLossDisplayStatus.EXECUTED], + [StopLossOrderStatus.CANCELLED, StopLossDisplayStatus.CANCELLED], + [StopLossOrderStatus.EXPIRED, StopLossDisplayStatus.EXPIRED], + ] as const)('maps the terminal order status %s to %s', (status, expected) => { + expect(getStopLossDisplayStatus({ ...ORDER, status })).toBe(expected) + }) + + it('is Active while open with no settlement attempt', () => { + expect(getStopLossDisplayStatus(ORDER)).toBe(StopLossDisplayStatus.ACTIVE) + }) + + it.each([ + [StopLossExecutionStatus.CREATED, StopLossDisplayStatus.TRIGGERED], + [StopLossExecutionStatus.PENDING, StopLossDisplayStatus.TRIGGERED], + [StopLossExecutionStatus.FAILED, StopLossDisplayStatus.FAILED], + [StopLossExecutionStatus.NOT_MINED, StopLossDisplayStatus.FAILED], + ] as const)('derives %s from the latest execution as %s', (status, expected) => { + expect(getStopLossDisplayStatus({ ...ORDER, executions: [execution(status)] })).toBe(expected) + }) + + it('reads only the latest attempt when an earlier one failed', () => { + const order = { + ...ORDER, + executions: [execution(StopLossExecutionStatus.FAILED, 0), execution(StopLossExecutionStatus.PENDING, 1)], + } + expect(getStopLossDisplayStatus(order)).toBe(StopLossDisplayStatus.TRIGGERED) + }) + + it('reads the highest executionNum even when the array arrives out of order', () => { + const order = { + ...ORDER, + executions: [execution(StopLossExecutionStatus.PENDING, 1), execution(StopLossExecutionStatus.FAILED, 0)], + } + expect(getStopLossDisplayStatus(order)).toBe(StopLossDisplayStatus.TRIGGERED) + }) + + it('keeps a successful attempt on an order the service has not settled yet as Active', () => { + const order = { ...ORDER, executions: [execution(StopLossExecutionStatus.SUCCESS)] } + expect(getStopLossDisplayStatus(order)).toBe(StopLossDisplayStatus.ACTIVE) + }) +}) + +describe('isActiveStopLossStatus', () => { + it('keeps triggered orders in the active table but files a failure under history', () => { + expect(isActiveStopLossStatus(StopLossDisplayStatus.ACTIVE)).toBe(true) + expect(isActiveStopLossStatus(StopLossDisplayStatus.TRIGGERED)).toBe(true) + // The service still calls a failed order `Open`; Active has no status column to explain it. + expect(isActiveStopLossStatus(StopLossDisplayStatus.FAILED)).toBe(false) + expect(isActiveStopLossStatus(StopLossDisplayStatus.EXECUTED)).toBe(false) + expect(isActiveStopLossStatus(StopLossDisplayStatus.CANCELLED)).toBe(false) + expect(isActiveStopLossStatus(StopLossDisplayStatus.EXPIRED)).toBe(false) + }) +}) + +describe('clampStopLossDeadline', () => { + it('passes through a deadline the service accepts', () => { + expect(clampStopLossDeadline(1790000000)).toBe(1790000000) + expect(clampStopLossDeadline(MAX_STOP_LOSS_DEADLINE)).toBe(MAX_STOP_LOSS_DEADLINE) + }) + + it('caps an expires-never choice that would otherwise be rejected', () => { + // now + 36500 days, the sentinel a "Never Expires" option produces + expect(clampStopLossDeadline(4939594415)).toBe(MAX_STOP_LOSS_DEADLINE) + }) + + it('floors fractional seconds', () => { + expect(clampStopLossDeadline(1790000000.9)).toBe(1790000000) + }) +}) + +describe('stripEmptyEip712Salt', () => { + const typedData = { + domain: { name: 'KSSmartIntentRouter', version: '1', chainId: '0x2105', verifyingContract: '0xFec4', salt: '' }, + types: { EIP712Domain: [] }, + message: {}, + primaryType: 'IntentData', + } + + it('drops the empty salt strict signers reject', () => { + expect(stripEmptyEip712Salt(typedData).domain).not.toHaveProperty('salt') + }) + + it('leaves the rest of the domain untouched', () => { + expect(stripEmptyEip712Salt(typedData).domain).toEqual({ + name: 'KSSmartIntentRouter', + version: '1', + chainId: '0x2105', + verifyingContract: '0xFec4', + }) + }) + + it('keeps a real salt', () => { + const withSalt = { ...typedData, domain: { ...typedData.domain, salt: '0xabc' } } + expect(stripEmptyEip712Salt(withSalt).domain).toHaveProperty('salt', '0xabc') + }) +}) + +describe('buildStopLossPayload', () => { + const USDC = new Token(ChainId.BASE, '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', 6, 'USDC') + const WETH_BASE = WETH[ChainId.BASE] + const NATIVE = NativeCurrencies[ChainId.BASE] + + const params = { + chainId: ChainId.BASE, + account: '0x9965507D1a55bcC2695C58ba16FB37d819B0A4dc', + currencyIn: WETH_BASE, + currencyOut: USDC as Currency, + inputAmount: '0.1', + triggerPrice: '2400', + slippage: 50, + expiredAt: 1790000000_000, + maxFeesPercentage: [1, 1], + maxGasPercentage: 50, + } + + it('converts the human amount to raw units of the sold token', () => { + expect(buildStopLossPayload(params).amountIn).toBe('100000000000000000') + }) + + it('sells the wrapped token when the form holds native currency', () => { + const payload = buildStopLossPayload({ ...params, currencyIn: NATIVE }) + expect(payload.tokenIn).toBe(WETH_BASE.address) + }) + + it('converts the expiry from milliseconds to seconds', () => { + expect(buildStopLossPayload(params).deadline).toBe(1790000000) + }) + + it('caps an expires-never deadline the service would reject', () => { + expect(buildStopLossPayload({ ...params, expiredAt: 4939594415_000 }).deadline).toBe(MAX_STOP_LOSS_DEADLINE) + }) + + it('carries the trigger as an lte condition and leaves staleness to the feed default', () => { + expect(buildStopLossPayload(params).condition).toEqual({ + field: { type: 'oracle_price', value: { lte: '2400' } }, + }) + }) + + it('omits the optional source field when unset', () => { + expect(buildStopLossPayload(params)).not.toHaveProperty('source') + }) +}) + +describe('resolveExecutionAmountOut', () => { + const USDC_OUT = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913' + const withExtra = (amount: string, amountUsd?: string, priceUsd?: string): StopLossExecution => ({ + ...execution(StopLossExecutionStatus.SUCCESS), + extraData: { + amountOut: { amount, amountUsd }, + tokensInfo: priceUsd ? [{ address: USDC_OUT, priceUsd, decimal: 6 }] : undefined, + }, + }) + + it('reads a raw value as raw when the USD figure agrees', () => { + // 176235015 raw USDC = 176.235 USDC ≈ $176.24 + expect(resolveExecutionAmountOut(withExtra('176235015', '176.24', '1.0'), USDC_OUT, 6)).toBeCloseTo(176.235015, 5) + }) + + it('reads a human value as human when the raw reading contradicts the USD figure', () => { + // 1500 as raw would be 0.0015 USDC ≈ $0.0015, nowhere near the reported $1500 + expect(resolveExecutionAmountOut(withExtra('1500', '1500', '1.0'), USDC_OUT, 6)).toBe(1500) + }) + + it('falls back to the documented raw reading when there is nothing to check against', () => { + expect(resolveExecutionAmountOut(withExtra('176235015'), USDC_OUT, 6)).toBeCloseTo(176.235015, 5) + }) + + it('ignores a token entry for a different address', () => { + const wrongToken = withExtra('176235015', '176.24', '1.0') + expect(resolveExecutionAmountOut(wrongToken, '0x0000000000000000000000000000000000000001', 6)).toBeCloseTo( + 176.235015, + 5, + ) + }) + + it.each([ + ['no execution', undefined], + ['a missing amount', { ...execution(StopLossExecutionStatus.SUCCESS), extraData: {} }], + [ + 'a non-numeric amount', + { ...execution(StopLossExecutionStatus.SUCCESS), extraData: { amountOut: { amount: 'n/a' } } }, + ], + ])('returns nothing for %s', (_label, input) => { + expect(resolveExecutionAmountOut(input as StopLossExecution | undefined, USDC_OUT, 6)).toBeUndefined() + }) + + it('returns nothing when the token decimals are unknown', () => { + expect(resolveExecutionAmountOut(withExtra('176235015'), USDC_OUT, undefined)).toBeUndefined() + }) +}) + +describe('parseStopLossOrder', () => { + it('accepts a well-formed order and coerces a string chainId', () => { + expect(parseStopLossOrder({ ...ORDER, chainId: '8453' })?.chainId).toBe(ChainId.BASE) + }) + + it.each([ + ['a non-numeric amountIn', { amountIn: '0.1' }], + ['a missing amountIn', { amountIn: undefined }], + ['an unsupported chain', { chainId: 999999 }], + ['an unknown status', { status: 'OrderStatusSomethingNew' }], + ['a missing trigger price', { condition: { field: { type: 'oracle_price', value: {} } } }], + ['a missing tokenOut', { tokenOut: '' }], + ['a non-numeric slippage', { slippage: '50' }], + ])('rejects %s', (_label, patch) => { + expect(parseStopLossOrder({ ...ORDER, ...patch })).toBeNull() + }) + + it('rejects a non-object', () => { + expect(parseStopLossOrder(null)).toBeNull() + expect(parseStopLossOrder('order')).toBeNull() + }) +}) + +describe('parseStopLossOrders', () => { + it('drops only the invalid rows and reports how many', () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined) + + const orders = parseStopLossOrders([ORDER, { ...ORDER, amountIn: 'not-a-number' }, { ...ORDER, id: 5 }]) + + expect(orders.map(order => order.id)).toEqual([4, 5]) + expect(consoleError).toHaveBeenCalledWith(expect.stringContaining('Dropped 1 of 3')) + consoleError.mockRestore() + }) + + it('returns an empty list for a missing orders array', () => { + expect(parseStopLossOrders(undefined)).toEqual([]) + }) +}) + +describe('getStopLossRecreateDraft', () => { + const DEFAULT_EXPIRE = 30 * 24 * 60 * 60 + + it('carries the trigger price and slippage of the original order', () => { + expect(getStopLossRecreateDraft(ORDER, DEFAULT_EXPIRE)).toMatchObject({ triggerPrice: '2400', slippage: 50 }) + }) + + it('carries expiry as the original duration, not its past deadline', () => { + // The fixture ran 1783258149 → 1783344543, i.e. one day. + expect(getStopLossRecreateDraft(ORDER, DEFAULT_EXPIRE).expire).toBe(ORDER.deadline - ORDER.createdAt) + expect(getStopLossRecreateDraft(ORDER, DEFAULT_EXPIRE).expire).toBe(86394) + }) + + it('falls back to the default when the recorded window is not a positive duration', () => { + const sameInstant = { ...ORDER, deadline: ORDER.createdAt } + const reversed = { ...ORDER, deadline: ORDER.createdAt - 1000 } + expect(getStopLossRecreateDraft(sameInstant, DEFAULT_EXPIRE).expire).toBe(DEFAULT_EXPIRE) + expect(getStopLossRecreateDraft(reversed, DEFAULT_EXPIRE).expire).toBe(DEFAULT_EXPIRE) + }) + + it('falls back to the default slippage when the order carries none', () => { + expect(getStopLossRecreateDraft({ ...ORDER, slippage: 0 }, DEFAULT_EXPIRE).slippage).toBe(50) + }) + + it('yields an empty trigger when the condition is missing rather than throwing', () => { + const noCondition = { ...ORDER, condition: undefined } as unknown as typeof ORDER + expect(getStopLossRecreateDraft(noCondition, DEFAULT_EXPIRE).triggerPrice).toBe('') + }) +}) diff --git a/apps/kyberswap-interface/src/components/StopLoss/utils.ts b/apps/kyberswap-interface/src/components/StopLoss/utils.ts new file mode 100644 index 0000000000..8a747f637b --- /dev/null +++ b/apps/kyberswap-interface/src/components/StopLoss/utils.ts @@ -0,0 +1,238 @@ +import { Currency } from '@kyberswap/ks-sdk-core' + +import { DEFAULT_STOP_LOSS_SLIPPAGE } from 'components/StopLoss/constants' +import { + StopLossCorePayload, + StopLossDisplayStatus, + StopLossExecution, + StopLossExecutionStatus, + StopLossOrder, + StopLossOrderStatus, + StopLossTypedData, +} from 'components/StopLoss/types' +import { isSupportedChainId } from 'constants/networks' +import { tryParseAmount } from 'state/swap/hooks' + +/** The service rejects any deadline past 2100-01-01, so an "expires never" choice lands here instead. */ +export const MAX_STOP_LOSS_DEADLINE = 4102444800 + +export const clampStopLossDeadline = (deadlineInSeconds: number) => + Math.min(Math.floor(deadlineInSeconds), MAX_STOP_LOSS_DEADLINE) + +/** + * The service emits `"salt": ""` on the EIP-712 domain, which strict signers reject because they + * expect bytes32. `salt` is absent from the EIP712Domain type list, so dropping it leaves the digest + * unchanged. + */ +export const stripEmptyEip712Salt = (typedData: StopLossTypedData): StopLossTypedData => { + const { salt, ...domain } = typedData.domain as { salt?: unknown } + return salt === '' || salt === undefined ? { ...typedData, domain } : typedData +} + +/** + * The current attempt. Picked by the highest `executionNum` rather than array position, so it does + * not rest on an ordering the service has never promised. + */ +export const getLatestExecution = (order: StopLossOrder): StopLossExecution | undefined => + order.executions?.length + ? order.executions.reduce((latest, e) => (e.executionNum > latest.executionNum ? e : latest)) + : undefined + +const IN_FLIGHT_EXECUTION_STATUSES = [StopLossExecutionStatus.CREATED, StopLossExecutionStatus.PENDING] +const FAILED_EXECUTION_STATUSES = [StopLossExecutionStatus.FAILED, StopLossExecutionStatus.NOT_MINED] + +/** + * Collapses the order status and its latest settlement attempt into the single state a row shows. + * An order stays `Open` while a settlement is in flight or after one failed, so those two cases are + * only visible through the executions. + */ +export const getStopLossDisplayStatus = (order: StopLossOrder): StopLossDisplayStatus => { + switch (order.status) { + case StopLossOrderStatus.DONE: + return StopLossDisplayStatus.EXECUTED + case StopLossOrderStatus.CANCELLED: + return StopLossDisplayStatus.CANCELLED + case StopLossOrderStatus.EXPIRED: + return StopLossDisplayStatus.EXPIRED + default: { + const execution = getLatestExecution(order) + if (!execution) return StopLossDisplayStatus.ACTIVE + if (IN_FLIGHT_EXECUTION_STATUSES.includes(execution.status)) return StopLossDisplayStatus.TRIGGERED + if (FAILED_EXECUTION_STATUSES.includes(execution.status)) return StopLossDisplayStatus.FAILED + return StopLossDisplayStatus.ACTIVE + } + } +} + +const ACTIVE_DISPLAY_STATUSES = [StopLossDisplayStatus.ACTIVE, StopLossDisplayStatus.TRIGGERED] + +/** + * Which table an order belongs in. Keyed on the *display* status, not the service's, because the + * service has no failed state: a failed settlement leaves the order reading `Open`, so splitting on + * the raw status would file every failure under Active — where the layout has no status column to + * show it in. A failed execution is never retried, so the order is finished even though the service + * keeps calling it open until the deadline passes. + */ +export const isActiveStopLossStatus = (status: StopLossDisplayStatus) => ACTIVE_DISPLAY_STATUSES.includes(status) + +/** Trigger price as a human decimal string, tokenOut per tokenIn. */ +export const getStopLossTriggerPrice = (order: StopLossOrder) => order.condition?.field?.value?.lte ?? '' + +/** The settlement transaction, available once an attempt reached the chain. */ +export const getStopLossExecutionTxHash = (order: StopLossOrder) => getLatestExecution(order)?.hash + +/** + * Why a triggered order never settled. + * + * Hard-coded placeholder: neither the order nor its executions carry a reason, so every failure reads + * the same. The single place to swap once the service returns one — see the pending BE request. + */ +export const getStopLossFailureReason = (_order: StopLossOrder): string => 'insufficient liquidity' + +/** + * The card inputs that reproduce a past order. + * + * Expiry is carried as the original *duration*, not the original deadline: the deadline is a fixed + * point in time, so an expired order would clone to one already past its deadline, and any other + * closed order to a shorter window than the user originally chose. + */ +export const getStopLossRecreateDraft = ( + order: StopLossOrder, + defaultExpire: number, +): { triggerPrice: string; slippage: number; expire: number } => { + const duration = order.deadline - order.createdAt + return { + triggerPrice: getStopLossTriggerPrice(order), + slippage: Number.isFinite(order.slippage) && order.slippage > 0 ? order.slippage : DEFAULT_STOP_LOSS_SLIPPAGE, + expire: Number.isFinite(duration) && duration > 0 ? duration : defaultExpire, + } +} + +export type BuildStopLossPayloadParams = { + chainId: number + account: string + currencyIn: Currency + currencyOut: Currency + /** Human amount as typed on the form. */ + inputAmount: string + /** Trigger price as a human decimal string, tokenOut per tokenIn. */ + triggerPrice: string + /** Basis points. */ + slippage: number + /** Milliseconds, as the expiry control produces it. */ + expiredAt: number + maxFeesPercentage: number[] + maxGasPercentage: number + source?: string +} + +/** The payload shared by estimate-fee, sign-message and create. */ +export const buildStopLossPayload = ({ + chainId, + account, + currencyIn, + currencyOut, + inputAmount, + triggerPrice, + slippage, + expiredAt, + maxFeesPercentage, + maxGasPercentage, + source, +}: BuildStopLossPayloadParams): StopLossCorePayload => ({ + chainId, + userWallet: account, + // Native currency is wrapped before settlement, so the order always sells the wrapped token. + tokenIn: currencyIn.wrapped.address, + tokenOut: currencyOut.wrapped.address, + amountIn: tryParseAmount(inputAmount, currencyIn.wrapped)?.quotient?.toString() ?? '0', + slippage, + deadline: clampStopLossDeadline(expiredAt / 1000), + maxFeesPercentage, + maxGasPercentage, + ...(source ? { source } : {}), + condition: { + field: { + // `maxStaleness` is left out so each feed applies its own default. The value is signed into the + // intent, so a window a feed cannot meet would be unfixable without cancelling and re-signing; + // which windows each feed can meet is not something this app knows. + type: 'oracle_price', + value: { lte: triggerPrice }, + }, + }, +}) + +/** + * Reads the received amount from an execution. + * + * `amountOut.amount` is documented as raw units while its sibling `amountIn.amount` is human-readable, + * and a whole number is valid under either reading — the two differ by 10^decimals, so guessing wrong + * shows a settlement figure off by orders of magnitude. The execution carries `amountUsd` and a + * per-token `priceUsd`, which together say which reading the number must be. + */ +export const resolveExecutionAmountOut = ( + execution: StopLossExecution | undefined, + tokenOut: string, + decimals: number | undefined, +): number | undefined => { + const raw = execution?.extraData?.amountOut?.amount + if (!raw || decimals === undefined || !/^\d+(\.\d+)?$/.test(raw)) return undefined + + const asHuman = Number(raw) + if (!Number.isFinite(asHuman)) return undefined + const asRaw = asHuman / 10 ** decimals + + const amountUsd = Number(execution?.extraData?.amountOut?.amountUsd) + const priceUsd = Number( + execution?.extraData?.tokensInfo?.find(token => token.address?.toLowerCase() === tokenOut.toLowerCase())?.priceUsd, + ) + // Without both references the documented reading is all there is to go on. + if (!Number.isFinite(amountUsd) || amountUsd <= 0 || !Number.isFinite(priceUsd) || priceUsd <= 0) return asRaw + + return Math.abs(asRaw * priceUsd - amountUsd) <= Math.abs(asHuman * priceUsd - amountUsd) ? asRaw : asHuman +} + +const isRawAmount = (value: unknown): value is string => typeof value === 'string' && /^\d+$/.test(value) + +const isNonEmptyString = (value: unknown): value is string => typeof value === 'string' && value.length > 0 + +const ORDER_STATUSES = Object.values(StopLossOrderStatus) as string[] + +/** + * Rejects an order the UI cannot render rather than letting a missing field reach the SDK. A backend + * contract change then shows up as dropped rows in the console instead of a blank page. + */ +export const parseStopLossOrder = (raw: unknown): StopLossOrder | null => { + if (!raw || typeof raw !== 'object') return null + const order = raw as Record + + const chainId = Number(order.chainId) + const trigger = (order.condition as StopLossOrder['condition'] | undefined)?.field?.value?.lte + + if ( + typeof order.id !== 'number' || + !isSupportedChainId(chainId) || + !ORDER_STATUSES.includes(order.status as string) || + !isNonEmptyString(order.tokenIn) || + !isNonEmptyString(order.tokenOut) || + !isRawAmount(order.amountIn) || + !isNonEmptyString(trigger) || + typeof order.slippage !== 'number' || + typeof order.deadline !== 'number' + ) { + return null + } + + return { ...(order as unknown as StopLossOrder), chainId } +} + +export const parseStopLossOrders = (raw: unknown): StopLossOrder[] => { + const list = Array.isArray(raw) ? raw : [] + const orders = list.map(parseStopLossOrder).filter((order): order is StopLossOrder => order !== null) + + if (orders.length !== list.length) { + console.error(`Dropped ${list.length - orders.length} of ${list.length} stop-loss orders failing validation`) + } + + return orders +} diff --git a/apps/kyberswap-interface/src/components/SwapForm/SlippageSetting.tsx b/apps/kyberswap-interface/src/components/SwapForm/SlippageSetting.tsx index 99bcb5c59e..81ab4e58a4 100644 --- a/apps/kyberswap-interface/src/components/SwapForm/SlippageSetting.tsx +++ b/apps/kyberswap-interface/src/components/SwapForm/SlippageSetting.tsx @@ -39,6 +39,12 @@ export const DropdownIcon = ({
) +/** + * Grid placement for a setting that renders a compact header plus an expanding panel, where the two + * belong in different cells. `header` and `panel` are the caller's placement classes. + */ +export type SettingGridCells = { header?: string; panel?: string } + type Props = { rightComponent?: ReactNode tooltip?: ReactNode @@ -49,15 +55,35 @@ type Props = { default: number presets: number[] } + /** + * Feature-local value. Without it the control reads and writes the global Swap setting, which is + * wrong for a form that stores its own slippage on the order it creates. + */ + slippage?: { value: number; onChange: (value: number) => void } + /** Renders regardless of the Swap settings pin, for forms that own the control outright. */ + alwaysVisible?: boolean + /** + * Places the header and the expanding panel as separate cells of the caller's grid: the root stops + * being a box (`display: contents`) so its two children become grid items directly. Lets a caller + * keep a narrow header column while the panel, which needs more room than the column has, spans + * the full grid width. Requires the caller to be a grid; leave unset everywhere else. + */ + gridCells?: SettingGridCells } -const SlippageSetting = ({ rightComponent, tooltip, slippageInfo }: Props) => { +const SlippageSetting = ({ rightComponent, tooltip, slippageInfo, slippage, alwaysVisible, gridCells }: Props) => { const [searchParams, setSearchParams] = useSearchParams() const [expanded, setExpanded] = useState(false) const [isHighlight, setIsHighlight] = useState(false) const [triedSimulatedSlippage, setTriedSimulatedSlippage] = useState(false) const [isDegenMode] = useDegenModeManager() - const { rawSlippage, setRawSlippage, isSlippageControlPinned } = useSlippageSettingByPage() + const { + rawSlippage: globalSlippage, + setRawSlippage: setGlobalSlippage, + isSlippageControlPinned, + } = useSlippageSettingByPage() + const rawSlippage = slippage ? slippage.value : globalSlippage + const setRawSlippage = slippage ? slippage.onChange : setGlobalSlippage const defaultSlippage = useDefaultSlippageByPair() const defaultSlp = slippageInfo ? slippageInfo.default : defaultSlippage @@ -86,7 +112,8 @@ const SlippageSetting = ({ rightComponent, tooltip, slippageInfo }: Props) => { [pairCategory, slippageInfo], ) - const actionFromUrl = searchParams.get('action') + // The deep link targets the Swap setting, so an instance holding its own value ignores it. + const actionFromUrl = slippage ? null : searchParams.get('action') useEffect(() => { if (actionFromUrl === 'open-slippage-panel') { setExpanded(true) @@ -100,13 +127,13 @@ const SlippageSetting = ({ rightComponent, tooltip, slippageInfo }: Props) => { } }, [actionFromUrl, searchParams, setSearchParams]) - if (!isSlippageControlPinned) { + if (!isSlippageControlPinned && !alwaysVisible) { return null } return ( -
-
+
+
Max Slippage: @@ -135,9 +162,13 @@ const SlippageSetting = ({ rightComponent, tooltip, slippageInfo }: Props) => {
setExpanded(e => !e)} + data-testid="slippage-setting-toggle" className="flex cursor-pointer items-center gap-1 hover:brightness-[0.85]" > - + {msg ? ( {formatSlippage(rawSlippage)} @@ -158,6 +189,9 @@ const SlippageSetting = ({ rightComponent, tooltip, slippageInfo }: Props) => { className={cn( 'grid transition-[grid-template-rows,opacity] duration-200 ease-in-out', expanded ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0', + // As a grid item the panel's automatic minimum size would hold the collapsed 0fr row open. + gridCells && 'min-h-0', + gridCells?.panel, )} >
@@ -192,7 +226,7 @@ const SlippageSetting = ({ rightComponent, tooltip, slippageInfo }: Props) => { {slippageInfo ? ( - msg && + msg && ) : ( )} diff --git a/apps/kyberswap-interface/src/components/SwapForm/SwapModal/index.tsx b/apps/kyberswap-interface/src/components/SwapForm/SwapModal/index.tsx index 39fcb4ce7e..067b01b4ca 100644 --- a/apps/kyberswap-interface/src/components/SwapForm/SwapModal/index.tsx +++ b/apps/kyberswap-interface/src/components/SwapForm/SwapModal/index.tsx @@ -4,6 +4,7 @@ import { useCallback, useEffect, useState } from 'react' import { useDispatch } from 'react-redux' import Modal from 'components/Modal' +import SetExitPriceButton from 'components/StopLoss/SetExitPriceButton' import { useSwapFormContext } from 'components/SwapForm/SwapFormContext' import { BuildRouteResult } from 'components/SwapForm/hooks/useBuildRoute' import { @@ -157,6 +158,14 @@ const SwapModal: React.FC = props => { hash={txHash} onDismiss={handleDismiss} tokenAddToMetaMask={tokenAddToMetaMask as Token} + extraAction={ + + } /> ) } diff --git a/apps/kyberswap-interface/src/components/TransactionConfirmationModal/index.tsx b/apps/kyberswap-interface/src/components/TransactionConfirmationModal/index.tsx index 144d469040..e6a763d43e 100644 --- a/apps/kyberswap-interface/src/components/TransactionConfirmationModal/index.tsx +++ b/apps/kyberswap-interface/src/components/TransactionConfirmationModal/index.tsx @@ -112,6 +112,8 @@ type TransactionSubmittedContentProps = { scanLink?: string chainId: ChainId tokenAddToMetaMask?: Token + /** Optional follow-up offered above Close, e.g. protecting the token just bought. */ + extraAction?: React.ReactNode } export function TransactionSubmittedContent({ @@ -120,6 +122,7 @@ export function TransactionSubmittedContent({ hash, tokenAddToMetaMask, scanLink, + extraAction, }: TransactionSubmittedContentProps) { return (
@@ -145,6 +148,7 @@ export function TransactionSubmittedContent({ {tokenAddToMetaMask?.address && } + {extraAction} Close diff --git a/apps/kyberswap-interface/src/components/Web3Provider/BitcoinProvider/providers/ledger.ts b/apps/kyberswap-interface/src/components/Web3Provider/BitcoinProvider/providers/ledger.ts index b5600663f4..b074d0b861 100644 --- a/apps/kyberswap-interface/src/components/Web3Provider/BitcoinProvider/providers/ledger.ts +++ b/apps/kyberswap-interface/src/components/Web3Provider/BitcoinProvider/providers/ledger.ts @@ -267,7 +267,7 @@ export const createLedgerProvider = ({ outputs: [ { amount: amountToBuffer(+amount), - script: Buffer.from(bitcoin.address.toOutputScript(recipient, bitcoin.networks.bitcoin)), + script: bitcoin.address.toOutputScript(recipient, bitcoin.networks.bitcoin), }, ], } @@ -275,7 +275,7 @@ export const createLedgerProvider = ({ if (changeAmount > 546) { transaction.outputs.push({ amount: amountToBuffer(changeAmount), - script: Buffer.from(bitcoin.address.toOutputScript(sender, bitcoin.networks.bitcoin)), + script: bitcoin.address.toOutputScript(sender, bitcoin.networks.bitcoin), }) } diff --git a/apps/kyberswap-interface/src/constants/env.ts b/apps/kyberswap-interface/src/constants/env.ts index 64b730c6be..06b864dc8a 100644 --- a/apps/kyberswap-interface/src/constants/env.ts +++ b/apps/kyberswap-interface/src/constants/env.ts @@ -61,7 +61,8 @@ export const REFERRAL_URL = required('REFERRAL_URL') export const TOKEN_API_URL = required('TOKEN_API_URL') export const AFFILIATE_SERVICE_URL = required('AFFILIATE_SERVICE') export const SOLANA_RPC = required('SOLANA_RPC') -export const SMART_EXIT_API_URL = required('SMART_EXIT_API_URL') +/** Smart Exit and Stop-Loss are two products of one conditional-order service, on a single host. */ +export const CONDITIONAL_SERVICE_URL = required('CONDITIONAL_SERVICE_URL') export const CROSSCHAIN_AGGREGATOR_API = required('CROSSCHAIN_AGGREGATOR_API') type FirebaseConfig = { diff --git a/apps/kyberswap-interface/src/constants/index.ts b/apps/kyberswap-interface/src/constants/index.ts index 91bcd1e1d1..291703ecd4 100644 --- a/apps/kyberswap-interface/src/constants/index.ts +++ b/apps/kyberswap-interface/src/constants/index.ts @@ -36,6 +36,7 @@ export const APP_PATHS = { KYBERDAO_VOTE: '/kyberdao/vote', KYBERDAO_KNC_UTILITY: '/kyberdao/knc-utility', LIMIT: '/limit', + STOP_LOSS: '/stop-loss', PROFILE_MANAGE: '/manage', VERIFY_AUTH: '/auth', @@ -111,6 +112,9 @@ export const RTK_QUERY_TAGS = { GET_LIMIT_ORDER_INSUFFICIENT: 'GET_LIMIT_ORDER_INSUFFICIENT', GET_LIMIT_ORDER_ACTIVE_MAKING_AMOUNT: 'GET_LIMIT_ORDER_ACTIVE_MAKING_AMOUNT', + // stop loss + GET_STOP_LOSS_ORDER_LIST: 'GET_STOP_LOSS_ORDER_LIST', + // smart exit GET_SMART_EXIT_ORDERS: 'GET_SMART_EXIT_ORDERS', } diff --git a/apps/kyberswap-interface/src/constants/networks.ts b/apps/kyberswap-interface/src/constants/networks.ts index 6f5a845049..b875faf723 100644 --- a/apps/kyberswap-interface/src/constants/networks.ts +++ b/apps/kyberswap-interface/src/constants/networks.ts @@ -76,6 +76,24 @@ export const isSupportLimitOrder = (chainId: ChainId, envKey: EnvKeys = ENV_KEY) return limitOrder === '*' || (limitOrder || []).includes(envKey) } +/** + * Stop-loss runs on a fixed chain set instead of the per-chain, per-env `limitOrder` config. + * + * Every entry must be one the conditional-order service accepts: it rejects an unknown chain with + * `chain id is not supported`, and one bad id fails the whole order-list request, so listing a chain + * it does not know breaks the table for every chain at once. Polygon is left out for that reason. + * Chains it accepts but has no oracle feeds for stay in — the form degrades to "not available yet". + */ +const STOP_LOSS_NETWORKS: ChainId[] = [ + ChainId.MAINNET, + ChainId.ARBITRUM, + ChainId.BSCMAINNET, + ChainId.OPTIMISM, + ChainId.BASE, +] + +export const isSupportStopLoss = (chainId: ChainId): boolean => STOP_LOSS_NETWORKS.includes(chainId) + export const MAINNET_NETWORKS: ChainId[] = [ ChainId.MAINNET, ChainId.ARBITRUM, diff --git a/apps/kyberswap-interface/src/entry-server.tsx b/apps/kyberswap-interface/src/entry-server.tsx index 8ef14c8472..cbf3265c1f 100644 --- a/apps/kyberswap-interface/src/entry-server.tsx +++ b/apps/kyberswap-interface/src/entry-server.tsx @@ -38,7 +38,11 @@ const DEFAULT_NETWORK_ROUTE = NETWORKS_INFO[ChainId.MAINNET].route const ROOT_SOURCE_ROUTE = `${APP_PATHS.SWAP}/${DEFAULT_NETWORK_ROUTE}` const distinctPageRoutes = SITEMAP_PAGE_ROUTES.filter( - route => route !== '/' && !route.startsWith(`${APP_PATHS.SWAP}/`) && !route.startsWith(`${APP_PATHS.LIMIT}/`), + route => + route !== '/' && + !route.startsWith(`${APP_PATHS.SWAP}/`) && + !route.startsWith(`${APP_PATHS.LIMIT}/`) && + !route.startsWith(`${APP_PATHS.STOP_LOSS}/`), ) export const prerenderManifest = { @@ -64,6 +68,11 @@ export const prerenderManifest = { sourceRoute: `${APP_PATHS.LIMIT}/${DEFAULT_NETWORK_ROUTE}`, outputPath: 'limit/index.html', }, + { + product: 'stop-loss', + sourceRoute: `${APP_PATHS.STOP_LOSS}/${DEFAULT_NETWORK_ROUTE}`, + outputPath: 'stop-loss/index.html', + }, ], ogSkeletons: [ { @@ -81,7 +90,7 @@ export const prerenderManifest = { } as const const getRouteNetworkSlug = (url: string) => { - const productPath = [APP_PATHS.SWAP, APP_PATHS.LIMIT, APP_PATHS.BUY, APP_PATHS.SELL].find(path => + const productPath = [APP_PATHS.SWAP, APP_PATHS.LIMIT, APP_PATHS.STOP_LOSS, APP_PATHS.BUY, APP_PATHS.SELL].find(path => url.startsWith(`${path}/`), ) return productPath ? url.slice(productPath.length + 1).split(/[/?#]/, 1)[0] : undefined diff --git a/apps/kyberswap-interface/src/hooks/usePageLocation.ts b/apps/kyberswap-interface/src/hooks/usePageLocation.ts index 79816fc014..3927cb5df7 100644 --- a/apps/kyberswap-interface/src/hooks/usePageLocation.ts +++ b/apps/kyberswap-interface/src/hooks/usePageLocation.ts @@ -12,6 +12,7 @@ const usePageLocation = () => { isPartnerSwap || isUserSwap || location.pathname.startsWith(APP_PATHS.LIMIT) || + location.pathname.startsWith(APP_PATHS.STOP_LOSS) || location.pathname.startsWith(APP_PATHS.CROSS_CHAIN) return { diff --git a/apps/kyberswap-interface/src/hooks/useTracking.ts b/apps/kyberswap-interface/src/hooks/useTracking.ts index b5f2c8e772..ca0f2a4244 100644 --- a/apps/kyberswap-interface/src/hooks/useTracking.ts +++ b/apps/kyberswap-interface/src/hooks/useTracking.ts @@ -159,6 +159,16 @@ export enum TRACKING_EVENT_TYPE { ANNOUNCEMENT_CLICK_CTA_POPUP, ANNOUNCEMENT_CLICK_CLEAR_ALL_INBOXES, + // Stop Loss + SL_PAGE_VIEWED, + SL_TOKEN_SELECTED, + SL_REVIEW_OPENED, + SL_ORDER_PLACED, + SL_ORDER_CANCELLED, + SL_RECREATE_CLICKED, + SL_INELIGIBLE_TOKEN, + SL_EXIT_PRICE_ENTRY_CLICKED, + // Limit Order LO_PAGE_VIEWED, LO_CLICK_PLACE_ORDER, @@ -1205,6 +1215,38 @@ export default function useTracking(currencies?: { [field in Field]?: Currency } formoTrack('Gas refund - KNC Utility source click', { source }) break } + case TRACKING_EVENT_TYPE.SL_PAGE_VIEWED: { + formoTrack('Stop Loss - Sub Tab Opened', payload) + break + } + case TRACKING_EVENT_TYPE.SL_TOKEN_SELECTED: { + formoTrack('Stop Loss - Token Selected', payload) + break + } + case TRACKING_EVENT_TYPE.SL_REVIEW_OPENED: { + formoTrack('Stop Loss - Review Opened', payload) + break + } + case TRACKING_EVENT_TYPE.SL_ORDER_PLACED: { + formoTrack('Stop Loss - Order Placed', payload) + break + } + case TRACKING_EVENT_TYPE.SL_ORDER_CANCELLED: { + formoTrack('Stop Loss - Order Cancelled', payload) + break + } + case TRACKING_EVENT_TYPE.SL_RECREATE_CLICKED: { + formoTrack('Stop Loss - Recreate Clicked', payload) + break + } + case TRACKING_EVENT_TYPE.SL_INELIGIBLE_TOKEN: { + formoTrack('Stop Loss - Ineligible Token', payload) + break + } + case TRACKING_EVENT_TYPE.SL_EXIT_PRICE_ENTRY_CLICKED: { + formoTrack('Exit Price Entry Clicked', payload) + break + } case TRACKING_EVENT_TYPE.LO_PAGE_VIEWED: { formoTrack('Limit Order Page Viewed') break diff --git a/apps/kyberswap-interface/src/pages/App.tsx b/apps/kyberswap-interface/src/pages/App.tsx index 8fb787aff3..a6b5c53e25 100644 --- a/apps/kyberswap-interface/src/pages/App.tsx +++ b/apps/kyberswap-interface/src/pages/App.tsx @@ -25,6 +25,7 @@ import { NETWORKS_INFO, SUPPORTED_NETWORKS, isSupportLimitOrder, + isSupportStopLoss, } from 'constants/networks' import { useActiveWeb3React } from 'hooks' import usePageLocation from 'hooks/usePageLocation' @@ -77,6 +78,8 @@ const EarnPositionDetail = lazy(() => import('pages/Earns/PositionDetail')) const SmartExit = lazy(() => import('pages/Earns/SmartExitOrders')) const PoolDetail = lazy(() => import('pages/Earns/PoolDetail')) +const StopLossPage = lazy(() => import('pages/Swap/StopLossPage')) + const Recap2025Redirect = lazy(() => import('pages/Recap2025Redirect')) const AppWrapper = ({ children }: { children: React.ReactNode }) => ( @@ -281,6 +284,18 @@ export default function App() { /> )} + } /> + {isSupportStopLoss(chainId) && ( + + + + } + /> + )} + } /> <> {/* My Pools Routes */} diff --git a/apps/kyberswap-interface/src/pages/Swap/LimitPage.tsx b/apps/kyberswap-interface/src/pages/Swap/LimitPage.tsx index 7ffee79b90..e1632ba7d0 100644 --- a/apps/kyberswap-interface/src/pages/Swap/LimitPage.tsx +++ b/apps/kyberswap-interface/src/pages/Swap/LimitPage.tsx @@ -1,14 +1,9 @@ -import { lazy } from 'react' - import LimitOrderForm from 'components/LimitOrder/Form/LimitOrderForm' import { useCurrenciesByPage } from 'pages/Swap/hooks/useCurrenciesByPage' import { useTradeController } from 'pages/Swap/hooks/useTradeController' import { SwapLayout } from 'pages/Swap/layout/SwapLayout' import { TAB } from 'pages/Swap/layout/Tabs' - -const OrderList = lazy(() => import('components/LimitOrder/OrderList')) -const SwapSettingsPanel = lazy(() => import('pages/Swap/components/SwapSettingsPanel')) -const TokenInfo = lazy(() => import('components/TokenInfo')) +import { OrderList, SwapSettingsPanel, TokenInfo } from 'pages/Swap/layout/lazyPanels' const LimitPage = () => { const controller = useTradeController(TAB.LIMIT) diff --git a/apps/kyberswap-interface/src/pages/Swap/StopLossPage.tsx b/apps/kyberswap-interface/src/pages/Swap/StopLossPage.tsx new file mode 100644 index 0000000000..fbd986652d --- /dev/null +++ b/apps/kyberswap-interface/src/pages/Swap/StopLossPage.tsx @@ -0,0 +1,35 @@ +import StopLossForm from 'components/StopLoss/Form/StopLossForm' +import { useCurrenciesByPage } from 'pages/Swap/hooks/useCurrenciesByPage' +import { useTradeController } from 'pages/Swap/hooks/useTradeController' +import { SwapLayout } from 'pages/Swap/layout/SwapLayout' +import { TAB } from 'pages/Swap/layout/Tabs' +import { OrderList, SwapSettingsPanel, TokenInfo } from 'pages/Swap/layout/lazyPanels' + +/** + * Stop-loss is a sub-tab of Limit Order on its own route, so it keeps the Limit Order top-level tab + * selected and reuses the shared trade shell. + */ +const StopLossPage = () => { + const controller = useTradeController(TAB.LIMIT) + const { activeTab, highlightDegenMode, onBackToMainTab, setActiveTab } = controller + const { currencies } = useCurrenciesByPage() + + return ( + }> + {activeTab === TAB.LIMIT && } + {activeTab === TAB.INFO && } + {activeTab === TAB.SETTINGS && ( + setActiveTab(TAB.LIQUIDITY_SOURCES)} + onClickCrossChainSources={() => setActiveTab(TAB.CROSS_CHAIN_SOURCES)} + /> + )} + + ) +} + +export default StopLossPage diff --git a/apps/kyberswap-interface/src/pages/Swap/SwapPage.tsx b/apps/kyberswap-interface/src/pages/Swap/SwapPage.tsx index e49803ccc6..c78a6e1457 100644 --- a/apps/kyberswap-interface/src/pages/Swap/SwapPage.tsx +++ b/apps/kyberswap-interface/src/pages/Swap/SwapPage.tsx @@ -1,4 +1,4 @@ -import { lazy, useState } from 'react' +import { useState } from 'react' import { PopulatedSwapForm } from 'pages/Swap/components/PopulatedSwapForm' import { SwapRightPanel } from 'pages/Swap/components/SwapRightPanel' @@ -6,12 +6,9 @@ import { useCurrenciesByPage } from 'pages/Swap/hooks/useCurrenciesByPage' import { useTradeController } from 'pages/Swap/hooks/useTradeController' import { SwapLayout } from 'pages/Swap/layout/SwapLayout' import { TAB } from 'pages/Swap/layout/Tabs' +import { LiquiditySourcesPanel, SwapSettingsPanel, TokenInfo } from 'pages/Swap/layout/lazyPanels' import type { DetailedRouteSummary } from 'types/route' -const LiquiditySourcesPanel = lazy(() => import('pages/Swap/components/LiquiditySourcesPanel')) -const SwapSettingsPanel = lazy(() => import('pages/Swap/components/SwapSettingsPanel')) -const TokenInfo = lazy(() => import('components/TokenInfo')) - const SwapPage = () => { const controller = useTradeController(TAB.SWAP) const { activeTab, highlightDegenMode, onBackToMainTab, setActiveTab } = controller diff --git a/apps/kyberswap-interface/src/pages/Swap/hooks/useCurrenciesByPage.ts b/apps/kyberswap-interface/src/pages/Swap/hooks/useCurrenciesByPage.ts index cfc4d2ca90..d8d155e298 100644 --- a/apps/kyberswap-interface/src/pages/Swap/hooks/useCurrenciesByPage.ts +++ b/apps/kyberswap-interface/src/pages/Swap/hooks/useCurrenciesByPage.ts @@ -7,7 +7,7 @@ import { useLimitState } from 'state/limit/hooks' import { Field } from 'state/swap/actions' import { useInputCurrency, useOutputCurrency } from 'state/swap/hooks' import { currencyId } from 'utils/currencyId' -import { isSwapLikePath } from 'utils/routes' +import { getTradeProductPath, isSwapLikePath } from 'utils/routes' export const useCurrenciesByPage = () => { const { networkInfo, chainId } = useActiveWeb3React() @@ -32,7 +32,7 @@ export const useCurrenciesByPage = () => { ) const shareUrl = useMemo(() => { - const path = `${isSwapPage ? APP_PATHS.SWAP : APP_PATHS.LIMIT}/${networkInfo.route}${ + const path = `${getTradeProductPath(pathname)}/${networkInfo.route}${ currencyIn && currencyOut ? `?${new URLSearchParams({ inputCurrency: currencyId(currencyIn, chainId), @@ -41,7 +41,7 @@ export const useCurrenciesByPage = () => { : '' }` return `${window.location.origin}${isCrossChainPage ? APP_PATHS.CROSS_CHAIN : path}` - }, [networkInfo.route, currencyIn, currencyOut, chainId, isSwapPage, isCrossChainPage]) + }, [networkInfo.route, currencyIn, currencyOut, chainId, pathname, isCrossChainPage]) return { currencies, diff --git a/apps/kyberswap-interface/src/pages/Swap/hooks/useTradeController.ts b/apps/kyberswap-interface/src/pages/Swap/hooks/useTradeController.ts index 724f0f1708..fdcffc7ac7 100644 --- a/apps/kyberswap-interface/src/pages/Swap/hooks/useTradeController.ts +++ b/apps/kyberswap-interface/src/pages/Swap/hooks/useTradeController.ts @@ -1,12 +1,12 @@ import { useCallback, useEffect, useState } from 'react' import { useLocation, useNavigate, useSearchParams } from 'react-router-dom' -import { APP_PATHS } from 'constants/index' import { useActiveWeb3React } from 'hooks' import { NETWORKS_INFO } from 'hooks/useChainsConfig' import useParsedQueryString from 'hooks/useParsedQueryString' import { useRequiredDegenMode } from 'pages/Swap/hooks/useRequiredDegenMode' import { type MainTab, TAB, isSettingTab } from 'pages/Swap/layout/Tabs' +import { getTradeProductPath } from 'utils/routes' export type TradeController = { activeMainTab: TAB @@ -31,9 +31,11 @@ export const useTradeController = (mainTab: MainTab): TradeController => { const outputCurrency = searchParams.get('outputCurrency') if (inputCurrency || outputCurrency) { - if (pathname.includes(APP_PATHS.LIMIT)) - navigate(`${APP_PATHS.LIMIT}/${NETWORKS_INFO[chainId].route}/${inputCurrency || ''}-to-${outputCurrency || ''}`) - else navigate(`/swap/${NETWORKS_INFO[chainId].route}/${inputCurrency || ''}-to-${outputCurrency || ''}`) + navigate( + `${getTradeProductPath(pathname)}/${NETWORKS_INFO[chainId].route}/${inputCurrency || ''}-to-${ + outputCurrency || '' + }`, + ) } // eslint-disable-next-line react-hooks/exhaustive-deps }, [searchParams, chainId, navigate]) diff --git a/apps/kyberswap-interface/src/pages/Swap/layout/Header.tsx b/apps/kyberswap-interface/src/pages/Swap/layout/Header.tsx index 62fb168567..94e2809563 100644 --- a/apps/kyberswap-interface/src/pages/Swap/layout/Header.tsx +++ b/apps/kyberswap-interface/src/pages/Swap/layout/Header.tsx @@ -26,7 +26,9 @@ export const Header = ({ activeTab, setActiveTab, customChainId, activeMainTab } const { pathname } = useLocation() const selectedTab = activeMainTab || activeTab - const isLimitPage = pathname.startsWith(APP_PATHS.LIMIT) || selectedTab === TAB.LIMIT + // Stop-loss lives under the Limit Order top-level tab, so it must be excluded before the limit copy applies. + const isStopLossPage = pathname.startsWith(APP_PATHS.STOP_LOSS) + const isLimitPage = !isStopLossPage && (pathname.startsWith(APP_PATHS.LIMIT) || selectedTab === TAB.LIMIT) const isSwapPage = isSwapLikePath(pathname) || selectedTab == TAB.SWAP const isCrossChainPage = pathname.startsWith(APP_PATHS.CROSS_CHAIN) || selectedTab === TAB.CROSS_CHAIN @@ -48,6 +50,16 @@ export const Header = ({ activeTab, setActiveTab, customChainId, activeMainTab } {t`Buy or sell tokens at customized prices`} )} + {isStopLossPage && ( + <> + Sell automatically when the price drops. + + Set a trigger price and KyberSwap sells at the best available market price when the oracle price reaches + it. Free to place, and your tokens stay in your wallet until it executes. + + {t`Protect your positions from sudden price drops`} + + )} {isSwapPage && ( <> Swap any token at the best rate across chains. diff --git a/apps/kyberswap-interface/src/pages/Swap/layout/lazyPanels.ts b/apps/kyberswap-interface/src/pages/Swap/layout/lazyPanels.ts new file mode 100644 index 0000000000..14c8694c3a --- /dev/null +++ b/apps/kyberswap-interface/src/pages/Swap/layout/lazyPanels.ts @@ -0,0 +1,14 @@ +import { lazy } from 'react' + +/** + * One `lazy()` instance per panel, shared by every trade page. + * + * Declaring these per page makes the same module a *different component type* on each route, so + * moving between Swap, Limit and Stop Loss unmounts the panel and suspends on it again. The only + * Suspense boundary sits around the whole app body, so that suspension replaces the entire page with + * the route skeleton — a visible flash on a switch the UI presents as a tab. + */ +export const OrderList = lazy(() => import('components/LimitOrder/OrderList')) +export const SwapSettingsPanel = lazy(() => import('pages/Swap/components/SwapSettingsPanel')) +export const TokenInfo = lazy(() => import('components/TokenInfo')) +export const LiquiditySourcesPanel = lazy(() => import('pages/Swap/components/LiquiditySourcesPanel')) diff --git a/apps/kyberswap-interface/src/pages/Swap/redirects.tsx b/apps/kyberswap-interface/src/pages/Swap/redirects.tsx index 3a48fc8604..e46c6f0de6 100644 --- a/apps/kyberswap-interface/src/pages/Swap/redirects.tsx +++ b/apps/kyberswap-interface/src/pages/Swap/redirects.tsx @@ -3,7 +3,7 @@ import type { ReactNode } from 'react' import { Navigate, useLocation, useParams } from 'react-router-dom' import { APP_PATHS, ETHER_ADDRESS, ZERO_ADDRESS } from 'constants/index' -import { isSupportLimitOrder } from 'constants/networks' +import { isSupportLimitOrder, isSupportStopLoss } from 'constants/networks' import { NativeCurrencies, STABLE_TOKENS } from 'constants/tokens' import { useActiveWeb3React } from 'hooks' import { type SwapIntent, resolveSwapIntentPair } from 'utils/routes' @@ -17,7 +17,9 @@ export const RedirectPathToTradeNetwork = () => { let redirectTo = '' - if (pathname.startsWith(APP_PATHS.LIMIT) && isSupportLimitOrder(chainId)) { + if (pathname.startsWith(APP_PATHS.STOP_LOSS) && isSupportStopLoss(chainId)) { + redirectTo = APP_PATHS.STOP_LOSS + } else if (pathname.startsWith(APP_PATHS.LIMIT) && isSupportLimitOrder(chainId)) { redirectTo = APP_PATHS.LIMIT } else { redirectTo = APP_PATHS.SWAP diff --git a/apps/kyberswap-interface/src/render.smoke.test.tsx b/apps/kyberswap-interface/src/render.smoke.test.tsx index 937e818df6..c1b6a1e17e 100644 --- a/apps/kyberswap-interface/src/render.smoke.test.tsx +++ b/apps/kyberswap-interface/src/render.smoke.test.tsx @@ -1,10 +1,11 @@ import { describe, expect, it } from 'vitest' -// Representative route trees must complete the same async static render used by distinct pages and the two +// Representative route trees must complete the same async static render used by distinct pages and the // shared product shells. This waits for React.lazy route chunks and validates real content, not only fallback UI. const ROUTES = [ - // Limit Orders - per chain + // Limit Orders and Stop Loss - per chain '/limit/base', + '/stop-loss/base', // Cross-chain and Earn '/cross-chain', '/earn', @@ -45,7 +46,7 @@ describe('SSR render smoke', () => { expect(lineaHtml).not.toMatch(/]*aria-current="page")(?=[^>]*href="\/swap\/ethereum")[^>]*>/) }) - it('prerenders only distinct pages and two shared product shells', async () => { + it('prerenders only distinct pages and the shared product shells', async () => { const { prerenderManifest } = await import('entry-server') expect(prerenderManifest.rootPage).toEqual({ @@ -56,11 +57,13 @@ describe('SSR render smoke', () => { expect(prerenderManifest.tradeShells).toEqual([ { outputPath: 'swap/index.html', product: 'swap', sourceRoute: '/swap/ethereum' }, { outputPath: 'limit/index.html', product: 'limit', sourceRoute: '/limit/ethereum' }, + { outputPath: 'stop-loss/index.html', product: 'stop-loss', sourceRoute: '/stop-loss/ethereum' }, ]) expect(prerenderManifest.distinctPages).toHaveLength(11) expect( prerenderManifest.distinctPages.every( - ({ pathname }) => !pathname.startsWith('/swap/') && !pathname.startsWith('/limit/'), + ({ pathname }) => + !pathname.startsWith('/swap/') && !pathname.startsWith('/limit/') && !pathname.startsWith('/stop-loss/'), ), ).toBe(true) }) diff --git a/apps/kyberswap-interface/src/services/smartExit.ts b/apps/kyberswap-interface/src/services/smartExit.ts index 0c0a24554c..25e803e4d2 100644 --- a/apps/kyberswap-interface/src/services/smartExit.ts +++ b/apps/kyberswap-interface/src/services/smartExit.ts @@ -1,7 +1,7 @@ import { ChainId } from '@kyberswap/ks-sdk-core' import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react' -import { SMART_EXIT_API_URL } from 'constants/env' +import { CONDITIONAL_SERVICE_URL } from 'constants/env' import { RTK_QUERY_TAGS } from 'constants/index' import { SmartExitCondition, SmartExitFee, SmartExitOrder } from 'pages/Earns/types' @@ -47,7 +47,7 @@ export interface SmartExitConfig { const smartExitApi = createApi({ reducerPath: 'smartExitApi', - baseQuery: fetchBaseQuery({ baseUrl: SMART_EXIT_API_URL }), + baseQuery: fetchBaseQuery({ baseUrl: CONDITIONAL_SERVICE_URL }), tagTypes: [RTK_QUERY_TAGS.GET_SMART_EXIT_ORDERS], endpoints: builder => ({ getSmartExitConfig: builder.query({ diff --git a/apps/kyberswap-interface/src/services/stopLoss.ts b/apps/kyberswap-interface/src/services/stopLoss.ts new file mode 100644 index 0000000000..666b710f7f --- /dev/null +++ b/apps/kyberswap-interface/src/services/stopLoss.ts @@ -0,0 +1,168 @@ +import { ChainId } from '@kyberswap/ks-sdk-core' +import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react' + +import { + StopLossConfig, + StopLossCorePayload, + StopLossFee, + StopLossOraclePrice, + StopLossOrder, + StopLossOrderStatus, + StopLossSupportedToken, + StopLossTypedData, +} from 'components/StopLoss/types' +import { parseStopLossOrders } from 'components/StopLoss/utils' +import { CONDITIONAL_SERVICE_URL } from 'constants/env' +import { RTK_QUERY_TAGS } from 'constants/index' + +// Every response is wrapped in { code, message, data }; a non-zero code carries `errorEntities` +// naming the offending fields. +type ApiEnvelope = { code: number; message: string; data: T } + +type SupportedTokensResponse = { chainId: number; tokens: StopLossSupportedToken[] } +type ListOrdersResponse = { orders: unknown[]; pagination?: { totalItems?: number } } +type PublicConfigResponse = { config?: { smartIntentAddress?: string } } + +export type StopLossListParams = { + userWallet: string + chainIds?: ChainId[] + status?: StopLossOrderStatus + tokenIns?: string[] + tokenOuts?: string[] + page?: number + pageSize?: number +} + +export type StopLossListResult = { orders: StopLossOrder[]; totalItems: number } + +export type StopLossCancelParams = { chainId: number; userWallet: string; orderId: number } + +export type StopLossBatchCancelParams = { chainId: number; userWallet: string; orderIds: number[] } + +export type StopLossBatchCancelResult = { + orderId: number + success: boolean + errorCode?: string + errorMessage?: string +} + +export type StopLossOraclePriceParams = { chainId: ChainId; base: string; quote: string } + +const ORDERS_PATH = '/v1/orders/stop-loss' + +const stopLossApi = createApi({ + reducerPath: 'stopLossApi', + baseQuery: fetchBaseQuery({ baseUrl: CONDITIONAL_SERVICE_URL }), + tagTypes: [RTK_QUERY_TAGS.GET_STOP_LOSS_ORDER_LIST], + endpoints: builder => ({ + // The address tokenIn is approved to, and the EIP-712 verifying contract. Shared with Smart Exit. + getStopLossConfig: builder.query({ + query: chainId => ({ url: '/v1/configs/public', params: { chainId } }), + transformResponse: (response: ApiEnvelope) => ({ + smartIntentAddress: response?.data?.config?.smartIntentAddress ?? '', + }), + }), + + // Tokens with an oracle feed on this chain. Addresses only — symbols come from the app token list. + getStopLossSupportedTokens: builder.query({ + query: chainId => ({ url: `${ORDERS_PATH}/supported-tokens`, params: { chainId } }), + transformResponse: (response: ApiEnvelope) => response?.data?.tokens ?? [], + }), + + getStopLossOrders: builder.query({ + query: ({ userWallet, chainIds, status, tokenIns, tokenOuts, page = 1, pageSize = 10 }) => { + const params = new URLSearchParams({ + userWallet, + page: String(page), + pageSize: String(pageSize), + }) + if (status) params.append('status', status) + chainIds?.forEach(chainId => params.append('chainIds', String(chainId))) + tokenIns?.forEach(token => params.append('tokenIns', token)) + tokenOuts?.forEach(token => params.append('tokenOuts', token)) + + // A trailing slash before the query string redirects, so the path stays bare. + return { url: `${ORDERS_PATH}?${params.toString()}` } + }, + transformResponse: (response: ApiEnvelope) => { + // Rendered in the order the service returns them: it rejects every `sorts` value, and the + // table offers no column sorting to reconcile with. + const orders = parseStopLossOrders(response?.data?.orders) + return { orders, totalItems: response?.data?.pagination?.totalItems ?? orders.length } + }, + providesTags: [RTK_QUERY_TAGS.GET_STOP_LOSS_ORDER_LIST], + }), + + // Must run before sign-message: the BE doc requires each maxFeesPercentage entry to be at least + // the live protocol fee, and the cap is signed into the intent. + estimateStopLossFee: builder.mutation({ + query: body => ({ url: `${ORDERS_PATH}/estimate-fee`, method: 'POST', body }), + transformResponse: (response: ApiEnvelope) => response?.data, + }), + + getStopLossSignMessage: builder.mutation({ + query: body => ({ url: `${ORDERS_PATH}/sign-message`, method: 'POST', body }), + transformResponse: (response: ApiEnvelope) => response?.data, + }), + + createStopLossOrder: builder.mutation({ + query: body => ({ url: ORDERS_PATH, method: 'POST', body }), + transformResponse: (response: ApiEnvelope) => response?.data, + invalidatesTags: [RTK_QUERY_TAGS.GET_STOP_LOSS_ORDER_LIST], + }), + + // Cancelling is user-signed too, one order per signature — the service takes a single orderId. + getStopLossCancelSignMessage: builder.mutation({ + query: body => ({ url: `${ORDERS_PATH}/cancel/sign-message`, method: 'POST', body }), + transformResponse: (response: ApiEnvelope) => response?.data, + }), + + cancelStopLossOrder: builder.mutation({ + query: body => ({ url: `${ORDERS_PATH}/cancel`, method: 'POST', body }), + invalidatesTags: [RTK_QUERY_TAGS.GET_STOP_LOSS_ORDER_LIST], + }), + + // Up to 100 orders under one signature, all on the same chain and order type. + getStopLossBatchCancelSignMessage: builder.mutation({ + query: body => ({ url: `${ORDERS_PATH}/cancel/batch/sign-message`, method: 'POST', body }), + transformResponse: (response: ApiEnvelope) => response?.data, + }), + + batchCancelStopLossOrders: builder.mutation< + StopLossBatchCancelResult[], + StopLossBatchCancelParams & { signature: string } + >({ + query: body => ({ url: `${ORDERS_PATH}/cancel/batch`, method: 'POST', body }), + // A verified signature returns code 0 even when individual ids fail, so the per-order results + // are the only place a partial failure shows up. + transformResponse: (response: ApiEnvelope<{ results?: StopLossBatchCancelResult[] }>) => + response?.data?.results ?? [], + invalidatesTags: [RTK_QUERY_TAGS.GET_STOP_LOSS_ORDER_LIST], + }), + + // The exact cross-rate the trigger is evaluated against, so the form shows what actually fires. + getStopLossOraclePrice: builder.query({ + query: ({ chainId, base, quote }) => ({ + url: `${ORDERS_PATH}/oracle-price`, + params: { chainId, base, quote }, + }), + transformResponse: (response: ApiEnvelope) => response?.data, + }), + }), +}) + +export const { + useGetStopLossConfigQuery, + useGetStopLossSupportedTokensQuery, + useGetStopLossOrdersQuery, + useGetStopLossOraclePriceQuery, + useEstimateStopLossFeeMutation, + useGetStopLossSignMessageMutation, + useCreateStopLossOrderMutation, + useGetStopLossCancelSignMessageMutation, + useCancelStopLossOrderMutation, + useGetStopLossBatchCancelSignMessageMutation, + useBatchCancelStopLossOrdersMutation, +} = stopLossApi + +export default stopLossApi diff --git a/apps/kyberswap-interface/src/state/index.ts b/apps/kyberswap-interface/src/state/index.ts index 53aef0117b..661ed3faf3 100644 --- a/apps/kyberswap-interface/src/state/index.ts +++ b/apps/kyberswap-interface/src/state/index.ts @@ -27,6 +27,7 @@ import rewardMerklApi from 'services/rewardMerkl' import routeApi from 'services/route' import smartExitApi from 'services/smartExit' import socialApi from 'services/social' +import stopLossApi from 'services/stopLoss' import tipLinkApi from 'services/tipLink' import tokenApi from 'services/token' import tokenCatalogApi from 'services/tokenCatalog' @@ -46,6 +47,7 @@ import mint from 'state/mint/reducer' import pair from 'state/pair/reducer' import pools from 'state/pools/reducer' import profile from 'state/profile/reducer' +import stopLoss from 'state/stopLoss/reducer' import swap from 'state/swap/reducer' import tokenPrices from 'state/tokenPrices' import topTokens from 'state/topTokens' @@ -87,6 +89,7 @@ const rootReducer = combineReducers({ transactions, crossChainSwap, swap, + stopLoss, mint, mintV2, burn, @@ -102,6 +105,7 @@ const rootReducer = combineReducers({ [coingeckoApi.reducerPath]: coingeckoApi.reducer, [contractQuery.reducerPath]: contractQuery.reducer, [limitOrderApi.reducerPath]: limitOrderApi.reducer, + [stopLossApi.reducerPath]: stopLossApi.reducer, [externalApi.reducerPath]: externalApi.reducer, [kyberDAO.reducerPath]: kyberDAO.reducer, @@ -142,6 +146,7 @@ const apiMiddlewares: Middleware[] = [ externalApi, contractQuery, limitOrderApi, + stopLossApi, aggregatorStatsApi, announcementApi, publicAnnouncementApi, diff --git a/apps/kyberswap-interface/src/state/lists/updater.ts b/apps/kyberswap-interface/src/state/lists/updater.ts index ab91de94f1..cf37a4983d 100644 --- a/apps/kyberswap-interface/src/state/lists/updater.ts +++ b/apps/kyberswap-interface/src/state/lists/updater.ts @@ -19,7 +19,11 @@ const getPathChainId = (pathname: string): ChainId | undefined => { const chainIdFromFirstSegment = getChainIdFromSlug(firstSegment) if (chainIdFromFirstSegment) return chainIdFromFirstSegment - if ([APP_PATHS.SWAP, APP_PATHS.LIMIT, LEGACY_POOL_APP_PATHS.MY_POOLS].some(path => firstSegment === path.slice(1))) { + if ( + [APP_PATHS.SWAP, APP_PATHS.LIMIT, APP_PATHS.STOP_LOSS, LEGACY_POOL_APP_PATHS.MY_POOLS].some( + path => firstSegment === path.slice(1), + ) + ) { return getChainIdFromSlug(secondSegment) } diff --git a/apps/kyberswap-interface/src/state/stopLoss/reducer.ts b/apps/kyberswap-interface/src/state/stopLoss/reducer.ts new file mode 100644 index 0000000000..0d1eb61943 --- /dev/null +++ b/apps/kyberswap-interface/src/state/stopLoss/reducer.ts @@ -0,0 +1,41 @@ +import { type PayloadAction, createSlice } from '@reduxjs/toolkit' + +import { DEFAULT_STOP_LOSS_SLIPPAGE, STOP_LOSS_DEFAULT_EXPIRE } from 'components/StopLoss/constants' + +/** + * The stop-loss card's own inputs. The pair and the sell amount are not here — those live in the swap + * state, which is what carries them between Swap, Limit and Stop Loss. + * + * Held in the store rather than the form because the card unmounts on every product switch: the + * right-panel tabs move between `/limit` and `/stop-loss`, so a component-local draft is destroyed by + * something the UI presents as a tab. It is also how Recreate hands a past order back to the form. + */ +export type StopLossFormState = { + triggerPrice: string + /** Basis points. */ + slippage: number + /** Seconds from now, used whenever `customDateExpire` is unset. */ + expire: number + /** Unix ms. A timestamp rather than a Date so the store stays serialisable. */ + customDateExpire: number | undefined +} + +export const DEFAULT_STOP_LOSS_FORM_STATE: StopLossFormState = { + triggerPrice: '', + slippage: DEFAULT_STOP_LOSS_SLIPPAGE, + expire: STOP_LOSS_DEFAULT_EXPIRE, + customDateExpire: undefined, +} + +const stopLossSlice = createSlice({ + name: 'stopLoss', + initialState: DEFAULT_STOP_LOSS_FORM_STATE, + reducers: { + updateStopLossForm: (state, { payload }: PayloadAction>) => ({ ...state, ...payload }), + resetStopLossForm: () => DEFAULT_STOP_LOSS_FORM_STATE, + }, +}) + +export const { updateStopLossForm, resetStopLossForm } = stopLossSlice.actions + +export default stopLossSlice.reducer diff --git a/apps/kyberswap-interface/src/state/swap/hooks.ts b/apps/kyberswap-interface/src/state/swap/hooks.ts index 778c0fabdc..9b66bea9c3 100644 --- a/apps/kyberswap-interface/src/state/swap/hooks.ts +++ b/apps/kyberswap-interface/src/state/swap/hooks.ts @@ -18,7 +18,7 @@ import { useDegenModeManager } from 'state/user/hooks' import { isAddress } from 'utils/address' import { Aggregator } from 'utils/aggregator' import { parseFraction } from 'utils/numbers' -import { getSwapIntentFromPath, resolveSwapIntentPair } from 'utils/routes' +import { getSwapIntentFromPath, getTradeProductPath, resolveSwapIntentPair } from 'utils/routes' interface ParsedUrlQuery { [key: string]: string | string[] @@ -72,7 +72,7 @@ export function useSwapActionHandlers(): { const currentSearchParams = new URLSearchParams(window.location.search) const searchString = currentSearchParams.toString() - const newPath = `/${window.location.pathname.startsWith('/limit') ? 'limit' : 'swap'}/${ + const newPath = `${getTradeProductPath(window.location.pathname)}/${ NETWORKS_INFO[chainId].route }/${encodeURIComponent(f)}-to-${encodeURIComponent(to)}` @@ -100,7 +100,7 @@ export function useSwapActionHandlers(): { const onSwitchTokens = useCallback(() => { navigate( - `/${window.location.pathname.startsWith('/limit') ? 'limit' : 'swap'}/${ + `${getTradeProductPath(window.location.pathname)}/${ NETWORKS_INFO[chainId].route }/${toCurrency}-to-${fromCurrency}`, ) @@ -108,7 +108,7 @@ export function useSwapActionHandlers(): { const onSwitchTokensV2 = useCallback(() => { navigate( - `/${window.location.pathname.startsWith('/limit') ? 'limit' : 'swap'}/${ + `${getTradeProductPath(window.location.pathname)}/${ NETWORKS_INFO[chainId].route }/${toCurrency}-to-${fromCurrency}`, ) diff --git a/apps/kyberswap-interface/src/utils/routes.test.ts b/apps/kyberswap-interface/src/utils/routes.test.ts index 39108bd178..41f0a7b4e4 100644 --- a/apps/kyberswap-interface/src/utils/routes.test.ts +++ b/apps/kyberswap-interface/src/utils/routes.test.ts @@ -7,6 +7,7 @@ import { SwapIntent, getSwapIntentFromPath, getSyncedNetworkPathname, + getTradeProductPath, isPathOrChild, isSwapLikePath, resolveSwapIntentPair, @@ -69,12 +70,27 @@ describe('getSwapIntentFromPath', () => { }) }) +describe('getTradeProductPath', () => { + it.each([ + ['/limit', '/limit'], + ['/limit/base/eth-to-usdc', '/limit'], + ['/stop-loss', '/stop-loss'], + ['/stop-loss/base/eth-to-usdc', '/stop-loss'], + ['/swap/base/eth-to-usdc', '/swap'], + ['/cross-chain', '/swap'], + ['/limited-partners', '/swap'], + ] as const)('maps %s to %s', (pathname, expected) => { + expect(getTradeProductPath(pathname)).toBe(expected) + }) +}) + describe('getSyncedNetworkPathname', () => { it.each([ ['/swap/ethereum/eth-to-usdc', '/swap/base'], ['/buy/ethereum/wbtc', '/swap/base'], ['/sell/ethereum/wbtc', '/swap/base'], ['/limit/ethereum/eth-to-usdc', '/limit/base'], + ['/stop-loss/ethereum/eth-to-usdc', '/stop-loss/base'], ['/pools/ethereum', '/pools/base'], ] as const)('syncs %s to %s', (pathname, expected) => { expect(getSyncedNetworkPathname(pathname, 'ethereum', 'base')).toBe(expected) diff --git a/apps/kyberswap-interface/src/utils/routes.ts b/apps/kyberswap-interface/src/utils/routes.ts index 1f4a77ae01..59120446f8 100644 --- a/apps/kyberswap-interface/src/utils/routes.ts +++ b/apps/kyberswap-interface/src/utils/routes.ts @@ -6,6 +6,13 @@ const SWAP_LIKE_PATHS = [APP_PATHS.SWAP, APP_PATHS.BUY, APP_PATHS.SELL] export const isSwapLikePath = (pathname: string) => SWAP_LIKE_PATHS.some(path => isPathOrChild(pathname, path)) +// Trade products addressed as `{product}/{network}/{tokenIn}-to-{tokenOut}`. Swap owns every other trade path. +const TRADE_PRODUCT_PATHS = [APP_PATHS.LIMIT, APP_PATHS.STOP_LOSS] as const + +/** Resolves which trade product a pathname belongs to, so token selection keeps the user on that product. */ +export const getTradeProductPath = (pathname: string) => + TRADE_PRODUCT_PATHS.find(path => isPathOrChild(pathname, path)) ?? APP_PATHS.SWAP + export enum SwapIntent { BUY = 'buy', SELL = 'sell', @@ -26,8 +33,11 @@ export const getSyncedNetworkPathname = (pathname: string, networkParam: string, return `${APP_PATHS.SWAP}/${networkRoute}` } - if (syncedPathname.startsWith(`${APP_PATHS.LIMIT}/${networkRoute}/`)) { - return `${APP_PATHS.LIMIT}/${networkRoute}` + const selfCanonicalProductPath = TRADE_PRODUCT_PATHS.find(path => + syncedPathname.startsWith(`${path}/${networkRoute}/`), + ) + if (selfCanonicalProductPath) { + return `${selfCanonicalProductPath}/${networkRoute}` } return syncedPathname