+
{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 ? (
+
+ Market
+
+ ) : 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 => (
+
onChangeTriggerPercent(String(percent))}
+ >
+ {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
+}) => (
+
+ onChange(true)}
+ className={cn(
+ 'cursor-pointer border-0 border-r border-darkBorder bg-transparent pr-3',
+ isActiveTab ? 'text-primary' : 'text-subText hover:text-text',
+ )}
+ >
+ Active Orders
+
+ onChange(false)}
+ className={cn(
+ 'cursor-pointer border-0 bg-transparent pl-3',
+ !isActiveTab ? 'text-primary' : 'text-subText hover:text-text',
+ )}
+ >
+
+ Order History
+
+
+ History
+
+
+
+)
+
+/**
+ * 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 (
+
+ {label}
+
+
+ )
+}
+
+/** 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) => (
+ {
+ // `tab` names a panel of the product being left, so it must not ride along.
+ const nextSearch = new URLSearchParams(search)
+ nextSearch.delete('tab')
+ // The destination page is code-split, and the app's only Suspense boundary wraps the whole
+ // body — so without a transition React swaps the entire page for the route skeleton while
+ // the chunk loads. A transition keeps the current card on screen until the next one is ready.
+ startTransition(() => {
+ navigate({
+ pathname: `${path}/${network || networkInfo.route}${currency ? `/${currency}` : ''}`,
+ search: nextSearch.toString(),
+ })
+ })
+ }}
+ className={cn(
+ // The CSS reset forces text-transform:none on buttons, so uppercase belongs here, not on the row.
+ 'cursor-pointer border-0 bg-transparent px-3 py-1 uppercase first:pl-0 hover:text-text',
+ index < TABS.length - 1 && 'border-r border-darkBorder',
+ path === activePath ? 'text-primary' : 'text-subText',
+ )}
+ >
+ {label}
+
+ ))}
+
+ )
+}
+
+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 (
-