diff --git a/apps/wallet/src/shared/deepBook/context.tsx b/apps/wallet/src/shared/deepBook/context.tsx new file mode 100644 index 0000000000000..769eb92bb4cce --- /dev/null +++ b/apps/wallet/src/shared/deepBook/context.tsx @@ -0,0 +1,58 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 +import { useActiveAccount } from '_app/hooks/useActiveAccount'; +import { useGetOwnedObjects } from '@mysten/core'; +import { useSuiClient } from '@mysten/dapp-kit'; +import { DeepBookClient } from '@mysten/deepbook'; +import { createContext, useContext, useMemo, type ReactNode } from 'react'; + +type DeepBookContextProps = { + client: DeepBookClient; + accountCapId: string; +}; + +const DeepBookContext = createContext(null); + +interface DeepBookContextProviderProps { + children: ReactNode; +} + +export function useDeepBookContext() { + const context = useContext(DeepBookContext); + if (!context) { + throw new Error('useDeepBookContext must be used within a DeepBookContextProvider'); + } + return context; +} + +export function DeepBookContextProvider({ children }: DeepBookContextProviderProps) { + const suiClient = useSuiClient(); + const activeAccount = useActiveAccount(); + const activeAccountAddress = activeAccount?.address; + + const { data } = useGetOwnedObjects( + activeAccountAddress, + { + MatchAll: [{ StructType: '0xdee9::custodian_v2::AccountCap' }], + }, + 1, + ); + + const objectContent = data?.pages?.[0]?.data?.[0]?.data?.content; + const objectFields = objectContent?.dataType === 'moveObject' ? objectContent?.fields : null; + + const accountCapId = (objectFields as Record)?.owner as string; + + const deepBookClient = useMemo(() => { + return new DeepBookClient(suiClient, accountCapId); + }, [accountCapId, suiClient]); + + const contextValue = useMemo(() => { + return { + client: deepBookClient, + accountCapId, + }; + }, [accountCapId, deepBookClient]); + + return {children}; +} diff --git a/apps/wallet/src/shared/experimentation/features.ts b/apps/wallet/src/shared/experimentation/features.ts index eb6fd1295b651..97d322e6dda83 100644 --- a/apps/wallet/src/shared/experimentation/features.ts +++ b/apps/wallet/src/shared/experimentation/features.ts @@ -27,6 +27,7 @@ export enum FEATURES { WALLET_APPS_BANNER_CONFIG = 'wallet-apps-banner-config', WALLET_INTERSTITIAL_CONFIG = 'wallet-interstitial-config', WALLET_DEFI = 'wallet-defi', + WALLET_FEE_ADDRESS = 'wallet-fee-address', } export function setAttributes(network?: { apiEnv: API_ENV; customRPC?: string | null }) { diff --git a/apps/wallet/src/ui/app/hooks/index.ts b/apps/wallet/src/ui/app/hooks/index.ts index 5473cff19129c..963da8e619f6c 100644 --- a/apps/wallet/src/ui/app/hooks/index.ts +++ b/apps/wallet/src/ui/app/hooks/index.ts @@ -16,6 +16,8 @@ export { useGetTxnRecipientAddress } from './useGetTxnRecipientAddress'; export { useQueryTransactionsByAddress } from './useQueryTransactionsByAddress'; export { useGetTransferAmount } from './useGetTransferAmount'; export { useOwnedNFT } from './useOwnedNFT'; +export { useSortedCoinsByCategories } from './useSortedCoinsByCategories'; +export * from './useDeepBook'; export * from './useTransactionData'; export * from './useActiveAddress'; export * from './useGetAllCoins'; diff --git a/apps/wallet/src/ui/app/hooks/useDeepBook.ts b/apps/wallet/src/ui/app/hooks/useDeepBook.ts index 51d974dc5b42d..94e50187f2cc8 100644 --- a/apps/wallet/src/ui/app/hooks/useDeepBook.ts +++ b/apps/wallet/src/ui/app/hooks/useDeepBook.ts @@ -1,23 +1,28 @@ // Copyright (c) Mysten Labs, Inc. // SPDX-License-Identifier: Apache-2.0 -import { getActiveNetworkSuiClient } from '_shared/sui-client'; -import { DeepBookClient } from '@mysten/deepbook'; +import { useActiveAccount } from '_app/hooks/useActiveAccount'; +import { type WalletSigner } from '_app/WalletSigner'; +import { + DEEPBOOK_KEY, + DEFAULT_WALLET_FEE_ADDRESS, + ESTIMATED_GAS_FEES_PERCENTAGE, + ONE_SUI_DEEPBOOK, + WALLET_FEES_PERCENTAGE, +} from '_pages/swap/constants'; +import { useDeepBookContext } from '_shared/deepBook/context'; +import { FEATURES } from '_shared/experimentation/features'; +import { useFeatureValue } from '@growthbook/growthbook-react'; +import { roundFloat, useGetObject } from '@mysten/core'; +import { useSuiClient } from '@mysten/dapp-kit'; +import { type DeepBookClient } from '@mysten/deepbook'; +import { TransactionBlock } from '@mysten/sui.js/builder'; +import { type CoinStruct, type SuiClient } from '@mysten/sui.js/client'; import { SUI_TYPE_ARG } from '@mysten/sui.js/utils'; -import { useQuery } from '@tanstack/react-query'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; import BigNumber from 'bignumber.js'; import { useMemo } from 'react'; -const DEEPBOOK_KEY = 'deepbook'; - -export const mainnetPools = { - SUI_USDC_1: '0x18d871e3c3da99046dfc0d3de612c5d88859bc03b8f0568bd127d0e70dbc58be', - SUI_USDC_2: '0x7f526b1263c4b91b43c9e646419b5696f424de28dda3c1e6658cc0a54558baa7', // not working currently due to pagination - WETH_USDC_1: '0xd9e45ab5440d61cc52e3b2bd915cdd643146f7593d587c715bc7bfa48311d826', - TBTC_USDC_1: '0xf0f663cf87f1eb124da2fc9be813e0ce262146f3df60bc2052d738eb41a25899', - USDT_USDC_1: '0x5deafda22b6b86127ea4299503362638bea0ca33bb212ea3a67b029356b8b955', -}; - export enum Coins { SUI = 'SUI', USDC = 'USDC', @@ -26,97 +31,107 @@ export enum Coins { TBTC = 'TBTC', } -export const coinsMap = { - [Coins.SUI]: '0x0000000000000000000000000000000000000000000000000000000000000002::sui::SUI', - [Coins.USDC]: '0x5d4b302506645c37ff133b98c4b50a5ae14841659738d6d733d59d0d217a93bf::coin::COIN', - [Coins.USDT]: '0xc060006111016b8a020ad5b33834984a437aaa7d3c74c18e09a95d48aceab08c::coin::COIN', - [Coins.WETH]: '0xaf8cd5edc19c4512f4259f0bee101a40d41ebed738ade5874359610ef8eeced5::coin::COIN', - [Coins.TBTC]: '0xbc3a676894871284b3ccfb2eec66f428612000e2a6e6d23f592ce8833c27c973::coin::COIN', +export const mainnetDeepBook: { + pools: Record; + coinsMap: Record; +} = { + pools: { + SUI_USDC: [ + '0x7f526b1263c4b91b43c9e646419b5696f424de28dda3c1e6658cc0a54558baa7', + '0x18d871e3c3da99046dfc0d3de612c5d88859bc03b8f0568bd127d0e70dbc58be', + ], + WETH_USDC: ['0xd9e45ab5440d61cc52e3b2bd915cdd643146f7593d587c715bc7bfa48311d826'], + TBTC_USDC: ['0xf0f663cf87f1eb124da2fc9be813e0ce262146f3df60bc2052d738eb41a25899'], + USDT_USDC: ['0x5deafda22b6b86127ea4299503362638bea0ca33bb212ea3a67b029356b8b955'], + }, + coinsMap: { + [Coins.SUI]: '0x0000000000000000000000000000000000000000000000000000000000000002::sui::SUI', + [Coins.USDC]: '0x5d4b302506645c37ff133b98c4b50a5ae14841659738d6d733d59d0d217a93bf::coin::COIN', + [Coins.USDT]: '0xc060006111016b8a020ad5b33834984a437aaa7d3c74c18e09a95d48aceab08c::coin::COIN', + [Coins.WETH]: '0xaf8cd5edc19c4512f4259f0bee101a40d41ebed738ade5874359610ef8eeced5::coin::COIN', + [Coins.TBTC]: '0xbc3a676894871284b3ccfb2eec66f428612000e2a6e6d23f592ce8833c27c973::coin::COIN', + }, }; -export const allowedSwapCoinsList = [SUI_TYPE_ARG, coinsMap[Coins.USDC]]; +export function useDeepBookConfigs() { + return mainnetDeepBook; +} + +export function useRecognizedCoins() { + const coinsMap = useDeepBookConfigs().coinsMap; + return Object.values(coinsMap); +} + +export const allowedSwapCoinsList = [SUI_TYPE_ARG, mainnetDeepBook.coinsMap[Coins.USDC]]; export function getUSDCurrency(amount: number | null) { if (typeof amount !== 'number') { return null; } - return amount.toLocaleString('en', { + return roundFloat(amount).toLocaleString('en', { style: 'currency', currency: 'USD', }); } -async function getDeepBookClient(): Promise { - const suiClient = await getActiveNetworkSuiClient(); - return new DeepBookClient(suiClient); -} - export function useDeepbookPools() { + const deepBookClient = useDeepBookContext().client; + return useQuery({ queryKey: [DEEPBOOK_KEY, 'get-all-pools'], - queryFn: async () => { - const deepBookClient = await getDeepBookClient(); - return deepBookClient.getAllPools({}); - }, + queryFn: () => deepBookClient.getAllPools({}), }); } -async function getPriceForPool( - poolName: keyof typeof mainnetPools, +async function getDeepBookPriceForCoin( + coin: Coins, + pools: Record, + isAsk: boolean, deepBookClient: DeepBookClient, ) { - const { bestBidPrice, bestAskPrice } = await deepBookClient.getMarketPrice( - mainnetPools[poolName], - ); - - if (bestBidPrice && bestAskPrice) { - return (bestBidPrice + bestAskPrice) / 2n; - } - - return bestBidPrice || bestAskPrice; -} - -async function getDeepBookPriceForCoin(coin: Coins, deepbookClient: DeepBookClient) { if (coin === Coins.USDC) { return 1n; } - const poolName1 = `${coin}_USDC_1` as keyof typeof mainnetPools; - const poolName2 = coin === Coins.SUI ? 'SUI_USDC_2' : null; + const poolName = `${coin}_USDC`; + const poolIds = pools[poolName]; + const promises = poolIds.map(async (poolId) => { + const { bestBidPrice, bestAskPrice } = await deepBookClient.getMarketPrice(poolId); - const promises = [getPriceForPool(poolName1, deepbookClient)]; - if (poolName2) { - promises.push(getPriceForPool(poolName2, deepbookClient)); - } + return isAsk ? bestBidPrice : bestAskPrice; + }); - const [price1, price2] = await Promise.all(promises); + const prices = await Promise.all(promises); - if (price1 && price2) { - return (price1 + price2) / 2n; - } + const filter: bigint[] = prices.filter((price): price is bigint => { + return typeof price === 'bigint' && price !== 0n; + }); - return price1 || price2; -} + const total = filter.reduce((acc, price) => { + return acc + price; + }, 0n); -async function getDeepbookPricesInUSD(coins: Coins[], deepBookClient: DeepBookClient) { - const promises = coins.map((coin) => getDeepBookPriceForCoin(coin, deepBookClient)); - return Promise.all(promises); + return total / BigInt(filter.length); } -function useDeepbookPricesInUSD(coins: Coins[]) { +function useDeepbookPricesInUSD(coins: Coins[], isAsk: boolean) { + const deepBookClient = useDeepBookContext().client; + const deepbookPools = useDeepBookConfigs().pools; + return useQuery({ - queryKey: [DEEPBOOK_KEY, 'get-prices-usd', coins], + queryKey: [DEEPBOOK_KEY, 'get-prices-usd', coins, isAsk], queryFn: async () => { - const deepBookClient = await getDeepBookClient(); - - return getDeepbookPricesInUSD(coins, deepBookClient); + const promises = coins.map((coin) => + getDeepBookPriceForCoin(coin, deepbookPools, isAsk, deepBookClient), + ); + return Promise.all(promises); }, }); } -function useAveragePrice(base: Coins, quote: Coins) { - const { data: prices, ...rest } = useDeepbookPricesInUSD([base, quote]); +function useAveragePrice(base: Coins, quote: Coins, isAsk: boolean) { + const { data: prices, ...rest } = useDeepbookPricesInUSD([base, quote], isAsk); const averagePrice = useMemo(() => { const basePrice = new BigNumber((prices?.[0] ?? 1n).toString()); @@ -143,30 +158,338 @@ function useAveragePrice(base: Coins, quote: Coins) { export function useBalanceConversion( balance: BigInt | BigNumber | null, - base: Coins, - quote: Coins, + from: Coins, + to: Coins, + conversionRate: number = 1, ) { - const { data: averagePrice, ...rest } = useAveragePrice(base, quote); + const { data: averagePrice, ...rest } = useAveragePrice(from, to, to === Coins.USDC); + + const averagePriceWithConversion = averagePrice.shiftedBy(conversionRate); const rawValue = useMemo(() => { - if (!averagePrice || !balance) return null; + if (!averagePriceWithConversion || !balance) return null; - const rawUsdValue = new BigNumber(balance.toString()).multipliedBy(averagePrice).toNumber(); + const rawUsdValue = new BigNumber(balance.toString()) + .multipliedBy(averagePriceWithConversion) + .toNumber(); if (isNaN(rawUsdValue)) { return null; } return rawUsdValue; - }, [averagePrice, balance]); + }, [averagePriceWithConversion, balance]); return { rawValue, - averagePrice, + averagePrice: averagePriceWithConversion, ...rest, }; } -export function useSuiBalanceInUSDC(suiBalance: BigInt | BigNumber | null) { - return useBalanceConversion(suiBalance, Coins.SUI, Coins.USDC); +const MAX_COINS_PER_REQUEST = 10; + +export async function getCoinsByBalance({ + coinType, + balance, + suiClient, + address, +}: { + coinType: string; + balance: string; + suiClient: SuiClient; + address: string; +}) { + let cursor: string | undefined | null = null; + let currentBalance = 0n; + let hasNextPage = true; + const coins = []; + const bigIntBalance = BigInt(new BigNumber(balance).integerValue(BigNumber.ROUND_UP).toString()); + + while (currentBalance < bigIntBalance && hasNextPage) { + const { data, nextCursor } = await suiClient.getCoins({ + owner: address, + coinType, + cursor, + limit: MAX_COINS_PER_REQUEST, + }); + + if (!data || !data.length) { + break; + } + + for (const coin of data) { + currentBalance += BigInt(coin.balance); + coins.push(coin); + + if (currentBalance >= bigIntBalance) { + break; + } + } + + cursor = nextCursor; + hasNextPage = !!nextCursor; + } + + if (!coins.length) { + throw new Error('No coins found in balance'); + } + + return coins; +} + +function formatBalanceToLotSize(balance: string, lotSize: number) { + const balanceBigNumber = new BigNumber(balance); + const lotSizeBigNumber = new BigNumber(lotSize); + const remainder = balanceBigNumber.mod(lotSizeBigNumber); + + if (remainder.isEqualTo(0)) { + return balanceBigNumber.toString(); + } + + const roundedDownBalance = balanceBigNumber.minus(remainder); + return roundedDownBalance.abs().toString(); +} + +async function getPlaceMarketOrderTxn({ + deepBookClient, + poolId, + accountCapId, + address, + isAsk, + lotSize, + baseBalance, + quoteBalance, + quoteCoins, + walletFeeAddress, +}: { + deepBookClient: DeepBookClient; + poolId: string; + accountCapId: string; + address: string; + isAsk: boolean; + lotSize: number; + baseBalance: string; + quoteBalance: string; + baseCoins: CoinStruct[]; + quoteCoins: CoinStruct[]; + walletFeeAddress: string; +}) { + const txb = new TransactionBlock(); + const accountCap = accountCapId || deepBookClient.createAccountCap(txb); + + let swapCoin; + let balanceToSwap; + let walletFeeCoin; + let txnResult; + + if (isAsk) { + const bigNumberBaseBalance = new BigNumber(baseBalance); + + if (bigNumberBaseBalance.isLessThan(ONE_SUI_DEEPBOOK)) { + balanceToSwap = bigNumberBaseBalance.minus( + bigNumberBaseBalance.times(ESTIMATED_GAS_FEES_PERCENTAGE / 100), + ); + } else { + balanceToSwap = bigNumberBaseBalance; + } + + const walletFee = balanceToSwap + .times(WALLET_FEES_PERCENTAGE / 100) + .integerValue(BigNumber.ROUND_DOWN) + .toString(); + + balanceToSwap = formatBalanceToLotSize(balanceToSwap.minus(walletFee).toString(), lotSize); + swapCoin = txb.splitCoins(txb.gas, [balanceToSwap]); + walletFeeCoin = txb.splitCoins(txb.gas, [walletFee]); + txnResult = await deepBookClient.placeMarketOrder( + accountCap, + poolId, + BigInt(balanceToSwap), + isAsk ? 'ask' : 'bid', + isAsk ? swapCoin : undefined, + isAsk ? undefined : swapCoin, + undefined, + address, + txb, + ); + } else { + const primaryCoinInput = txb.object(quoteCoins[0].coinObjectId); + const restCoins = quoteCoins.slice(1); + + if (restCoins.length) { + txb.mergeCoins( + primaryCoinInput, + restCoins.map((coin) => txb.object(coin.coinObjectId)), + ); + } + + const walletFee = new BigNumber(quoteBalance) + .times(WALLET_FEES_PERCENTAGE / 100) + .integerValue(BigNumber.ROUND_DOWN) + .toString(); + + balanceToSwap = new BigNumber(quoteBalance).minus(walletFee).toString(); + + const [swapCoin, walletCoin] = txb.splitCoins(primaryCoinInput, [balanceToSwap, walletFee]); + + txnResult = await deepBookClient.swapExactQuoteForBase( + poolId, + swapCoin, + BigInt(balanceToSwap), + address, + undefined, + txb, + ); + + walletFeeCoin = walletCoin; + } + + if (!accountCapId) { + txnResult.transferObjects([accountCap], address); + } + + if (walletFeeCoin) txnResult.transferObjects([walletFeeCoin], walletFeeAddress); + + return txnResult; +} + +export function useGetEstimate({ + accountCapId, + signer, + coinType, + poolId, + baseBalance, + quoteBalance, + isAsk, +}: { + accountCapId: string; + signer: WalletSigner | null; + coinType: string; + poolId: string; + baseBalance: string; + quoteBalance: string; + isAsk: boolean; +}) { + const walletFeeAddress = useFeatureValue(FEATURES.WALLET_FEE_ADDRESS, DEFAULT_WALLET_FEE_ADDRESS); + const queryClient = useQueryClient(); + const suiClient = useSuiClient(); + const activeAccount = useActiveAccount(); + const activeAddress = activeAccount?.address; + const deepBookClient = useDeepBookContext().client; + + const { data } = useGetObject(poolId); + const objectFields = + data?.data?.content?.dataType === 'moveObject' ? data?.data?.content?.fields : null; + + const lotSize = (objectFields as Record)?.lot_size; + + return useQuery({ + // eslint-disable-next-line @tanstack/query/exhaustive-deps + queryKey: [ + DEEPBOOK_KEY, + 'get-estimate', + poolId, + accountCapId, + coinType, + activeAddress, + baseBalance, + quoteBalance, + isAsk, + lotSize, + ], + queryFn: async () => { + const [baseCoins, quoteCoins] = await Promise.all([ + getCoinsByBalance({ + coinType, + balance: baseBalance, + suiClient, + address: activeAddress!, + }), + getCoinsByBalance({ + coinType, + balance: quoteBalance, + suiClient, + address: activeAddress!, + }), + ]); + + if ((isAsk && !baseCoins.length) || (!isAsk && !quoteCoins.length)) { + throw new Error('No coins found in balance'); + } + + const txn = await getPlaceMarketOrderTxn({ + deepBookClient, + poolId, + accountCapId, + address: activeAddress!, + isAsk, + lotSize: Number(lotSize), + baseCoins, + quoteCoins, + baseBalance, + quoteBalance, + walletFeeAddress, + }); + + if (!accountCapId) { + await queryClient.invalidateQueries(['get-owned-objects']); + } + + const dryRunResponse = await signer!.dryRunTransactionBlock({ transactionBlock: txn }); + + return { + txn, + dryRunResponse, + }; + }, + enabled: + !!baseBalance && + baseBalance !== '0' && + !!quoteBalance && + quoteBalance !== '0' && + !!signer && + !!activeAddress, + }); +} + +export async function isExceedingSlippageTolerance({ + slipPercentage, + poolId, + deepBookClient, + conversionRate, + baseCoinAmount, + quoteCoinAmount, + isAsk, +}: { + slipPercentage: string; + poolId: string; + deepBookClient: DeepBookClient; + conversionRate: number; + baseCoinAmount?: string; + quoteCoinAmount?: string; + isAsk: boolean; +}) { + if (!baseCoinAmount || !quoteCoinAmount) { + return false; + } + + const bigNumberBaseCoinAmount = new BigNumber(baseCoinAmount).abs(); + const bigNumberQuoteCoinAmount = new BigNumber(quoteCoinAmount).abs(); + + const averagePricePaid = bigNumberQuoteCoinAmount + .dividedBy(bigNumberBaseCoinAmount) + .shiftedBy(conversionRate); + + const { bestBidPrice, bestAskPrice } = await deepBookClient.getMarketPrice(poolId); + + if (!bestBidPrice || !bestAskPrice) { + return false; + } + + const slip = new BigNumber(isAsk ? bestBidPrice.toString() : bestAskPrice.toString()).dividedBy( + averagePricePaid, + ); + + return new BigNumber('1').minus(slip).abs().isGreaterThan(slipPercentage); } diff --git a/apps/wallet/src/ui/app/index.tsx b/apps/wallet/src/ui/app/index.tsx index 9a3e616d73cae..9a325ac2bf9bc 100644 --- a/apps/wallet/src/ui/app/index.tsx +++ b/apps/wallet/src/ui/app/index.tsx @@ -2,6 +2,8 @@ // SPDX-License-Identifier: Apache-2.0 import { useAppDispatch, useAppSelector } from '_hooks'; +import { SwapPage } from '_pages/swap'; +import { FromAssets } from '_pages/swap/FromAssets'; import { setNavVisibility } from '_redux/slices/app'; import { isLedgerAccountSerializedUI } from '_src/background/accounts/LedgerAccount'; import { persistableStorage } from '_src/shared/analytics/amplitude'; @@ -173,6 +175,8 @@ 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 41dd31200ab4b..c95da248125de 100644 --- a/apps/wallet/src/ui/app/pages/home/tokens/TokensDetails.tsx +++ b/apps/wallet/src/ui/app/pages/home/tokens/TokensDetails.tsx @@ -3,7 +3,6 @@ import { allowedSwapCoinsList } from '_app/hooks/useDeepBook'; import { useIsWalletDefiEnabled } from '_app/hooks/useIsWalletDefiEnabled'; -import { useSortedCoinsByCategories } from '_app/hooks/useSortedCoinsByCategories'; import { LargeButton } from '_app/shared/LargeButton'; import { Text } from '_app/shared/text'; import { ButtonOrLink } from '_app/shared/utils/ButtonOrLink'; @@ -11,7 +10,7 @@ import Alert from '_components/alert'; import { CoinIcon } from '_components/coin-icon'; import Loading from '_components/loading'; import { filterAndSortTokenBalances } from '_helpers'; -import { useAppSelector, useCoinsReFetchingConfig } from '_hooks'; +import { useAppSelector, useCoinsReFetchingConfig, useSortedCoinsByCategories } from '_hooks'; import { ampli } from '_src/shared/analytics/ampli'; import { API_ENV } from '_src/shared/api-env'; import { FEATURES } from '_src/shared/experimentation/features'; @@ -105,7 +104,7 @@ export function TokenRow({ return ( {renderActions ? ( -
+
void; + disabled?: boolean; +}) { + return ( + + + + + {symbol} + + {!disabled && } + +
+ } + > + {!!tokenBalance && ( +
+ {tokenBalance} {symbol} +
+ )} + + ); +} diff --git a/apps/wallet/src/ui/app/pages/swap/BaseAssets.tsx b/apps/wallet/src/ui/app/pages/swap/FromAssets.tsx similarity index 85% rename from apps/wallet/src/ui/app/pages/swap/BaseAssets.tsx rename to apps/wallet/src/ui/app/pages/swap/FromAssets.tsx index 6b1d248c1f2f9..cad4b4ea858b6 100644 --- a/apps/wallet/src/ui/app/pages/swap/BaseAssets.tsx +++ b/apps/wallet/src/ui/app/pages/swap/FromAssets.tsx @@ -1,16 +1,16 @@ // Copyright (c) Mysten Labs, Inc. // SPDX-License-Identifier: Apache-2.0 -import { useSortedCoinsByCategories } from '_app/hooks/useSortedCoinsByCategories'; import Loading from '_components/loading'; import Overlay from '_components/overlay'; import { filterAndSortTokenBalances } from '_helpers'; -import { useActiveAddress, useCoinsReFetchingConfig } from '_hooks'; +import { useActiveAddress, useCoinsReFetchingConfig, useSortedCoinsByCategories } from '_hooks'; import { TokenRow } from '_pages/home/tokens/TokensDetails'; import { useSuiClientQuery } from '@mysten/dapp-kit'; +import { Fragment } from 'react'; import { useNavigate } from 'react-router-dom'; -export function BaseAssets() { +export function FromAssets() { const navigate = useNavigate(); const selectedAddress = useActiveAddress(); const { staleTime, refetchInterval } = useCoinsReFetchingConfig(); @@ -34,9 +34,8 @@ export function BaseAssets() {
{recognized?.map((coinBalance, index) => { return ( - <> + { navigate( @@ -46,7 +45,7 @@ export function BaseAssets() { /> {index !== recognized.length - 1 &&
} - + ); })}
diff --git a/apps/wallet/src/ui/app/pages/swap/GasFeeSection.tsx b/apps/wallet/src/ui/app/pages/swap/GasFeeSection.tsx new file mode 100644 index 0000000000000..d70a6702a9715 --- /dev/null +++ b/apps/wallet/src/ui/app/pages/swap/GasFeeSection.tsx @@ -0,0 +1,77 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 +import { Coins, getUSDCurrency, useBalanceConversion } from '_app/hooks/useDeepBook'; +import { Text } from '_app/shared/text'; +import { DescriptionItem } from '_pages/approval-request/transaction-request/DescriptionList'; +import { SUI_CONVERSION_RATE, WALLET_FEES_PERCENTAGE } from '_pages/swap/constants'; +import { GAS_TYPE_ARG } from '_redux/slices/sui-objects/Coin'; +import { useCoinMetadata, useFormatCoin } from '@mysten/core'; +import { SUI_TYPE_ARG } from '@mysten/sui.js/utils'; +import BigNumber from 'bignumber.js'; +import { useMemo } from 'react'; + +export function GasFeeSection({ + activeCoinType, + totalGas, + amount, + isValid, +}: { + activeCoinType: string | null; + amount: string; + isValid: boolean; + totalGas: string; +}) { + const { data: activeCoinData } = useCoinMetadata(activeCoinType); + const isAsk = activeCoinType === SUI_TYPE_ARG; + + const estimatedFees = useMemo(() => { + if (!amount || !isValid) { + return null; + } + + return new BigNumber(amount).times(WALLET_FEES_PERCENTAGE / 100); + }, [amount, isValid]); + + const { rawValue } = useBalanceConversion( + estimatedFees, + isAsk ? Coins.SUI : Coins.USDC, + isAsk ? Coins.USDC : Coins.SUI, + isAsk ? -SUI_CONVERSION_RATE : SUI_CONVERSION_RATE, + ); + + const [gas, symbol] = useFormatCoin(totalGas, GAS_TYPE_ARG); + + const formattedEstimatedFees = getUSDCurrency(rawValue); + + return ( +
+ + Fees ({WALLET_FEES_PERCENTAGE}%) + + } + > + + {estimatedFees + ? `${estimatedFees.toLocaleString()} ${activeCoinData?.symbol} (${formattedEstimatedFees})` + : '--'} + + + +
+ + + Estimated Gas Fee + + } + > + + {totalGas && isValid ? `${gas} ${symbol}` : '--'} + + +
+ ); +} diff --git a/apps/wallet/src/ui/app/pages/swap/MaxSlippage.tsx b/apps/wallet/src/ui/app/pages/swap/MaxSlippage.tsx new file mode 100644 index 0000000000000..fec339b7deb9f --- /dev/null +++ b/apps/wallet/src/ui/app/pages/swap/MaxSlippage.tsx @@ -0,0 +1,89 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 +import BottomMenuLayout, { Content, Menu } from '_app/shared/bottom-menu-layout'; +import { Button } from '_app/shared/ButtonUI'; +import { InputWithActionButton } from '_app/shared/InputWithAction'; +import { Text } from '_app/shared/text'; +import { IconTooltip } from '_app/shared/tooltip'; +import Alert from '_components/alert'; +import { IconButton } from '_components/IconButton'; +import Overlay from '_components/overlay'; +import { DescriptionItem } from '_pages/approval-request/transaction-request/DescriptionList'; +import { type FormValues } from '_pages/swap/constants'; +import { Settings16 } from '@mysten/icons/src'; +import { useFormContext } from 'react-hook-form'; + +const MAX_SLIPPAGE_COPY = + 'Slippage % is the difference between the price you expect to pay or receive for a coin when you initiate a transaction and the actual price at which the transaction is executed.'; + +export function MaxSlippage({ onOpen }: { onOpen: () => void }) { + const { watch } = useFormContext(); + const allowedMaxSlippagePercentage = watch('allowedMaxSlippagePercentage'); + + return ( + + Max Slippage Tolerance +
+ +
+
+ } + > +
+ + {allowedMaxSlippagePercentage}% + + + } /> +
+ + ); +} + +export function MaxSlippageModal({ isOpen, onClose }: { onClose: () => void; isOpen: boolean }) { + const { + register, + formState: { errors }, + } = useFormContext(); + + const errorString = errors.allowedMaxSlippagePercentage?.message; + + return ( + +
+ + +
+
+ + your max slippage tolerance + +
+ %
} + /> + {errorString ? ( +
+ {errorString} +
+ ) : null} +
+ + {MAX_SLIPPAGE_COPY} + +
+
+ + + + + +
+ + ); +} diff --git a/apps/wallet/src/ui/app/pages/swap/ToAssetSection.tsx b/apps/wallet/src/ui/app/pages/swap/ToAssetSection.tsx new file mode 100644 index 0000000000000..85482d54ea983 --- /dev/null +++ b/apps/wallet/src/ui/app/pages/swap/ToAssetSection.tsx @@ -0,0 +1,180 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 +import { + Coins, + getUSDCurrency, + useDeepBookConfigs, + useRecognizedCoins, +} from '_app/hooks/useDeepBook'; +import { Text } from '_app/shared/text'; +import Alert from '_components/alert'; +import { IconButton } from '_components/IconButton'; +import { DescriptionItem } from '_pages/approval-request/transaction-request/DescriptionList'; +import { AssetData } from '_pages/swap/AssetData'; +import { + MAX_FLOAT, + SUI_CONVERSION_RATE, + USDC_DECIMALS, + type FormValues, +} from '_pages/swap/constants'; +import { MaxSlippage, MaxSlippageModal } from '_pages/swap/MaxSlippage'; +import { ToAssets } from '_pages/swap/ToAssets'; +import { useSuiUsdcBalanceConversion, useSwapData } from '_pages/swap/utils'; +import { useCoinMetadata } from '@mysten/core'; +import { Refresh16 } from '@mysten/icons'; +import { type BalanceChange } from '@mysten/sui.js/client'; +import { SUI_TYPE_ARG } from '@mysten/sui.js/utils'; +import BigNumber from 'bignumber.js'; +import clsx from 'classnames'; +import { useEffect, useState } from 'react'; +import { useFormContext } from 'react-hook-form'; + +export function ToAssetSection({ + activeCoinType, + balanceChanges, + slippageErrorString, + baseCoinType, + quoteCoinType, +}: { + activeCoinType: string | null; + balanceChanges: BalanceChange[]; + slippageErrorString: string; + baseCoinType: string; + quoteCoinType: string; +}) { + const coinsMap = useDeepBookConfigs().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, + activeCoinType: activeCoinType || '', + }); + + const toAssetBalance = isAsk ? formattedQuoteBalance : formattedBaseBalance; + const toAssetMetaData = isAsk ? quoteCoinMetadata : baseCoinMetadata; + + const { + watch, + setValue, + formState: { isValid }, + } = useFormContext(); + const { data: activeCoinData } = useCoinMetadata(activeCoinType); + 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_DECIMALS) + .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'); + + const { suiUsdc, usdcSui } = useSuiUsdcBalanceConversion({ amount }); + const balanceConversionData = isAsk ? suiUsdc : usdcSui; + const { rawValue, averagePrice, refetch, isRefetching } = balanceConversionData || {}; + + const averagePriceAsString = averagePrice.toFixed(MAX_FLOAT).toString(); + + if (!toAssetMetaData.data) { + return null; + } + + return ( +
+ setToAssetOpen(false)} + onRowClick={(coinType) => { + setToAssetOpen(false); + }} + /> + { + setToAssetOpen(true); + }} + /> +
+ {toAssetAmountAsNum && !isRefetching ? ( + <> + + {toAssetAmountAsNum} + + + {toAssetSymbol} + + + ) : ( + + -- + + )} +
+ {rawValue && ( +
+ + {isRefetching ? '--' : getUSDCurrency(isAsk ? toAssetAmountAsNum : Number(amount))} + + } + > +
+ + 1 {activeCoinData?.symbol} = {isRefetching ? '--' : averagePriceAsString}{' '} + {toAssetSymbol} + + } + onClick={() => refetch()} + loading={isRefetching} + /> +
+
+ +
+ + setSlippageModalOpen(true)} /> + + {slippageErrorString && ( +
+ {slippageErrorString} +
+ )} + + setSlippageModalOpen(false)} + /> +
+ )} +
+ ); +} diff --git a/apps/wallet/src/ui/app/pages/swap/QuoteAssets.tsx b/apps/wallet/src/ui/app/pages/swap/ToAssets.tsx similarity index 88% rename from apps/wallet/src/ui/app/pages/swap/QuoteAssets.tsx rename to apps/wallet/src/ui/app/pages/swap/ToAssets.tsx index fceec689f22fb..d022c8dee75c1 100644 --- a/apps/wallet/src/ui/app/pages/swap/QuoteAssets.tsx +++ b/apps/wallet/src/ui/app/pages/swap/ToAssets.tsx @@ -8,13 +8,7 @@ import { useSuiClientQuery } from '@mysten/dapp-kit'; import { Fragment } from 'react'; import { useSearchParams } from 'react-router-dom'; -function QuoteAsset({ - coinType, - onClick, -}: { - coinType: string; - onClick: (coinType: string) => void; -}) { +function ToAsset({ coinType, onClick }: { coinType: string; onClick: (coinType: string) => void }) { const accountAddress = useActiveAddress(); const [searchParams] = useSearchParams(); const activeCoinType = searchParams.get('type'); @@ -41,7 +35,7 @@ function QuoteAsset({ ); } -export function QuoteAssets({ +export function ToAssets({ onClose, isOpen, onRowClick, @@ -57,7 +51,7 @@ export function QuoteAssets({
{recognizedCoins.map((coinType, index) => ( - + {index !== recognizedCoins.length - 1 &&
} ))} diff --git a/apps/wallet/src/ui/app/pages/swap/constants.ts b/apps/wallet/src/ui/app/pages/swap/constants.ts new file mode 100644 index 0000000000000..705e150db087c --- /dev/null +++ b/apps/wallet/src/ui/app/pages/swap/constants.ts @@ -0,0 +1,18 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 +export const DEEPBOOK_KEY = 'deepbook'; +export const SUI_CONVERSION_RATE = 6; +export const USDC_DECIMALS = 9; +export const MAX_FLOAT = 2; +export const WALLET_FEES_PERCENTAGE = 0.5; +export const ESTIMATED_GAS_FEES_PERCENTAGE = 1; +export const ONE_SUI_DEEPBOOK = 1000000000; +export const DEFAULT_WALLET_FEE_ADDRESS = + '0x55b0eb986766351d802ac3e1bbb8750a072b3fa40c782ebe3a0f48c9099f7fd3'; +export const DEFAULT_MAX_SLIPPAGE_PERCENTAGE = '0.5'; +export const initialValues = { + amount: '', + toAssetType: '', + allowedMaxSlippagePercentage: DEFAULT_MAX_SLIPPAGE_PERCENTAGE, +}; +export type FormValues = typeof initialValues; diff --git a/apps/wallet/src/ui/app/pages/swap/index.tsx b/apps/wallet/src/ui/app/pages/swap/index.tsx new file mode 100644 index 0000000000000..c9bfe3224c538 --- /dev/null +++ b/apps/wallet/src/ui/app/pages/swap/index.tsx @@ -0,0 +1,457 @@ +// 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 { InputWithActionButton } from '_app/shared/InputWithAction'; +import { ButtonOrLink } from '_app/shared/utils/ButtonOrLink'; +import Loading from '_components/loading'; +import Overlay from '_components/overlay'; +import { filterAndSortTokenBalances } from '_helpers'; +import { + allowedSwapCoinsList, + Coins, + getUSDCurrency, + isExceedingSlippageTolerance, + useCoinsReFetchingConfig, + useDeepBookConfigs, + useGetEstimate, + useSortedCoinsByCategories, +} from '_hooks'; +import { + initialValues, + SUI_CONVERSION_RATE, + USDC_DECIMALS, + type FormValues, +} from '_pages/swap/constants'; +import { useSuiUsdcBalanceConversion, useSwapData } from '_pages/swap/utils'; +import { DeepBookContextProvider, useDeepBookContext } from '_shared/deepBook/context'; +import { useTransactionSummary, useZodForm } from '@mysten/core'; +import { useSuiClientQuery } from '@mysten/dapp-kit'; +import { ArrowDown12, ArrowRight16 } from '@mysten/icons'; +import { type BalanceChange, type DryRunTransactionBlockResponse } from '@mysten/sui.js/client'; +import { SUI_DECIMALS, SUI_TYPE_ARG } from '@mysten/sui.js/utils'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import BigNumber from 'bignumber.js'; +import clsx from 'classnames'; +import { useMemo, useState } from 'react'; +import { useWatch, 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'; + +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}`; +} + +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(); + + return aAmount.isGreaterThan(bAmount) ? -1 : 1; + }); +} + +export function SwapPageContent() { + const [slippageErrorString, setSlippageErrorString] = useState(''); + const queryClient = useQueryClient(); + const mainnetPools = useDeepBookConfigs().pools; + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const activeAccount = useActiveAccount(); + const signer = useSigner(activeAccount); + const activeAccountAddress = activeAccount?.address; + const { staleTime, refetchInterval } = useCoinsReFetchingConfig(); + const coinsMap = useDeepBookConfigs().coinsMap; + const deepBookClient = useDeepBookContext().client; + + const accountCapId = useDeepBookContext().accountCapId; + + 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, + isLoading, + } = useSwapData({ + baseCoinType, + quoteCoinType, + activeCoinType: activeCoinType || '', + }); + + 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 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.NEVER; + } + + if (bigNumberValue.lt(0)) { + context.addIssue({ + code: 'custom', + message: 'Amount must be greater than 0.', + }); + 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', + }); + 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.', + }); + return z.NEVER; + } + + return percent; + }), + }); + }, [isAsk, baseCoinDecimals, quoteCoinDecimals, maxBaseBalance, maxQuoteBalance]); + + const form = useZodForm({ + mode: 'all', + schema: validationSchema, + defaultValues: { + ...initialValues, + toAssetType: coinsMap.USDC, + }, + }); + + const { + register, + setValue, + control, + handleSubmit, + reset, + formState: { isValid, isSubmitting, errors }, + } = form; + + const renderButtonToCoinsList = useMemo(() => { + return ( + recognized.length > 1 && + recognized.some((coin) => allowedSwapCoinsList.includes(coin.coinType)) + ); + }, [recognized]); + + const amount = useWatch({ + name: 'amount', + control, + }); + + const isPayAll = amount === (isAsk ? formattedBaseTokenBalance : formattedQuoteTokenBalance); + + const { suiUsdc, usdcSui } = useSuiUsdcBalanceConversion({ amount }); + const rawInputSuiUsdc = suiUsdc.rawValue; + const rawInputUsdcSui = usdcSui.rawValue; + + const atcText = useMemo(() => { + if (isAsk) { + return getSwapPageAtcText(baseCoinSymbol, quoteCoinType, coinsMap); + } + return getSwapPageAtcText(quoteCoinSymbol, baseCoinType, coinsMap); + }, [isAsk, baseCoinSymbol, baseCoinType, coinsMap, quoteCoinSymbol, quoteCoinType]); + + const baseBalance = new BigNumber(isAsk ? amount || 0 : rawInputUsdcSui || 0) + .shiftedBy(SUI_DECIMALS) + .toString(); + const quoteBalance = new BigNumber(isAsk ? rawInputSuiUsdc || 0 : amount || 0) + .shiftedBy(SUI_CONVERSION_RATE) + .toString(); + + const { + data: dataFromEstimate, + isLoading: dataFromEstimateLoading, + isError: dataFromEstimateError, + } = useGetEstimate({ + signer, + accountCapId, + coinType: activeCoinType || '', + poolId, + baseBalance, + quoteBalance, + isAsk, + }); + + const recognizedPackagesList = useRecognizedPackages(); + + const txnSummary = useTransactionSummary({ + transaction: dataFromEstimate?.dryRunResponse as DryRunTransactionBlockResponse, + recognizedPackagesList, + currentAddress: activeAccountAddress, + }); + + const totalGas = txnSummary?.gas?.totalGas; + const balanceChanges = dataFromEstimate?.dryRunResponse?.balanceChanges || []; + + const { mutate: handleSwap, isLoading: isSwapLoading } = useMutation({ + mutationFn: async (formData: FormValues) => { + const txn = dataFromEstimate?.txn; + + const baseCoins = getCoinsFromBalanceChanges(baseCoinType, balanceChanges); + const quoteCoins = getCoinsFromBalanceChanges(quoteCoinType, balanceChanges); + + const baseCoinAmount = baseCoins[0]?.amount; + const quoteCoinAmount = quoteCoins[0]?.amount; + + if (!baseCoinAmount || !quoteCoinAmount) { + throw new Error(ErrorStrings.MISSING_DATA); + } + + const isExceedingSlippage = await isExceedingSlippageTolerance({ + slipPercentage: formData.allowedMaxSlippagePercentage, + poolId, + deepBookClient, + conversionRate: USDC_DECIMALS, + baseCoinAmount, + quoteCoinAmount, + isAsk, + }); + + if (!balanceChanges.length) { + throw new Error(ErrorStrings.NOT_ENOUGH_BALANCE); + } + + if (isExceedingSlippage) { + throw new Error(ErrorStrings.SLIPPAGE_EXCEEDS_TOLERANCE); + } + + if (!txn || !signer) { + throw new Error(ErrorStrings.MISSING_DATA); + } + + return signer!.signAndExecuteTransactionBlock({ + transactionBlock: txn!, + options: { + showInput: true, + showEffects: true, + showEvents: true, + }, + }); + }, + onSuccess: (response) => { + queryClient.invalidateQueries(['get-coins']); + queryClient.invalidateQueries(['coin-balance']); + + const receiptUrl = `/receipt?txdigest=${encodeURIComponent( + response.digest, + )}&from=transactions`; + return navigate(receiptUrl); + }, + onError: (error: Error) => { + if (error.message === ErrorStrings.SLIPPAGE_EXCEEDS_TOLERANCE) { + setSlippageErrorString(error.message); + } + }, + }); + + const handleOnsubmit: SubmitHandler = (formData) => { + handleSwap(formData); + }; + + return ( + navigate('/')}> +
+ + + +
+
+ {activeCoinType && ( + + )} + + { + setValue( + 'amount', + activeCoinType === SUI_TYPE_ARG + ? formattedBaseTokenBalance + : formattedQuoteTokenBalance, + { shouldDirty: true }, + ); + }} + /> + + {isValid && !!amount && ( +
+
+ {isPayAll ? '~ ' : ''} + {getUSDCurrency(isAsk ? rawInputSuiUsdc : Number(amount))} +
+
+ )} +
+ + { + navigate( + `/swap?${new URLSearchParams({ + type: activeCoinType === SUI_TYPE_ARG ? coinsMap.USDC : SUI_TYPE_ARG, + }).toString()}`, + ); + reset(); + }} + > +
+
+ +
+
+ + + + +
+ +
+ + + + + + + +
+ + ); +} + +export function SwapPage() { + return ( + + + + ); +} diff --git a/apps/wallet/src/ui/app/pages/swap/utils.ts b/apps/wallet/src/ui/app/pages/swap/utils.ts index c96070a3d1aab..a64f8b980a482 100644 --- a/apps/wallet/src/ui/app/pages/swap/utils.ts +++ b/apps/wallet/src/ui/app/pages/swap/utils.ts @@ -1,16 +1,79 @@ // Copyright (c) Mysten Labs, Inc. // SPDX-License-Identifier: Apache-2.0 +import { useActiveAccount } from '_app/hooks/useActiveAccount'; +import { Coins, useBalanceConversion, useCoinsReFetchingConfig } from '_hooks'; +import { SUI_CONVERSION_RATE } from '_pages/swap/constants'; +import { useFormatCoin } from '@mysten/core'; +import { useSuiClientQuery } from '@mysten/dapp-kit'; +import BigNumber from 'bignumber.js'; -import { coinsMap } from '_app/hooks/useDeepBook'; +export function useSwapData({ + baseCoinType, + quoteCoinType, + activeCoinType, +}: { + baseCoinType: string; + quoteCoinType: string; + activeCoinType: string; +}) { + const activeAccount = useActiveAccount(); + const activeAccountAddress = activeAccount?.address; + const { staleTime, refetchInterval } = useCoinsReFetchingConfig(); -export const DEFAULT_MAX_SLIPPAGE_PERCENTAGE = 0.5; -export const FEES_PERCENTAGE = 0.03; + const { data: baseCoinBalanceData, isLoading: baseCoinBalanceDataLoading } = useSuiClientQuery( + 'getBalance', + { coinType: baseCoinType, owner: activeAccountAddress! }, + { enabled: !!activeAccountAddress, refetchInterval, staleTime }, + ); -export const initialValues = { - amount: '', - isPayAll: false, - quoteAssetType: coinsMap.USDC, - allowedMaxSlippagePercentage: DEFAULT_MAX_SLIPPAGE_PERCENTAGE, -}; + const { data: quoteCoinBalanceData, isLoading: quoteCoinBalanceDataLoading } = useSuiClientQuery( + 'getBalance', + { coinType: quoteCoinType, owner: activeAccountAddress! }, + { enabled: !!activeAccountAddress, refetchInterval, staleTime }, + ); -export type FormValues = typeof initialValues; + const rawBaseBalance = baseCoinBalanceData?.totalBalance; + const rawQuoteBalance = quoteCoinBalanceData?.totalBalance; + + const [formattedBaseBalance, baseCoinSymbol, baseCoinMetadata] = useFormatCoin( + rawBaseBalance, + baseCoinType, + ); + const [formattedQuoteBalance, quoteCoinSymbol, quoteCoinMetadata] = useFormatCoin( + rawQuoteBalance, + quoteCoinType, + ); + + return { + baseCoinBalanceData, + quoteCoinBalanceData, + formattedBaseBalance, + formattedQuoteBalance, + baseCoinSymbol, + quoteCoinSymbol, + baseCoinMetadata, + quoteCoinMetadata, + isLoading: baseCoinBalanceDataLoading || quoteCoinBalanceDataLoading, + }; +} + +export function useSuiUsdcBalanceConversion({ amount }: { amount: string }) { + const suiUsdc = useBalanceConversion( + new BigNumber(amount), + Coins.SUI, + Coins.USDC, + -SUI_CONVERSION_RATE, + ); + + const usdcSui = useBalanceConversion( + new BigNumber(amount), + Coins.USDC, + Coins.SUI, + SUI_CONVERSION_RATE, + ); + + return { + suiUsdc, + usdcSui, + }; +} diff --git a/apps/wallet/src/ui/app/pages/swap/validation.ts b/apps/wallet/src/ui/app/pages/swap/validation.ts deleted file mode 100644 index 0f4f80f655a60..0000000000000 --- a/apps/wallet/src/ui/app/pages/swap/validation.ts +++ /dev/null @@ -1,11 +0,0 @@ -// Copyright (c) Mysten Labs, Inc. -// SPDX-License-Identifier: Apache-2.0 - -import { createTokenValidation } from '_shared/validation'; -import * as Yup from 'yup'; - -export function validate(...args: Parameters) { - return Yup.object({ - amount: createTokenValidation(...args), - }); -} diff --git a/apps/wallet/src/ui/app/shared/InputWithAction.tsx b/apps/wallet/src/ui/app/shared/InputWithAction.tsx index ede48a242ee6e..ac7da3c4bee4d 100644 --- a/apps/wallet/src/ui/app/shared/InputWithAction.tsx +++ b/apps/wallet/src/ui/app/shared/InputWithAction.tsx @@ -3,7 +3,7 @@ import { Text } from '_app/shared/text'; import NumberInput from '_components/number-input'; -import { cva, type VariantProps } from 'class-variance-authority'; +import { cva, cx, type VariantProps } from 'class-variance-authority'; import { useField, useFormikContext } from 'formik'; import type { ComponentProps, ReactNode } from 'react'; import { forwardRef } from 'react'; @@ -127,6 +127,8 @@ type InputWithActionZodFormProps = VariantProps & ActionButtonProps & { errorString?: string; suffix?: ReactNode; + prefix?: ReactNode; + onActionClicked?: PillProps['onClick']; }; export const InputWithActionButton = forwardRef( @@ -143,40 +145,49 @@ export const InputWithActionButton = forwardRef { + const prefixContent = prefix ? ( + + + {prefix} + + + ) : null; + return ( <> -
+
+ {prefixContent} {suffix && value && ( -
- {value} - - - {suffix} - - +
+ {prefixContent} + {value} + {suffix}
)} -
- -
+ {onActionClicked && ( +
+ +
+ )}
{errorString ? ( diff --git a/apps/wallet/src/ui/app/shared/utils/ButtonOrLink.tsx b/apps/wallet/src/ui/app/shared/utils/ButtonOrLink.tsx index e97b1953097b1..4a29e6171724f 100644 --- a/apps/wallet/src/ui/app/shared/utils/ButtonOrLink.tsx +++ b/apps/wallet/src/ui/app/shared/utils/ButtonOrLink.tsx @@ -1,6 +1,7 @@ // Copyright (c) Mysten Labs, Inc. // SPDX-License-Identifier: Apache-2.0 +import clsx from 'classnames'; import { forwardRef, type ComponentProps, type ReactNode, type Ref } from 'react'; import { Link, type LinkProps } from 'react-router-dom'; @@ -70,6 +71,7 @@ export const ButtonOrLink = forwardRef