diff --git a/src/app/common/hooks/use-calculate-sip10-fiat-value.ts b/src/app/common/hooks/use-calculate-sip10-fiat-value.ts index 1c98f119cd4..49e12a7a384 100644 --- a/src/app/common/hooks/use-calculate-sip10-fiat-value.ts +++ b/src/app/common/hooks/use-calculate-sip10-fiat-value.ts @@ -11,7 +11,7 @@ import { useConfigSbtc } from '@app/query/common/remote-config/remote-config.que import { getPrincipalFromContractId } from '../utils'; -function castBitcoinMarketDataToSbtcMarketData(bitcoinMarketData: MarketData) { +export function castBitcoinMarketDataToSbtcMarketData(bitcoinMarketData: MarketData) { return createMarketData( createMarketPair('sBTC', 'USD'), createMoney(bitcoinMarketData.price.amount.toNumber(), 'USD') diff --git a/src/app/components/nonce-setter.tsx b/src/app/components/nonce-setter.tsx index b0ee3e83771..530cba5f41f 100644 --- a/src/app/components/nonce-setter.tsx +++ b/src/app/components/nonce-setter.tsx @@ -4,17 +4,13 @@ import { useFormikContext } from 'formik'; import { useNextNonce } from '@leather.io/query'; -import { - StacksSendFormValues, - StacksTransactionFormValues, - type SwapFormValues, -} from '@shared/models/form.model'; +import { StacksSendFormValues, StacksTransactionFormValues } from '@shared/models/form.model'; import { useCurrentStacksAccountAddress } from '@app/store/accounts/blockchain/stacks/stacks-account.hooks'; export function NonceSetter() { const { setFieldValue, touched, values } = useFormikContext< - StacksSendFormValues | StacksTransactionFormValues | SwapFormValues + StacksSendFormValues | StacksTransactionFormValues >(); const stxAddress = useCurrentStacksAccountAddress(); const { data: nextNonce } = useNextNonce(stxAddress); diff --git a/src/app/features/activity-list/activity-list.tsx b/src/app/features/activity-list/activity-list.tsx index b9c50e337dc..ef20970b1c8 100644 --- a/src/app/features/activity-list/activity-list.tsx +++ b/src/app/features/activity-list/activity-list.tsx @@ -135,7 +135,7 @@ export function ActivityList() { {hasPendingTransactions && ( )} diff --git a/src/app/features/activity-list/components/pending-transaction-list/pending-transaction-list.tsx b/src/app/features/activity-list/components/pending-transaction-list/pending-transaction-list.tsx index 3174080c244..09bad94b809 100644 --- a/src/app/features/activity-list/components/pending-transaction-list/pending-transaction-list.tsx +++ b/src/app/features/activity-list/components/pending-transaction-list/pending-transaction-list.tsx @@ -11,12 +11,12 @@ import { PendingTransactionListLayout } from './pending-transaction-list.layout' interface PendingTransactionListProps { bitcoinTxs: BitcoinTx[]; - sBtcDeposits: SbtcDepositInfo[]; + sbtcDeposits: SbtcDepositInfo[]; stacksTxs: MempoolTransaction[]; } export function PendingTransactionList({ bitcoinTxs, - sBtcDeposits, + sbtcDeposits, stacksTxs, }: PendingTransactionListProps) { return ( @@ -24,7 +24,7 @@ export function PendingTransactionList({ {bitcoinTxs.map(tx => ( ))} - {sBtcDeposits.map(deposit => ( + {sbtcDeposits.map(deposit => ( ))} {stacksTxs.map(tx => ( diff --git a/src/app/pages/swap/bitflow-swap-container.tsx b/src/app/pages/swap/bitflow-swap-container.tsx deleted file mode 100644 index baf8ec3ad9a..00000000000 --- a/src/app/pages/swap/bitflow-swap-container.tsx +++ /dev/null @@ -1,284 +0,0 @@ -import { useCallback, useState } from 'react'; -import { Outlet, useNavigate } from 'react-router-dom'; - -import type { P2Ret } from '@scure/btc-signer/payment'; -import { bytesToHex } from '@stacks/common'; -import { type ContractCallPayload, TransactionTypes } from '@stacks/connect'; -import { - AnchorMode, - PostConditionMode, - serializeCV, - serializePostCondition, -} from '@stacks/transactions'; - -import { isError, isUndefined } from '@leather.io/utils'; - -import { logger } from '@shared/logger'; -import type { SwapFormValues } from '@shared/models/form.model'; -import { RouteUrls } from '@shared/route-urls'; -import { bitflow } from '@shared/utils/bitflow-sdk'; - -import { LoadingKeys, useLoading } from '@app/common/hooks/use-loading'; -import { Content, Page } from '@app/components/layout'; -import { BitcoinNativeSegwitAccountLoader } from '@app/components/loaders/bitcoin-account-loader'; -import { PageHeader } from '@app/features/container/headers/page.header'; -import type { - SbtcSponsorshipEligibility, - TransactionBase, -} from '@app/query/sbtc/sponsored-transactions.query'; -import type { Signer } from '@app/store/accounts/blockchain/bitcoin/bitcoin-signer'; -import { useCurrentStacksAccount } from '@app/store/accounts/blockchain/stacks/stacks-account.hooks'; -import { useGenerateStacksContractCallUnsignedTx } from '@app/store/transactions/contract-call.hooks'; -import { useSignStacksTransaction } from '@app/store/transactions/transaction.hooks'; - -import { getCrossChainSwapSubmissionData, getStacksSwapSubmissionData } from './bitflow-swap.utils'; -import { SwapForm } from './components/swap-form'; -import { generateSwapRoutes } from './generate-swap-routes'; -import { useBitflowSwap } from './hooks/use-bitflow-swap'; -import { useSbtcDepositTransaction } from './hooks/use-sbtc-deposit-transaction'; -import { useSponsorTransactionFees } from './hooks/use-sponsor-tx-fees'; -import { useStacksBroadcastSwap } from './hooks/use-stacks-broadcast-swap'; -import { useSwapNavigate } from './hooks/use-swap-navigate'; -import { SwapContext, SwapProvider } from './swap.context'; - -// TODO: Refactor coupled Bitflow and Bitcoin swap containers, they should be separate -export const bitflowSwapRoutes = generateSwapRoutes( - }> - {signer => } - -); - -interface BitflowSwapContainerProps { - btcSigner?: Signer; -} -function BitflowSwapContainer({ btcSigner }: BitflowSwapContainerProps) { - const [unsignedTx, setUnsignedTx] = useState(); - const [isSendingMax, setIsSendingMax] = useState(false); - const [isPreparingSwapReview, setIsPreparingSwapReview] = useState(false); - const navigate = useNavigate(); - const swapNavigate = useSwapNavigate(); - const { setIsLoading, setIsIdle, isLoading } = useLoading(LoadingKeys.SUBMIT_SWAP_TRANSACTION); - const currentAccount = useCurrentStacksAccount(); - const generateUnsignedTx = useGenerateStacksContractCallUnsignedTx(); - const signTx = useSignStacksTransaction(); - const broadcastStacksSwap = useStacksBroadcastSwap(); - const { onDepositSbtc, onReviewDepositSbtc } = useSbtcDepositTransaction(btcSigner); - - const [sponsorshipEligibility, setSponsorshipEligibility] = useState< - SbtcSponsorshipEligibility | undefined - >(); - - const { checkEligibilityForSponsor, submitSponsoredTx } = useSponsorTransactionFees(); - - const { - fetchRouteQuote, - fetchQuoteAmount, - isCrossChainSwap, - isFetchingExchangeRate, - onSetIsCrossChainSwap, - onSetIsFetchingExchangeRate, - onSetSwapSubmissionData, - slippage, - bitflowSwapAssets, - swappableAssetsBase, - swappableAssetsQuote, - swapSubmissionData, - } = useBitflowSwap(btcSigner); - - const onSubmitSwapForReview = useCallback( - async (values: SwapFormValues) => { - try { - setIsPreparingSwapReview(true); - if ( - isUndefined(currentAccount) || - isUndefined(values.swapAssetBase) || - isUndefined(values.swapAssetQuote) - ) { - logger.error('Error submitting swap for review'); - return; - } - - if (isCrossChainSwap) { - const swapData = getCrossChainSwapSubmissionData(values); - const sBtcDepositData = await onReviewDepositSbtc(swapData, isSendingMax); - onSetSwapSubmissionData({ - ...swapData, - fee: sBtcDepositData?.fee ?? 0, - maxSignerFee: sBtcDepositData?.maxSignerFee, - txData: { deposit: sBtcDepositData?.deposit }, - }); - return swapNavigate(RouteUrls.SwapReview); - } - - const routeQuote = await fetchRouteQuote( - values.swapAssetBase, - values.swapAssetQuote, - values.swapAmountBase - ); - - if (!routeQuote) return; - - const stacksSwapData = getStacksSwapSubmissionData({ - bitflowSwapAssets, - routeQuote, - slippage, - values, - }); - - const swapExecutionData = { - route: routeQuote.route, - amount: Number(stacksSwapData.swapAmountBase), - tokenXDecimals: routeQuote.tokenXDecimals, - tokenYDecimals: routeQuote.tokenYDecimals, - }; - - const swapParams = await bitflow.getSwapParams( - swapExecutionData, - currentAccount.address, - slippage - ); - - if (!routeQuote) return; - - const formValues = { - fee: stacksSwapData.fee, - feeCurrency: stacksSwapData.feeCurrency, - feeType: stacksSwapData.feeType, - nonce: stacksSwapData.nonce, - }; - - const payload: ContractCallPayload = { - anchorMode: AnchorMode.Any, - contractAddress: swapParams.contractAddress, - contractName: swapParams.contractName, - functionName: swapParams.functionName, - functionArgs: swapParams.functionArgs.map(x => bytesToHex(serializeCV(x))), - postConditionMode: PostConditionMode.Deny, - postConditions: swapParams.postConditions.map(pc => - bytesToHex(serializePostCondition(pc)) - ), - publicKey: currentAccount?.stxPublicKey, - sponsored: false, - txType: TransactionTypes.ContractCall, - }; - - const unsignedTx = await generateUnsignedTx(payload, formValues); - if (!unsignedTx) - return logger.error('Attempted to generate unsigned tx, but tx is undefined'); - - const sponsorshipEligibility = await checkEligibilityForSponsor(values, unsignedTx); - stacksSwapData.sponsored = sponsorshipEligibility.isEligible; - - setUnsignedTx(unsignedTx); - setSponsorshipEligibility(sponsorshipEligibility); - onSetSwapSubmissionData(stacksSwapData); - - swapNavigate(RouteUrls.SwapReview); - } finally { - setIsPreparingSwapReview(false); - } - }, - [ - currentAccount, - isCrossChainSwap, - fetchRouteQuote, - bitflowSwapAssets, - slippage, - generateUnsignedTx, - checkEligibilityForSponsor, - onSetSwapSubmissionData, - swapNavigate, - onReviewDepositSbtc, - isSendingMax, - ] - ); - - const onSubmitSwap = useCallback(async () => { - if (isLoading) return; - - if (isUndefined(currentAccount) || isUndefined(swapSubmissionData)) { - logger.error('Error submitting swap data to sign'); - return; - } - - if ( - isUndefined(swapSubmissionData.swapAssetBase) || - isUndefined(swapSubmissionData.swapAssetQuote) - ) { - logger.error('No assets selected to perform swap'); - return; - } - - setIsLoading(); - - if (isCrossChainSwap) { - return await onDepositSbtc(swapSubmissionData); - } - - try { - if (sponsorshipEligibility?.isEligible) - return await submitSponsoredTx(sponsorshipEligibility.unsignedSponsoredTx!); - - if (!unsignedTx?.transaction) return logger.error('No unsigned tx to sign'); - - const signedTx = await signTx(unsignedTx.transaction); - if (!signedTx) - return logger.error('Attempted to generate raw tx, but signed tx is undefined'); - - return await broadcastStacksSwap(signedTx); - } catch (e) { - navigate(RouteUrls.SwapError, { - state: { - message: isError(e) ? e.message : '', - title: 'Swap Error', - }, - }); - } finally { - setIsIdle(); - } - }, [ - broadcastStacksSwap, - currentAccount, - isCrossChainSwap, - isLoading, - navigate, - onDepositSbtc, - setIsIdle, - setIsLoading, - signTx, - sponsorshipEligibility, - submitSponsoredTx, - swapSubmissionData, - unsignedTx, - ]); - - const swapContextValue: SwapContext = { - fetchQuoteAmount, - isCrossChainSwap, - isFetchingExchangeRate, - isSendingMax, - isPreparingSwapReview, - onSetIsCrossChainSwap, - onSetIsFetchingExchangeRate, - onSetIsSendingMax: value => setIsSendingMax(value), - onSubmitSwapForReview, - onSubmitSwap, - swappableAssetsBase, - swappableAssetsQuote, - swapSubmissionData, - }; - - return ( - - {/* Swap uses routed dialogs to choose assets so needs onBackLocation to go Home */} - - - - - - - - - - ); -} diff --git a/src/app/pages/swap/bitflow-swap.utils.ts b/src/app/pages/swap/bitflow-swap.utils.ts deleted file mode 100644 index 22b7cbf2e3d..00000000000 --- a/src/app/pages/swap/bitflow-swap.utils.ts +++ /dev/null @@ -1,70 +0,0 @@ -import BigNumber from 'bignumber.js'; -import type { RouteQuote } from 'bitflow-sdk'; - -import { BtcFeeType, FeeTypes } from '@leather.io/models'; -import { type SwapAsset, defaultSwapFee } from '@leather.io/query'; -import { capitalize, isDefined } from '@leather.io/utils'; - -import type { SwapFormValues } from '@shared/models/form.model'; - -import type { SwapSubmissionData } from './swap.context'; - -function estimateLiquidityFee(dexPath: string[]) { - return new BigNumber(dexPath.length).times(0.3).toNumber(); -} - -function formatDexPathItem(dex: string) { - const name = dex.split('_')[0]; - return name === 'ALEX' ? name : capitalize(name.toLowerCase()); -} - -interface getStacksSwapSubmissionDataArgs { - bitflowSwapAssets: SwapAsset[]; - routeQuote: RouteQuote; - slippage: number; - values: SwapFormValues; -} -export function getStacksSwapSubmissionData({ - bitflowSwapAssets, - routeQuote, - slippage, - values, -}: getStacksSwapSubmissionDataArgs): SwapSubmissionData { - return { - fee: defaultSwapFee.amount.toString(), - feeCurrency: 'STX', - feeType: FeeTypes[FeeTypes.Middle], - liquidityFee: estimateLiquidityFee(routeQuote.route.dex_path), - nonce: values.nonce, - protocol: 'Bitflow', - dexPath: routeQuote.route.dex_path.map(formatDexPathItem), - router: routeQuote.route.token_path - .map(x => bitflowSwapAssets.find(asset => asset.tokenId === x)) - .filter(isDefined), - slippage, - swapAmountBase: values.swapAmountBase, - swapAmountQuote: values.swapAmountQuote, - swapAssetBase: values.swapAssetBase, - swapAssetQuote: values.swapAssetQuote, - timestamp: new Date().toISOString(), - }; -} - -export function getCrossChainSwapSubmissionData(values: SwapFormValues): SwapSubmissionData { - return { - fee: 0, - feeCurrency: 'BTC', - feeType: BtcFeeType.Standard, - liquidityFee: 0, - maxSignerFee: 0, - protocol: 'Bitcoin L2 Labs', - dexPath: [], - router: [values.swapAssetBase, values.swapAssetQuote].filter(isDefined), - slippage: 0, - swapAmountBase: values.swapAmountBase, - swapAmountQuote: values.swapAmountQuote, - swapAssetBase: values.swapAssetBase, - swapAssetQuote: values.swapAssetQuote, - timestamp: new Date().toISOString(), - }; -} diff --git a/src/app/pages/swap/components/swap-asset-dialog/components/swap-asset-item.tsx b/src/app/pages/swap/components/swap-asset-dialog/components/swap-asset-item.tsx index 33b59491ee8..29d3340b1f8 100644 --- a/src/app/pages/swap/components/swap-asset-dialog/components/swap-asset-item.tsx +++ b/src/app/pages/swap/components/swap-asset-dialog/components/swap-asset-item.tsx @@ -1,7 +1,7 @@ import { SwapSelectors } from '@tests/selectors/swap.selectors'; import { sanitize } from 'dompurify'; -import { type SwapAsset, isFtAsset, useGetFungibleTokenMetadataQuery } from '@leather.io/query'; +import { isFtAsset, useGetFungibleTokenMetadataQuery } from '@leather.io/query'; import { Avatar, ItemLayout, @@ -11,6 +11,7 @@ import { } from '@leather.io/ui'; import { formatMoneyWithoutSymbol, isString } from '@leather.io/utils'; +import type { SwapAsset } from '@app/pages/swap/form/swap-form.schema'; import { convertSwapAssetBalanceToFiat } from '@app/pages/swap/swap.utils'; interface SwapAssetItemProps { diff --git a/src/app/pages/swap/components/swap-asset-dialog/components/swap-asset-list.tsx b/src/app/pages/swap/components/swap-asset-dialog/components/swap-asset-list.tsx index 40fcd4eda29..9bf42c45587 100644 --- a/src/app/pages/swap/components/swap-asset-dialog/components/swap-asset-list.tsx +++ b/src/app/pages/swap/components/swap-asset-dialog/components/swap-asset-list.tsx @@ -1,7 +1,7 @@ import { SwapSelectors } from '@tests/selectors/swap.selectors'; import { Stack } from 'leather-styles/jsx'; -import type { SwapAsset } from '@leather.io/query'; +import type { SwapAsset } from '@app/pages/swap/form/swap-form.schema'; import { SwapAssetItem } from './swap-asset-item'; import { useSwapAssetList } from './use-swap-asset-list'; diff --git a/src/app/pages/swap/components/swap-asset-dialog/components/use-swap-asset-list.tsx b/src/app/pages/swap/components/swap-asset-dialog/components/use-swap-asset-list.tsx index 0b266ca1206..d70079b5ba0 100644 --- a/src/app/pages/swap/components/swap-asset-dialog/components/use-swap-asset-list.tsx +++ b/src/app/pages/swap/components/swap-asset-dialog/components/use-swap-asset-list.tsx @@ -1,10 +1,9 @@ import { useCallback } from 'react'; +import { useFormContext } from 'react-hook-form'; import { useNavigate, useParams } from 'react-router-dom'; import BigNumber from 'bignumber.js'; -import { useFormikContext } from 'formik'; -import type { SwapAsset } from '@leather.io/query'; import { convertAmountToFractionalUnit, createMoney, @@ -12,18 +11,20 @@ import { isUndefined, } from '@leather.io/utils'; -import type { SwapFormValues } from '@shared/models/form.model'; import { RouteUrls } from '@shared/route-urls'; -import { useSwapContext } from '@app/pages/swap/swap.context'; +import type { SwapAsset, SwapFormSchema } from '@app/pages/swap/form/swap-form.schema'; +import { type SwapBaseContext, useSwapContext } from '@app/pages/swap/swap.context'; import type { SwapAssetListProps } from './swap-asset-list'; -export function useSwapAssetList({ assets, type }: SwapAssetListProps) { - const { setFieldError, setFieldValue, values } = useFormikContext(); - const { fetchQuoteAmount, onSetIsCrossChainSwap } = useSwapContext(); +export function useSwapAssetList({ assets, type }: SwapAssetListProps) { + const { clearErrors, setValue, watch } = useFormContext(); + const { swapData } = useSwapContext(); + const { fetchQuoteAmount, onSetIsCrossChainSwap } = swapData; const navigate = useNavigate(); const { base, quote } = useParams(); + const values = watch(); const isBaseList = type === 'base'; const isQuoteList = type === 'quote'; @@ -45,7 +46,7 @@ export function useSwapAssetList({ assets, type }: SwapAssetListProps) { const onSelectBaseAsset = useCallback( (baseAsset: SwapAsset) => { - void setFieldValue('swapAssetBase', baseAsset); + setValue('swapAssetBase', baseAsset); // Handle bridge assets if (baseAsset.name === 'BTC') { onSetIsCrossChainSwap(true); @@ -55,16 +56,16 @@ export function useSwapAssetList({ assets, type }: SwapAssetListProps) { onSetIsCrossChainSwap(false); navigate(RouteUrls.Swap.replace(':base', baseAsset.name).replace(':quote', quote ?? '')); }, - [navigate, onSetIsCrossChainSwap, quote, setFieldValue] + [navigate, onSetIsCrossChainSwap, quote, setValue] ); const onSelectQuoteAsset = useCallback( (quoteAsset: SwapAsset) => { - void setFieldValue('swapAssetQuote', quoteAsset); - setFieldError('swapAssetQuote', undefined); + setValue('swapAssetQuote', quoteAsset); + clearErrors('swapAssetQuote'); navigate(RouteUrls.Swap.replace(':base', base ?? '').replace(':quote', quoteAsset.name)); }, - [base, navigate, setFieldError, setFieldValue] + [base, clearErrors, navigate, setValue] ); const onFetchQuoteAmount = useCallback( @@ -72,11 +73,11 @@ export function useSwapAssetList({ assets, type }: SwapAssetListProps) { const quoteAmount = await fetchQuoteAmount(baseAsset, quoteAsset, values.swapAmountBase); // Handle race condition; make sure quote amount is 1:1 if (baseAsset.name === 'BTC') { - void setFieldValue('swapAmountQuote', values.swapAmountBase); + setValue('swapAmountQuote', values.swapAmountBase); return; } if (isUndefined(quoteAmount)) { - void setFieldValue('swapAmountQuote', ''); + setValue('swapAmountQuote', ''); return; } const quoteAmountAsMoney = createMoney( @@ -84,10 +85,10 @@ export function useSwapAssetList({ assets, type }: SwapAssetListProps) { quoteAsset?.balance.symbol ?? '', quoteAsset?.balance.decimals ); - void setFieldValue('swapAmountQuote', formatMoneyWithoutSymbol(quoteAmountAsMoney)); - setFieldError('swapAmountQuote', undefined); + setValue('swapAmountQuote', formatMoneyWithoutSymbol(quoteAmountAsMoney)); + clearErrors('swapAmountQuote'); }, - [fetchQuoteAmount, setFieldError, setFieldValue, values.swapAmountBase] + [clearErrors, fetchQuoteAmount, setValue, values.swapAmountBase] ); return { diff --git a/src/app/pages/swap/components/swap-asset-dialog/swap-asset-dialog-base.tsx b/src/app/pages/swap/components/swap-asset-dialog/swap-asset-dialog-base.tsx index 1f42f72e3fa..f52ca89ad51 100644 --- a/src/app/pages/swap/components/swap-asset-dialog/swap-asset-dialog-base.tsx +++ b/src/app/pages/swap/components/swap-asset-dialog/swap-asset-dialog-base.tsx @@ -3,11 +3,11 @@ import { Sheet, SheetHeader } from '@leather.io/ui'; import { RouteUrls } from '@shared/route-urls'; import { useSwapNavigate } from '../../hooks/use-swap-navigate'; -import { useSwapContext } from '../../swap.context'; +import { type SwapBaseContext, useSwapContext } from '../../swap.context'; import { SwapAssetList } from './components/swap-asset-list'; -export function SwapAssetSheetBase() { - const { swappableAssetsBase } = useSwapContext(); +export function SwapAssetSheetBase() { + const { swappableAssetsBase } = useSwapContext(); const navigate = useSwapNavigate(); return ( diff --git a/src/app/pages/swap/components/swap-asset-dialog/swap-asset-dialog-quote.tsx b/src/app/pages/swap/components/swap-asset-dialog/swap-asset-dialog-quote.tsx index 34bfd5386b3..c1a9cd7d691 100644 --- a/src/app/pages/swap/components/swap-asset-dialog/swap-asset-dialog-quote.tsx +++ b/src/app/pages/swap/components/swap-asset-dialog/swap-asset-dialog-quote.tsx @@ -3,11 +3,11 @@ import { Sheet, SheetHeader } from '@leather.io/ui'; import { RouteUrls } from '@shared/route-urls'; import { useSwapNavigate } from '../../hooks/use-swap-navigate'; -import { useSwapContext } from '../../swap.context'; +import { type SwapBaseContext, useSwapContext } from '../../swap.context'; import { SwapAssetList } from './components/swap-asset-list'; -export function SwapAssetSheetQuote() { - const { swappableAssetsQuote } = useSwapContext(); +export function SwapAssetSheetQuote() { + const { swappableAssetsQuote } = useSwapContext(); const navigate = useSwapNavigate(); return ( diff --git a/src/app/pages/swap/components/swap-asset-select/components/select-asset-trigger-button.tsx b/src/app/pages/swap/components/swap-asset-select/components/select-asset-trigger-button.tsx index 2be96d1decd..e0c972c9cb2 100644 --- a/src/app/pages/swap/components/swap-asset-select/components/select-asset-trigger-button.tsx +++ b/src/app/pages/swap/components/swap-asset-select/components/select-asset-trigger-button.tsx @@ -1,7 +1,6 @@ -import type React from 'react'; +import { Controller, useFormContext } from 'react-hook-form'; import { SwapSelectors } from '@tests/selectors/swap.selectors'; -import { useField } from 'formik'; import { HStack, styled } from 'leather-styles/jsx'; import { @@ -11,11 +10,12 @@ import { defaultFallbackDelay, getAvatarFallback, } from '@leather.io/ui'; -import { isString } from '@leather.io/utils'; + +import type { SwapFormSchema } from '@app/pages/swap/form/swap-form.schema'; interface SelectAssetTriggerButtonProps { - icon?: React.ReactNode; - name: string; + icon?: string; + name: 'swapAssetBase' | 'swapAssetQuote'; onSelectAsset(): void; symbol: string; } @@ -25,29 +25,30 @@ export function SelectAssetTriggerButton({ onSelectAsset, symbol, }: SelectAssetTriggerButtonProps) { - const [field] = useField(name); + const { control } = useFormContext(); const fallback = getAvatarFallback(symbol); return ( - + ( + + )} + /> ); } diff --git a/src/app/pages/swap/components/swap-asset-select/components/swap-amount-field.tsx b/src/app/pages/swap/components/swap-asset-select/components/swap-amount-field.tsx index d87d2907cca..e4e454e75a3 100644 --- a/src/app/pages/swap/components/swap-asset-select/components/swap-amount-field.tsx +++ b/src/app/pages/swap/components/swap-asset-select/components/swap-amount-field.tsx @@ -1,8 +1,8 @@ -import { ChangeEvent, useEffect } from 'react'; +import { ChangeEvent } from 'react'; +import { Controller, useFormContext } from 'react-hook-form'; import { SwapSelectors } from '@tests/selectors/swap.selectors'; import BigNumber from 'bignumber.js'; -import { useField, useFormikContext } from 'formik'; import { Stack, styled } from 'leather-styles/jsx'; import { @@ -13,13 +13,10 @@ import { isUndefined, } from '@leather.io/utils'; -import type { SwapFormValues } from '@shared/models/form.model'; +import type { SwapFormSchema } from '@app/pages/swap/form/swap-form.schema'; +import { type SwapBaseContext, useSwapContext } from '@app/pages/swap/swap.context'; -import { useShowFieldError } from '@app/common/form-utils'; - -import { useSwapContext } from '../../../swap.context'; - -function getPlaceholderValue(name: string, values: SwapFormValues) { +function getPlaceholderValue(name: string, values: SwapFormSchema) { if (name === 'swapAmountBase' && isDefined(values.swapAssetBase)) return '0'; if (name === 'swapAmountQuote' && isDefined(values.swapAssetQuote)) return '0'; return '-'; @@ -28,29 +25,36 @@ function getPlaceholderValue(name: string, values: SwapFormValues) { interface SwapAmountFieldProps { amountAsFiat?: string; isDisabled?: boolean; - name: string; + name: 'swapAmountBase' | 'swapAmountQuote'; } -export function SwapAmountField({ amountAsFiat, isDisabled, name }: SwapAmountFieldProps) { - const { fetchQuoteAmount, isFetchingExchangeRate, onSetIsSendingMax } = useSwapContext(); - const { setFieldError, setFieldValue, values } = useFormikContext(); - const [field] = useField(name); - const showError = useShowFieldError(name) && name === 'swapAmountBase' && values.swapAssetQuote; +export function SwapAmountField({ + amountAsFiat, + isDisabled, + name, +}: SwapAmountFieldProps) { + const { swapData } = useSwapContext(); + const { fetchQuoteAmount, isFetchingExchangeRate, onSetIsSendingMax } = swapData; + const { clearErrors, control, setValue, watch, getFieldState } = useFormContext(); + const values = watch(); - useEffect(() => { - // Clear quote amount if quote asset is reset - if (isUndefined(values.swapAssetQuote)) { - void setFieldValue('swapAmountQuote', ''); - } - }, [setFieldValue, values]); + const field = getFieldState(name); - async function onBlur(event: ChangeEvent) { + const showError = + field.isDirty && + field.isTouched && + field.error && + name === 'swapAmountBase' && + values.swapAssetQuote; + + async function onBlurFetchQuote(event: ChangeEvent) { const { swapAssetBase, swapAssetQuote } = values; if (isUndefined(swapAssetBase) || isUndefined(swapAssetQuote)) return; onSetIsSendingMax(false); const value = event.currentTarget.value; const toAmount = await fetchQuoteAmount(swapAssetBase, swapAssetQuote, value); + if (isUndefined(toAmount)) { - await setFieldValue('swapAmountQuote', ''); + setValue('swapAmountQuote', ''); return; } const toAmountAsMoney = createMoney( @@ -61,36 +65,43 @@ export function SwapAmountField({ amountAsFiat, isDisabled, name }: SwapAmountFi values.swapAssetQuote?.balance.symbol ?? '', values.swapAssetQuote?.balance.decimals ); - await setFieldValue('swapAmountQuote', formatMoneyWithoutSymbol(toAmountAsMoney)); - setFieldError('swapAmountQuote', undefined); + setValue('swapAmountQuote', formatMoneyWithoutSymbol(toAmountAsMoney)); + clearErrors('swapAmountQuote'); } return ( - { - field.onBlur(e); - await onBlur(e); - }} + ( + { + onBlur(); + await onBlurFetchQuote(e); + }} + /> + )} /> {amountAsFiat ? ( diff --git a/src/app/pages/swap/components/swap-asset-select/components/swap-asset-select.layout.tsx b/src/app/pages/swap/components/swap-asset-select/components/swap-asset-select.layout.tsx index f2cab41b1b3..067410ee466 100644 --- a/src/app/pages/swap/components/swap-asset-select/components/swap-asset-select.layout.tsx +++ b/src/app/pages/swap/components/swap-asset-select/components/swap-asset-select.layout.tsx @@ -19,7 +19,7 @@ interface SwapAssetSelectLayoutProps { caption?: string; error?: string; icon?: string; - name: string; + name: 'swapAssetBase' | 'swapAssetQuote'; onSelectAsset(): void; onClickHandler?(): void; showError?: boolean; @@ -73,7 +73,7 @@ export function SwapAssetSelectLayout({ /> {caption ? ( - + (); +export function SwapToggleButton() { + const { swapData } = useSwapContext(); + const { fetchQuoteAmount, isFetchingExchangeRate, onSetIsSendingMax } = swapData; + const { setValue, watch } = useFormContext(); const navigate = useNavigate(); + const values = watch(); async function onToggleSwapAssets() { onSetIsSendingMax(false); @@ -24,19 +26,19 @@ export function SwapToggleButton() { const prevAssetBase = values.swapAssetBase; const prevAssetQuote = values.swapAssetQuote; - void setFieldValue('swapAssetBase', prevAssetQuote); - void setFieldValue('swapAssetQuote', prevAssetBase); - void setFieldValue('swapAmountBase', prevAmountQuote); + setValue('swapAssetBase', prevAssetQuote); + setValue('swapAssetQuote', prevAssetBase); + setValue('swapAmountBase', prevAmountQuote); if (isDefined(prevAssetBase) && isDefined(prevAssetQuote)) { const quoteAmount = await fetchQuoteAmount(prevAssetQuote, prevAssetBase, prevAmountQuote); if (isUndefined(quoteAmount)) { - void setFieldValue('swapAmountQuote', ''); + setValue('swapAmountQuote', ''); return; } - void setFieldValue('swapAmountQuote', Number(quoteAmount)); + setValue('swapAmountQuote', quoteAmount); } else { - void setFieldValue('swapAmountQuote', Number(prevAmountBase)); + setValue('swapAmountQuote', prevAmountBase); } navigate( RouteUrls.Swap.replace(':base', prevAssetQuote?.name ?? '').replace( diff --git a/src/app/pages/swap/components/swap-asset-select/swap-asset-select-base.tsx b/src/app/pages/swap/components/swap-asset-select/swap-asset-select-base.tsx index a48abfb16de..ddd92acc1fb 100644 --- a/src/app/pages/swap/components/swap-asset-select/swap-asset-select-base.tsx +++ b/src/app/pages/swap/components/swap-asset-select/swap-asset-select-base.tsx @@ -1,5 +1,6 @@ +import { useFormContext } from 'react-hook-form'; + import BigNumber from 'bignumber.js'; -import { useField, useFormikContext } from 'formik'; import { convertAmountToFractionalUnit, @@ -11,13 +12,11 @@ import { isUndefined, } from '@leather.io/utils'; -import type { SwapFormValues } from '@shared/models/form.model'; import { RouteUrls } from '@shared/route-urls'; -import { useShowFieldError } from '@app/common/form-utils'; - +import type { SwapFormSchema } from '../../form/swap-form.schema'; import { useSwapNavigate } from '../../hooks/use-swap-navigate'; -import { useSwapContext } from '../../swap.context'; +import { type SwapBaseContext, useSwapContext } from '../../swap.context'; import { convertInputAmountValueToFiat } from '../../swap.utils'; import { SwapAmountField } from './components/swap-amount-field'; import { SwapAssetSelectLayout } from './components/swap-asset-select.layout'; @@ -27,23 +26,26 @@ const maxAvailableTooltip = 'Amount of funds that are immediately available for use, after taking into account any pending transactions or holds placed on your account by the protocol.'; const sendingMaxTooltip = 'When sending max, this amount is affected by the fee you choose.'; -export function SwapAssetSelectBase() { - const { fetchQuoteAmount, isFetchingExchangeRate, isSendingMax, onSetIsSendingMax } = - useSwapContext(); - const { setFieldValue, setFieldError, values } = useFormikContext(); - const [amountField, amountFieldMeta, amountFieldHelpers] = useField('swapAmountBase'); - const showError = useShowFieldError('swapAmountBase'); - const [assetField] = useField('swapAssetBase'); +export function SwapAssetSelectBase() { + const { swapData } = useSwapContext(); + const { fetchQuoteAmount, isFetchingExchangeRate, isSendingMax, onSetIsSendingMax } = swapData; + const { clearErrors, setValue, getFieldState, watch } = useFormContext(); const navigate = useSwapNavigate(); + const values = watch(); + const field = getFieldState('swapAmountBase'); + const showError = field.isDirty && field.isTouched && field.error; + const amountAsFiat = - isDefined(assetField.value && amountField.value) && + isDefined(values.swapAssetBase) && convertInputAmountValueToFiat( - assetField.value.balance, - assetField.value.marketData, - amountField.value + values.swapAssetBase.balance, + values.swapAssetBase.marketData, + values.swapAmountBase ); - const formattedBalance = formatMoneyWithoutSymbol(assetField.value.balance); + const formattedBalance = values.swapAssetBase + ? formatMoneyWithoutSymbol(values.swapAssetBase.balance) + : ''; const isSwapAssetBaseBalanceGreaterThanZero = values.swapAssetBase?.balance.amount.isGreaterThan(0); @@ -51,12 +53,12 @@ export function SwapAssetSelectBase() { const { swapAssetBase, swapAssetQuote } = values; if (isFetchingExchangeRate || isUndefined(swapAssetBase)) return; onSetIsSendingMax(!isSendingMax); - await amountFieldHelpers.setValue(Number(formattedBalance)); - await amountFieldHelpers.setTouched(true); + setValue('swapAmountBase', formattedBalance); + // await amountFieldHelpers.setTouched(true); if (isUndefined(swapAssetQuote)) return; const toAmount = await fetchQuoteAmount(swapAssetBase, swapAssetQuote, formattedBalance); if (isUndefined(toAmount)) { - await setFieldValue('swapAmountQuote', ''); + setValue('swapAmountQuote', ''); return; } const toAmountAsMoney = createMoney( @@ -67,31 +69,31 @@ export function SwapAssetSelectBase() { values.swapAssetQuote?.balance.symbol ?? '', values.swapAssetQuote?.balance.decimals ); - await setFieldValue('swapAmountQuote', formatMoneyWithoutSymbol(toAmountAsMoney)); - setFieldError('swapAmountQuote', undefined); + setValue('swapAmountQuote', formatMoneyWithoutSymbol(toAmountAsMoney)); + clearErrors('swapAmountQuote'); } return ( navigate(RouteUrls.SwapAssetSelectBase)} onClickHandler={onSetMaxBalanceAsAmountToSwap} showError={!!(showError && values.swapAssetQuote)} swapAmountInput={ } - symbol={assetField.value.name} + symbol={values.swapAssetBase?.name ?? 'Select asset'} title="You pay" tooltipLabel={isSendingMax ? sendingMaxTooltip : maxAvailableTooltip} value={formattedBalance} diff --git a/src/app/pages/swap/components/swap-asset-select/swap-asset-select-quote.tsx b/src/app/pages/swap/components/swap-asset-select/swap-asset-select-quote.tsx index d9cda93070d..af39c4817c2 100644 --- a/src/app/pages/swap/components/swap-asset-select/swap-asset-select-quote.tsx +++ b/src/app/pages/swap/components/swap-asset-select/swap-asset-select-quote.tsx @@ -1,4 +1,4 @@ -import { useField } from 'formik'; +import { useFormContext } from 'react-hook-form'; import { formatMoneyWithoutSymbol, @@ -11,31 +11,33 @@ import { RouteUrls } from '@shared/route-urls'; import { LoadingSpinner } from '@app/components/loading-spinner'; +import type { SwapFormSchema } from '../../form/swap-form.schema'; import { useSwapNavigate } from '../../hooks/use-swap-navigate'; -import { useSwapContext } from '../../swap.context'; +import { type SwapBaseContext, useSwapContext } from '../../swap.context'; import { convertInputAmountValueToFiat } from '../../swap.utils'; import { SwapAmountField } from './components/swap-amount-field'; import { SwapAssetSelectLayout } from './components/swap-asset-select.layout'; -export function SwapAssetSelectQuote() { - const { isCrossChainSwap, isFetchingExchangeRate } = useSwapContext(); - const [amountField] = useField('swapAmountQuote'); - const [assetField] = useField('swapAssetQuote'); +export function SwapAssetSelectQuote() { + const { swapData } = useSwapContext(); + const { isCrossChainSwap, isFetchingExchangeRate } = swapData; + const { watch } = useFormContext(); const navigate = useSwapNavigate(); + const values = watch(); const amountAsFiat = - isDefined(assetField.value && amountField.value) && + isDefined(values.swapAssetQuote) && convertInputAmountValueToFiat( - assetField.value.balance, - assetField.value.marketData, - amountField.value + values.swapAssetQuote.balance, + values.swapAssetQuote.marketData, + values.swapAmountQuote ); return ( navigate(RouteUrls.SwapAssetSelectQuote)} showToggle={!isCrossChainSwap} swapAmountInput={ @@ -43,19 +45,23 @@ export function SwapAssetSelectQuote() { ) : ( ) } - symbol={assetField.value?.name ?? 'Select asset'} + symbol={values.swapAssetQuote?.name ?? 'Select asset'} title="You receive" - value={assetField.value?.balance ? formatMoneyWithoutSymbol(assetField.value?.balance) : '0'} + value={ + values.swapAssetQuote?.balance + ? formatMoneyWithoutSymbol(values.swapAssetQuote?.balance) + : '0' + } /> ); } diff --git a/src/app/pages/swap/components/swap-assets-pair/swap-asset-item.layout.tsx b/src/app/pages/swap/components/swap-assets-pair/swap-asset-item.layout.tsx index 2983d0f745b..03fbb119ab4 100644 --- a/src/app/pages/swap/components/swap-assets-pair/swap-asset-item.layout.tsx +++ b/src/app/pages/swap/components/swap-assets-pair/swap-asset-item.layout.tsx @@ -1,16 +1,14 @@ -import type React from 'react'; - import { SwapSelectors } from '@tests/selectors/swap.selectors'; import { sanitize } from 'dompurify'; import { HStack, styled } from 'leather-styles/jsx'; import type { Money } from '@leather.io/models'; import { Flag } from '@leather.io/ui'; -import { formatMoneyWithoutSymbol, isString } from '@leather.io/utils'; +import { formatMoneyWithoutSymbol } from '@leather.io/utils'; interface SwapAssetItemLayoutProps { caption: string; - icon: React.ReactNode; + icon: string; symbol: string; value: Money; } @@ -18,17 +16,13 @@ export function SwapAssetItemLayout({ caption, icon, symbol, value }: SwapAssetI return ( - ) : ( - icon - ) + } spacing="space.03" width="100%" diff --git a/src/app/pages/swap/components/swap-assets-pair/swap-assets-pair.tsx b/src/app/pages/swap/components/swap-assets-pair/swap-assets-pair.tsx index 3c6d931ad77..8aeebe1ec3a 100644 --- a/src/app/pages/swap/components/swap-assets-pair/swap-assets-pair.tsx +++ b/src/app/pages/swap/components/swap-assets-pair/swap-assets-pair.tsx @@ -1,20 +1,18 @@ +import { useFormContext } from 'react-hook-form'; import { useNavigate } from 'react-router-dom'; -import { useFormikContext } from 'formik'; - import { createMoneyFromDecimal, isUndefined } from '@leather.io/utils'; -import type { SwapFormValues } from '@shared/models/form.model'; import { RouteUrls } from '@shared/route-urls'; +import type { SwapFormSchema } from '../../form/swap-form.schema'; import { SwapAssetItemLayout } from './swap-asset-item.layout'; import { SwapAssetsPairLayout } from './swap-assets-pair.layout'; export function SwapAssetsPair() { - const { - values: { swapAmountBase, swapAmountQuote, swapAssetBase, swapAssetQuote }, - } = useFormikContext(); + const { watch } = useFormContext(); const navigate = useNavigate(); + const { swapAmountBase, swapAmountQuote, swapAssetBase, swapAssetQuote } = watch(); if (isUndefined(swapAssetBase) || isUndefined(swapAssetQuote)) { navigate(RouteUrls.Swap, { replace: true }); diff --git a/src/app/pages/swap/components/swap-details/bitcoin-swap-details.tsx b/src/app/pages/swap/components/swap-details/bitcoin-swap-details.tsx new file mode 100644 index 00000000000..4dd9d3330c6 --- /dev/null +++ b/src/app/pages/swap/components/swap-details/bitcoin-swap-details.tsx @@ -0,0 +1,77 @@ +import { useFormContext } from 'react-hook-form'; + +import { SwapSelectors } from '@tests/selectors/swap.selectors'; +import BigNumber from 'bignumber.js'; +import { HStack, styled } from 'leather-styles/jsx'; + +import { ChevronRightIcon } from '@leather.io/ui'; +import { + convertAmountToBaseUnit, + createMoney, + createMoneyFromDecimal, + formatMoneyPadded, + isUndefined, + satToBtc, +} from '@leather.io/utils'; + +import { useSwapContext } from '@app/pages/swap/swap.context'; + +import type { BitcoinSwapContext } from '../../containers/bitcoin-swap-container'; +import type { SwapFormSchema } from '../../form/swap-form.schema'; +import { SwapDetailLayout } from './swap-detail.layout'; +import { SwapDetailsLayout } from './swap-details.layout'; + +function RouteNames() { + return ( + + BTC + + sBTC + + ); +} + +export function BitcoinSwapDetails() { + const { swapData } = useSwapContext(); + const { getValues } = useFormContext(); + const values = getValues(); + + if (isUndefined(values.swapAssetBase) || isUndefined(values.swapAssetQuote)) return null; + + const maxSignerFee = satToBtc(swapData.maxSignerFee ?? 0); + + const formattedMinToReceive = formatMoneyPadded( + createMoneyFromDecimal( + new BigNumber(values.swapAmountQuote).minus(maxSignerFee), + values.swapAssetQuote.balance.symbol, + values.swapAssetQuote.balance.decimals + ) + ); + + return ( + + + + + + } + /> + + + + + + ); +} diff --git a/src/app/pages/swap/components/swap-details/stacks-swap-details.tsx b/src/app/pages/swap/components/swap-details/stacks-swap-details.tsx new file mode 100644 index 00000000000..97af9b7ae1a --- /dev/null +++ b/src/app/pages/swap/components/swap-details/stacks-swap-details.tsx @@ -0,0 +1,108 @@ +import { useFormContext } from 'react-hook-form'; + +import { SwapSelectors } from '@tests/selectors/swap.selectors'; +import BigNumber from 'bignumber.js'; +import { HStack, styled } from 'leather-styles/jsx'; + +import { ChevronRightIcon } from '@leather.io/ui'; +import { + convertAmountToBaseUnit, + createMoney, + createMoneyFromDecimal, + formatMoneyPadded, + isDefined, + isUndefined, +} from '@leather.io/utils'; + +import { useSwapContext } from '@app/pages/swap/swap.context'; + +import type { StacksSwapContext } from '../../containers/stacks-swap-container'; +import type { SwapAsset, SwapFormSchema } from '../../form/swap-form.schema'; +import { toCommaSeparatedWithAnd } from '../../swap.utils'; +import { SwapDetailLayout } from './swap-detail.layout'; +import { SwapDetailsLayout } from './swap-details.layout'; +import { getStacksSwapDataFromRouteQuote } from './swap-details.utils'; + +function RouteNames(props: { router?: SwapAsset[] }) { + const { router } = props; + if (!router) return; + return router.map((route, i) => { + const insertIcon = isDefined(router[i + 1]); + return ( + + {route.name} + {insertIcon && } + + ); + }); +} + +const sponsoredFeeLabel = + 'Sponsorship may not apply when you have pending transactions. In such cases, if you choose to proceed, the associated costs will be deducted from your balance.'; + +export function StacksSwapDetails() { + const { swapData, swappableAssetsBase } = useSwapContext(); + const { getValues } = useFormContext(); + const values = getValues(); + + if (isUndefined(values.swapAssetBase) || isUndefined(values.swapAssetQuote)) return null; + + const routeQuoteDetails = + swapData.routeQuote && + getStacksSwapDataFromRouteQuote({ + routeQuote: swapData.routeQuote, + swapAssets: swappableAssetsBase, + }); + + const formattedMinToReceive = formatMoneyPadded( + createMoneyFromDecimal( + new BigNumber(values.swapAmountQuote).times(1 - swapData.slippage), + values.swapAssetQuote.balance.symbol, + values.swapAssetQuote.balance.decimals + ) + ); + + const getFormattedPoweredBy = () => { + const uniqueDexList = Array.from(new Set(routeQuoteDetails?.dexPath)); + const isOnlySwapProtocol = uniqueDexList.length === 1 && uniqueDexList[0] === swapData.protocol; + return isOnlySwapProtocol || !uniqueDexList.length + ? swapData.protocol + : `${toCommaSeparatedWithAnd(uniqueDexList)} via ${swapData.protocol}`; + }; + + return ( + + + + + + } + /> + + + + + + + ); +} diff --git a/src/app/pages/swap/components/swap-details/swap-details.tsx b/src/app/pages/swap/components/swap-details/swap-details.tsx deleted file mode 100644 index c7ae3980cb5..00000000000 --- a/src/app/pages/swap/components/swap-details/swap-details.tsx +++ /dev/null @@ -1,112 +0,0 @@ -import { SwapSelectors } from '@tests/selectors/swap.selectors'; -import BigNumber from 'bignumber.js'; -import { HStack, styled } from 'leather-styles/jsx'; - -import { ChevronRightIcon } from '@leather.io/ui'; -import { - convertAmountToBaseUnit, - createMoney, - createMoneyFromDecimal, - formatMoneyPadded, - isDefined, - isUndefined, - satToBtc, -} from '@leather.io/utils'; - -import { SwapSubmissionData, useSwapContext } from '@app/pages/swap/swap.context'; - -import { toCommaSeparatedWithAnd } from '../../swap.utils'; -import { SwapDetailLayout } from './swap-detail.layout'; -import { SwapDetailsLayout } from './swap-details.layout'; - -function RouteNames(props: { swapSubmissionData: SwapSubmissionData }) { - return props.swapSubmissionData.router.map((route, i) => { - const insertIcon = isDefined(props.swapSubmissionData.router[i + 1]); - return ( - - {route.name} - {insertIcon && } - - ); - }); -} - -const sponsoredFeeLabel = - 'Sponsorship may not apply when you have pending transactions. In such cases, if you choose to proceed, the associated costs will be deducted from your balance.'; - -export function SwapDetails() { - const { swapSubmissionData } = useSwapContext(); - - if ( - isUndefined(swapSubmissionData) || - isUndefined(swapSubmissionData.swapAssetBase) || - isUndefined(swapSubmissionData.swapAssetQuote) - ) - return null; - - const maxSignerFee = satToBtc(swapSubmissionData.maxSignerFee ?? 0); - - const formattedMinToReceive = formatMoneyPadded( - createMoneyFromDecimal( - new BigNumber(swapSubmissionData.swapAmountQuote) - .times(1 - swapSubmissionData.slippage) - .minus(maxSignerFee), - swapSubmissionData.swapAssetQuote.balance.symbol, - swapSubmissionData.swapAssetQuote.balance.decimals - ) - ); - - const getFormattedPoweredBy = () => { - const uniqueDexList = Array.from(new Set(swapSubmissionData.dexPath)); - const isOnlySwapProtocol = - uniqueDexList.length === 1 && uniqueDexList[0] === swapSubmissionData.protocol; - return isOnlySwapProtocol || !uniqueDexList.length - ? swapSubmissionData.protocol - : `${toCommaSeparatedWithAnd(uniqueDexList)} via ${swapSubmissionData.protocol}`; - }; - - return ( - - - - - - } - /> - - - - - {maxSignerFee ? ( - - ) : null} - - {Number(swapSubmissionData?.nonce) >= 0 ? ( - - ) : null} - - ); -} diff --git a/src/app/pages/swap/components/swap-details/swap-details.utils.ts b/src/app/pages/swap/components/swap-details/swap-details.utils.ts new file mode 100644 index 00000000000..01322e99184 --- /dev/null +++ b/src/app/pages/swap/components/swap-details/swap-details.utils.ts @@ -0,0 +1,29 @@ +import BigNumber from 'bignumber.js'; +import type { RouteQuote } from 'bitflow-sdk'; + +import { capitalize, isDefined } from '@leather.io/utils'; + +import type { SwapAsset } from '../../form/swap-form.schema'; + +function estimateLiquidityFee(dexPath: string[]) { + return new BigNumber(dexPath.length).times(0.3).toNumber(); +} + +function formatDexPathItem(dex: string) { + const name = dex.split('_')[0]; + return name === 'ALEX' ? name : capitalize(name.toLowerCase()); +} + +interface getStacksSwapDataArgs { + swapAssets: SwapAsset[]; + routeQuote: RouteQuote; +} +export function getStacksSwapDataFromRouteQuote({ routeQuote, swapAssets }: getStacksSwapDataArgs) { + return { + liquidityFee: estimateLiquidityFee(routeQuote.route.dex_path), + dexPath: routeQuote.route.dex_path.map(formatDexPathItem), + router: routeQuote.route.token_path + .map(x => swapAssets.find(asset => asset.tokenId === x)) + .filter(isDefined), + }; +} diff --git a/src/app/pages/swap/components/swap-form.tsx b/src/app/pages/swap/components/swap-form.tsx deleted file mode 100644 index 4faefe3572e..00000000000 --- a/src/app/pages/swap/components/swap-form.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { Form, Formik } from 'formik'; -import { Box } from 'leather-styles/jsx'; - -import { HasChildren } from '@app/common/has-children'; -import { NonceSetter } from '@app/components/nonce-setter'; - -import { useSwapForm } from '../hooks/use-swap-form'; - -export function SwapForm({ children }: HasChildren) { - const { initialValues, validationSchema } = useSwapForm(); - - return ( - {}} - validateOnChange={false} - validateOnMount - validationSchema={validationSchema} - > - - -
{children}
-
-
- ); -} diff --git a/src/app/pages/swap/components/swap-review/bitcoin-swap-review.tsx b/src/app/pages/swap/components/swap-review/bitcoin-swap-review.tsx new file mode 100644 index 00000000000..0d5d879609c --- /dev/null +++ b/src/app/pages/swap/components/swap-review/bitcoin-swap-review.tsx @@ -0,0 +1,10 @@ +import { BitcoinSwapDetails } from '../swap-details/bitcoin-swap-details'; +import { SwapReviewLayout } from './swap-review.layout'; + +export function BitcoinSwapReview() { + return ( + + + + ); +} diff --git a/src/app/pages/swap/components/swap-review/stacks-swap-review.tsx b/src/app/pages/swap/components/swap-review/stacks-swap-review.tsx new file mode 100644 index 00000000000..a79f5b06ccd --- /dev/null +++ b/src/app/pages/swap/components/swap-review/stacks-swap-review.tsx @@ -0,0 +1,10 @@ +import { StacksSwapDetails } from '../swap-details/stacks-swap-details'; +import { SwapReviewLayout } from './swap-review.layout'; + +export function StacksSwapReview() { + return ( + + + + ); +} diff --git a/src/app/pages/swap/components/swap-review.tsx b/src/app/pages/swap/components/swap-review/swap-review.layout.tsx similarity index 51% rename from src/app/pages/swap/components/swap-review.tsx rename to src/app/pages/swap/components/swap-review/swap-review.layout.tsx index 0f55a275206..23e09d65d4c 100644 --- a/src/app/pages/swap/components/swap-review.tsx +++ b/src/app/pages/swap/components/swap-review/swap-review.layout.tsx @@ -1,19 +1,24 @@ +import { useFormContext } from 'react-hook-form'; import { Outlet } from 'react-router-dom'; import { SwapSelectors } from '@tests/selectors/swap.selectors'; -import { Button, Callout } from '@leather.io/ui'; +import { Button } from '@leather.io/ui'; +import type { HasChildren } from '@app/common/has-children'; import { LoadingKeys, useLoading } from '@app/common/hooks/use-loading'; import { Card } from '@app/components/layout'; -import { useSwapContext } from '../swap.context'; -import { SwapAssetsPair } from './swap-assets-pair/swap-assets-pair'; -import { SwapDetails } from './swap-details/swap-details'; +import type { SwapFormSchema } from '../../form/swap-form.schema'; +import { type SwapBaseContext, useSwapContext } from '../../swap.context'; +import { SwapAssetsPair } from '../swap-assets-pair/swap-assets-pair'; -export function SwapReview() { - const { isCrossChainSwap, onSubmitSwap } = useSwapContext(); +export function SwapReviewLayout({ children }: HasChildren) { + const { swapData } = useSwapContext(); + const { onSubmitSwap } = swapData; + const { getValues } = useFormContext(); const { isLoading } = useLoading(LoadingKeys.SUBMIT_SWAP_TRANSACTION); + const values = getValues(); return ( <> @@ -25,20 +30,15 @@ export function SwapReview() { disabled={isLoading} data-testid={SwapSelectors.SwapSubmitBtn} type="button" - onClick={onSubmitSwap} + onClick={() => onSubmitSwap(values)} fullWidth > Swap } > - {isCrossChainSwap && ( - - Note that bridging from sBTC back to BTC is currently unavailable. - - )} - + {children} diff --git a/src/app/pages/swap/containers/bitcoin-swap-container.tsx b/src/app/pages/swap/containers/bitcoin-swap-container.tsx new file mode 100644 index 00000000000..e87b38330f4 --- /dev/null +++ b/src/app/pages/swap/containers/bitcoin-swap-container.tsx @@ -0,0 +1,84 @@ +import type { P2Ret } from '@scure/btc-signer/payment'; + +import type { UtxoResponseItem } from '@leather.io/query'; + +import { BitcoinNativeSegwitAccountLoader } from '@app/components/loaders/bitcoin-account-loader'; +import type { Signer } from '@app/store/accounts/blockchain/bitcoin/bitcoin-signer'; + +import { SwapForm } from '../form/swap-form'; +import { defaultSwapFormValues } from '../form/swap-form.schema'; +import { generateSwapRoutes } from '../generate-swap-routes'; +import { type SbtcDeposit, defaultMaxSignerFee } from '../hooks/use-sbtc-deposit-transaction'; +import { BitcoinUtxosLoader } from '../loaders/bitcoin-utxos-loader'; +import { SwapProvider } from '../swap-provider'; +import type { SwapBaseContext } from '../swap.context'; +import { SwapContainer } from './swap-container'; +import { useBitcoinSwap } from './use-bitcoin-swap'; + +export interface BitcoinSwapContext extends SwapBaseContext { + deposit?: SbtcDeposit; + maxSignerFee: number; + signer: Signer; + utxos: UtxoResponseItem[]; +} + +export const bitcoinSwapRoutes = generateSwapRoutes( + + {signer => ( + + {utxos => { + return ; + }} + + )} + , + 'bitcoin' +); + +interface BitcoinSwapContainerProps { + signer: Signer; + utxos: UtxoResponseItem[]; +} +export function BitcoinSwapContainer({ signer, utxos }: BitcoinSwapContainerProps) { + const { + deposit, + fee, + isCrossChainSwap, + isFetchingExchangeRate, + isPreparingSwapReview, + isSendingMax, + fetchQuoteAmount, + onSetIsCrossChainSwap, + onSetIsSendingMax, + onSubmitSwapForReview, + onSubmitSwap, + } = useBitcoinSwap(signer, utxos); + + return ( + + initialData={{ + defaultValues: defaultSwapFormValues, + deposit, + fee, + maxSignerFee: defaultMaxSignerFee, + protocol: 'sBTC Protocol', + signer, + timestamp: new Date().toISOString(), + utxos, + isCrossChainSwap, + isFetchingExchangeRate, + isPreparingSwapReview, + isSendingMax, + fetchQuoteAmount, + onSetIsCrossChainSwap, + onSetIsSendingMax, + onSubmitSwapForReview, + onSubmitSwap, + }} + > + + + + + ); +} diff --git a/src/app/pages/swap/containers/stacks-swap-container.tsx b/src/app/pages/swap/containers/stacks-swap-container.tsx new file mode 100644 index 00000000000..e5dbe2dbeaf --- /dev/null +++ b/src/app/pages/swap/containers/stacks-swap-container.tsx @@ -0,0 +1,75 @@ +import type { RouteQuote } from 'bitflow-sdk'; + +import { SwapForm } from '../form/swap-form'; +import { defaultSwapFormValues } from '../form/swap-form.schema'; +import { generateSwapRoutes } from '../generate-swap-routes'; +import { StacksNonceLoader } from '../loaders/stacks-nonce-loader'; +import { SwapProvider } from '../swap-provider'; +import type { SwapBaseContext } from '../swap.context'; +import { SwapContainer } from './swap-container'; +import { useStacksSwap } from './use-stacks-swap'; + +export interface StacksSwapContext extends SwapBaseContext { + nonce: number | string; + routeQuote?: RouteQuote; + slippage: number; + sponsored: boolean; +} + +export const stacksSwapRoutes = generateSwapRoutes( + + {nonce => { + return ; + }} + , + 'stacks' +); + +interface StacksSwapContainerProps { + nonce: number | string; +} +export function StacksSwapContainer({ nonce }: StacksSwapContainerProps) { + const { + fee, + routeQuote, + slippage, + sponsored, + isCrossChainSwap, + isFetchingExchangeRate, + isPreparingSwapReview, + isSendingMax, + fetchQuoteAmount, + onSetIsCrossChainSwap, + onSetIsSendingMax, + onSubmitSwapForReview, + onSubmitSwap, + } = useStacksSwap(nonce); + + return ( + + initialData={{ + defaultValues: defaultSwapFormValues, + fee, + nonce, + protocol: 'Bitflow', + routeQuote, + slippage, + sponsored, + timestamp: new Date().toISOString(), + isCrossChainSwap, + isFetchingExchangeRate, + isPreparingSwapReview, + isSendingMax, + fetchQuoteAmount, + onSetIsCrossChainSwap, + onSetIsSendingMax, + onSubmitSwapForReview, + onSubmitSwap, + }} + > + + + + + ); +} diff --git a/src/app/pages/swap/containers/swap-container.tsx b/src/app/pages/swap/containers/swap-container.tsx new file mode 100644 index 00000000000..8d1b9f1a782 --- /dev/null +++ b/src/app/pages/swap/containers/swap-container.tsx @@ -0,0 +1,16 @@ +import { RouteUrls } from '@shared/route-urls'; + +import type { HasChildren } from '@app/common/has-children'; +import { Content, Page } from '@app/components/layout'; +import { PageHeader } from '@app/features/container/headers/page.header'; + +export function SwapContainer({ children }: HasChildren) { + return ( + <> + + + {children} + + + ); +} diff --git a/src/app/pages/swap/containers/use-bitcoin-swap.tsx b/src/app/pages/swap/containers/use-bitcoin-swap.tsx new file mode 100644 index 00000000000..c4c1265c502 --- /dev/null +++ b/src/app/pages/swap/containers/use-bitcoin-swap.tsx @@ -0,0 +1,95 @@ +import { useCallback, useState } from 'react'; + +import type { P2Ret } from '@scure/btc-signer/payment'; + +import type { UtxoResponseItem } from '@leather.io/query'; +import { delay, isUndefined } from '@leather.io/utils'; + +import { logger } from '@shared/logger'; +import { RouteUrls } from '@shared/route-urls'; + +import { LoadingKeys, useLoading } from '@app/common/hooks/use-loading'; +import type { Signer } from '@app/store/accounts/blockchain/bitcoin/bitcoin-signer'; + +import type { SwapAsset, SwapFormSchema } from '../form/swap-form.schema'; +import { type SbtcDeposit, useSbtcDepositTransaction } from '../hooks/use-sbtc-deposit-transaction'; +import { useSwapNavigate } from '../hooks/use-swap-navigate'; + +export function useBitcoinSwap(signer: Signer, utxos: UtxoResponseItem[]) { + const [isCrossChainSwap, setIsCrossChainSwap] = useState(true); + const [isFetchingExchangeRate, setIsFetchingExchangeRate] = useState(false); + const [isSendingMax, setIsSendingMax] = useState(false); + const [isPreparingSwapReview, setIsPreparingSwapReview] = useState(false); + const [deposit, setDeposit] = useState(); + const [fee, setFee] = useState(); + const { setIsLoading, isLoading } = useLoading(LoadingKeys.SUBMIT_SWAP_TRANSACTION); + const swapNavigate = useSwapNavigate(); + + const { onDepositSbtc, onReviewDepositSbtc } = useSbtcDepositTransaction(signer, utxos); + + const onSubmitSwapForReview = useCallback( + async (values: SwapFormSchema) => { + try { + setIsPreparingSwapReview(true); + if (isUndefined(values.swapAssetBase) || isUndefined(values.swapAssetQuote)) { + logger.error('Error submitting swap for review'); + return; + } + + const data = await onReviewDepositSbtc(values, isSendingMax); + if (!data) return; + + setDeposit(data.deposit); + setFee(data.fee); + + return swapNavigate(RouteUrls.SwapReview); + } finally { + setIsPreparingSwapReview(false); + } + }, + [swapNavigate, onReviewDepositSbtc, isSendingMax] + ); + + const onSubmitSwap = useCallback( + async (values: SwapFormSchema) => { + if (isLoading) return; + + if (isUndefined(values.swapAssetBase) || isUndefined(values.swapAssetQuote)) { + logger.error('No assets selected to perform swap'); + return; + } + + setIsLoading(); + + return await onDepositSbtc(deposit); + }, + [deposit, isLoading, onDepositSbtc, setIsLoading] + ); + + // TODO: Implement fetch when exchange rate is available + const fetchQuoteAmount = useCallback( + async (base: SwapAsset, quote: SwapAsset, baseAmount: string): Promise => { + if (!base || !quote) return; + setIsFetchingExchangeRate(true); + await delay(100); // Simulate API call + setIsFetchingExchangeRate(false); + // Return 1:1 rate for now + return baseAmount; + }, + [] + ); + + return { + deposit, + fee, + isSendingMax, + isCrossChainSwap, + isFetchingExchangeRate, + isPreparingSwapReview, + fetchQuoteAmount, + onSetIsCrossChainSwap: (value: boolean) => setIsCrossChainSwap(value), + onSetIsSendingMax: (value: boolean) => setIsSendingMax(value), + onSubmitSwapForReview, + onSubmitSwap, + }; +} diff --git a/src/app/pages/swap/containers/use-stacks-swap.tsx b/src/app/pages/swap/containers/use-stacks-swap.tsx new file mode 100644 index 00000000000..1a6376e99e1 --- /dev/null +++ b/src/app/pages/swap/containers/use-stacks-swap.tsx @@ -0,0 +1,240 @@ +import { useCallback, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; + +import { bytesToHex } from '@stacks/common'; +import { type ContractCallPayload, TransactionTypes } from '@stacks/connect'; +import { + AnchorMode, + PostConditionMode, + serializeCV, + serializePostCondition, +} from '@stacks/transactions'; +import type { RouteQuote } from 'bitflow-sdk'; + +import { defaultSwapFee } from '@leather.io/query'; +import { isError, isUndefined } from '@leather.io/utils'; + +import { logger } from '@shared/logger'; +import { RouteUrls } from '@shared/route-urls'; +import { bitflow } from '@shared/utils/bitflow-sdk'; + +import { LoadingKeys, useLoading } from '@app/common/hooks/use-loading'; +import type { + SbtcSponsorshipEligibility, + TransactionBase, +} from '@app/query/sbtc/sponsored-transactions.query'; +import { useCurrentStacksAccount } from '@app/store/accounts/blockchain/stacks/stacks-account.hooks'; +import { useGenerateStacksContractCallUnsignedTx } from '@app/store/transactions/contract-call.hooks'; +import { useSignStacksTransaction } from '@app/store/transactions/transaction.hooks'; + +import type { SwapAsset, SwapFormSchema } from '../form/swap-form.schema'; +import { useSponsorTransactionFees } from '../hooks/use-sponsor-tx-fees'; +import { useStacksBroadcastSwap } from '../hooks/use-stacks-broadcast-swap'; +import { useSwapNavigate } from '../hooks/use-swap-navigate'; + +export function useStacksSwap(nonce: number | string) { + const [isCrossChainSwap, setIsCrossChainSwap] = useState(false); + const [unsignedTx, setUnsignedTx] = useState(); + const [slippage, _setSlippage] = useState(0.04); + const [isSendingMax, setIsSendingMax] = useState(false); + const [isFetchingExchangeRate, setIsFetchingExchangeRate] = useState(false); + const [isPreparingSwapReview, setIsPreparingSwapReview] = useState(false); + const [fee, _setFee] = useState(defaultSwapFee.amount.toNumber()); + const [routeQuote, setRouteQuote] = useState(); + const { setIsLoading, setIsIdle, isLoading } = useLoading(LoadingKeys.SUBMIT_SWAP_TRANSACTION); + const currentAccount = useCurrentStacksAccount(); + const generateUnsignedTx = useGenerateStacksContractCallUnsignedTx(); + const signTx = useSignStacksTransaction(); + const broadcastStacksSwap = useStacksBroadcastSwap(); + const swapNavigate = useSwapNavigate(); + const navigate = useNavigate(); + + const [sponsorshipEligibility, setSponsorshipEligibility] = useState< + SbtcSponsorshipEligibility | undefined + >(); + + const { checkEligibilityForSponsor, submitSponsoredTx } = useSponsorTransactionFees(); + + const fetchRouteQuote = useCallback( + async ( + base: SwapAsset, + quote: SwapAsset, + baseAmount: string + ): Promise => { + if (!baseAmount || !base || !quote) return; + try { + const result = await bitflow.getQuoteForRoute( + base.tokenId, + quote.tokenId, + Number(baseAmount) + ); + if (!result.bestRoute) { + logger.error('No swap route found'); + return; + } + return result.bestRoute; + } catch (e) { + logger.error('Error fetching exchange rate from Bitflow', e); + return; + } + }, + [] + ); + + const fetchQuoteAmount = useCallback( + async (base: SwapAsset, quote: SwapAsset, baseAmount: string): Promise => { + setIsFetchingExchangeRate(true); + const routeQuote = await fetchRouteQuote(base, quote, baseAmount); + setIsFetchingExchangeRate(false); + if (!routeQuote) return; + return String(routeQuote.quote); + }, + [fetchRouteQuote] + ); + + const onSubmitSwapForReview = useCallback( + async (values: SwapFormSchema) => { + try { + setIsPreparingSwapReview(true); + if ( + isUndefined(currentAccount) || + isUndefined(values.swapAssetBase) || + isUndefined(values.swapAssetQuote) + ) { + logger.error('Error submitting swap for review'); + return; + } + + const routeQuote = await fetchRouteQuote( + values.swapAssetBase, + values.swapAssetQuote, + values.swapAmountBase + ); + + if (!routeQuote) return; + + const swapExecutionData = { + route: routeQuote.route, + amount: Number(values.swapAmountBase), + tokenXDecimals: routeQuote.tokenXDecimals, + tokenYDecimals: routeQuote.tokenYDecimals, + }; + + const swapParams = await bitflow.getSwapParams( + swapExecutionData, + currentAccount.address, + slippage + ); + + if (!routeQuote) return; + setRouteQuote(routeQuote); + + const payload: ContractCallPayload = { + anchorMode: AnchorMode.Any, + contractAddress: swapParams.contractAddress, + contractName: swapParams.contractName, + functionName: swapParams.functionName, + functionArgs: swapParams.functionArgs.map(x => bytesToHex(serializeCV(x))), + postConditionMode: PostConditionMode.Deny, + postConditions: swapParams.postConditions.map(pc => + bytesToHex(serializePostCondition(pc)) + ), + publicKey: currentAccount?.stxPublicKey, + sponsored: false, + txType: TransactionTypes.ContractCall, + }; + + const unsignedTx = await generateUnsignedTx(payload, { fee, nonce }); + if (!unsignedTx) + return logger.error('Attempted to generate unsigned tx, but tx is undefined'); + + const sponsorshipEligibility = await checkEligibilityForSponsor(unsignedTx); + + setUnsignedTx(unsignedTx); + setSponsorshipEligibility(sponsorshipEligibility); + + swapNavigate(RouteUrls.SwapReview); + } finally { + setIsPreparingSwapReview(false); + } + }, + [ + currentAccount, + fetchRouteQuote, + slippage, + generateUnsignedTx, + fee, + nonce, + checkEligibilityForSponsor, + swapNavigate, + ] + ); + + const onSubmitSwap = useCallback( + async (values: SwapFormSchema) => { + if (isLoading) return; + + if (isUndefined(currentAccount)) { + logger.error('Error submitting swap data to sign'); + return; + } + + if (isUndefined(values.swapAssetBase) || isUndefined(values.swapAssetQuote)) { + logger.error('No assets selected to perform swap'); + return; + } + + setIsLoading(); + + try { + if (sponsorshipEligibility?.isEligible) + return await submitSponsoredTx(sponsorshipEligibility.unsignedSponsoredTx!); + + if (!unsignedTx?.transaction) return logger.error('No unsigned tx to sign'); + + const signedTx = await signTx(unsignedTx.transaction); + if (!signedTx) + return logger.error('Attempted to generate raw tx, but signed tx is undefined'); + + return await broadcastStacksSwap(signedTx); + } catch (e) { + navigate(RouteUrls.SwapError, { + state: { + message: isError(e) ? e.message : '', + title: 'Swap Error', + }, + }); + } finally { + setIsIdle(); + } + }, + [ + broadcastStacksSwap, + currentAccount, + isLoading, + navigate, + setIsIdle, + setIsLoading, + signTx, + sponsorshipEligibility, + submitSponsoredTx, + unsignedTx, + ] + ); + + return { + fee, + isCrossChainSwap, + isFetchingExchangeRate, + isPreparingSwapReview, + isSendingMax, + routeQuote, + slippage, + sponsored: sponsorshipEligibility?.isEligible || false, + fetchQuoteAmount, + onSetIsCrossChainSwap: (value: boolean) => setIsCrossChainSwap(value), + onSetIsSendingMax: (value: boolean) => setIsSendingMax(value), + onSubmitSwapForReview, + onSubmitSwap, + }; +} diff --git a/src/app/pages/swap/form/swap-form.schema.ts b/src/app/pages/swap/form/swap-form.schema.ts new file mode 100644 index 00000000000..0d25225a09b --- /dev/null +++ b/src/app/pages/swap/form/swap-form.schema.ts @@ -0,0 +1,85 @@ +import React from 'react'; + +import BigNumber from 'bignumber.js'; +import { z } from 'zod'; + +import { convertAmountToFractionalUnit, createMoney } from '@leather.io/utils'; + +import { FormErrorMessages } from '@shared/error-messages'; + +// TODO: Relocate to mono repo +// BigNumber schema +const bigNumberSchema = z.instanceof(BigNumber); + +// Currency schema +const cryptoCurrencySchema = z.union([z.literal('BTC'), z.literal('STX'), z.string()]); +const fiatCurrencySchema = z.union([ + z.enum(['USD', 'EUR', 'GBP', 'AUD', 'CAD', 'CNY', 'JPY', 'KRW']), + z.string(), +]); +const currencySchema = z.union([cryptoCurrencySchema, fiatCurrencySchema]); + +// Money schema +const moneySchema = z.object({ + amount: bigNumberSchema, + symbol: currencySchema, + decimals: z.number(), +}); + +// MarketPair schema +const marketPairSchema = z.object({ + base: cryptoCurrencySchema, + quote: fiatCurrencySchema, +}); + +// MarketData schema +const marketDataSchema = z.object({ + pair: marketPairSchema, + price: moneySchema, +}); + +const swapAssetSchema = z.object({ + address: z.string().optional(), + balance: moneySchema, + tokenId: currencySchema, + displayName: z.string().optional(), + fallback: z.string(), + icon: z.string(), + name: z.string(), + marketData: marketDataSchema, + principal: z.string(), +}); + +export type SwapAsset = z.infer; + +export const swapFormSchema = z.object({ + swapAmountBase: z + .string({ + required_error: FormErrorMessages.AmountRequired, + }) + .refine(val => typeof Number(val) === 'number', { + message: FormErrorMessages.MustBeNumber, + }) + .refine(val => Number(val) > 0, { + message: FormErrorMessages.MustBePositive, + }), + swapAmountQuote: z + .string() + .refine(val => !isNaN(Number(val)), { + message: FormErrorMessages.MustBeNumber, + }) + .refine(val => Number(val) > 0, { + message: FormErrorMessages.MustBePositive, + }), + swapAssetBase: swapAssetSchema.optional(), + swapAssetQuote: swapAssetSchema.optional(), +}); + +export type SwapFormSchema = z.infer; + +export const defaultSwapFormValues: SwapFormSchema = { + swapAmountBase: '', + swapAmountQuote: '', + swapAssetBase: undefined, + swapAssetQuote: undefined, +}; diff --git a/src/app/pages/swap/form/swap-form.tsx b/src/app/pages/swap/form/swap-form.tsx new file mode 100644 index 00000000000..582854ed2ef --- /dev/null +++ b/src/app/pages/swap/form/swap-form.tsx @@ -0,0 +1,25 @@ +import { FormProvider, useForm } from 'react-hook-form'; +import { Outlet } from 'react-router-dom'; + +import { zodResolver } from '@hookform/resolvers/zod'; + +import { type SwapBaseContext, useSwapContext } from '../swap.context'; +import { type SwapFormSchema } from './swap-form.schema'; +import { useSwapFormSchema } from './use-swap-form-schema'; + +export function SwapForm() { + const { swapData } = useSwapContext(); + const { defaultValues } = swapData; + const swapFormSchema = useSwapFormSchema(); + const formMethods = useForm({ + mode: 'onBlur', + defaultValues, + resolver: zodResolver(swapFormSchema, { async: true }), + }); + + return ( + + + + ); +} diff --git a/src/app/pages/swap/form/use-swap-form-schema.tsx b/src/app/pages/swap/form/use-swap-form-schema.tsx new file mode 100644 index 00000000000..f87c09f0992 --- /dev/null +++ b/src/app/pages/swap/form/use-swap-form-schema.tsx @@ -0,0 +1,125 @@ +import { useMemo } from 'react'; + +import { cvToValue, hexToCV } from '@stacks/transactions'; +import BigNumber from 'bignumber.js'; +import { z } from 'zod'; + +import { BTC_DECIMALS } from '@leather.io/constants'; +import { + convertAmountToBaseUnit, + convertAmountToFractionalUnit, + createMoney, +} from '@leather.io/utils'; + +import { FormErrorMessages } from '@shared/error-messages'; + +import { + defaultSbtcLimits, + useGetCurrentSbtcSupply, + useGetSbtcLimits, +} from '@app/query/sbtc/sbtc-limits.query'; + +import { type SwapBaseContext, useSwapContext } from '../swap.context'; +import { swapFormSchema } from './swap-form.schema'; + +export function useSwapFormSchema() { + const { swapData } = useSwapContext(); + const { isCrossChainSwap, isFetchingExchangeRate } = swapData; + const { data: sbtcLimits } = useGetSbtcLimits(); + const { data: supply } = useGetCurrentSbtcSupply(); + + const remainingSbtcPegCapSupply = useMemo(() => { + const sbtcPegCap = sbtcLimits?.pegCap; + if (!sbtcPegCap) return; + const currentSupplyValue = supply?.result && cvToValue(hexToCV(supply?.result)); + return convertAmountToFractionalUnit( + createMoney(new BigNumber(Number(sbtcPegCap - currentSupplyValue)), 'BTC', BTC_DECIMALS) + ); + }, [sbtcLimits?.pegCap, supply?.result]); + + const sbtcDepositCapMin = createMoney( + new BigNumber(sbtcLimits?.perDepositMinimum ?? defaultSbtcLimits.perDepositMinimum), + 'BTC' + ); + const sbtcDepositCapMax = createMoney( + new BigNumber(sbtcLimits?.perDepositCap ?? defaultSbtcLimits.perDepositCap), + 'BTC' + ); + + return swapFormSchema + .superRefine((data, ctx) => { + if (isFetchingExchangeRate) return; + console.log('hello', data.swapAmountBase, data.swapAssetBase); + if (data.swapAmountBase && data.swapAssetBase) { + const valueInFractionalUnit = convertAmountToFractionalUnit( + createMoney( + new BigNumber(data.swapAmountBase), + data.swapAssetBase.balance.symbol, + data.swapAssetBase.balance.decimals + ) + ); + if (data.swapAssetBase.balance.amount.isLessThan(valueInFractionalUnit)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['swapAmountBase'], + message: FormErrorMessages.InsufficientFunds, + }); + } + } + }) + .refine( + data => { + const { swapAmountBase, swapAssetBase } = data; + if (!swapAmountBase || !swapAssetBase || !isCrossChainSwap) return true; + const valueInFractionalUnit = convertAmountToFractionalUnit( + createMoney( + new BigNumber(swapAmountBase), + swapAssetBase.balance.symbol, + swapAssetBase.balance.decimals + ) + ); + if (valueInFractionalUnit.isLessThan(sbtcDepositCapMin.amount)) return false; + return true; + }, + { + path: ['swapAmountBase'], + message: `Min amount is ${convertAmountToBaseUnit(sbtcDepositCapMin).toString()} BTC`, + } + ) + .refine( + data => { + const { swapAmountBase, swapAssetBase } = data; + if (!swapAmountBase || !swapAssetBase || !isCrossChainSwap) return true; + const valueInFractionalUnit = convertAmountToFractionalUnit( + createMoney( + new BigNumber(swapAmountBase), + swapAssetBase.balance.symbol, + swapAssetBase.balance.decimals + ) + ); + if (valueInFractionalUnit.isGreaterThan(sbtcDepositCapMax.amount)) return false; + return true; + }, + { + path: ['swapAmountBase'], + message: `Max amount is ${convertAmountToBaseUnit(sbtcDepositCapMax).toString()} BTC`, + } + ) + .refine( + data => { + const { swapAmountBase, swapAssetBase } = data; + if (!swapAmountBase || !swapAssetBase || !isCrossChainSwap) return true; + const valueInFractionalUnit = convertAmountToFractionalUnit( + createMoney( + new BigNumber(swapAmountBase), + swapAssetBase.balance.symbol, + swapAssetBase.balance.decimals + ) + ); + if (!remainingSbtcPegCapSupply) return true; + if (valueInFractionalUnit.isGreaterThan(remainingSbtcPegCapSupply)) return false; + return true; + }, + { path: ['swapAmountBase'], message: 'Amount exceeds current mint cap' } + ); +} diff --git a/src/app/pages/swap/generate-swap-routes.tsx b/src/app/pages/swap/generate-swap-routes.tsx index b8084376a95..d2f18496606 100644 --- a/src/app/pages/swap/generate-swap-routes.tsx +++ b/src/app/pages/swap/generate-swap-routes.tsx @@ -1,5 +1,7 @@ import { Route } from 'react-router-dom'; +import type { Blockchain } from '@leather.io/models'; + import { RouteUrls } from '@shared/route-urls'; import { ledgerBitcoinTxSigningRoutes } from '@app/features/ledger/flows/bitcoin-tx-signing/ledger-bitcoin-sign-tx-container'; @@ -9,21 +11,32 @@ import { AccountGate } from '@app/routes/account-gate'; import { SwapAssetSheetBase } from './components/swap-asset-dialog/swap-asset-dialog-base'; import { SwapAssetSheetQuote } from './components/swap-asset-dialog/swap-asset-dialog-quote'; import { SwapError } from './components/swap-error'; -import { SwapReview } from './components/swap-review'; +import { BitcoinSwapReview } from './components/swap-review/bitcoin-swap-review'; +import { StacksSwapReview } from './components/swap-review/stacks-swap-review'; import { Swap } from './swap'; +import type { SwapBaseContext } from './swap.context'; -export function generateSwapRoutes(container: React.ReactNode) { +export function generateSwapRoutes( + container: React.ReactNode, + chain: Blockchain +) { return ( {container}}> - }> + />}> } /> } /> } /> - }> - {ledgerBitcoinTxSigningRoutes} - {ledgerStacksTxSigningRoutes} - + {chain === 'bitcoin' && ( + }> + {ledgerBitcoinTxSigningRoutes} + + )} + {chain === 'stacks' && ( + }> + {ledgerStacksTxSigningRoutes} + + )} ); } diff --git a/src/app/pages/swap/hooks/use-all-swappable-assets.tsx b/src/app/pages/swap/hooks/use-all-swappable-assets.tsx new file mode 100644 index 00000000000..9d69b55718e --- /dev/null +++ b/src/app/pages/swap/hooks/use-all-swappable-assets.tsx @@ -0,0 +1,47 @@ +import { useMemo } from 'react'; + +import { isDefined, migratePositiveAssetBalancesToTop } from '@leather.io/utils'; + +import { useConfigSbtc } from '@app/query/common/remote-config/remote-config.query'; +import { useCurrentStacksAccountAddress } from '@app/store/accounts/blockchain/stacks/stacks-account.hooks'; + +import type { SwapAsset } from '../form/swap-form.schema'; +import { useBtcSwapAsset } from './use-bitcoin-bridge-assets'; +import { useBitflowSwappableAssets } from './use-bitflow-swappable-assets'; + +const bitflowSBtcTokenId = 'token-sbtc'; + +function getBitflowSwappableAssetsWithSbtcAtTop(assets: SwapAsset[]) { + const bitflowSbtcAsset = assets.find(asset => asset.tokenId === bitflowSBtcTokenId); + const bitflowAssetsWithSbtcRemoved = assets.filter(asset => asset.tokenId !== bitflowSBtcTokenId); + return [ + bitflowSbtcAsset, + ...migratePositiveAssetBalancesToTop(bitflowAssetsWithSbtcRemoved), + ].filter(isDefined); +} + +export function useAllSwappableAssets() { + const address = useCurrentStacksAccountAddress(); + const { data: bitflowSwapAssets = [] } = useBitflowSwappableAssets(address); + const { isSbtcEnabled } = useConfigSbtc(); + + const btcAsset = useBtcSwapAsset(); + const sortedStacksSwapAssets = useMemo( + () => getBitflowSwappableAssetsWithSbtcAtTop(bitflowSwapAssets), + [bitflowSwapAssets] + ); + const allSwappableAssetsBase = useMemo(() => { + if (!isSbtcEnabled) return migratePositiveAssetBalancesToTop(bitflowSwapAssets); + return [btcAsset, ...sortedStacksSwapAssets]; + }, [bitflowSwapAssets, btcAsset, isSbtcEnabled, sortedStacksSwapAssets]); + + const allSwappableAssetsQuote = useMemo(() => { + if (!isSbtcEnabled) return bitflowSwapAssets; + return sortedStacksSwapAssets; + }, [bitflowSwapAssets, isSbtcEnabled, sortedStacksSwapAssets]); + + return { + allSwappableAssetsBase, + allSwappableAssetsQuote, + }; +} diff --git a/src/app/pages/swap/hooks/use-bitcoin-bridge-assets.tsx b/src/app/pages/swap/hooks/use-bitcoin-bridge-assets.tsx new file mode 100644 index 00000000000..308e38d273c --- /dev/null +++ b/src/app/pages/swap/hooks/use-bitcoin-bridge-assets.tsx @@ -0,0 +1,50 @@ +import { useMemo } from 'react'; + +import BtcAvatarIconSrc from '@assets/avatars/btc-avatar-icon.png'; + +import { useCryptoCurrencyMarketDataMeanAverage } from '@leather.io/query'; +import { getPrincipalFromContractId } from '@leather.io/utils'; + +import { castBitcoinMarketDataToSbtcMarketData } from '@app/common/hooks/use-calculate-sip10-fiat-value'; +import { useBtcCryptoAssetBalanceNativeSegwit } from '@app/query/bitcoin/balance/btc-balance-native-segwit.hooks'; +import { useSbtcSip10Token } from '@app/query/sbtc/sbtc-token.query'; +import { useCurrentAccountNativeSegwitIndexZeroSignerNullable } from '@app/store/accounts/blockchain/bitcoin/native-segwit-account.hooks'; + +import type { SwapAsset } from '../form/swap-form.schema'; + +export function useBtcSwapAsset() { + const nativeSegwitSigner = useCurrentAccountNativeSegwitIndexZeroSignerNullable(); + const currentBitcoinAddress = nativeSegwitSigner?.address ?? ''; + const { balance } = useBtcCryptoAssetBalanceNativeSegwit(currentBitcoinAddress); + const bitcoinMarketData = useCryptoCurrencyMarketDataMeanAverage('BTC'); + + return useMemo((): SwapAsset => { + return { + balance: balance.availableBalance, + tokenId: 'token-btc', + displayName: 'Bitcoin', + fallback: 'BT', + icon: BtcAvatarIconSrc, + name: 'BTC', + marketData: bitcoinMarketData, + principal: '', + }; + }, [balance.availableBalance, bitcoinMarketData]); +} + +export function useSBtcSwapAsset() { + const sbtcToken = useSbtcSip10Token(); + const bitcoinMarketData = useCryptoCurrencyMarketDataMeanAverage('BTC'); + const { info, balance } = sbtcToken; + + return { + balance: balance.availableBalance, + tokenId: 'token-sbtc', + displayName: 'sBTC', + fallback: 'SB', + icon: info.imageCanonicalUri, + name: 'sBTC', + marketData: castBitcoinMarketDataToSbtcMarketData(bitcoinMarketData), + principal: getPrincipalFromContractId(info.contractId), + }; +} diff --git a/src/app/pages/swap/hooks/use-bitflow-swap.tsx b/src/app/pages/swap/hooks/use-bitflow-swap.tsx deleted file mode 100644 index 1b5f01d3000..00000000000 --- a/src/app/pages/swap/hooks/use-bitflow-swap.tsx +++ /dev/null @@ -1,108 +0,0 @@ -import { useCallback, useMemo, useState } from 'react'; - -import type { P2Ret } from '@scure/btc-signer/payment'; -import type { RouteQuote } from 'bitflow-sdk'; - -import { type SwapAsset } from '@leather.io/query'; -import { isDefined, migratePositiveAssetBalancesToTop } from '@leather.io/utils'; - -import { logger } from '@shared/logger'; -import { bitflow } from '@shared/utils/bitflow-sdk'; - -import { useConfigSbtc } from '@app/query/common/remote-config/remote-config.query'; -import type { Signer } from '@app/store/accounts/blockchain/bitcoin/bitcoin-signer'; -import { useCurrentStacksAccountAddress } from '@app/store/accounts/blockchain/stacks/stacks-account.hooks'; -import { useHasLedgerKeys } from '@app/store/ledger/ledger.selectors'; - -import { SwapSubmissionData } from '../swap.context'; -import { useBitflowSwappableAssets } from './use-bitflow-swappable-assets'; -import { useBtcSwapAsset } from './use-btc-bridge-asset'; - -const bitflowSBtcTokenId = 'token-sbtc'; - -function getBitflowSwappableAssetsWithSbtcAtTop(assets: SwapAsset[]) { - const bitflowSbtcAsset = assets.find(asset => asset.tokenId === bitflowSBtcTokenId); - const bitflowAssetsWithSbtcRemoved = assets.filter(asset => asset.tokenId !== bitflowSBtcTokenId); - return [ - bitflowSbtcAsset, - ...migratePositiveAssetBalancesToTop(bitflowAssetsWithSbtcRemoved), - ].filter(isDefined); -} - -export function useBitflowSwap(btcSigner?: Signer) { - const [isCrossChainSwap, setIsCrossChainSwap] = useState(false); - const [swapSubmissionData, setSwapSubmissionData] = useState(); - const [slippage, _setSlippage] = useState(0.04); - const [isFetchingExchangeRate, setIsFetchingExchangeRate] = useState(false); - const address = useCurrentStacksAccountAddress(); - const { data: bitflowSwapAssets = [] } = useBitflowSwappableAssets(address); - const { isSbtcEnabled } = useConfigSbtc(); - const isLedger = useHasLedgerKeys(); - - const createBtcAsset = useBtcSwapAsset(btcSigner); - const btcAsset = createBtcAsset(); - - const swappableAssetsBase = useMemo(() => { - if (!isSbtcEnabled || !btcSigner || isLedger) - return migratePositiveAssetBalancesToTop(bitflowSwapAssets); - return [btcAsset, ...getBitflowSwappableAssetsWithSbtcAtTop(bitflowSwapAssets)]; - }, [bitflowSwapAssets, btcAsset, btcSigner, isLedger, isSbtcEnabled]); - - const swappableAssetsQuote = useMemo(() => { - if (!isSbtcEnabled) return bitflowSwapAssets; - return getBitflowSwappableAssetsWithSbtcAtTop(bitflowSwapAssets); - }, [bitflowSwapAssets, isSbtcEnabled]); - - const fetchRouteQuote = useCallback( - async ( - base: SwapAsset, - quote: SwapAsset, - baseAmount: string - ): Promise => { - if (!baseAmount || !base || !quote || isCrossChainSwap) return; - try { - const result = await bitflow.getQuoteForRoute( - base.tokenId, - quote.tokenId, - Number(baseAmount) - ); - if (!result.bestRoute) { - logger.error('No swap route found'); - return; - } - return result.bestRoute; - } catch (e) { - logger.error('Error fetching exchange rate from Bitflow', e); - return; - } - }, - [isCrossChainSwap] - ); - - const fetchQuoteAmount = useCallback( - async (base: SwapAsset, quote: SwapAsset, baseAmount: string): Promise => { - setIsFetchingExchangeRate(true); - const routeQuote = await fetchRouteQuote(base, quote, baseAmount); - setIsFetchingExchangeRate(false); - if (isCrossChainSwap) return baseAmount; // 1:1 swap - if (!routeQuote) return; - return String(routeQuote.quote); - }, - [fetchRouteQuote, isCrossChainSwap] - ); - - return { - fetchRouteQuote, - fetchQuoteAmount, - isCrossChainSwap, - isFetchingExchangeRate, - onSetIsCrossChainSwap: (value: boolean) => setIsCrossChainSwap(value), - onSetIsFetchingExchangeRate: (value: boolean) => setIsFetchingExchangeRate(value), - onSetSwapSubmissionData: (value: SwapSubmissionData) => setSwapSubmissionData(value), - slippage, - bitflowSwapAssets, - swappableAssetsBase, - swappableAssetsQuote, - swapSubmissionData, - }; -} diff --git a/src/app/pages/swap/hooks/use-bitflow-swappable-assets.tsx b/src/app/pages/swap/hooks/use-bitflow-swappable-assets.tsx index b88be77c571..e1655fb42ab 100644 --- a/src/app/pages/swap/hooks/use-bitflow-swappable-assets.tsx +++ b/src/app/pages/swap/hooks/use-bitflow-swappable-assets.tsx @@ -6,7 +6,6 @@ import type { Token } from 'bitflow-sdk'; import { type Currency, createMarketData, createMarketPair } from '@leather.io/models'; import { - type SwapAsset, useAlexSdkLatestPricesQuery, useStxAvailableUnlockedBalance, useTransferableSip10Tokens, @@ -21,6 +20,7 @@ import { import { useSip10FiatMarketData } from '@app/common/hooks/use-calculate-sip10-fiat-value'; import { createGetBitflowAvailableTokensQueryOptions } from '@app/query/bitflow-sdk/bitflow-available-tokens.query'; +import type { SwapAsset } from '../form/swap-form.schema'; import { sortSwapAssets } from '../swap.utils'; const alexStxTokenId: Currency = 'token-wstx'; diff --git a/src/app/pages/swap/hooks/use-btc-bridge-asset.tsx b/src/app/pages/swap/hooks/use-btc-bridge-asset.tsx deleted file mode 100644 index ea578dd5a5e..00000000000 --- a/src/app/pages/swap/hooks/use-btc-bridge-asset.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { useCallback } from 'react'; - -import BtcAvatarIconSrc from '@assets/avatars/btc-avatar-icon.png'; -import type { P2Ret } from '@scure/btc-signer/payment'; - -import { type SwapAsset, useCryptoCurrencyMarketDataMeanAverage } from '@leather.io/query'; - -import { useBtcCryptoAssetBalanceNativeSegwit } from '@app/query/bitcoin/balance/btc-balance-native-segwit.hooks'; -import type { Signer } from '@app/store/accounts/blockchain/bitcoin/bitcoin-signer'; - -export function useBtcSwapAsset(btcSigner?: Signer) { - const currentBitcoinAddress = btcSigner?.address ?? ''; - const { balance } = useBtcCryptoAssetBalanceNativeSegwit(currentBitcoinAddress); - const bitcoinMarketData = useCryptoCurrencyMarketDataMeanAverage('BTC'); - - return useCallback((): SwapAsset => { - return { - balance: balance.availableBalance, - tokenId: 'token-btc', - displayName: 'Bitcoin', - fallback: 'BT', - icon: BtcAvatarIconSrc, - name: 'BTC', - marketData: bitcoinMarketData, - principal: '', - }; - }, [balance.availableBalance, bitcoinMarketData]); -} diff --git a/src/app/pages/swap/hooks/use-sbtc-deposit-transaction.tsx b/src/app/pages/swap/hooks/use-sbtc-deposit-transaction.tsx index c85c274d51c..b7e13989806 100644 --- a/src/app/pages/swap/hooks/use-sbtc-deposit-transaction.tsx +++ b/src/app/pages/swap/hooks/use-sbtc-deposit-transaction.tsx @@ -14,7 +14,7 @@ import { } from 'sbtc'; import type { BitcoinNetworkModes } from '@leather.io/models'; -import { useAverageBitcoinFeeRates } from '@leather.io/query'; +import { type UtxoResponseItem, useAverageBitcoinFeeRates } from '@leather.io/query'; import { btcToSat, createMoney } from '@leather.io/utils'; import { logger } from '@shared/logger'; @@ -26,20 +26,19 @@ import { determineUtxosForSpendAll, } from '@app/common/transactions/bitcoin/coinselect/local-coin-selection'; import { useToast } from '@app/features/toasts/use-toast'; -import { useCurrentNativeSegwitUtxos } from '@app/query/bitcoin/address/utxos-by-address.hooks'; import { useBreakOnNonCompliantEntity } from '@app/query/common/compliance-checker/compliance-checker.query'; import { useBitcoinScureLibNetworkConfig } from '@app/store/accounts/blockchain/bitcoin/bitcoin-keychain'; import type { Signer } from '@app/store/accounts/blockchain/bitcoin/bitcoin-signer'; import { useCurrentStacksAccount } from '@app/store/accounts/blockchain/stacks/stacks-account.hooks'; import { useCurrentNetwork } from '@app/store/networks/networks.selectors'; -import type { SwapSubmissionData } from '../swap.context'; +import type { SwapFormSchema } from '../form/swap-form.schema'; // Also set as defaults in sbtc lib -const maxSignerFee = 80_000; +export const defaultMaxSignerFee = 80_000; const reclaimLockTime = 144; -interface SbtcDeposit { +export interface SbtcDeposit { address: string; depositScript: string; reclaimScript: string; @@ -61,11 +60,10 @@ function getSbtcNetworkConfig(network: BitcoinNetworkModes) { const clientMainnet = new SbtcApiClientMainnet(); const clientTestnet = new SbtcApiClientTestnet(); -export function useSbtcDepositTransaction(btcSigner?: Signer) { +export function useSbtcDepositTransaction(signer: Signer, utxos: UtxoResponseItem[]) { const toast = useToast(); const { setIsIdle } = useLoading(LoadingKeys.SUBMIT_SWAP_TRANSACTION); const stacksAccount = useCurrentStacksAccount(); - const { data: utxos } = useCurrentNativeSegwitUtxos(); const { data: feeRates } = useAverageBitcoinFeeRates(); const networkMode = useBitcoinScureLibNetworkConfig(); const navigate = useNavigate(); @@ -82,18 +80,18 @@ export function useSbtcDepositTransaction(btcSigner?: Signer) { useBreakOnNonCompliantEntity(); return { - async onReviewDepositSbtc(swapData: SwapSubmissionData, isSendingMax: boolean) { - if (!stacksAccount || !utxos || !btcSigner) return; + async onReviewDepositSbtc(values: SwapFormSchema, isSendingMax: boolean) { + if (!stacksAccount || !utxos) return; try { const deposit: SbtcDeposit = buildSbtcDepositTx({ - amountSats: btcToSat(swapData.swapAmountQuote).toNumber(), + amountSats: btcToSat(values.swapAmountQuote).toNumber(), network: getSbtcNetworkConfig(network.chain.bitcoin.mode), stacksAddress: stacksAccount.address, signersPublicKey: await client.fetchSignersPublicKey(), - maxSignerFee, + maxSignerFee: defaultMaxSignerFee, reclaimLockTime, - reclaimPublicKey: bytesToHex(btcSigner.publicKey).slice(2), + reclaimPublicKey: bytesToHex(signer.publicKey).slice(2), }); const determineUtxosArgs = { @@ -111,7 +109,7 @@ export function useSbtcDepositTransaction(btcSigner?: Signer) { ? determineUtxosForSpendAll(determineUtxosArgs) : determineUtxosForSpend(determineUtxosArgs); - const p2wpkh = btc.p2wpkh(btcSigner.publicKey, networkMode); + const p2wpkh = btc.p2wpkh(signer.publicKey, networkMode); for (const input of inputs) { deposit.transaction.addInput({ @@ -129,34 +127,28 @@ export function useSbtcDepositTransaction(btcSigner?: Signer) { outputs.forEach(output => { // Add change output if (!output.address) { - deposit.transaction.addOutputAddress( - btcSigner.address, - BigInt(output.value), - networkMode - ); + deposit.transaction.addOutputAddress(signer.address, BigInt(output.value), networkMode); return; } }); - return { deposit, fee, maxSignerFee }; + return { deposit, fee }; } catch (error) { logger.error('Error generating deposit transaction', error); return null; } }, - async onDepositSbtc(swapSubmissionData: SwapSubmissionData) { - if (!stacksAccount || !btcSigner) return; - const sBtcDeposit = swapSubmissionData.txData?.deposit as SbtcDeposit; - + async onDepositSbtc(deposit?: SbtcDeposit) { + if (!deposit) return; try { - btcSigner.sign(sBtcDeposit.transaction); - sBtcDeposit.transaction.finalize(); - logger.info('Deposit', { deposit: sBtcDeposit }); + signer.sign(deposit.transaction); + deposit.transaction.finalize(); + logger.info('Deposit', { deposit }); - const txid = await client.broadcastTx(sBtcDeposit.transaction); + const txid = await client.broadcastTx(deposit.transaction); logger.info('Broadcasted tx', txid); - await client.notifySbtc(sBtcDeposit); + await client.notifySbtc(deposit); toast.success('Transaction submitted!'); setIsIdle(); navigate(RouteUrls.Activity); diff --git a/src/app/pages/swap/hooks/use-sponsor-tx-fees.tsx b/src/app/pages/swap/hooks/use-sponsor-tx-fees.tsx index 29b9ef4553c..4b9874689b6 100644 --- a/src/app/pages/swap/hooks/use-sponsor-tx-fees.tsx +++ b/src/app/pages/swap/hooks/use-sponsor-tx-fees.tsx @@ -7,7 +7,6 @@ import { FeeTypes } from '@leather.io/models'; import { defaultFeesMaxValuesAsMoney } from '@leather.io/query'; import { logger } from '@shared/logger'; -import type { SwapFormValues } from '@shared/models/form.model'; import { RouteUrls } from '@shared/route-urls'; import { LoadingKeys, useLoading } from '@app/common/hooks/use-loading'; @@ -27,11 +26,11 @@ export function useSponsorTransactionFees() { const navigate = useNavigate(); const toast = useToast(); - const checkEligibilityForSponsor = async (values: SwapFormValues, baseTx: TransactionBase) => { + const checkEligibilityForSponsor = async (baseTx: TransactionBase) => { return await verifySponsoredSbtcTransaction({ apiUrl: sponsorshipApiUrl, baseTx, - nonce: Number(values.nonce), + nonce: Number(baseTx.options.nonce), fee: defaultFeesMaxValuesAsMoney[FeeTypes.Middle].amount.toNumber(), }); }; diff --git a/src/app/pages/swap/hooks/use-swap-assets-from-route.ts b/src/app/pages/swap/hooks/use-swap-assets-from-route.ts index dfb3c4dedc2..2287355e9a5 100644 --- a/src/app/pages/swap/hooks/use-swap-assets-from-route.ts +++ b/src/app/pages/swap/hooks/use-swap-assets-from-route.ts @@ -1,16 +1,16 @@ import { useEffect } from 'react'; +import { useFormContext } from 'react-hook-form'; import { useNavigate, useParams } from 'react-router-dom'; -import { useFormikContext } from 'formik'; - -import type { SwapFormValues } from '@shared/models/form.model'; import { RouteUrls } from '@shared/route-urls'; -import { useSwapContext } from '../swap.context'; +import type { SwapFormSchema } from '../form/swap-form.schema'; +import { type SwapBaseContext, useSwapContext } from '../swap.context'; + +export function useSwapAssetsFromRoute() { + const { swappableAssetsBase, swappableAssetsQuote } = useSwapContext(); + const { setValue, trigger } = useFormContext(); -export function useSwapAssetsFromRoute() { - const { swappableAssetsBase, swappableAssetsQuote } = useSwapContext(); - const { setFieldValue, values, validateForm } = useFormikContext(); const { base, quote } = useParams(); const navigate = useNavigate(); @@ -18,29 +18,20 @@ export function useSwapAssetsFromRoute() { // Handle if same asset selected; reset assets // Should not happen bc of list filtering if (base === quote) { - void setFieldValue('swapAssetQuote', undefined); - void setFieldValue('swapAmountQuote', ''); + setValue('swapAssetQuote', undefined); + setValue('swapAmountQuote', ''); return navigate(RouteUrls.Swap.replace(':base', 'STX').replace(':quote', '')); } if (base) - void setFieldValue( + setValue( 'swapAssetBase', swappableAssetsBase.find(asset => asset.name === base) ); if (quote) - void setFieldValue( + setValue( 'swapAssetQuote', swappableAssetsQuote.find(asset => asset.name === quote) ); - void validateForm(); - }, [ - base, - navigate, - quote, - setFieldValue, - swappableAssetsBase, - swappableAssetsQuote, - validateForm, - values.swapAssetBase, - ]); + void trigger(); + }, [base, navigate, quote, setValue, swappableAssetsBase, swappableAssetsQuote, trigger]); } diff --git a/src/app/pages/swap/hooks/use-swap-form.tsx b/src/app/pages/swap/hooks/use-swap-form.tsx deleted file mode 100644 index b9856277120..00000000000 --- a/src/app/pages/swap/hooks/use-swap-form.tsx +++ /dev/null @@ -1,144 +0,0 @@ -import { useMemo } from 'react'; - -import { cvToValue, hexToCV } from '@stacks/transactions'; -import BigNumber from 'bignumber.js'; -import * as yup from 'yup'; - -import { BTC_DECIMALS } from '@leather.io/constants'; -import { type SwapAsset } from '@leather.io/query'; -import { - convertAmountToBaseUnit, - convertAmountToFractionalUnit, - createMoney, -} from '@leather.io/utils'; - -import { FormErrorMessages } from '@shared/error-messages'; -import { type SwapFormValues } from '@shared/models/form.model'; - -import { - defaultSbtcLimits, - useGetCurrentSbtcSupply, - useGetSbtcLimits, -} from '@app/query/sbtc/sbtc-limits.query'; - -import { useSwapContext } from '../swap.context'; - -export function useSwapForm() { - const { isCrossChainSwap, isFetchingExchangeRate } = useSwapContext(); - const { data: sBtcLimits } = useGetSbtcLimits(); - const { data: supply } = useGetCurrentSbtcSupply(); - - const remainingSbtcPegCapSupply = useMemo(() => { - const sBtcPegCap = sBtcLimits?.pegCap; - if (!sBtcPegCap) return; - const currentSupplyValue = supply?.result && cvToValue(hexToCV(supply?.result)); - return convertAmountToFractionalUnit( - createMoney(new BigNumber(Number(sBtcPegCap - currentSupplyValue)), 'BTC', BTC_DECIMALS) - ); - }, [sBtcLimits?.pegCap, supply?.result]); - - const sBtcDepositCapMin = createMoney( - new BigNumber(sBtcLimits?.perDepositMinimum ?? defaultSbtcLimits.perDepositMinimum), - 'BTC' - ); - const sBtcDepositCapMax = createMoney( - new BigNumber(sBtcLimits?.perDepositCap ?? defaultSbtcLimits.perDepositCap), - 'BTC' - ); - - const initialValues: SwapFormValues = { - fee: '0', - feeCurrency: '', - feeType: '', - nonce: 0, - swapAmountBase: '', - swapAmountQuote: '', - swapAssetBase: undefined, - swapAssetQuote: undefined, - }; - - const validationSchema = yup.object({ - swapAssetBase: yup.object().required(), - swapAssetQuote: yup.object().required(), - swapAmountBase: yup - .number() - .test({ - message: 'Insufficient balance', - test(value) { - if (isFetchingExchangeRate) return true; - const { swapAssetBase } = this.parent; - const valueInFractionalUnit = convertAmountToFractionalUnit( - createMoney( - new BigNumber(Number(value)), - swapAssetBase.balance.symbol, - swapAssetBase.balance.decimals - ) - ); - if (swapAssetBase.balance.amount.isLessThan(valueInFractionalUnit)) return false; - return true; - }, - }) - .test({ - message: `Min amount is ${convertAmountToBaseUnit(sBtcDepositCapMin).toString()} BTC`, - test(value) { - if (!isCrossChainSwap) return true; - const { swapAssetBase } = this.parent; - const valueInFractionalUnit = convertAmountToFractionalUnit( - createMoney( - new BigNumber(Number(value)), - swapAssetBase.balance.symbol, - swapAssetBase.balance.decimals - ) - ); - if (valueInFractionalUnit.isLessThan(sBtcDepositCapMin.amount)) return false; - return true; - }, - }) - .test({ - message: `Max amount is ${convertAmountToBaseUnit(sBtcDepositCapMax).toString()} BTC`, - test(value) { - if (!isCrossChainSwap) return true; - const { swapAssetBase } = this.parent; - const valueInFractionalUnit = convertAmountToFractionalUnit( - createMoney( - new BigNumber(Number(value)), - swapAssetBase.balance.symbol, - swapAssetBase.balance.decimals - ) - ); - if (valueInFractionalUnit.isGreaterThan(sBtcDepositCapMax.amount)) return false; - return true; - }, - }) - .test({ - message: 'Amount exceeds capped supply', - test(value) { - if (!isCrossChainSwap) return true; - const { swapAssetBase } = this.parent; - const valueInFractionalUnit = convertAmountToFractionalUnit( - createMoney( - new BigNumber(Number(value)), - swapAssetBase.balance.symbol, - swapAssetBase.balance.decimals - ) - ); - if (!remainingSbtcPegCapSupply) return true; - if (valueInFractionalUnit.isGreaterThan(remainingSbtcPegCapSupply)) return false; - return true; - }, - }) - .required(FormErrorMessages.AmountRequired) - .typeError(FormErrorMessages.MustBeNumber) - .positive(FormErrorMessages.MustBePositive), - swapAmountQuote: yup - .number() - .required(FormErrorMessages.AmountRequired) - .typeError(FormErrorMessages.MustBeNumber) - .positive(FormErrorMessages.MustBePositive), - }); - - return { - initialValues, - validationSchema, - }; -} diff --git a/src/app/pages/swap/loaders/bitcoin-utxos-loader.tsx b/src/app/pages/swap/loaders/bitcoin-utxos-loader.tsx new file mode 100644 index 00000000000..4c06ed85abc --- /dev/null +++ b/src/app/pages/swap/loaders/bitcoin-utxos-loader.tsx @@ -0,0 +1,12 @@ +import type { UtxoResponseItem } from '@leather.io/query'; + +import { useCurrentNativeSegwitUtxos } from '@app/query/bitcoin/address/utxos-by-address.hooks'; + +interface BitcoinUtxosLoaderProps { + children(utxos: UtxoResponseItem[]): React.ReactNode; +} +export function BitcoinUtxosLoader({ children }: BitcoinUtxosLoaderProps) { + const { data: utxos = [] } = useCurrentNativeSegwitUtxos(); + if (!utxos.length) return null; + return children(utxos); +} diff --git a/src/app/pages/swap/loaders/stacks-nonce-loader.tsx b/src/app/pages/swap/loaders/stacks-nonce-loader.tsx new file mode 100644 index 00000000000..86a255f16cf --- /dev/null +++ b/src/app/pages/swap/loaders/stacks-nonce-loader.tsx @@ -0,0 +1,14 @@ +import { useNextNonce } from '@leather.io/query'; + +import { useCurrentStacksAccountAddress } from '@app/store/accounts/blockchain/stacks/stacks-account.hooks'; + +interface StacksNonceLoaderProps { + children(nonce: number | string): React.ReactNode; +} +export function StacksNonceLoader({ children }: StacksNonceLoaderProps) { + const stxAddress = useCurrentStacksAccountAddress(); + const { data: nextNonce } = useNextNonce(stxAddress); + + if (!nextNonce) return null; + return children(nextNonce?.nonce ?? ''); +} diff --git a/src/app/pages/swap/swap-provider.tsx b/src/app/pages/swap/swap-provider.tsx new file mode 100644 index 00000000000..fdfed34f75f --- /dev/null +++ b/src/app/pages/swap/swap-provider.tsx @@ -0,0 +1,49 @@ +import { useState } from 'react'; + +import type { HasChildren } from '@app/common/has-children'; + +import type { SwapAsset } from './form/swap-form.schema'; +import { useAllSwappableAssets } from './hooks/use-all-swappable-assets'; +import { type SwapBaseContext, swapContext as SwapContext } from './swap.context'; + +interface SwapProviderProps extends HasChildren { + initialData: T; +} +export function SwapProvider({ + children, + initialData, +}: SwapProviderProps) { + const [swapData, setSwapData] = useState(initialData); + const { allSwappableAssetsBase, allSwappableAssetsQuote } = useAllSwappableAssets(); + const [swappableAssetsBase, setSwappableAssetsBase] = + useState(allSwappableAssetsBase); + const [swappableAssetsQuote, setSwappableAssetsQuote] = + useState(allSwappableAssetsQuote); + + function onSetSwapData(key: keyof T, value: T[keyof T]) { + setSwapData(prev => ({ ...prev, [key]: value })); + } + + function onSetSwappableAssetsBase(assets: SwapAsset[]) { + setSwappableAssetsBase(assets); + } + + function onSetSwappableAssetsQuote(assets: SwapAsset[]) { + setSwappableAssetsQuote(assets); + } + + return ( + + {children} + + ); +} diff --git a/src/app/pages/swap/swap.context.ts b/src/app/pages/swap/swap.context.ts index 2ff547145e6..b1e575bbb0c 100644 --- a/src/app/pages/swap/swap.context.ts +++ b/src/app/pages/swap/swap.context.ts @@ -1,43 +1,37 @@ import { createContext, useContext } from 'react'; +import type { FieldValues } from 'react-hook-form'; -import type { SwapAsset } from '@leather.io/query'; +import type { SwapAsset, SwapFormSchema } from './form/swap-form.schema'; -import type { SwapFormValues } from '@shared/models/form.model'; - -export interface SwapSubmissionData extends SwapFormValues { - liquidityFee: number; - maxSignerFee?: number; +export interface SwapBaseContext { + defaultValues: FieldValues; + fee?: number; protocol: string; - router: SwapAsset[]; - dexPath?: string[]; - slippage: number; - sponsored?: boolean; timestamp: string; - txData?: Record; -} - -export interface SwapContext { - fetchQuoteAmount(from: SwapAsset, to: SwapAsset, fromAmount: string): Promise; isCrossChainSwap: boolean; isFetchingExchangeRate: boolean; - isSendingMax: boolean; isPreparingSwapReview: boolean; + isSendingMax: boolean; + fetchQuoteAmount(from: SwapAsset, to: SwapAsset, fromAmount: string): Promise; onSetIsCrossChainSwap(value: boolean): void; - onSetIsFetchingExchangeRate(value: boolean): void; onSetIsSendingMax(value: boolean): void; - onSubmitSwapForReview(values: SwapFormValues): Promise | void; - onSubmitSwap(): Promise | void; + onSubmitSwapForReview(values: SwapFormSchema): Promise | void; + onSubmitSwap(values: SwapFormSchema): Promise | void; +} + +export interface SwapContext { + swapData: T; swappableAssetsBase: SwapAsset[]; swappableAssetsQuote: SwapAsset[]; - swapSubmissionData?: SwapSubmissionData; + onSetSwapData(key: keyof T, value: T[keyof T]): void; + onSetSwappableAssetsBase(assets: SwapAsset[]): void; + onSetSwappableAssetsQuote(assets: SwapAsset[]): void; } -const swapContext = createContext(null); +export const swapContext = createContext | null>(null); -export function useSwapContext() { - const context = useContext(swapContext); - if (!context) throw new Error('No SwapContext found'); +export function useSwapContext() { + const context = useContext(swapContext) as SwapContext; + if (!context) throw new Error('`useSwapContext` must be used within a `SwapProvider`'); return context; } - -export const SwapProvider = swapContext.Provider; diff --git a/src/app/pages/swap/swap.tsx b/src/app/pages/swap/swap.tsx index 5ec9c44116d..430e774ff7b 100644 --- a/src/app/pages/swap/swap.tsx +++ b/src/app/pages/swap/swap.tsx @@ -1,29 +1,35 @@ +import { useFormContext } from 'react-hook-form'; import { Outlet } from 'react-router-dom'; import { SwapSelectors } from '@tests/selectors/swap.selectors'; -import { useFormikContext } from 'formik'; import { Button } from '@leather.io/ui'; import { isUndefined } from '@leather.io/utils'; -import type { SwapFormValues } from '@shared/models/form.model'; - import { Card } from '@app/components/layout'; import { LoadingSpinner } from '@app/components/loading-spinner'; import { SwapAssetSelectBase } from './components/swap-asset-select/swap-asset-select-base'; import { SwapAssetSelectQuote } from './components/swap-asset-select/swap-asset-select-quote'; +import type { SwapFormSchema } from './form/swap-form.schema'; import { useSwapAssetsFromRoute } from './hooks/use-swap-assets-from-route'; -import { useSwapContext } from './swap.context'; +import { type SwapBaseContext, useSwapContext } from './swap.context'; -export function Swap() { - const { isFetchingExchangeRate, isPreparingSwapReview, onSubmitSwapForReview } = useSwapContext(); - const { dirty, isValid, values, submitForm } = useFormikContext(); +export function Swap() { + const { swapData } = useSwapContext(); + const { isFetchingExchangeRate, isPreparingSwapReview, onSubmitSwapForReview } = swapData; + const { formState, handleSubmit, watch } = useFormContext(); + const swapAssetBase = watch('swapAssetBase'); + const { isDirty, isValid } = formState; useSwapAssetsFromRoute(); - if (isUndefined(values.swapAssetBase)) return ; + async function onSubmitForm(values: SwapFormSchema) { + await onSubmitSwapForReview(values); + } + if (isUndefined(swapAssetBase)) return ; + console.log('form', formState); return ( { - await submitForm(); // Validate form - await onSubmitSwapForReview(values); - }} + disabled={!(isDirty && isValid) || isFetchingExchangeRate || isPreparingSwapReview} + onClick={handleSubmit(onSubmitForm)} type="submit" fullWidth > @@ -45,7 +48,6 @@ export function Swap() { > - ); diff --git a/src/app/pages/swap/swap.utils.ts b/src/app/pages/swap/swap.utils.ts index 7cff499e1aa..3e202f0074d 100644 --- a/src/app/pages/swap/swap.utils.ts +++ b/src/app/pages/swap/swap.utils.ts @@ -1,5 +1,4 @@ import type { MarketData, Money } from '@leather.io/models'; -import type { SwapAsset } from '@leather.io/query'; import { baseCurrencyAmountInQuote, createMoney, @@ -8,6 +7,8 @@ import { unitToFractionalUnit, } from '@leather.io/utils'; +import type { SwapAsset } from './form/swap-form.schema'; + export function convertSwapAssetBalanceToFiat(asset: SwapAsset) { if ( !asset.marketData || diff --git a/src/app/query/bitcoin/address/utxos-by-address.hooks.ts b/src/app/query/bitcoin/address/utxos-by-address.hooks.ts index 9d1891d1c9b..7aaf3a6fd0f 100644 --- a/src/app/query/bitcoin/address/utxos-by-address.hooks.ts +++ b/src/app/query/bitcoin/address/utxos-by-address.hooks.ts @@ -1,6 +1,6 @@ import { useNativeSegwitUtxosByAddress } from '@leather.io/query'; -import { useCurrentAccountNativeSegwitIndexZeroSignerNullable } from '@app/store/accounts/blockchain/bitcoin/native-segwit-account.hooks'; +import { useCurrentAccountNativeSegwitIndexZeroSigner } from '@app/store/accounts/blockchain/bitcoin/native-segwit-account.hooks'; const defaultArgs = { filterInscriptionUtxos: true, @@ -15,8 +15,8 @@ const defaultArgs = { export function useCurrentNativeSegwitUtxos(args = defaultArgs) { const { filterInscriptionUtxos, filterPendingTxsUtxos, filterRunesUtxos } = args; - const nativeSegwitSigner = useCurrentAccountNativeSegwitIndexZeroSignerNullable(); - const address = nativeSegwitSigner?.address ?? ''; + const nativeSegwitSigner = useCurrentAccountNativeSegwitIndexZeroSigner(); + const address = nativeSegwitSigner.address; return useNativeSegwitUtxosByAddress({ address, diff --git a/src/app/query/bitcoin/balance/btc-balance-native-segwit.hooks.ts b/src/app/query/bitcoin/balance/btc-balance-native-segwit.hooks.ts index eaf046d3dce..cf8ffb2040a 100644 --- a/src/app/query/bitcoin/balance/btc-balance-native-segwit.hooks.ts +++ b/src/app/query/bitcoin/balance/btc-balance-native-segwit.hooks.ts @@ -2,20 +2,16 @@ import { useMemo } from 'react'; import BigNumber from 'bignumber.js'; -import type { BtcCryptoAssetBalance, Money } from '@leather.io/models'; import { useNativeSegwitUtxosByAddress, useRunesEnabled } from '@leather.io/query'; -import { createMoney, isUndefined, sumNumbers } from '@leather.io/utils'; +import { + createBtcCryptoAssetBalance, + createMoney, + isUndefined, + sumNumbers, +} from '@leather.io/utils'; import { useCurrentAccountNativeSegwitIndexZeroSigner } from '@app/store/accounts/blockchain/bitcoin/native-segwit-account.hooks'; -function createBtcCryptoAssetBalance(balance: Money): BtcCryptoAssetBalance { - return { - availableBalance: balance, - protectedBalance: createMoney(0, 'BTC'), - uneconomicalBalance: createMoney(0, 'BTC'), - }; -} - export function useBtcCryptoAssetBalanceNativeSegwit(address: string) { const runesEnabled = useRunesEnabled(); diff --git a/src/app/query/sbtc/sbtc-token.query.ts b/src/app/query/sbtc/sbtc-token.query.ts new file mode 100644 index 00000000000..41dfbfddb3a --- /dev/null +++ b/src/app/query/sbtc/sbtc-token.query.ts @@ -0,0 +1,36 @@ +import BigNumber from 'bignumber.js'; + +import { BTC_DECIMALS } from '@leather.io/constants'; +import { + type Sip10TokenAssetDetails, + useStacksAccountBalanceFungibleTokens, + useStacksFungibleTokensMetadata, +} from '@leather.io/query'; +import { createBaseCryptoAssetBalance, createMoney, isDefined } from '@leather.io/utils'; + +import { useCurrentStacksAccountAddress } from '@app/store/accounts/blockchain/stacks/stacks-account.hooks'; + +import { useConfigSbtc } from '../common/remote-config/remote-config.query'; + +export function useSbtcTokenCryptoAssetBalance() { + const { contractId } = useConfigSbtc(); + const stxAddress = useCurrentStacksAccountAddress(); + const { data: tokens = {} } = useStacksAccountBalanceFungibleTokens(stxAddress); + const sbtToken = Object.entries(tokens).find(([key, _v]) => key === contractId)?.[1]; + + return createBaseCryptoAssetBalance( + createMoney(sbtToken ? Number(sbtToken.balance) : new BigNumber(0), 'sBTC', BTC_DECIMALS) + ); +} + +export function useSbtcTokenCryptoAssetInfo() { + const { contractId } = useConfigSbtc(); + const infoResults = useStacksFungibleTokensMetadata([contractId]); + return infoResults.map(query => query.data).filter(isDefined)[0]; +} + +export function useSbtcSip10Token(): Sip10TokenAssetDetails { + const balance = useSbtcTokenCryptoAssetBalance(); + const info = useSbtcTokenCryptoAssetInfo(); + return { balance, info }; +} diff --git a/src/app/routes/app-routes.tsx b/src/app/routes/app-routes.tsx index 11848ea7037..92b453e35ab 100644 --- a/src/app/routes/app-routes.tsx +++ b/src/app/routes/app-routes.tsx @@ -43,7 +43,8 @@ import { RequestError } from '@app/pages/request-error/request-error'; import { BroadcastError } from '@app/pages/send/broadcast-error/broadcast-error'; import { sendOrdinalRoutes } from '@app/pages/send/ordinal-inscription/ordinal-routes'; import { sendCryptoAssetFormRoutes } from '@app/pages/send/send-crypto-asset-form/send-crypto-asset-form.routes'; -import { bitflowSwapRoutes } from '@app/pages/swap/bitflow-swap-container'; +import { bitcoinSwapRoutes } from '@app/pages/swap/containers/bitcoin-swap-container'; +import { stacksSwapRoutes } from '@app/pages/swap/containers/stacks-swap-container'; import { UnauthorizedRequest } from '@app/pages/unauthorized-request/unauthorized-request'; import { Unlock } from '@app/pages/unlock'; import { ViewSecretKey } from '@app/pages/view-secret-key/view-secret-key'; @@ -200,7 +201,8 @@ function useAppRoutes() { } /> - {bitflowSwapRoutes} + {stacksSwapRoutes} + {bitcoinSwapRoutes} {/* OnBoarding Routes */} { + async (payload: ContractCallPayload, values: Record) => { if (!account) return; const options: GenerateUnsignedTransactionOptions = { publicKey: account.stxPublicKey, - nonce: Number(values?.nonce) ?? nextNonce?.nonce, + nonce: Number(values.nonce) ?? nextNonce?.nonce, fee: values.fee ?? 0, txData: { ...payload, network }, }; diff --git a/src/shared/models/form.model.ts b/src/shared/models/form.model.ts index 5d485c2789d..bfc5bb44b47 100644 --- a/src/shared/models/form.model.ts +++ b/src/shared/models/form.model.ts @@ -1,5 +1,6 @@ import type { Inscription, Money } from '@leather.io/models'; -import type { SwapAsset } from '@leather.io/query'; + +import type { SwapAsset } from '@app/pages/swap/form/swap-form.schema'; export interface BitcoinSendFormValues { amount: number | string;