diff --git a/apps/core/src/utils/transaction/getBalanceChangeSummary.ts b/apps/core/src/utils/transaction/getBalanceChangeSummary.ts index 9f89165a2c367..51fe8b51067b6 100644 --- a/apps/core/src/utils/transaction/getBalanceChangeSummary.ts +++ b/apps/core/src/utils/transaction/getBalanceChangeSummary.ts @@ -18,7 +18,7 @@ export type BalanceChange = { export type BalanceChangeByOwner = Record; export type BalanceChangeSummary = BalanceChangeByOwner | null; -function getOwnerAddress(owner: ObjectOwner): string { +export function getOwnerAddress(owner: ObjectOwner): string { if (typeof owner === 'object') { if ('AddressOwner' in owner) { return owner.AddressOwner; diff --git a/apps/wallet/src/ui/app/helpers/filterAndSortTokenBalances.ts b/apps/wallet/src/ui/app/helpers/filterAndSortTokenBalances.ts index 47ae3b0f3ad9b..6872ad3e8fcfa 100644 --- a/apps/wallet/src/ui/app/helpers/filterAndSortTokenBalances.ts +++ b/apps/wallet/src/ui/app/helpers/filterAndSortTokenBalances.ts @@ -1,7 +1,9 @@ // Copyright (c) Mysten Labs, Inc. // SPDX-License-Identifier: Apache-2.0 +import { USDC_TYPE_ARG } from '_pages/swap/utils'; import { type CoinBalance } from '@mysten/sui/client'; +import { SUI_TYPE_ARG } from '@mysten/sui/utils'; // Sort tokens by symbol and total balance // Move this to the API backend @@ -9,11 +11,23 @@ import { type CoinBalance } from '@mysten/sui/client'; export function filterAndSortTokenBalances(tokens: CoinBalance[]) { return tokens .filter((token) => Number(token.totalBalance) > 0) - .sort((a, b) => - (getCoinSymbol(a.coinType) + Number(a.totalBalance)).localeCompare( + .sort((a, b) => { + if (a.coinType === SUI_TYPE_ARG) { + return -1; + } + if (b.coinType === SUI_TYPE_ARG) { + return 1; + } + if (a.coinType === USDC_TYPE_ARG) { + return -1; + } + if (b.coinType === USDC_TYPE_ARG) { + return 1; + } + return (getCoinSymbol(a.coinType) + Number(a.totalBalance)).localeCompare( getCoinSymbol(b.coinType) + Number(b.totalBalance), - ), - ); + ); + }); } export function getCoinSymbol(coinTypeArg: string) { diff --git a/apps/wallet/src/ui/app/hooks/useSupportedCoins.ts b/apps/wallet/src/ui/app/hooks/useSupportedCoins.ts new file mode 100644 index 0000000000000..65d8ee279c0ab --- /dev/null +++ b/apps/wallet/src/ui/app/hooks/useSupportedCoins.ts @@ -0,0 +1,13 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 +import { useAppsBackend } from '@mysten/core'; +import { useQuery } from '@tanstack/react-query'; + +export function useSupportedCoins() { + const { request } = useAppsBackend(); + + return useQuery({ + queryKey: ['supported-coins-apps-backend'], + queryFn: async () => request<{ supported: string[] }>('swap/coins'), + }); +} diff --git a/apps/wallet/src/ui/app/hooks/useValidSwapTokensList.ts b/apps/wallet/src/ui/app/hooks/useValidSwapTokensList.ts new file mode 100644 index 0000000000000..fea5757201d5e --- /dev/null +++ b/apps/wallet/src/ui/app/hooks/useValidSwapTokensList.ts @@ -0,0 +1,68 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 +import { useActiveAddress } from '_app/hooks/useActiveAddress'; +import { useGetAllBalances } from '_app/hooks/useGetAllBalances'; +import { useRecognizedPackages } from '_app/hooks/useRecognizedPackages'; +import { useSupportedCoins } from '_app/hooks/useSupportedCoins'; +import { type CoinBalance } from '@mysten/sui/client'; +import { + normalizeStructTag, + normalizeSuiObjectId, + parseStructTag, + SUI_TYPE_ARG, +} from '@mysten/sui/utils'; +import { useMemo } from 'react'; + +export function filterTokenBalances(tokens: CoinBalance[]) { + return tokens.filter( + (token) => Number(token.totalBalance) > 0 || token.coinType === SUI_TYPE_ARG, + ); +} + +export function useValidSwapTokensList() { + const address = useActiveAddress(); + const { data, isLoading: isSupportedCoinsLoading } = useSupportedCoins(); + const { data: rawCoinBalances, isLoading: isGetAllBalancesLoading } = useGetAllBalances( + address || '', + ); + const packages = useRecognizedPackages(); + const normalizedPackages = useMemo( + () => packages.map((id) => normalizeSuiObjectId(id)), + [packages], + ); + + const supported = useMemo( + () => + data?.supported.filter((type) => normalizedPackages.includes(parseStructTag(type).address)), + [data, normalizedPackages], + ); + + const coinBalances = useMemo( + () => (rawCoinBalances ? filterTokenBalances(rawCoinBalances) : null), + [rawCoinBalances], + ); + + const validSwaps = useMemo( + () => + supported?.sort((a, b) => { + const suiType = normalizeStructTag(SUI_TYPE_ARG); + const balanceA = BigInt( + coinBalances?.find( + (balance) => normalizeStructTag(balance.coinType) === normalizeStructTag(a), + )?.totalBalance ?? 0, + ); + const balanceB = BigInt( + coinBalances?.find( + (balance) => normalizeStructTag(balance.coinType) === normalizeStructTag(b), + )?.totalBalance ?? 0, + ); + return a === suiType ? -1 : b === suiType ? 1 : Number(balanceB - balanceA); + }) ?? [], + [supported, coinBalances], + ); + + return { + isLoading: isSupportedCoinsLoading || isGetAllBalancesLoading, + data: validSwaps, + }; +} diff --git a/apps/wallet/src/ui/app/index.tsx b/apps/wallet/src/ui/app/index.tsx index a4618cac04346..318ca3b529009 100644 --- a/apps/wallet/src/ui/app/index.tsx +++ b/apps/wallet/src/ui/app/index.tsx @@ -4,7 +4,7 @@ import { useAppDispatch, useAppSelector } from '_hooks'; import { UsdcPromo } from '_pages/home/usdc-promo/UsdcPromo'; import { SwapPage } from '_pages/swap'; -import { FromAssets } from '_pages/swap/FromAssets'; +import { CoinsSelectionPage } from '_pages/swap/CoinsSelectionPage'; import { setNavVisibility } from '_redux/slices/app'; import { isLedgerAccountSerializedUI } from '_src/background/accounts/LedgerAccount'; import { persistableStorage } from '_src/shared/analytics/amplitude'; @@ -178,7 +178,7 @@ const App = () => { } /> } /> } /> - } /> + } /> } /> } /> } /> diff --git a/apps/wallet/src/ui/app/pages/home/tokens/TokensDetails.tsx b/apps/wallet/src/ui/app/pages/home/tokens/TokensDetails.tsx index a0e907441aaf4..19296c0687f89 100644 --- a/apps/wallet/src/ui/app/pages/home/tokens/TokensDetails.tsx +++ b/apps/wallet/src/ui/app/pages/home/tokens/TokensDetails.tsx @@ -10,12 +10,7 @@ import Alert from '_components/alert'; import { CoinIcon } from '_components/coin-icon'; import Loading from '_components/loading'; import { filterAndSortTokenBalances } from '_helpers'; -import { - useAllowedSwapCoinsList, - useAppSelector, - useCoinsReFetchingConfig, - useSortedCoinsByCategories, -} from '_hooks'; +import { useAppSelector, useCoinsReFetchingConfig, useSortedCoinsByCategories } from '_hooks'; import { UsdcPromoBanner } from '_pages/home/usdc-promo/UsdcPromoBanner'; import { DELEGATED_STAKES_QUERY_REFETCH_INTERVAL, @@ -116,12 +111,9 @@ export function TokenRow({ const params = new URLSearchParams({ type: coinBalance.coinType, }); - const allowedSwapCoinsList = useAllowedSwapCoinsList(); const balanceInUsd = useBalanceInUSD(coinBalance.coinType, coinBalance.totalBalance); - const isRenderSwapButton = allowedSwapCoinsList.includes(coinType); - const coinMetadataOverrides = useCoinMetadataOverrides(); return ( Send - {isRenderSwapButton && ( - { - ampli.clickedSwapCoin({ - coinType: coinBalance.coinType, - totalBalance: Number(formatted), - sourceFlow: 'TokenRow', - }); - }} - > - Swap - - )} + { + ampli.clickedSwapCoin({ + coinType: coinBalance.coinType, + totalBalance: Number(formatted), + sourceFlow: 'TokenRow', + }); + }} + > + Swap + ) : ( -
+
{symbol} diff --git a/apps/wallet/src/ui/app/pages/home/usdc-promo/UsdcPromo.tsx b/apps/wallet/src/ui/app/pages/home/usdc-promo/UsdcPromo.tsx index 66f1ca3dc5c08..4278c317898e3 100644 --- a/apps/wallet/src/ui/app/pages/home/usdc-promo/UsdcPromo.tsx +++ b/apps/wallet/src/ui/app/pages/home/usdc-promo/UsdcPromo.tsx @@ -14,7 +14,7 @@ export function UsdcPromo() { const [searchParams] = useSearchParams(); const fromCoinType = searchParams.get('type'); const presetAmount = searchParams.get('presetAmount'); - const { promoBannerSheetTitle, promoBannerSheetContent } = useUsdcPromo(); + const { promoBannerSheetTitle, promoBannerSheetContent, ctaLabel } = useUsdcPromo(); return (
@@ -33,7 +33,7 @@ export function UsdcPromo() {
+ + {!!coinType && } + + {coinMetadata?.symbol || 'Select coin'} + + {!disabled && } + } > - {!!tokenBalance && ( -
+ {!!balance && ( +
Balance
{' '} - {tokenBalance} {symbol} + {balance?.formatted} {coinMetadata?.symbol}
)} diff --git a/apps/wallet/src/ui/app/pages/swap/CoinsSelectionPage.tsx b/apps/wallet/src/ui/app/pages/swap/CoinsSelectionPage.tsx new file mode 100644 index 0000000000000..f49bb2e9edacd --- /dev/null +++ b/apps/wallet/src/ui/app/pages/swap/CoinsSelectionPage.tsx @@ -0,0 +1,78 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { useGetAllBalances } from '_app/hooks/useGetAllBalances'; +import { useValidSwapTokensList } from '_app/hooks/useValidSwapTokensList'; +import Loading from '_components/loading'; +import Overlay from '_components/overlay'; +import { useActiveAddress, useSortedCoinsByCategories } from '_hooks'; +import { TokenRow } from '_pages/home/tokens/TokensDetails'; +import { normalizeStructTag } from '@mysten/sui/utils'; +import { Fragment } from 'react'; +import { useNavigate, useSearchParams } from 'react-router-dom'; + +export function CoinsSelectionPage() { + const navigate = useNavigate(); + const selectedAddress = useActiveAddress(); + const [searchParams] = useSearchParams(); + const fromCoinType = searchParams.get('fromCoinType'); + const toCoinType = searchParams.get('toCoinType'); + const source = searchParams.get('source'); + const currentAmount = searchParams.get('currentAmount'); + + const { data: swapFromTokensList, isLoading } = useValidSwapTokensList(); + const swapToTokensList = swapFromTokensList.filter((token) => { + if (!fromCoinType) { + return true; + } + return normalizeStructTag(token) !== normalizeStructTag(fromCoinType); + }); + + const allowedCoinTypes = source === 'fromCoinType' ? swapFromTokensList : swapToTokensList; + + const { data: coinBalances, isPending } = useGetAllBalances(selectedAddress || ''); + + const { recognized } = useSortedCoinsByCategories(coinBalances ?? []); + + return ( + navigate(-1)}> + +
+ {allowedCoinTypes.map((coinType, index) => { + const coinBalance = recognized?.find((coin) => coin.coinType === coinType) || {}; + const totalBalance = + coinBalances?.find( + (balance) => normalizeStructTag(balance.coinType) === normalizeStructTag(coinType), + )?.totalBalance ?? '0'; + + return ( + + { + const params = fromCoinType + ? { type: fromCoinType, toType: coinType, presetAmount: currentAmount || '0' } + : { + type: coinType, + toType: toCoinType || '', + presetAmount: currentAmount || '0', + }; + navigate(`/swap?${new URLSearchParams(params)}`); + }} + /> + +
+ + ); + })} +
+ + + ); +} diff --git a/apps/wallet/src/ui/app/pages/swap/FromAssets.tsx b/apps/wallet/src/ui/app/pages/swap/FromAssets.tsx deleted file mode 100644 index 4a22562c883ca..0000000000000 --- a/apps/wallet/src/ui/app/pages/swap/FromAssets.tsx +++ /dev/null @@ -1,74 +0,0 @@ -// Copyright (c) Mysten Labs, Inc. -// SPDX-License-Identifier: Apache-2.0 - -import Loading from '_components/loading'; -import Overlay from '_components/overlay'; -import { filterAndSortTokenBalances } from '_helpers'; -import { - useActiveAddress, - useAllowedSwapCoinsList, - useCoinsReFetchingConfig, - useSortedCoinsByCategories, -} from '_hooks'; -import { TokenRow } from '_pages/home/tokens/TokensDetails'; -import { DeepBookContextProvider } from '_shared/deepBook/context'; -import { useSuiClientQuery } from '@mysten/dapp-kit'; -import { Fragment } from 'react'; -import { useNavigate } from 'react-router-dom'; - -function FromAssetsComponent() { - const navigate = useNavigate(); - const selectedAddress = useActiveAddress(); - const { staleTime, refetchInterval } = useCoinsReFetchingConfig(); - - const { data: coins, isPending } = useSuiClientQuery( - 'getAllBalances', - { owner: selectedAddress! }, - { - enabled: !!selectedAddress, - refetchInterval, - staleTime, - select: filterAndSortTokenBalances, - }, - ); - - const { recognized } = useSortedCoinsByCategories(coins ?? []); - const allowedSwapCoinsList = useAllowedSwapCoinsList(); - - const renderedRecognizedCoins = recognized.filter(({ coinType }) => - allowedSwapCoinsList.includes(coinType), - ); - - return ( - navigate(-1)}> - -
- {renderedRecognizedCoins?.map((coinBalance, index) => { - return ( - - { - navigate( - `/swap?${new URLSearchParams({ type: coinBalance.coinType }).toString()}`, - ); - }} - /> - - {index !== recognized.length - 1 &&
} - - ); - })} -
- - - ); -} - -export function FromAssets() { - return ( - - - - ); -} diff --git a/apps/wallet/src/ui/app/pages/swap/GasFeesSummary.tsx b/apps/wallet/src/ui/app/pages/swap/GasFeesSummary.tsx new file mode 100644 index 0000000000000..3fc0ad8869b9c --- /dev/null +++ b/apps/wallet/src/ui/app/pages/swap/GasFeesSummary.tsx @@ -0,0 +1,63 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { Text } from '_app/shared/text'; +import { DescriptionItem } from '_pages/approval-request/transaction-request/DescriptionList'; +import { getGasSummary, useCoinMetadata, useFormatCoin } from '@mysten/core'; +import { type DryRunTransactionBlockResponse } from '@mysten/sui/client'; +import { SUI_TYPE_ARG } from '@mysten/sui/utils'; +import { useMemo } from 'react'; + +interface GasFeesSummaryProps { + transaction?: DryRunTransactionBlockResponse; + feePercentage?: number; + accessFees?: string; + accessFeeType?: string; +} + +export function GasFeesSummary({ + transaction, + feePercentage, + accessFees, + accessFeeType, +}: GasFeesSummaryProps) { + const gasSummary = useMemo(() => { + if (!transaction) return null; + return getGasSummary(transaction); + }, [transaction]); + const totalGas = gasSummary?.totalGas; + const [gasAmount, gasSymbol] = useFormatCoin(totalGas, SUI_TYPE_ARG); + + const { data: accessFeeMetadata } = useCoinMetadata(accessFeeType); + + return ( +
+ + Access Fees ({feePercentage ? `${feePercentage * 100}%` : '--'}) + + } + > + + {accessFees ?? '--'} + {accessFeeMetadata?.symbol ? ` ${accessFeeMetadata.symbol}` : ''} + + + +
+ + + Estimated Gas Fee + + } + > + + {gasAmount ? `${gasAmount} ${gasSymbol}` : '--'} + + +
+ ); +} diff --git a/apps/wallet/src/ui/app/pages/swap/ToAssetSection.tsx b/apps/wallet/src/ui/app/pages/swap/ToAssetSection.tsx deleted file mode 100644 index c35b3d67bbf50..0000000000000 --- a/apps/wallet/src/ui/app/pages/swap/ToAssetSection.tsx +++ /dev/null @@ -1,167 +0,0 @@ -// Copyright (c) Mysten Labs, Inc. -// SPDX-License-Identifier: Apache-2.0 -import { useRecognizedCoins } from '_app/hooks/deepbook'; -import { Button } from '_app/shared/ButtonUI'; -import { InputWithActionButton } from '_app/shared/InputWithAction'; -import { Text } from '_app/shared/text'; -import Alert from '_components/alert'; -import { AssetData } from '_pages/swap/AssetData'; -import { - Coins, - SUI_CONVERSION_RATE, - USDC_CONVERSION_RATE, - type FormValues, -} from '_pages/swap/constants'; -import { MaxSlippage, MaxSlippageModal } from '_pages/swap/MaxSlippage'; -import { ToAssets } from '_pages/swap/ToAssets'; -import { getUSDCurrency, useSwapData } from '_pages/swap/utils'; -import { useDeepBookContext } from '_shared/deepBook/context'; -import { type BalanceChange } from '@mysten/sui/client'; -import { SUI_TYPE_ARG } from '@mysten/sui/utils'; -import BigNumber from 'bignumber.js'; -import clsx from 'clsx'; -import { useEffect, useState } from 'react'; -import { useFormContext } from 'react-hook-form'; - -export function ToAssetSection({ - activeCoinType, - balanceChanges, - slippageErrorString, - baseCoinType, - quoteCoinType, - loading, - refetch, - error, -}: { - activeCoinType: string | null; - balanceChanges: BalanceChange[]; - slippageErrorString: string; - baseCoinType: string; - quoteCoinType: string; - loading: boolean; - refetch: () => void; - error: Error | null; -}) { - const coinsMap = useDeepBookContext().configs.coinsMap; - const recognizedCoins = useRecognizedCoins(); - const [isToAssetOpen, setToAssetOpen] = useState(false); - const [isSlippageModalOpen, setSlippageModalOpen] = useState(false); - const isAsk = activeCoinType === SUI_TYPE_ARG; - - const { formattedBaseBalance, formattedQuoteBalance, baseCoinMetadata, quoteCoinMetadata } = - useSwapData({ - baseCoinType, - quoteCoinType, - }); - - const toAssetBalance = isAsk ? formattedQuoteBalance : formattedBaseBalance; - const toAssetMetaData = isAsk ? quoteCoinMetadata : baseCoinMetadata; - - const { - watch, - setValue, - formState: { isValid }, - } = useFormContext(); - const toAssetType = watch('toAssetType'); - - const rawToAssetAmount = balanceChanges.find( - (balanceChange) => balanceChange.coinType === toAssetType, - )?.amount; - - const toAssetAmountAsNum = new BigNumber(rawToAssetAmount || '0') - .shiftedBy(isAsk ? -SUI_CONVERSION_RATE : -USDC_CONVERSION_RATE) - .toNumber(); - - useEffect(() => { - const newToAsset = isAsk ? coinsMap[Coins.USDC] : SUI_TYPE_ARG; - setValue('toAssetType', newToAsset); - }, [coinsMap, isAsk, setValue]); - - const toAssetSymbol = toAssetMetaData.data?.symbol ?? ''; - const amount = watch('amount'); - - if (!toAssetMetaData.data) { - return null; - } - - return ( -
- setToAssetOpen(false)} - onRowClick={(coinType) => { - setToAssetOpen(false); - }} - /> - { - setToAssetOpen(true); - }} - /> - - - {toAssetSymbol} - - ) - } - info={ - isValid && ( - - {getUSDCurrency(isAsk ? toAssetAmountAsNum : Number(amount))} - - ) - } - /> - - {isValid && toAssetAmountAsNum && amount ? ( -
- setSlippageModalOpen(true)} /> - - {slippageErrorString && ( -
- {slippageErrorString} -
- )} - - setSlippageModalOpen(false)} - /> -
- ) : null} - - {error && ( -
- - - Calculation failed - - {error.message || 'An error has occurred, try again.'} - -
- )} -
- ); -} diff --git a/apps/wallet/src/ui/app/pages/swap/ToAssets.tsx b/apps/wallet/src/ui/app/pages/swap/ToAssets.tsx deleted file mode 100644 index d022c8dee75c1..0000000000000 --- a/apps/wallet/src/ui/app/pages/swap/ToAssets.tsx +++ /dev/null @@ -1,61 +0,0 @@ -// Copyright (c) Mysten Labs, Inc. -// SPDX-License-Identifier: Apache-2.0 - -import Overlay from '_components/overlay'; -import { useActiveAddress, useCoinsReFetchingConfig } from '_hooks'; -import { TokenRow } from '_pages/home/tokens/TokensDetails'; -import { useSuiClientQuery } from '@mysten/dapp-kit'; -import { Fragment } from 'react'; -import { useSearchParams } from 'react-router-dom'; - -function ToAsset({ coinType, onClick }: { coinType: string; onClick: (coinType: string) => void }) { - const accountAddress = useActiveAddress(); - const [searchParams] = useSearchParams(); - const activeCoinType = searchParams.get('type'); - - const { staleTime, refetchInterval } = useCoinsReFetchingConfig(); - - const { data: coinBalance } = useSuiClientQuery( - 'getBalance', - { coinType: coinType, owner: accountAddress! }, - { enabled: !!accountAddress, refetchInterval, staleTime }, - ); - - if (!coinBalance || coinBalance.coinType === activeCoinType) { - return null; - } - - return ( - { - onClick(coinType); - }} - /> - ); -} - -export function ToAssets({ - onClose, - isOpen, - onRowClick, - recognizedCoins, -}: { - onClose: () => void; - isOpen: boolean; - onRowClick: (coinType: string) => void; - recognizedCoins: string[]; -}) { - return ( - -
- {recognizedCoins.map((coinType, index) => ( - - - {index !== recognizedCoins.length - 1 &&
} - - ))} -
- - ); -} diff --git a/apps/wallet/src/ui/app/pages/swap/index.tsx b/apps/wallet/src/ui/app/pages/swap/index.tsx index f33c3d26a8e80..e22562e13ceb1 100644 --- a/apps/wallet/src/ui/app/pages/swap/index.tsx +++ b/apps/wallet/src/ui/app/pages/swap/index.tsx @@ -1,338 +1,208 @@ // Copyright (c) Mysten Labs, Inc. // SPDX-License-Identifier: Apache-2.0 - import { useActiveAccount } from '_app/hooks/useActiveAccount'; -import { useRecognizedPackages } from '_app/hooks/useRecognizedPackages'; import { useSigner } from '_app/hooks/useSigner'; import BottomMenuLayout, { Content, Menu } from '_app/shared/bottom-menu-layout'; import { Button } from '_app/shared/ButtonUI'; import { Form } from '_app/shared/forms/Form'; +import { Heading } from '_app/shared/heading'; import { InputWithActionButton } from '_app/shared/InputWithAction'; import { Text } from '_app/shared/text'; import { ButtonOrLink } from '_app/shared/utils/ButtonOrLink'; -import Loading from '_components/loading'; +import Alert from '_components/alert'; +import LoadingIndicator from '_components/loading/LoadingIndicator'; import Overlay from '_components/overlay'; -import { filterAndSortTokenBalances } from '_helpers'; -import { - useAllowedSwapCoinsList, - useCoinsReFetchingConfig, - useGetEstimate, - useSortedCoinsByCategories, -} from '_hooks'; -import { AverageSection } from '_pages/swap/AverageSection'; -import { - Coins, - initialValues, - SUI_CONVERSION_RATE, - SUI_USDC_AVERAGE_CONVERSION_RATE, - USDC_CONVERSION_RATE, - type FormValues, -} from '_pages/swap/constants'; +import { parseAmount } from '_helpers'; +import { DescriptionItem } from '_pages/approval-request/transaction-request/DescriptionList'; +import { AssetData } from '_pages/swap/AssetData'; +import { GasFeesSummary } from '_pages/swap/GasFeesSummary'; +import { MaxSlippage, MaxSlippageModal } from '_pages/swap/MaxSlippage'; +import { useSwapTransaction } from '_pages/swap/useSwapTransaction'; import { - getAverageFromBalanceChanges, - getBalanceConversion, - getUSDCurrency, - isExceedingSlippageTolerance, - useSwapData, + DEFAULT_MAX_SLIPPAGE_PERCENTAGE, + formatSwapQuote, + maxSlippageFormSchema, + useCoinTypesFromRouteParams, + useGetBalance, } from '_pages/swap/utils'; import { ampli } from '_shared/analytics/ampli'; -import { DeepBookContextProvider, useDeepBookContext } from '_shared/deepBook/context'; -import { useTransactionSummary, useZodForm } from '@mysten/core'; -import { useSuiClientQuery } from '@mysten/dapp-kit'; +import { useFeatureValue } from '@growthbook/growthbook-react'; +import { useBalanceInUSD, useCoinMetadata, useZodForm } from '@mysten/core'; +import { useSuiClient } from '@mysten/dapp-kit'; import { ArrowDown12, ArrowRight16 } from '@mysten/icons'; -import { type DryRunTransactionBlockResponse } from '@mysten/sui/client'; -import { SUI_TYPE_ARG } from '@mysten/sui/utils'; +import { normalizeStructTag, SUI_TYPE_ARG } from '@mysten/sui/utils'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import BigNumber from 'bignumber.js'; import clsx from 'clsx'; -import { useEffect, useMemo, useState } from 'react'; -import { useWatch, type SubmitHandler } from 'react-hook-form'; +import { useMemo, useState } from 'react'; +import type { SubmitHandler } from 'react-hook-form'; import { useNavigate, useSearchParams } from 'react-router-dom'; import { z } from 'zod'; -import { AssetData } from './AssetData'; -import { GasFeeSection } from './GasFeeSection'; -import { ToAssetSection } from './ToAssetSection'; - -const MIN_INPUT = 0.1; - -enum ErrorStrings { - MISSING_DATA = 'Missing data', - SLIPPAGE_EXCEEDS_TOLERANCE = 'Current slippage exceeds tolerance', - NOT_ENOUGH_BALANCE = 'Not enough balance', -} - -function getSwapPageAtcText( - fromSymbol: string, - toAssetType: string, - coinsMap: Record, -) { - const toSymbol = - toAssetType === SUI_TYPE_ARG - ? Coins.SUI - : Object.entries(coinsMap).find(([key, value]) => value === toAssetType)?.[0] || ''; - - return `Swap ${fromSymbol} to ${toSymbol}`; -} - -export function SwapPageContent() { - const deepBookContext = useDeepBookContext(); - const [slippageErrorString, setSlippageErrorString] = useState(''); - const queryClient = useQueryClient(); - const mainnetPools = deepBookContext.configs.pools; +export function SwapPage() { const navigate = useNavigate(); - const [searchParams] = useSearchParams(); + const client = useSuiClient(); + const queryClient = useQueryClient(); const activeAccount = useActiveAccount(); const signer = useSigner(activeAccount); - const activeAccountAddress = activeAccount?.address; - const { staleTime, refetchInterval } = useCoinsReFetchingConfig(); - const coinsMap = deepBookContext.configs.coinsMap; - const deepBookClient = deepBookContext.client; - const accountCapId = deepBookContext.accountCapId; - const allowedSwapCoinsList = useAllowedSwapCoinsList(); - - const activeCoinType = searchParams.get('type'); - const isAsk = activeCoinType === SUI_TYPE_ARG; - - const baseCoinType = SUI_TYPE_ARG; - const quoteCoinType = coinsMap.USDC; - - const poolId = mainnetPools.SUI_USDC[0]; - - const { - baseCoinBalanceData, - quoteCoinBalanceData, - formattedBaseBalance, - formattedQuoteBalance, - baseCoinMetadata, - quoteCoinMetadata, - baseCoinSymbol, - quoteCoinSymbol, - isPending, - } = useSwapData({ - baseCoinType, - quoteCoinType, - }); - - const rawBaseBalance = baseCoinBalanceData?.totalBalance; - const rawQuoteBalance = quoteCoinBalanceData?.totalBalance; - - const { data: coinBalances } = useSuiClientQuery( - 'getAllBalances', - { owner: activeAccountAddress! }, - { - enabled: !!activeAccountAddress, - staleTime, - refetchInterval, - select: filterAndSortTokenBalances, - }, - ); - - const { recognized } = useSortedCoinsByCategories(coinBalances ?? []); - - const formattedBaseTokenBalance = formattedBaseBalance.replace(/,/g, ''); - - const formattedQuoteTokenBalance = formattedQuoteBalance.replace(/,/g, ''); - - const baseCoinDecimals = baseCoinMetadata.data?.decimals ?? 0; - const maxBaseBalance = rawBaseBalance || '0'; - - const quoteCoinDecimals = quoteCoinMetadata.data?.decimals ?? 0; - const maxQuoteBalance = rawQuoteBalance || '0'; + const [isSlippageModalOpen, setSlippageModalOpen] = useState(false); + const [searchParams] = useSearchParams(); + const currentAddress = activeAccount?.address; + const { fromCoinType, toCoinType } = useCoinTypesFromRouteParams(); + const defaultSlippage = useFeatureValue('defi-max-slippage', DEFAULT_MAX_SLIPPAGE_PERCENTAGE); + const maxSlippage = Number(searchParams.get('maxSlippage') || defaultSlippage); + const presetAmount = searchParams.get('presetAmount'); + const isSui = fromCoinType + ? normalizeStructTag(fromCoinType) === normalizeStructTag(SUI_TYPE_ARG) + : false; + const { data: fromCoinData } = useCoinMetadata(fromCoinType); const validationSchema = useMemo(() => { - return z.object({ - amount: z.string().transform((value, context) => { - const bigNumberValue = new BigNumber(value); - - if (!value.length) { - context.addIssue({ - code: 'custom', - message: 'Amount is required', + return z + .object({ + amount: z + .number({ + coerce: true, + invalid_type_error: 'Input must be number only', + }) + .pipe(z.coerce.string()), + }) + .merge(maxSlippageFormSchema) + .superRefine(async ({ amount }, ctx) => { + if (!fromCoinType) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Select a coin to swap from', }); return z.NEVER; } - if (bigNumberValue.lt(MIN_INPUT)) { - context.addIssue({ - code: 'custom', - message: `Minimum ${MIN_INPUT} ${isAsk ? baseCoinSymbol : quoteCoinSymbol}`, + const { totalBalance } = await client.getBalance({ + owner: currentAddress || '', + coinType: fromCoinType, + }); + const data = await client.getCoinMetadata({ coinType: fromCoinType }); + const bnAmount = new BigNumber(amount); + const bnMaxBalance = new BigNumber(totalBalance || 0).shiftedBy(-1 * (data?.decimals ?? 0)); + + if (bnAmount.isGreaterThan(bnMaxBalance)) { + ctx.addIssue({ + path: ['amount'], + code: z.ZodIssueCode.custom, + message: 'Insufficient balance', }); return z.NEVER; } - if (bigNumberValue.lt(0)) { - context.addIssue({ - code: 'custom', - message: 'Amount must be greater than 0', + if (!toCoinType) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Select a coin to swap to', }); return z.NEVER; } - const shiftedValue = isAsk ? baseCoinDecimals : quoteCoinDecimals; - const maxBalance = isAsk ? maxBaseBalance : maxQuoteBalance; - - if (bigNumberValue.shiftedBy(shiftedValue).gt(BigInt(maxBalance).toString())) { - context.addIssue({ - code: 'custom', - message: 'Not available in account', + if (!bnAmount.isFinite() || !bnAmount.isPositive()) { + ctx.addIssue({ + path: ['amount'], + code: z.ZodIssueCode.custom, + message: 'Expected a valid number', }); return z.NEVER; } - - return value; - }), - toAssetType: z.string(), - allowedMaxSlippagePercentage: z.string().transform((percent, context) => { - const numberPercent = Number(percent); - - if (numberPercent < 0 || numberPercent > 100) { - context.addIssue({ - code: 'custom', - message: 'Value must be between 0 and 100', + if (!bnAmount.gt(0)) { + ctx.addIssue({ + path: ['amount'], + code: z.ZodIssueCode.custom, + message: 'Value must be greater than 0', }); return z.NEVER; } + if (!fromCoinType || !toCoinType) { + return z.NEVER; + } + }); + }, [client, currentAddress, fromCoinType, toCoinType]); - return percent; - }), - }); - }, [ - isAsk, - baseCoinDecimals, - quoteCoinDecimals, - maxBaseBalance, - maxQuoteBalance, - baseCoinSymbol, - quoteCoinSymbol, - ]); + type FormType = z.infer; const form = useZodForm({ mode: 'all', schema: validationSchema, defaultValues: { - ...initialValues, - toAssetType: coinsMap.USDC, + allowedMaxSlippagePercentage: maxSlippage, + amount: presetAmount || '', }, }); const { - register, + watch, setValue, - control, handleSubmit, + register, reset, - formState: { isValid, isSubmitting, errors, isDirty }, + formState: { isValid: isFormValid, isSubmitting, errors }, } = form; - useEffect(() => { - if (isDirty) { - setSlippageErrorString(''); - } - }, [isDirty]); - - const renderButtonToCoinsList = useMemo(() => { - return ( - recognized.length > 1 && - recognized.some((coin) => allowedSwapCoinsList.includes(coin.coinType)) - ); - }, [allowedSwapCoinsList, recognized]); + const [allowedMaxSlippagePercentage, amount] = watch(['allowedMaxSlippagePercentage', 'amount']); - const amount = useWatch({ - name: 'amount', - control, + const { data: balance } = useGetBalance({ + coinType: fromCoinType!, + owner: currentAddress, }); - const baseBalance = amount && new BigNumber(amount).shiftedBy(USDC_CONVERSION_RATE).toString(); - const quoteBalance = amount && new BigNumber(amount).shiftedBy(SUI_CONVERSION_RATE).toString(); - - const isPayAll = amount === (isAsk ? formattedBaseTokenBalance : formattedQuoteTokenBalance); - - const atcText = useMemo(() => { - if (isAsk) { - return getSwapPageAtcText(baseCoinSymbol, quoteCoinType, coinsMap); - } - return getSwapPageAtcText(quoteCoinSymbol, baseCoinType, coinsMap); - }, [isAsk, baseCoinSymbol, baseCoinType, coinsMap, quoteCoinSymbol, quoteCoinType]); - + const GAS_RESERVE = 0.1; + const maxBalance = useMemo(() => { + const bnBalance = new BigNumber(balance?.totalBalance || 0).shiftedBy( + -1 * (fromCoinData?.decimals ?? 0), + ); + return isSui && bnBalance.gt(GAS_RESERVE) + ? bnBalance + .minus(GAS_RESERVE) + .decimalPlaces(fromCoinData?.decimals ?? 0) + .toString() + : bnBalance.decimalPlaces(fromCoinData?.decimals ?? 0).toString(); + }, [balance?.totalBalance, fromCoinData?.decimals, isSui]); + + const { data: toCoinData } = useCoinMetadata(toCoinType); + const fromCoinSymbol = fromCoinData?.symbol; + const toCoinSymbol = toCoinData?.symbol; + + const parsed = parseAmount(amount || '0', fromCoinData?.decimals || 0); + const isMaxBalance = new BigNumber(amount).isEqualTo(new BigNumber(maxBalance)); const { - error: estimateError, - data: dataFromEstimate, - isPending: dataFromEstimatePending, - isFetching: dataFromEstimateFetching, - isError: isDataFromEstimateError, - refetch: refetchEstimate, - } = useGetEstimate({ - signer, - accountCapId, - coinType: activeCoinType || '', - poolId, - baseBalance, - quoteBalance, - isAsk, - totalBaseBalance: formattedBaseTokenBalance, - totalQuoteBalance: formattedQuoteTokenBalance, - baseConversionRate: USDC_CONVERSION_RATE, - quoteConversionRate: SUI_CONVERSION_RATE, - enabled: isValid, - amount, - }); - - const recognizedPackagesList = useRecognizedPackages(); - - const txnSummary = useTransactionSummary({ - transaction: dataFromEstimate?.dryRunResponse as DryRunTransactionBlockResponse, - recognizedPackagesList, - currentAddress: activeAccountAddress, + data, + isPending: swapTransactionPending, + isLoading: swapTransactionLoading, + refetch, + error, + } = useSwapTransaction({ + sender: currentAddress, + fromType: fromCoinType || '', + toType: toCoinType || '', + amount: parsed.toString(), + slippage: Number(allowedMaxSlippagePercentage), + enabled: isFormValid && parsed > 0n && !!fromCoinType && !!toCoinType, }); - const totalGas = txnSummary?.gas?.totalGas; - const balanceChanges = dataFromEstimate?.dryRunResponse?.balanceChanges || []; - - const averages = getAverageFromBalanceChanges({ - balanceChanges, - baseCoinType, - quoteCoinType, - isAsk, - baseConversionRate: USDC_CONVERSION_RATE, - quoteConversionRate: SUI_CONVERSION_RATE, - }); - - const balance = getBalanceConversion({ - balance: new BigNumber(amount), - isAsk, - averages, - }); - - const formattedBalance = new BigNumber(balance) - .shiftedBy(isAsk ? SUI_USDC_AVERAGE_CONVERSION_RATE : -SUI_USDC_AVERAGE_CONVERSION_RATE) - .toNumber(); - - const { mutate: handleSwap, isPending: isSwapLoading } = useMutation({ - mutationFn: async (formData: FormValues) => { - const txn = dataFromEstimate?.txn; - - const isExceedingSlippage = await isExceedingSlippageTolerance({ - slipPercentage: formData.allowedMaxSlippagePercentage, - poolId, - deepBookClient, - conversionRate: USDC_CONVERSION_RATE, - isAsk, - average: averages.averageBaseToQuote, - }); - - if (!balanceChanges.length) { - throw new Error(ErrorStrings.NOT_ENOUGH_BALANCE); - } - - if (isExceedingSlippage) { - throw new Error(ErrorStrings.SLIPPAGE_EXCEEDS_TOLERANCE); - } + const swapData = useMemo(() => { + if (!data) return null; + return formatSwapQuote({ + result: data, + sender: currentAddress || '', + fromType: fromCoinType || '', + toType: toCoinType || '', + fromCoinDecimals: fromCoinData?.decimals ?? 0, + toCoinDecimals: toCoinData?.decimals ?? 0, + }); + }, [currentAddress, fromCoinType, toCoinType, fromCoinData, toCoinData, data]); - if (!txn || !signer) { - throw new Error(ErrorStrings.MISSING_DATA); - } + const toCoinBalanceInUSD = useBalanceInUSD(toCoinType || '', swapData?.toAmount ?? 0n); + const inputAmountInUSD = useBalanceInUSD(fromCoinType || '', parsed || 0n); + const { mutate: handleSwap, isPending: handleSwapPending } = useMutation({ + mutationFn: async (formData: FormType) => { + const txn = swapData?.transaction; return signer!.signAndExecuteTransactionBlock({ transactionBlock: txn!, options: { @@ -347,10 +217,10 @@ export function SwapPageContent() { queryClient.invalidateQueries({ queryKey: ['coin-balance'] }); ampli.swappedCoin({ - fromCoinType: isAsk ? baseCoinType : quoteCoinType, - toCoinType: isAsk ? quoteCoinType : baseCoinType, + fromCoinType: fromCoinType || '', + toCoinType: toCoinType || '', totalBalance: Number(amount), - estimatedReturnBalance: Number(formattedBalance), + estimatedReturnBalance: inputAmountInUSD || 0, }); const receiptUrl = `/receipt?txdigest=${encodeURIComponent( @@ -358,154 +228,213 @@ export function SwapPageContent() { )}&from=transactions`; return navigate(receiptUrl); }, - onError: (error: Error) => { - if (error.message === ErrorStrings.SLIPPAGE_EXCEEDS_TOLERANCE) { - setSlippageErrorString(error.message); - } - }, }); - const handleOnsubmit: SubmitHandler = (formData) => { + const handleOnsubmit: SubmitHandler = (formData) => { handleSwap(formData); }; + const showGasFeeBanner = !swapTransactionPending && swapData && isSui && isMaxBalance; + return ( navigate('/')}>
- - - -
+ + + +
+ +
+ + {isMaxBalance ? '~ ' : ''}$ + {new BigNumber(inputAmountInUSD || 0).toFixed(2)} + + ) + } + onActionClicked={() => { + setValue('amount', maxBalance, { shouldValidate: true }); + }} + /> +
+ {showGasFeeBanner && ( + + + {GAS_RESERVE} {fromCoinSymbol} has been set aside to cover estimated max gas + fees for this transaction + + + )} +
+ + { + navigate( + `/swap?${new URLSearchParams({ + type: toCoinType || '', + toType: fromCoinType || '', + }).toString()}`, + ); + reset(); + }} + > +
+
+ +
+
+ + +
- {activeCoinType && ( - - )} - -
- +
+ {swapTransactionLoading ? ( +
+ + + Calculating... + +
+ ) : ( +
+ + {swapData?.formattedToAmount ?? 0} + + + {toCoinSymbol} + +
- {isPayAll ? '~ ' : ''} - {getUSDCurrency(isAsk ? formattedBalance : Number(amount))} + ${new BigNumber(toCoinBalanceInUSD || 0).toFixed(2)} - ) - } - onActionClicked={() => { - setValue( - 'amount', - activeCoinType === SUI_TYPE_ARG - ? formattedBaseTokenBalance - : formattedQuoteTokenBalance, - { shouldValidate: true }, - ); - }} - /> +
+
+ )}
-
- { - navigate( - `/swap?${new URLSearchParams({ - type: activeCoinType === SUI_TYPE_ARG ? coinsMap.USDC : SUI_TYPE_ARG, - }).toString()}`, - ); - reset(); - }} - > -
-
- +
+ setSlippageModalOpen(true)} />
-
- + { + navigate( + `/swap?${new URLSearchParams({ + type: fromCoinType || '', + toType: toCoinType || '', + maxSlippage: allowedMaxSlippagePercentage.toString(), + }).toString()}`, + ); + setSlippageModalOpen(false); + }} + /> - + {error && ( +
+ + + Calculation failed + + + {error.message || 'An error has occurred, try again.'} + + +
+ )} +
- {isValid && ( -
- + {swapData?.estimatedRate && ( +
+ Estimated Rate}> + + 1 {fromCoinSymbol} ≈ {swapData?.estimatedRate} {toCoinSymbol} + +
)} -
- -
- - - - - - - + +
+ + + + + +
); } - -export function SwapPage() { - return ( - - - - ); -} diff --git a/apps/wallet/src/ui/app/pages/swap/useSwapTransaction.ts b/apps/wallet/src/ui/app/pages/swap/useSwapTransaction.ts new file mode 100644 index 0000000000000..94326c21a9049 --- /dev/null +++ b/apps/wallet/src/ui/app/pages/swap/useSwapTransaction.ts @@ -0,0 +1,97 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 +import { useSuiClient } from '@mysten/dapp-kit'; +import { type DryRunTransactionBlockResponse } from '@mysten/sui/client'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; + +export type SwapRequest = { + amount: string; + fromType?: string; + slippage: number; + sender?: string; + toType?: string; +}; + +export type SwapResponse = { + bytes: string; + error: string; + fee: { + percentage: number; + address: string; + }; + outAmount: string; + provider: string; +}; + +export type SwapResult = + | (SwapResponse & { + dryRunResponse: DryRunTransactionBlockResponse; + }) + | null; + +const getQueryKey = (params: SwapRequest) => ['swap', params]; + +async function* streamAsyncIterator(stream: ReadableStream): AsyncGenerator { + const reader = stream.getReader(); + const decoder = new TextDecoder('utf-8'); + let buffer = ''; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + + for (const line of lines) { + if (line.trim()) { + yield JSON.parse(line.trim()); + } + } + } + } finally { + reader.releaseLock(); + } +} + +export function useSwapTransaction({ enabled, ...params }: SwapRequest & { enabled: boolean }) { + const client = useSuiClient(); + const queryClient = useQueryClient(); + + return useQuery({ + queryKey: getQueryKey(params), + queryFn: async ({ signal }) => { + const response = await fetch('https://apps-backend.sui.io/swap', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(params), + signal, + }); + + if (!response.body || !response.ok) { + throw new Error(`Failed to fetch swap data ${response.statusText}`); + } + + for await (const swapResponse of streamAsyncIterator(response.body)) { + if (!swapResponse) continue; + if (swapResponse.error) throw new Error(swapResponse.error); + + const dryRunResponse = await client.dryRunTransactionBlock({ + transactionBlock: swapResponse.bytes, + }); + + queryClient.setQueryData(getQueryKey(params), { + dryRunResponse, + ...swapResponse, + }); + } + + return queryClient.getQueryData(getQueryKey(params)) ?? null; + }, + staleTime: 0, + enabled: enabled && !!params.amount && !!params.sender && !!params.fromType && !!params.toType, + }); +} diff --git a/apps/wallet/src/ui/app/pages/swap/utils.ts b/apps/wallet/src/ui/app/pages/swap/utils.ts index ebe8a2ddb375a..676fd3148795c 100644 --- a/apps/wallet/src/ui/app/pages/swap/utils.ts +++ b/apps/wallet/src/ui/app/pages/swap/utils.ts @@ -2,11 +2,26 @@ // SPDX-License-Identifier: Apache-2.0 import { useActiveAccount } from '_app/hooks/useActiveAccount'; import { useCoinsReFetchingConfig } from '_hooks'; -import { roundFloat, useFormatCoin } from '@mysten/core'; +import { type SwapResult } from '_pages/swap/useSwapTransaction'; +import { useFeatureValue } from '@growthbook/growthbook-react'; +import { + CoinFormat, + formatBalance, + getBalanceChangeSummary, + getOwnerAddress, + roundFloat, + useCoinMetadata, + useFormatCoin, +} from '@mysten/core'; import { useSuiClientQuery } from '@mysten/dapp-kit'; -import { type DeepBookClient } from '@mysten/deepbook'; -import { type BalanceChange } from '@mysten/sui/client'; +import { type TransactionEffects } from '@mysten/sui/client'; +import { Transaction } from '@mysten/sui/transactions'; +import { normalizeStructTag, SUI_DECIMALS, SUI_TYPE_ARG } from '@mysten/sui/utils'; import BigNumber from 'bignumber.js'; +import { useSearchParams } from 'react-router-dom'; +import { z } from 'zod'; + +export const DEFAULT_MAX_SLIPPAGE_PERCENTAGE = 1; export const W_USDC_TYPE_ARG = '0x5d4b302506645c37ff133b98c4b50a5ae14841659738d6d733d59d0d217a93bf::coin::COIN'; @@ -72,113 +87,136 @@ export function getUSDCurrency(amount?: number | null) { }); } -export async function isExceedingSlippageTolerance({ - slipPercentage, - poolId, - deepBookClient, - conversionRate, - isAsk, - average, -}: { - slipPercentage: string; - poolId: string; - deepBookClient: DeepBookClient; - conversionRate: number; - isAsk: boolean; - average: string; -}) { - const convertedAverage = new BigNumber(average).shiftedBy(conversionRate).toString(); - - const { bestBidPrice, bestAskPrice } = await deepBookClient.getMarketPrice(poolId); - - if (!bestBidPrice || !bestAskPrice) { - return false; +export const maxSlippageFormSchema = z.object({ + allowedMaxSlippagePercentage: z + .number({ + coerce: true, + invalid_type_error: 'Input must be number only', + }) + .positive() + .max(100, 'Value must be between 0 and 100'), +}); + +export function useCoinTypesFromRouteParams() { + const [searchParams] = useSearchParams(); + const fromCoinType = searchParams.get('type'); + const toCoinType = searchParams.get('toType'); + + // Both are already defined, just use them: + if (fromCoinType && toCoinType) { + return { fromCoinType, toCoinType }; } - const slip = new BigNumber(isAsk ? bestBidPrice.toString() : bestAskPrice.toString()).dividedBy( - convertedAverage, - ); + // Neither is set, default to SUI -> USDC + if (!fromCoinType && !toCoinType) { + return { fromCoinType: SUI_TYPE_ARG, toCoinType: USDC_TYPE_ARG }; + } - return new BigNumber('1').minus(slip).abs().isGreaterThan(slipPercentage); + return { fromCoinType, toCoinType }; } -function getCoinsFromBalanceChanges(coinType: string, balanceChanges: BalanceChange[]) { - return balanceChanges - .filter((balance) => { - return balance.coinType === coinType; - }) - .sort((a, b) => { - const aAmount = new BigNumber(a.amount).abs(); - const bAmount = new BigNumber(b.amount).abs(); +export function useGetBalance({ coinType, owner }: { coinType?: string; owner?: string }) { + const { data: coinMetadata } = useCoinMetadata(coinType); + const refetchInterval = useFeatureValue('wallet-balance-refetch-interval', 20_000); - return aAmount.isGreaterThan(bAmount) ? -1 : 1; - }); + return useSuiClientQuery( + 'getBalance', + { + coinType, + owner: owner!, + }, + { + select: (data) => { + const formatted = formatBalance( + data.totalBalance, + coinMetadata?.decimals ?? 0, + CoinFormat.ROUNDED, + ); + + return { + ...data, + formatted, + }; + }, + refetchInterval, + staleTime: 5_000, + enabled: !!owner && !!coinType, + }, + ); } -export function getAverageFromBalanceChanges({ - balanceChanges, - baseCoinType, - quoteCoinType, - isAsk, - baseConversionRate, - quoteConversionRate, +export const getTotalGasCost = (effects: TransactionEffects) => { + return ( + BigInt(effects.gasUsed.computationCost) + + BigInt(effects.gasUsed.storageCost) - + BigInt(effects.gasUsed.storageRebate) + ); +}; + +export function formatSwapQuote({ + result, + sender, + fromType, + toType, + fromCoinDecimals, + toCoinDecimals, }: { - balanceChanges: BalanceChange[]; - baseCoinType: string; - quoteCoinType: string; - isAsk: boolean; - baseConversionRate: number; - quoteConversionRate: number; + fromCoinDecimals: number; + fromType?: string; + result: SwapResult; + sender: string; + toCoinDecimals: number; + toType?: string; }) { - const baseCoins = getCoinsFromBalanceChanges(baseCoinType, balanceChanges); - const quoteCoins = getCoinsFromBalanceChanges(quoteCoinType, balanceChanges); - - if (!baseCoins.length || !quoteCoins.length) { - return { - averageBaseToQuote: '0', - averageQuoteToBase: '0', - }; - } + if (!result || !fromType || !toType) return null; + + const { dryRunResponse, fee } = result; + const { balanceChanges } = dryRunResponse; + const summary = getBalanceChangeSummary(dryRunResponse, []); + const fromAmount = + summary?.[sender]?.find( + (bc) => normalizeStructTag(bc.coinType) === normalizeStructTag(fromType), + )?.amount ?? 0n; + const toAmount = + summary?.[sender]?.find((bc) => normalizeStructTag(bc.coinType) === normalizeStructTag(toType)) + ?.amount ?? 0n; + + const formattedToAmount = formatBalance(toAmount, toCoinDecimals); + + const estimatedRate = new BigNumber(toAmount.toString()) + .shiftedBy(fromCoinDecimals - toCoinDecimals) + .dividedBy(new BigNumber(fromAmount.toString()).abs()) + .toFormat(toCoinDecimals); + + const accessFeeBalanceChange = balanceChanges.find( + (bc) => ![fee.address, sender].includes(getOwnerAddress(bc.owner)), + ); - const baseCoinAmount = new BigNumber(baseCoins[0].amount).abs(); - const quoteCoinAmount = new BigNumber(quoteCoins[0].amount).abs(); - const feesAmount = new BigNumber(isAsk ? baseCoins[1]?.amount : quoteCoins[1]?.amount) - .shiftedBy(isAsk ? -baseConversionRate : -quoteConversionRate) - .abs(); + const accessFees = new BigNumber((accessFeeBalanceChange?.amount || 0n).toString()).shiftedBy( + -toCoinDecimals, + ); + const coinOut = new BigNumber(toAmount.toString()).shiftedBy(-toCoinDecimals); + const accessFeePercentage = accessFees.dividedBy(coinOut).multipliedBy(100).toFormat(3); - const baseAndFees = baseCoinAmount.plus(feesAmount); - const quoteAndFees = quoteCoinAmount.plus(feesAmount); + const estimatedToAmount = new BigNumber(toAmount.toString()) + .shiftedBy(-toCoinDecimals) + .minus(accessFees) + .toFormat(toCoinDecimals); - const averageQuoteToBase = baseCoinAmount - .dividedBy(isAsk ? quoteCoinAmount : quoteAndFees) - .toString(); - const averageBaseToQuote = quoteCoinAmount - .dividedBy(isAsk ? baseAndFees : baseCoinAmount) - .toString(); + const gas = formatBalance(getTotalGasCost(dryRunResponse.effects), SUI_DECIMALS); return { - averageBaseToQuote, - averageQuoteToBase, + provider: result?.provider, + dryRunResponse, + transaction: Transaction.from(result.bytes), + estimatedRate, + formattedToAmount, + accessFeePercentage, + accessFees: accessFees.toFormat(toCoinDecimals), + accessFeeType: accessFeeBalanceChange?.coinType, + estimatedToAmount, + estimatedGas: gas, + toAmount: toAmount.toString(), + feePercentage: fee.percentage, }; } - -export function getBalanceConversion({ - balance, - averages, - isAsk, -}: { - isAsk: boolean; - balance: BigInt | BigNumber | null; - averages: { - averageBaseToQuote: string; - averageQuoteToBase: string; - }; -}) { - const bigNumberBalance = new BigNumber(balance?.toString() ?? '0'); - - if (isAsk) { - return bigNumberBalance.multipliedBy(averages.averageBaseToQuote).toString(); - } - - return bigNumberBalance.multipliedBy(averages.averageQuoteToBase).toString(); -} diff --git a/apps/wallet/src/ui/app/shared/InputWithAction.tsx b/apps/wallet/src/ui/app/shared/InputWithAction.tsx index b81032b5a8897..2e1938518c988 100644 --- a/apps/wallet/src/ui/app/shared/InputWithAction.tsx +++ b/apps/wallet/src/ui/app/shared/InputWithAction.tsx @@ -124,7 +124,7 @@ export function InputWithAction({ const inputWithActionZodFormStyles = cva( [ - 'transition flex flex-row items-center px-3 py-2 text-body font-semibold', + 'transition flex flex-row items-center px-3 py-2 text-body font-semibold overflow-hidden', 'placeholder:text-gray-60 w-full pr-[calc(20%_+_24px)]', 'border-solid border text-steel-darker', 'relative', @@ -250,7 +250,7 @@ export const InputWithActionButton = forwardRef +
{info} {onActionClicked && (