diff --git a/packages/widget-v2/src/components/AssetChainInput.tsx b/packages/widget-v2/src/components/AssetChainInput.tsx index f667845a..9aafc86d 100644 --- a/packages/widget-v2/src/components/AssetChainInput.tsx +++ b/packages/widget-v2/src/components/AssetChainInput.tsx @@ -23,6 +23,7 @@ export type AssetChainInputProps = { selectedAsset?: AssetAtom; priceChangePercentage?: number; isWaitingToUpdateInputValue?: boolean; + badPriceWarning?: boolean; }; export const AssetChainInput = ({ @@ -33,10 +34,13 @@ export const AssetChainInput = ({ handleChangeChain, priceChangePercentage, isWaitingToUpdateInputValue, + badPriceWarning, }: AssetChainInputProps) => { const theme = useTheme(); - const [showPriceChangePercentage, setShowPriceChangePercentage] = + const [_showPriceChangePercentage, setShowPriceChangePercentage] = useState(false); + + const showPriceChangePercentage = _showPriceChangePercentage || badPriceWarning; const assetDetails = useGetAssetDetails({ assetDenom: selectedAsset?.denom, amount: value, @@ -170,6 +174,7 @@ export const AssetChainInput = ({ {priceChangePercentage ? ( setShowPriceChangePercentage(true)} onMouseLeave={() => setShowPriceChangePercentage(false)} > @@ -180,14 +185,16 @@ export const AssetChainInput = ({ direction={(priceChangePercentage ?? 0) > 0 ? "up" : "down"} style={{ scale: showPriceChangePercentage ? "1" : "0.7" }} /> - {showPriceChangePercentage && ( + {(showPriceChangePercentage) && ( {priceChangePercentage}% )} ) : ( - {assetDetails.formattedUsdAmount ?? 0} + + {assetDetails.formattedUsdAmount ?? 0} + )} {assetDetails?.chainName ? ( props.theme.primary.background.normal}; `; -const StyledInput = styled.input <{ isWaitingToUpdateInputValue?: boolean }>` +const StyledInput = styled.input<{ isWaitingToUpdateInputValue?: boolean }>` all: unset; font-size: 38px; font-weight: 300; @@ -221,7 +228,9 @@ const StyledInput = styled.input <{ isWaitingToUpdateInputValue?: boolean }>` color: ${(props) => props.theme.primary.text.normal}; background-color: ${(props) => props.theme.primary.background.normal}; - ${(props) => props.isWaitingToUpdateInputValue && "animation: pulse 2s cubic-bezier(.4,0,.6,1) infinite;"} + ${(props) => + props.isWaitingToUpdateInputValue && + "animation: pulse 2s cubic-bezier(.4,0,.6,1) infinite;"} @keyframes pulse { 0% { opacity: 0.5; diff --git a/packages/widget-v2/src/components/Modal.tsx b/packages/widget-v2/src/components/Modal.tsx index aa41e0b9..cf048552 100644 --- a/packages/widget-v2/src/components/Modal.tsx +++ b/packages/widget-v2/src/components/Modal.tsx @@ -9,6 +9,7 @@ import { ErrorBoundary } from "react-error-boundary"; import { useAtom } from "jotai"; import { errorAtom, ErrorType } from "@/state/errorPage"; import { numberOfModalsOpenAtom } from "@/state/modal"; +import { themeAtom } from "@/state/skipClient"; export type ModalProps = { children: React.ReactNode; @@ -73,6 +74,7 @@ export const useModal = ( modal?: FC, initialArgs?: Partial ) => { + const [theme] = useAtom(themeAtom); const [numberOfModalsOpen, setNumberOfModalsOpen] = useAtom( numberOfModalsOpenAtom ); @@ -86,6 +88,7 @@ export const useModal = ( modalInstance.show({ stackedModal: numberOfModalsOpen > 0, ...showArgs, + theme, } as Partial); setNumberOfModalsOpen((prev) => prev + 1); }, @@ -98,7 +101,7 @@ export const useModal = ( modalInstance.hide(); }, }), - [modalInstance, setNumberOfModalsOpen, numberOfModalsOpen] + [modalInstance, theme, numberOfModalsOpen, setNumberOfModalsOpen] ); }; diff --git a/packages/widget-v2/src/components/Typography.tsx b/packages/widget-v2/src/components/Typography.tsx index ca572a00..7274b197 100644 --- a/packages/widget-v2/src/components/Typography.tsx +++ b/packages/widget-v2/src/components/Typography.tsx @@ -39,6 +39,7 @@ export const SmallText = styled.p` color: ${({ theme }) => theme.primary.text.lowContrast}; margin: 0; font-size: 13px; + line-height: 13px; ${textProps} `; @@ -50,6 +51,7 @@ export const SmallTextButton = styled(SmallText).attrs({ as: "button" })` export const Text = styled(SmallText)` color: ${(props) => props.theme.primary.text.normal}; font-size: 20px; + line-height: 20px; font-weight: 500; ${textProps} `; diff --git a/packages/widget-v2/src/constants/skipClientDefault.ts b/packages/widget-v2/src/constants/skipClientDefault.ts index ff138533..1cb7d784 100644 --- a/packages/widget-v2/src/constants/skipClientDefault.ts +++ b/packages/widget-v2/src/constants/skipClientDefault.ts @@ -9,4 +9,6 @@ export const endpointOptions = { }, }; -export const apiURL = `${appUrl}/api/widget/skip`; +export const devApiUrl = "https://api.dev.skip.build"; + +export const prodApiUrl = `${appUrl}/api/widget/skip`; diff --git a/packages/widget-v2/src/icons/QuestionMarkIcon.tsx b/packages/widget-v2/src/icons/QuestionMarkIcon.tsx new file mode 100644 index 00000000..28bce6d0 --- /dev/null +++ b/packages/widget-v2/src/icons/QuestionMarkIcon.tsx @@ -0,0 +1,21 @@ +type IconProps = { + color?: string; +}; + +export const QuestionMarkIcon = ({ color = "currentColor", ...props }: IconProps & React.SVGProps) => ( + + + +); diff --git a/packages/widget-v2/src/pages/SwapExecutionPage/SwapExecutionPageRouteDetailed.tsx b/packages/widget-v2/src/pages/SwapExecutionPage/SwapExecutionPageRouteDetailed.tsx index 8af0ee36..20b8c67a 100644 --- a/packages/widget-v2/src/pages/SwapExecutionPage/SwapExecutionPageRouteDetailed.tsx +++ b/packages/widget-v2/src/pages/SwapExecutionPage/SwapExecutionPageRouteDetailed.tsx @@ -114,7 +114,7 @@ export const SwapExecutionPageRouteDetailed = ({ return ( <> - + handleMouseEnterOperationType(index)} onMouseLeave={() => handleMouseLeaveOperationType(index)} @@ -129,7 +129,7 @@ export const SwapExecutionPageRouteDetailed = ({ )} - + theme.primary.text.ultraLowContrast}; background-color: ${({ theme }) => theme.secondary.background.normal}; box-sizing: border-box; + z-index: 1; `; const OperationTypeIconContainer = styled(Column).attrs({ @@ -182,3 +185,8 @@ const StyledSwapVenueOrBridgeImage = styled.img` width: 10px; height: 10px; `; + +const StyledOperationTypeAndTooltipContainer = styled(Row)` + position: relative; + height: 25px; +`; \ No newline at end of file diff --git a/packages/widget-v2/src/pages/SwapPage/SwapDetailModal.tsx b/packages/widget-v2/src/pages/SwapPage/SwapDetailModal.tsx index cf1f72a4..cc39dbe6 100644 --- a/packages/widget-v2/src/pages/SwapPage/SwapDetailModal.tsx +++ b/packages/widget-v2/src/pages/SwapPage/SwapDetailModal.tsx @@ -1,62 +1,84 @@ -import { css, styled } from "styled-components"; +import { css, styled, useTheme } from "styled-components"; import { createModal, ModalProps } from "@/components/Modal"; -import { Column, Row } from "@/components/Layout"; +import { Column, Row, Spacer } from "@/components/Layout"; import { SmallText } from "@/components/Typography"; import { RouteArrow } from "@/icons/RouteArrow"; import { SwapPageFooterItems } from "./SwapPageFooter"; import { useAtom, useAtomValue } from "jotai"; import { skipChainsAtom, skipRouteAtom } from "@/state/skipClient"; -import { useMemo } from "react"; +import { useMemo, useState } from "react"; import { swapSettingsAtom } from "@/state/swapPage"; import { formatUSD } from "@/utils/intl"; import { SLIPPAGE_OPTIONS } from "@/constants/widget"; import { getClientOperations, OperationType } from "@/utils/clientType"; import { convertTokenAmountToHumanReadableAmount } from "@/utils/crypto"; +import { getBrandButtonTextColor } from "@/utils/colors"; +import { QuestionMarkIcon } from "@/icons/QuestionMarkIcon"; export const SwapDetailModal = createModal((modalProps: ModalProps) => { + const theme = useTheme(); const { data: route } = useAtomValue(skipRouteAtom); const { data: chains } = useAtomValue(skipChainsAtom); const [swapSettings, setSwapSettings] = useAtom(swapSettingsAtom); + const [showMaxSlippageTooltip, setShowMaxSlippageTooltip] = useState(false); const chainsRoute = useMemo(() => { - return route?.chainIDs.map((chainID) => chains?.find((chain) => chain.chainID === chainID)); + return route?.chainIDs.map((chainID) => + chains?.find((chain) => chain.chainID === chainID) + ); }, [route, chains]); const clientOperations = route && getClientOperations(route.operations); + const usesEvmInOperations = useMemo(() => { + return clientOperations?.find(operation => operation.toChainID === "1"); + }, [clientOperations]); + const axelarTransferOperation = useMemo(() => { if (!clientOperations) return; - return clientOperations?.find((item) => item.type === OperationType.axelarTransfer); + return clientOperations?.find( + (item) => item.type === OperationType.axelarTransfer + ); }, [clientOperations]); const hyperlaneTransferOperation = useMemo(() => { if (!clientOperations) return; - return clientOperations?.find((item) => item.type === OperationType.hyperlaneTransfer); + return clientOperations?.find( + (item) => item.type === OperationType.hyperlaneTransfer + ); }, [clientOperations]); const axelarFee = useMemo(() => { if (axelarTransferOperation) { - const { feeAmount, feeAsset, usdFeeAmount } = - axelarTransferOperation; + const { feeAmount, feeAsset, usdFeeAmount } = axelarTransferOperation; if (!feeAmount || !feeAsset || !feeAsset.decimals) return; - const computed = convertTokenAmountToHumanReadableAmount(feeAmount, feeAsset.decimals); + const computed = convertTokenAmountToHumanReadableAmount( + feeAmount, + feeAsset.decimals + ); return { assetAmount: Number(computed), formattedAssetAmount: `${computed} ${feeAsset.symbol}`, - formattedUsdAmount: usdFeeAmount ? `${formatUSD(usdFeeAmount)}` : undefined + formattedUsdAmount: usdFeeAmount + ? `${formatUSD(usdFeeAmount)}` + : undefined, }; } }, [axelarTransferOperation]); const hyperlaneFee = useMemo(() => { if (hyperlaneTransferOperation) { - const { feeAmount, feeAsset, usdFeeAmount } = - hyperlaneTransferOperation; + const { feeAmount, feeAsset, usdFeeAmount } = hyperlaneTransferOperation; if (!feeAmount || !feeAsset || !feeAsset.decimals) return; - const computed = convertTokenAmountToHumanReadableAmount(feeAmount, feeAsset.decimals); + const computed = convertTokenAmountToHumanReadableAmount( + feeAmount, + feeAsset.decimals + ); return { assetAmount: Number(computed), formattedAssetAmount: `${computed} ${feeAsset.symbol}`, - formattedUsdAmount: usdFeeAmount ? `${formatUSD(usdFeeAmount)}` : undefined + formattedUsdAmount: usdFeeAmount + ? `${formatUSD(usdFeeAmount)}` + : undefined, }; } }, [hyperlaneTransferOperation]); @@ -92,7 +114,7 @@ export const SwapDetailModal = createModal((modalProps: ModalProps) => { return { assetAmount: Number(inAsset), formattedAssetAmount: `${inAsset} ${fee[0].originAsset.symbol}`, - formattedUsdAmount: `${formatUSD(computedUsd)}` + formattedUsdAmount: `${formatUSD(computedUsd)}`, }; }, [isSmartRelay, route?.estimatedFees]); @@ -104,11 +126,7 @@ export const SwapDetailModal = createModal((modalProps: ModalProps) => { {chainsRoute?.map((chain, index) => ( <> - + {index !== chainsRoute.length - 1 && ( )} @@ -120,12 +138,27 @@ export const SwapDetailModal = createModal((modalProps: ModalProps) => { Price Impact - {route?.swapPriceImpactPercent}% + + {route?.swapPriceImpactPercent}% + )} - Max Slippage + + Max Slippage + + setShowMaxSlippageTooltip(true)} + onMouseLeave={() => setShowMaxSlippageTooltip(false)} + /> + {showMaxSlippageTooltip && ( + + If price changes unfavorably during the transaction by more than + this amount, the transaction will revert + + )} + {SLIPPAGE_OPTIONS.map((val) => ( { + {(axelarFee || hyperlaneFee || smartRelayFee) && ( + + {axelarFee && ( + + Axelar Bridging Fee + + {axelarFee.formattedAssetAmount} ({axelarFee.formattedUsdAmount} + ) + + + )} + {hyperlaneFee && ( + + Hyperlane Bridging Fee + + {hyperlaneFee.formattedAssetAmount} ( + {hyperlaneFee.formattedUsdAmount}) + + + )} + {smartRelayFee && ( + + Relayer Fee + + {smartRelayFee.formattedAssetAmount} ( + {smartRelayFee.formattedUsdAmount}) + + + )} + + )} + { - (axelarFee || hyperlaneFee || smartRelayFee) && ( - - {axelarFee && ( - - Axelar Bridging Fee - {axelarFee.formattedAssetAmount} ({axelarFee.formattedUsdAmount}) - - )} - {hyperlaneFee && ( - - Hyperlane Bridging Fee - {hyperlaneFee.formattedAssetAmount} ({hyperlaneFee.formattedUsdAmount}) - - )} - {smartRelayFee && ( - - Relayer Fee - {smartRelayFee.formattedAssetAmount} ({smartRelayFee.formattedUsdAmount}) - - )} - - ) + usesEvmInOperations && + + This swap contains at least one EVM chain, so it might take longer. +
Read more about common finality times. +
+
} @@ -182,9 +231,12 @@ const StyledSlippageOptionLabel = styled(SmallText) <{ selected?: boolean }>` border-radius: 7px; padding: 4px 7px; white-space: nowrap; - color: ${(props) => props.theme.primary.text.normal}; + color: ${({ selected, theme }) => + selected + ? getBrandButtonTextColor(theme.brandColor) + : theme.primary.text.normal}; &:hover { - box-shadow:inset 0px 0px 0px 1px ${(props) => props.theme.brandColor}; + box-shadow: inset 0px 0px 0px 1px ${(props) => props.theme.brandColor}; opacity: 1; cursor: pointer; } @@ -200,5 +252,26 @@ const SwapDetailText = styled(Row).attrs({ as: SmallText, normalTextColor: true, })` + position: relative; letter-spacing: 0.26px; `; + +const StyledEvmWarningMessage = styled.div` + padding: 12px; + border-radius: 5px; + background-color: ${({ theme }) => theme.warning.background}; +`; + +const Tooltip = styled(SmallText).attrs({ + normalTextColor: true, +})` + position: absolute; + padding: 13px; + border-radius: 13px; + border: 1px solid ${({ theme }) => theme.primary.text.ultraLowContrast}; + background-color: ${({ theme }) => theme.secondary.background.normal}; + top: -30px; + left: 110px; + width: 250px; + z-index: 1; +`; diff --git a/packages/widget-v2/src/pages/SwapPage/SwapPage.tsx b/packages/widget-v2/src/pages/SwapPage/SwapPage.tsx index 8a47dec1..411a5d88 100644 --- a/packages/widget-v2/src/pages/SwapPage/SwapPage.tsx +++ b/packages/widget-v2/src/pages/SwapPage/SwapPage.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { useAtom, useAtomValue, useSetAtom } from "jotai"; import { AssetChainInput } from "@/components/AssetChainInput"; import { Column, Row } from "@/components/Layout"; @@ -9,6 +9,7 @@ import { getChainsContainingAsset, skipChainsAtom, skipRouteAtom, + skipBalancesRequestAtom, } from "@/state/skipClient"; import { sourceAssetAtom, @@ -32,6 +33,11 @@ import { currentPageAtom, Routes } from "@/state/router"; import { GhostButton, GhostButtonProps } from "@/components/Button"; import { ConnectedWalletModal } from "@/modals/ConnectedWalletModal/ConnectedWalletModal"; import styled, { css } from "styled-components"; +import { + useInsufficientSourceBalance, + useSetMaxAmount, +} from "./useSetMaxAmount"; +import { useSourceBalance } from "./useSourceBalance"; export const SwapPage = () => { const [container, setContainer] = useState(); @@ -42,7 +48,6 @@ export const SwapPage = () => { const [isWaitingForNewRoute] = useAtom(isWaitingForNewRouteAtom); const [destinationAsset, setDestinationAsset] = useAtom(destinationAssetAtom); const [swapDirection] = useAtom(swapDirectionAtom); - const setSwapDirection = useSetAtom(swapDirectionAtom); const [{ data: assets }] = useAtom(skipAssetsAtom); const [{ data: chains }] = useAtom(skipChainsAtom); const { @@ -55,9 +60,38 @@ export const SwapPage = () => { const selectWalletmodal = useModal(WalletSelectorModal); const setSwapExecutionState = useSetAtom(swapExecutionStateAtom); const setCurrentPage = useSetAtom(currentPageAtom); + const setSkipBalancesRequest = useSetAtom(skipBalancesRequestAtom); const connectedWalletModal = useModal(ConnectedWalletModal); + const sourceBalance = useSourceBalance(); + const insufficientBalance = useInsufficientSourceBalance(); + + const handleMaxButton = useSetMaxAmount(); const sourceAccount = useAccount(sourceAsset?.chainID); + + useEffect(() => { + if (isWaitingForNewRoute) return; + if (!sourceAsset || !sourceAccount) return; + const { chainID, denom } = sourceAsset; + const { address } = sourceAccount; + if (!denom || !chainID || !address) return; + + setSkipBalancesRequest({ + chains: { + [chainID]: { + address, + denoms: [denom], + }, + }, + }); + }, [ + isWaitingForNewRoute, + setSkipBalancesRequest, + sourceAccount, + sourceAsset, + sourceAsset?.chainID, + ]); + const sourceDetails = useGetAssetDetails({ assetDenom: sourceAsset?.denom, amount: sourceAsset?.amount, @@ -70,6 +104,19 @@ export const SwapPage = () => { chainId: destinationAsset?.chainID, }); + const formattedBalance = useMemo(() => { + if (sourceBalance === undefined) return ""; + + const amount = sourceBalance?.amount; + let formattedBalanceAmount = sourceBalance?.formattedAmount; + + if (amount === "0") { + formattedBalanceAmount = amount; + } + + return `${formattedBalanceAmount} ${sourceDetails?.symbol}`; + }, [sourceBalance, sourceDetails?.symbol]); + const chainsContainingSourceAsset = useMemo(() => { if (!chains || !assets || !sourceAsset?.symbol) return; const result = getChainsContainingAsset( @@ -158,14 +205,19 @@ export const SwapPage = () => { const swapButton = useMemo(() => { if (isWaitingForNewRoute) { - return ; + return ; } if (isRouteError) { - return ; + return ; } if (sourceAccount?.address) { + if (insufficientBalance) { + return ( + + ); + } return ( { }, [ isWaitingForNewRoute, isRouteError, + insufficientBalance, sourceAccount?.address, sourceAsset?.chainID, routeError?.message, @@ -237,27 +290,47 @@ export const SwapPage = () => { }} rightContent={ sourceAccount && ( - - { - connectedWalletModal.show(); - }} style={{ - padding: "8px 13px", - alignItems: "center", - gap: 8 - }}> - {sourceAccount && } - 125 ATOM - - { - connectedWalletModal.show(); - }} style={{ - padding: "8px 13px", - alignItems: "center", - }}> - Max - + + {formattedBalance && ( + <> + { + connectedWalletModal.show(); + }} + style={{ + padding: "8px 13px", + alignItems: "center", + gap: 8, + }} + > + {sourceAccount && ( + + )} + {formattedBalance} + + + + Max + + + )} ) } @@ -271,10 +344,7 @@ export const SwapPage = () => { swapDirection === "swap-out" && isWaitingForNewRoute } value={sourceAsset?.amount} - onChangeValue={(newValue) => { - setSourceAssetAmount(newValue); - setSwapDirection("swap-in"); - }} + onChangeValue={setSourceAssetAmount} /> { } value={destinationAsset?.amount} priceChangePercentage={priceChangePercentage} - onChangeValue={(newValue) => { - setDestinationAssetAmount(newValue); - setSwapDirection("swap-out"); - }} + badPriceWarning={route?.warning?.type === "BAD_PRICE_WARNING"} + onChangeValue={setDestinationAssetAmount} /> {swapButton} swapDetailsModal.show({ drawer: true, @@ -304,7 +373,7 @@ export const SwapPage = () => { }) } /> - +
{ diff --git a/packages/widget-v2/src/pages/SwapPage/SwapPageBridge.tsx b/packages/widget-v2/src/pages/SwapPage/SwapPageBridge.tsx index 8690697d..1e83653f 100644 --- a/packages/widget-v2/src/pages/SwapPage/SwapPageBridge.tsx +++ b/packages/widget-v2/src/pages/SwapPage/SwapPageBridge.tsx @@ -10,7 +10,7 @@ export const SwapPageBridge = () => { const [spin, setSpin] = useState(false); const invertSwap = useSetAtom(invertSwapAtom); const onInvertSwap = () => { - invertSwap("swap-out"); + invertSwap(); let spinTimeout = undefined; clearTimeout(spinTimeout); diff --git a/packages/widget-v2/src/pages/SwapPage/SwapPageFooter.tsx b/packages/widget-v2/src/pages/SwapPage/SwapPageFooter.tsx index 2c4b0092..3561ae36 100644 --- a/packages/widget-v2/src/pages/SwapPage/SwapPageFooter.tsx +++ b/packages/widget-v2/src/pages/SwapPage/SwapPageFooter.tsx @@ -19,17 +19,33 @@ export const SwapPageFooterItems = ({ showRouteInfo, }: SwapPageFooterItemsProps) => { const { data: route, isLoading } = useAtomValue(skipRouteAtom); - const estimatedTime = convertSecondsToMinutesOrHours(route?.estimatedRouteDurationSeconds); + const estimatedTime = convertSecondsToMinutesOrHours( + route?.estimatedRouteDurationSeconds + ); const renderRightContent = useMemo(() => { if (showRouteInfo && route) { return ( - {isLoading ? : <>{route?.txsRequired} tx required} + {isLoading ? ( + + ) : ( + <> + + {route?.txsRequired} tx required + + )} - {isLoading ? : estimatedTime ? <>{estimatedTime} : null} + {isLoading ? ( + + ) : estimatedTime ? ( + <> + + {estimatedTime} + + ) : null} ); @@ -49,18 +65,24 @@ export const SwapPageFooterItems = ({ export const SwapPageFooter = ({ onClick, + rightContent, + showRouteInfo, ...props }: { onClick?: () => void; -} & SwapPageFooterItemsProps) => { +} & SwapPageFooterItemsProps & React.ButtonHTMLAttributes) => { return ( - + ); }; diff --git a/packages/widget-v2/src/pages/SwapPage/useSetMaxAmount.ts b/packages/widget-v2/src/pages/SwapPage/useSetMaxAmount.ts new file mode 100644 index 00000000..059ad2ad --- /dev/null +++ b/packages/widget-v2/src/pages/SwapPage/useSetMaxAmount.ts @@ -0,0 +1,75 @@ + +import { convertHumanReadableAmountToCryptoAmount, convertTokenAmountToHumanReadableAmount } from "@/utils/crypto"; +import { useGetAssetDetails } from "@/hooks/useGetAssetDetails"; +import { sourceAssetAmountAtom, sourceAssetAtom } from "@/state/swapPage"; +import { useAtom, useSetAtom } from "jotai"; +import { skipChainsAtom } from "@/state/skipClient"; +import { useSourceBalance } from "./useSourceBalance"; +import { BigNumber } from "bignumber.js"; + + +const ETH_GAS_FEE = 0.01; +const COSMOS_GAS_FEE = 2_000_000; + +export const useGasFeeTokenAmount = () => { + const [sourceAsset] = useAtom(sourceAssetAtom); + const [{ data: chains }] = useAtom(skipChainsAtom); + + const sourceDetails = useGetAssetDetails({ + assetDenom: sourceAsset?.denom, + amount: sourceAsset?.amount, + chainId: sourceAsset?.chainID, + }); + + const feeAsset = chains?.find(chain => chain.chainID === sourceAsset?.chainID)?.feeAssets?.[0]; + + const chainType = sourceDetails?.chain?.chainType; + + switch (chainType) { + case "evm": + return Number(convertHumanReadableAmountToCryptoAmount(ETH_GAS_FEE, sourceDetails.asset?.decimals)); + case "cosmos": + if (!feeAsset?.gasPrice?.average || feeAsset.denom !== sourceAsset?.denom) return 0; + return Number(feeAsset.gasPrice.average) * (COSMOS_GAS_FEE); + case "svm": + default: + return 0; + } +}; + +export const useMaxAmountTokenMinusFees = () => { + const sourceBalance = useSourceBalance(); + const gasFeeTokenAmount = useGasFeeTokenAmount(); + const maxTokenAmount = sourceBalance?.amount; + + if (gasFeeTokenAmount && maxTokenAmount) { + const maxTokenAmountMinusGasFees = BigNumber(maxTokenAmount).minus(gasFeeTokenAmount).toString(); + const maxAmountMinusGasFees = convertTokenAmountToHumanReadableAmount(maxTokenAmountMinusGasFees); + + return maxAmountMinusGasFees; + } +}; + +export const useSetMaxAmount = () => { + const maxAmountTokenMinusFees = useMaxAmountTokenMinusFees(); + const setSourceAssetAmount = useSetAtom(sourceAssetAmountAtom); + + return () => { + if (maxAmountTokenMinusFees) { + setSourceAssetAmount(maxAmountTokenMinusFees); + } + }; +}; + +export const useInsufficientSourceBalance = () => { + const maxAmountTokenMinusFees = useMaxAmountTokenMinusFees(); + const [sourceAsset] = useAtom(sourceAssetAtom); + + if (!maxAmountTokenMinusFees || !sourceAsset?.amount) return true; + + if (BigNumber(maxAmountTokenMinusFees).isGreaterThanOrEqualTo(BigNumber(sourceAsset?.amount))) { + return false; + } + + return true; +}; \ No newline at end of file diff --git a/packages/widget-v2/src/pages/SwapPage/useSourceBalance.ts b/packages/widget-v2/src/pages/SwapPage/useSourceBalance.ts new file mode 100644 index 00000000..4dbf0886 --- /dev/null +++ b/packages/widget-v2/src/pages/SwapPage/useSourceBalance.ts @@ -0,0 +1,16 @@ +import { useAccount } from "@/hooks/useAccount"; +import { skipBalancesAtom } from "@/state/skipClient"; +import { sourceAssetAtom } from "@/state/swapPage"; +import { useAtom, useAtomValue } from "jotai"; + +export const useSourceBalance = () => { + const [sourceAsset] = useAtom(sourceAssetAtom); + const sourceAccount = useAccount(sourceAsset?.chainID); + const { data: skipBalances } = useAtomValue(skipBalancesAtom); + + if (!sourceAsset || !sourceAccount || !skipBalances) return; + const { chainID, denom } = sourceAsset; + if (!denom || !chainID) return; + + return skipBalances?.chains?.[chainID]?.denoms?.[denom]; +}; diff --git a/packages/widget-v2/src/state/skipClient.ts b/packages/widget-v2/src/state/skipClient.ts index ba0726a0..fdb8651f 100644 --- a/packages/widget-v2/src/state/skipClient.ts +++ b/packages/widget-v2/src/state/skipClient.ts @@ -5,9 +5,10 @@ import { Chain, RouteRequest, SkipClientOptions, + BalanceRequest, } from "@skip-go/client"; import { atomWithQuery } from "jotai-tanstack-query"; -import { apiURL, endpointOptions } from "@/constants/skipClientDefault"; +import { devApiUrl, endpointOptions } from "@/constants/skipClientDefault"; import { debouncedDestinationAssetAmountAtom, debouncedSourceAssetAmountAtom, @@ -17,7 +18,8 @@ import { sourceAssetAtom, swapDirectionAtom, } from "./swapPage"; -import { getAmountWei } from "@/utils/number"; +import { currentPageAtom, Routes } from "./router"; +import { convertHumanReadableAmountToCryptoAmount } from "@/utils/crypto"; import { walletsAtom } from "./wallets"; import { getWallet, WalletType } from "graz"; import { getWalletClient } from "@wagmi/core"; @@ -25,12 +27,15 @@ import { config } from "@/constants/wagmi"; import { WalletClient } from "viem"; import { solanaWallets } from "@/constants/solana"; import { Adapter } from "@solana/wallet-adapter-base"; +import { defaultTheme, Theme } from "@/widget/theme"; export const skipClientConfigAtom = atom({ - apiURL, + apiURL: devApiUrl, endpointOptions, }); +export const themeAtom = atom(defaultTheme); + export const skipClient = atom((get) => { const options = get(skipClientConfigAtom); const wallets = get(walletsAtom); @@ -142,10 +147,29 @@ export const skipSwapVenuesAtom = atomWithQuery((get) => { }; }); +export const skipBalancesRequestAtom = atom(); + +export const skipBalancesAtom = atomWithQuery((get) => { + const skip = get(skipClient); + const params = get(skipBalancesRequestAtom); + + return { + queryKey: ["skipBalances", params], + queryFn: async () => { + if (!params) { + throw new Error("No balance request provided"); + } + + return skip.balances(params); + }, + retry: 1, + }; +}); + type SkipTransactionStatusProps = { txsRequired: number; txs: { chainID: string; txHash: string }[] | undefined; -} +}; export const skipTransactionStatusPropsAtom = atom({ txsRequired: 0, @@ -181,14 +205,12 @@ const skipRouteRequestAtom = atom((get) => { const direction = get(swapDirectionAtom); const sourceAssetAmount = get(debouncedSourceAssetAmountAtom); const destinationAssetAmount = get(debouncedDestinationAssetAmountAtom); - const isInvertingSwap = get(isInvertingSwapAtom); if ( !sourceAsset?.chainID || !sourceAsset.denom || !destinationAsset?.chainID || - !destinationAsset.denom || - isInvertingSwap + !destinationAsset.denom ) { return undefined; } @@ -196,12 +218,11 @@ const skipRouteRequestAtom = atom((get) => { direction === "swap-in" ? { amountIn: - getAmountWei(sourceAssetAmount, sourceAsset.decimals) || "0", + convertHumanReadableAmountToCryptoAmount(sourceAssetAmount ?? "0", sourceAsset.decimals), } : { amountOut: - getAmountWei(destinationAssetAmount, destinationAsset.decimals) || - "0", + convertHumanReadableAmountToCryptoAmount(destinationAssetAmount ?? "0", destinationAsset.decimals), }; return { @@ -216,9 +237,17 @@ const skipRouteRequestAtom = atom((get) => { export const skipRouteAtom = atomWithQuery((get) => { const skip = get(skipClient); const params = get(skipRouteRequestAtom); + const currentPage = get(currentPageAtom); + const isInvertingSwap = get(isInvertingSwapAtom); get(routeAmountEffect); + const queryEnabled = + params !== undefined && + (Number(params.amountIn) > 0 || Number(params.amountOut) > 0) && + !isInvertingSwap && + currentPage === Routes.SwapPage; + return { queryKey: ["skipRoute", params], queryFn: async () => { @@ -238,8 +267,7 @@ export const skipRouteAtom = atomWithQuery((get) => { }); }, retry: 1, - enabled: - !!params && (Number(params.amountIn) > 0 || Number(params.amountOut) > 0), + enabled: queryEnabled, refetchInterval: 1000 * 30, }; }); diff --git a/packages/widget-v2/src/state/swapPage.ts b/packages/widget-v2/src/state/swapPage.ts index a0286d68..2c145f15 100644 --- a/packages/widget-v2/src/state/swapPage.ts +++ b/packages/widget-v2/src/state/swapPage.ts @@ -1,9 +1,9 @@ import { atom } from "jotai"; import { ClientAsset, skipRouteAtom } from "./skipClient"; import { atomEffect } from "jotai-effect"; -import { parseAmountWei } from "@/utils/number"; import { atomWithDebounce } from "@/utils/atomWithDebounce"; import { MinimalWallet } from "./wallets"; +import { convertTokenAmountToHumanReadableAmount } from "@/utils/crypto"; export type AssetAtom = Partial & { amount?: string; @@ -11,15 +11,11 @@ export type AssetAtom = Partial & { const ROUTE_REQUEST_DEBOUNCE_DELAY = 500; -export const { debouncedValueAtom: debouncedSourceAssetAmountAtom } = atomWithDebounce( - undefined, - ROUTE_REQUEST_DEBOUNCE_DELAY, -); +export const { debouncedValueAtom: debouncedSourceAssetAmountAtom } = + atomWithDebounce(undefined, ROUTE_REQUEST_DEBOUNCE_DELAY); -export const { debouncedValueAtom: debouncedDestinationAssetAmountAtom } = atomWithDebounce( - undefined, - ROUTE_REQUEST_DEBOUNCE_DELAY, -); +export const { debouncedValueAtom: debouncedDestinationAssetAmountAtom } = + atomWithDebounce(undefined, ROUTE_REQUEST_DEBOUNCE_DELAY); export const sourceAssetAtom = atom(); @@ -29,51 +25,51 @@ export const sourceAssetAmountAtom = atom( const oldSourceAsset = get(sourceAssetAtom); set(sourceAssetAtom, { ...oldSourceAsset, amount: newAmount }); set(debouncedSourceAssetAmountAtom, newAmount); - }, + set(swapDirectionAtom, "swap-in"); + } ); export const destinationAssetAtom = atom(); -export const isWaitingForNewRouteAtom = atom( - (get) => { - const sourceAmount = get(sourceAssetAmountAtom); - const destinationAmount = get(destinationAssetAmountAtom); - const debouncedSourceAmount = get(debouncedSourceAssetAmountAtom); - const debouncedDestinationAmount = get(debouncedDestinationAssetAmountAtom); - - const { isLoading } = get(skipRouteAtom); - const direction = get(swapDirectionAtom); - - if (direction === "swap-in") { - return (sourceAmount !== debouncedSourceAmount) || isLoading; - } else if (direction === "swap-out") { - return (destinationAmount !== debouncedDestinationAmount) || isLoading; - } - } -); - export const destinationAssetAmountAtom = atom( (get) => get(destinationAssetAtom)?.amount, - (get, set, newAmount: string) => { + (get, set, newAmount: string, callback?: () => void) => { const oldDestinationAsset = get(destinationAssetAtom); set(destinationAssetAtom, { ...oldDestinationAsset, amount: newAmount }); set(debouncedDestinationAssetAmountAtom, newAmount); - }, + set(swapDirectionAtom, "swap-out"); + callback?.(); + } ); +export const isWaitingForNewRouteAtom = atom((get) => { + const sourceAmount = get(sourceAssetAmountAtom); + const destinationAmount = get(destinationAssetAmountAtom); + const debouncedSourceAmount = get(debouncedSourceAssetAmountAtom); + const debouncedDestinationAmount = get(debouncedDestinationAssetAmountAtom); + + const { isLoading } = get(skipRouteAtom); + const direction = get(swapDirectionAtom); + + if (direction === "swap-in") { + return sourceAmount !== debouncedSourceAmount || isLoading; + } else if (direction === "swap-out") { + return destinationAmount !== debouncedDestinationAmount || isLoading; + } +}); + export type SwapDirection = "swap-in" | "swap-out"; export const swapDirectionAtom = atom("swap-in"); export const isInvertingSwapAtom = atom(false); -export const invertSwapAtom = atom(null, (get, set, swapDirection: SwapDirection) => { +export const invertSwapAtom = atom(null, (get, set) => { const sourceAsset = get(sourceAssetAtom); const destinationAsset = get(destinationAssetAtom); + const swapDirection = get(swapDirectionAtom); set(isInvertingSwapAtom, true); - set(swapDirectionAtom, swapDirection); - set(sourceAssetAtom, destinationAsset); if (destinationAsset?.amount) { set(sourceAssetAmountAtom, destinationAsset?.amount); @@ -81,10 +77,13 @@ export const invertSwapAtom = atom(null, (get, set, swapDirection: SwapDirection set(destinationAssetAtom, sourceAsset); if (sourceAsset?.amount) { - set(destinationAssetAmountAtom, sourceAsset?.amount); + set(destinationAssetAmountAtom, sourceAsset?.amount, () => { + const newSwapDirection = + swapDirection === "swap-in" ? "swap-out" : "swap-in"; + set(swapDirectionAtom, newSwapDirection); + set(isInvertingSwapAtom, false); + }); } - - set(isInvertingSwapAtom, false); }); export const connectedWalletAtom = atom(); @@ -99,8 +98,14 @@ export const routeAmountEffect = atomEffect((get, set) => { if (!route.data || !sourceAsset || !destinationAsset) return; - const swapInAmount = parseAmountWei(route.data.amountOut, destinationAsset.decimals); - const swapOutAmount = parseAmountWei(route.data.amountIn, sourceAsset.decimals); + const swapInAmount = convertTokenAmountToHumanReadableAmount( + route.data.amountOut, + destinationAsset.decimals + ); + const swapOutAmount = convertTokenAmountToHumanReadableAmount( + route.data.amountIn, + sourceAsset.decimals + ); const swapInAmountChanged = swapInAmount !== destinationAsset.amount; const swapOutAmountChanged = swapOutAmount !== sourceAsset.amount; diff --git a/packages/widget-v2/src/stories/Widget.stories.tsx b/packages/widget-v2/src/stories/Widget.stories.tsx index a6f604fd..4e52d985 100644 --- a/packages/widget-v2/src/stories/Widget.stories.tsx +++ b/packages/widget-v2/src/stories/Widget.stories.tsx @@ -3,7 +3,7 @@ import { ShowSwapWidget, SwapWidget, SwapWidgetProps } from "@/widget/Widget"; import { defaultTheme, lightTheme, Theme } from "@/widget/theme"; import NiceModal from "@ebay/nice-modal-react"; import { styled } from "styled-components"; -import { ReactElement, useEffect } from "react"; +import { ReactElement, useEffect, useMemo } from "react"; import { destinationAssetAtom, sourceAssetAmountAtom, sourceAssetAtom } from "@/state/swapPage"; import { useAtom, useSetAtom } from "jotai"; import { skipAssetsAtom } from "@/state/skipClient"; @@ -29,13 +29,15 @@ export const Widget = (props: Props) => { setSourceAssetAmount("1"); }, [destinationAsset, setDestinationAsset, setSourceAsset, setSourceAssetAmount, sourceAsset]); - return ; + return useMemo(() => { + return ; + }, [props]); }; export const Modal = (props: Props) => { return ( - + ); }; diff --git a/packages/widget-v2/src/utils/number.ts b/packages/widget-v2/src/utils/number.ts index abaf6040..e0ff00a5 100644 --- a/packages/widget-v2/src/utils/number.ts +++ b/packages/widget-v2/src/utils/number.ts @@ -1,5 +1,4 @@ import { BigNumber } from "bignumber.js"; -import { formatUnits } from "viem"; export function formatNumberWithCommas(str: string | number) { const parts = str.toString().split("."); @@ -11,24 +10,6 @@ export function formatNumberWithoutCommas(str: string | number) { return str.toString().replace(/,/g, ""); } -export function getAmountWei(amount?: string, decimals = 6) { - if (!amount || !amount) return "0"; - try { - return new BigNumber(formatNumberWithoutCommas(amount)).shiftedBy(decimals ?? 6).toFixed(0); - } catch (_err) { - return "0"; - } -} - -export function parseAmountWei(amount?: string, decimals = 6) { - if (!amount) return "0"; - try { - return formatUnits(BigInt(amount.replace(/,/g, "")), decimals ?? 6); - } catch (_err) { - return "0"; - } -} - export function calculatePercentageDifference(numberA: number | string, numberB: number | string, absoluteValue?: boolean) { const bigNumberA = BigNumber(numberA); const bigNumberB = BigNumber(numberB); diff --git a/packages/widget-v2/src/widget/Widget.tsx b/packages/widget-v2/src/widget/Widget.tsx index 3cd91e97..f6365c67 100644 --- a/packages/widget-v2/src/widget/Widget.tsx +++ b/packages/widget-v2/src/widget/Widget.tsx @@ -3,12 +3,12 @@ import NiceModal from "@ebay/nice-modal-react"; import { styled } from "styled-components"; import { createModal, useModal } from "@/components/Modal"; import { cloneElement, ReactElement, useEffect } from "react"; -import { PartialTheme } from "./theme"; +import { defaultTheme, PartialTheme } from "./theme"; import { Router } from "./Router"; import { useResetAtom } from "jotai/utils"; import { numberOfModalsOpenAtom } from "@/state/modal"; import { useSetAtom } from "jotai"; -import { skipClientConfigAtom } from "@/state/skipClient"; +import { skipClientConfigAtom, themeAtom } from "@/state/skipClient"; import { SkipClientOptions } from "@skip-go/client"; export type SwapWidgetProps = { @@ -17,9 +17,11 @@ export type SwapWidgetProps = { export const SwapWidget = ({ theme, ...skipClientConfig }: SwapWidgetProps) => { const setSkipClientConfig = useSetAtom(skipClientConfigAtom); + const setTheme = useSetAtom(themeAtom); useEffect(() => { setSkipClientConfig(skipClientConfig); - }, [setSkipClientConfig, skipClientConfig]); + setTheme({ ...defaultTheme, ...theme }); + }, [setSkipClientConfig, setTheme, skipClientConfig, theme]); return ( @@ -34,9 +36,11 @@ export const SwapWidget = ({ theme, ...skipClientConfig }: SwapWidgetProps) => { const SwapWidgetWithoutNiceModalProvider = ({ theme, ...skipClientConfig }: SwapWidgetProps) => { const setSkipClientConfig = useSetAtom(skipClientConfigAtom); + const setTheme = useSetAtom(themeAtom); useEffect(() => { setSkipClientConfig(skipClientConfig); - }, [setSkipClientConfig, skipClientConfig]); + setTheme({ ...defaultTheme, ...theme }); + }, [setSkipClientConfig, setTheme, skipClientConfig, theme]); return (