diff --git a/src/app/pages/home/components/account-actions.tsx b/src/app/pages/home/components/account-actions.tsx index e58bfd57b6c..2da3edef258 100644 --- a/src/app/pages/home/components/account-actions.tsx +++ b/src/app/pages/home/components/account-actions.tsx @@ -6,6 +6,7 @@ import { Box, Flex } from 'leather-styles/jsx'; import { ArrowsRepeatLeftRightIcon, CreditCardIcon, IconButton, InboxIcon } from '@leather.io/ui'; import { RouteUrls } from '@shared/route-urls'; +import { replaceRouteParams } from '@shared/utils/replace-route-params'; import { useConfigBitcoinEnabled, @@ -35,6 +36,15 @@ export function AccountActions() { ? RouteUrls.Receive : `${RouteUrls.Home}${RouteUrls.ReceiveStx}`; + function navigateToDefaultSwapRoute() { + return navigate( + replaceRouteParams(RouteUrls.Swap, { + base: 'STX', + quote: '', + }).replace('{chain}', 'stacks') + ); + } + return ( @@ -60,7 +70,7 @@ export function AccountActions() { disabled={swapsBtnDisabled} icon={} label="Swap" - onClick={() => navigate(RouteUrls.Swap.replace(':base', 'STX').replace(':quote', ''))} + onClick={navigateToDefaultSwapRoute} /> 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 3a593b4b241..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: 'sBTC Protocol', - 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/swap-asset-dialog-base.tsx b/src/app/pages/swap/components/swap-asset-dialog/swap-asset-dialog-base.tsx deleted file mode 100644 index 1f42f72e3fa..00000000000 --- a/src/app/pages/swap/components/swap-asset-dialog/swap-asset-dialog-base.tsx +++ /dev/null @@ -1,32 +0,0 @@ -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 { SwapAssetList } from './components/swap-asset-list'; - -export function SwapAssetSheetBase() { - const { swappableAssetsBase } = useSwapContext(); - const navigate = useSwapNavigate(); - - return ( - navigate(RouteUrls.Swap)} - header={ - - Choose asset
to swap - - } - variant="large" - onClose={() => navigate(RouteUrls.Swap)} - /> - } - > - -
- ); -} 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 deleted file mode 100644 index 34bfd5386b3..00000000000 --- a/src/app/pages/swap/components/swap-asset-dialog/swap-asset-dialog-quote.tsx +++ /dev/null @@ -1,32 +0,0 @@ -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 { SwapAssetList } from './components/swap-asset-list'; - -export function SwapAssetSheetQuote() { - const { swappableAssetsQuote } = useSwapContext(); - const navigate = useSwapNavigate(); - - return ( - navigate(RouteUrls.Swap)} - header={ - - Choose asset
to receive - - } - variant="large" - onClose={() => navigate(RouteUrls.Swap)} - /> - } - > - -
- ); -} 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 51b6d2b0673..cc30dbdaad5 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 @@ -16,7 +16,7 @@ import type { SwapFormValues } from '@shared/models/form.model'; import { useShowFieldError } from '@app/common/form-utils'; -import { useSwapContext } from '../../../swap.context'; +import { type BaseSwapContext, useSwapContext } from '../../../swap.context'; function getPlaceholderValue(name: string, values: SwapFormValues) { if (name === 'swapAmountBase' && isDefined(values.swapAssetBase)) return '0'; @@ -29,9 +29,19 @@ interface SwapAmountFieldProps { isDisabled?: boolean; name: string; } -export function SwapAmountField({ amountAsFiat, isDisabled, name }: SwapAmountFieldProps) { - const { fetchQuoteAmount, isCrossChainSwap, isFetchingExchangeRate, onSetIsSendingMax } = - useSwapContext(); +export function SwapAmountField>({ + amountAsFiat, + isDisabled, + name, +}: SwapAmountFieldProps) { + const { + isCrossChainSwap, + isFetchingExchangeRate, + onSetIsFetchingExchangeRate, + onSetIsSendingMax, + swapData, + } = useSwapContext(); + const { fetchQuoteAmount } = swapData; const { setFieldError, setFieldValue, values } = useFormikContext(); const [field] = useField(name); const showError = useShowFieldError(name) && name === 'swapAmountBase' && values.swapAssetQuote; @@ -41,7 +51,9 @@ export function SwapAmountField({ amountAsFiat, isDisabled, name }: SwapAmountFi if (isUndefined(swapAssetBase) || isUndefined(swapAssetQuote)) return; onSetIsSendingMax(false); const value = event.currentTarget.value; + onSetIsFetchingExchangeRate(true); const toAmount = await fetchQuoteAmount(swapAssetBase, swapAssetQuote, value); + onSetIsFetchingExchangeRate(false); const valueLengthAsDecimals = value.length - 1; if (isUndefined(toAmount) || valueLengthAsDecimals > swapAssetBase.balance.decimals) { await setFieldValue('swapAmountQuote', ''); diff --git a/src/app/pages/swap/components/swap-asset-select/components/swap-toggle-button.tsx b/src/app/pages/swap/components/swap-asset-select/components/swap-toggle-button.tsx index 0a219465afe..169c734f7f2 100644 --- a/src/app/pages/swap/components/swap-asset-select/components/swap-toggle-button.tsx +++ b/src/app/pages/swap/components/swap-asset-select/components/swap-toggle-button.tsx @@ -9,10 +9,14 @@ import { isDefined, isUndefined } from '@leather.io/utils'; import type { SwapFormValues } from '@shared/models/form.model'; import { RouteUrls } from '@shared/route-urls'; -import { useSwapContext } from '../../../swap.context'; +import { constructSwapRoute } from '@app/pages/swap/swap.routes'; -export function SwapToggleButton() { - const { fetchQuoteAmount, isFetchingExchangeRate, onSetIsSendingMax } = useSwapContext(); +import { type BaseSwapContext, useSwapContext } from '../../../swap.context'; + +export function SwapToggleButton>() { + const { isFetchingExchangeRate, onSetIsFetchingExchangeRate, onSetIsSendingMax, swapData } = + useSwapContext(); + const { chain, fetchQuoteAmount } = swapData; const { setFieldValue, values } = useFormikContext(); const navigate = useNavigate(); @@ -29,7 +33,9 @@ export function SwapToggleButton() { void setFieldValue('swapAmountBase', prevAmountQuote); if (isDefined(prevAssetBase) && isDefined(prevAssetQuote)) { + onSetIsFetchingExchangeRate(true); const quoteAmount = await fetchQuoteAmount(prevAssetQuote, prevAssetBase, prevAmountQuote); + onSetIsFetchingExchangeRate(false); if (isUndefined(quoteAmount)) { void setFieldValue('swapAmountQuote', ''); return; @@ -39,10 +45,14 @@ export function SwapToggleButton() { void setFieldValue('swapAmountQuote', Number(prevAmountBase)); } navigate( - RouteUrls.Swap.replace(':base', prevAssetQuote?.name ?? '').replace( - ':quote', - prevAssetBase?.name ?? '' - ) + constructSwapRoute({ + chain, + route: RouteUrls.Swap, + params: { + base: prevAssetQuote?.name ?? '', + quote: prevAssetBase?.name ?? '', + }, + }) ); } 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..fe8660e567f 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 @@ -17,7 +17,7 @@ import { RouteUrls } from '@shared/route-urls'; import { useShowFieldError } from '@app/common/form-utils'; import { useSwapNavigate } from '../../hooks/use-swap-navigate'; -import { useSwapContext } from '../../swap.context'; +import { type BaseSwapContext, 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,14 +27,14 @@ 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(); +export function SwapAssetSelectBase>() { + const { isFetchingExchangeRate, isSendingMax, onSetIsSendingMax, swapData } = useSwapContext(); + const { fetchQuoteAmount } = swapData; const { setFieldValue, setFieldError, values } = useFormikContext(); const [amountField, amountFieldMeta, amountFieldHelpers] = useField('swapAmountBase'); const showError = useShowFieldError('swapAmountBase'); const [assetField] = useField('swapAssetBase'); - const navigate = useSwapNavigate(); + const swapNavigate = useSwapNavigate(); const amountAsFiat = isDefined(assetField.value && amountField.value) && @@ -77,7 +77,7 @@ export function SwapAssetSelectBase() { error={amountFieldMeta.error} icon={assetField.value.icon} name="swapAmountBase" - onSelectAsset={() => navigate(RouteUrls.SwapAssetSelectBase)} + onSelectAsset={() => swapNavigate(RouteUrls.SwapAssetSelectBase)} onClickHandler={onSetMaxBalanceAsAmountToSwap} showError={!!(showError && values.swapAssetQuote)} swapAmountInput={ 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..a127fca66a7 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 @@ -12,16 +12,16 @@ import { RouteUrls } from '@shared/route-urls'; import { LoadingSpinner } from '@app/components/loading-spinner'; import { useSwapNavigate } from '../../hooks/use-swap-navigate'; -import { useSwapContext } from '../../swap.context'; +import { type BaseSwapContext, 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(); +export function SwapAssetSelectQuote>() { + const { isCrossChainSwap, isFetchingExchangeRate } = useSwapContext(); const [amountField] = useField('swapAmountQuote'); const [assetField] = useField('swapAssetQuote'); - const navigate = useSwapNavigate(); + const swapNavigate = useSwapNavigate(); const amountAsFiat = isDefined(assetField.value && amountField.value) && @@ -36,7 +36,7 @@ export function SwapAssetSelectQuote() { caption="You have" icon={assetField.value?.icon} name="swapAmountQuote" - onSelectAsset={() => navigate(RouteUrls.SwapAssetSelectQuote)} + onSelectAsset={() => swapNavigate(RouteUrls.SwapAssetSelectQuote)} showToggle={!isCrossChainSwap} swapAmountInput={ isFetchingExchangeRate ? ( diff --git a/src/app/pages/swap/components/swap-asset-dialog/components/swap-asset-item.tsx b/src/app/pages/swap/components/swap-asset-sheet/components/swap-asset-item.tsx similarity index 100% rename from src/app/pages/swap/components/swap-asset-dialog/components/swap-asset-item.tsx rename to src/app/pages/swap/components/swap-asset-sheet/components/swap-asset-item.tsx diff --git a/src/app/pages/swap/components/swap-asset-dialog/components/swap-asset-list.tsx b/src/app/pages/swap/components/swap-asset-sheet/components/swap-asset-list.tsx similarity index 100% rename from src/app/pages/swap/components/swap-asset-dialog/components/swap-asset-list.tsx rename to src/app/pages/swap/components/swap-asset-sheet/components/swap-asset-list.tsx 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-sheet/components/use-swap-asset-list.tsx similarity index 57% rename from src/app/pages/swap/components/swap-asset-dialog/components/use-swap-asset-list.tsx rename to src/app/pages/swap/components/swap-asset-sheet/components/use-swap-asset-list.tsx index 0b266ca1206..b99660a87f7 100644 --- a/src/app/pages/swap/components/swap-asset-dialog/components/use-swap-asset-list.tsx +++ b/src/app/pages/swap/components/swap-asset-sheet/components/use-swap-asset-list.tsx @@ -15,13 +15,18 @@ import { import type { SwapFormValues } from '@shared/models/form.model'; import { RouteUrls } from '@shared/route-urls'; -import { useSwapContext } from '@app/pages/swap/swap.context'; +import { type BaseSwapContext, useSwapContext } from '@app/pages/swap/swap.context'; +import { constructSwapRoute } from '@app/pages/swap/swap.routes'; import type { SwapAssetListProps } from './swap-asset-list'; -export function useSwapAssetList({ assets, type }: SwapAssetListProps) { +export function useSwapAssetList>({ + assets, + type, +}: SwapAssetListProps) { const { setFieldError, setFieldValue, values } = useFormikContext(); - const { fetchQuoteAmount, onSetIsCrossChainSwap } = useSwapContext(); + const { onSetIsFetchingExchangeRate, onSetIsCrossChainSwap, swapData } = useSwapContext(); + const { fetchQuoteAmount } = swapData; const navigate = useNavigate(); const { base, quote } = useParams(); @@ -29,19 +34,11 @@ export function useSwapAssetList({ assets, type }: SwapAssetListProps) { const isQuoteList = type === 'quote'; // Filter out selected asset from selectable assets - const selectableAssets = assets - .filter( - asset => - (isBaseList && asset.name !== values.swapAssetQuote?.name) || - (isQuoteList && asset.name !== values.swapAssetBase?.name) - ) - // Only show sBTC as quote option if BTC is selected as base - .filter( - asset => - isBaseList || - (isQuoteList && values.swapAssetBase?.name !== 'BTC') || - (isQuoteList && values.swapAssetBase?.name === 'BTC' && asset.name === 'sBTC') - ); + const selectableAssets = assets.filter( + asset => + (isBaseList && asset.name !== values.swapAssetQuote?.name) || + (isQuoteList && asset.name !== values.swapAssetBase?.name) + ); const onSelectBaseAsset = useCallback( (baseAsset: SwapAsset) => { @@ -49,27 +46,72 @@ export function useSwapAssetList({ assets, type }: SwapAssetListProps) { // Handle bridge assets if (baseAsset.name === 'BTC') { onSetIsCrossChainSwap(true); - return navigate(RouteUrls.Swap.replace(':base', baseAsset.name).replace(':quote', 'sBTC')); + return navigate( + constructSwapRoute({ + chain: 'bitcoin', + route: RouteUrls.Swap, + params: { + base: baseAsset.name, + quote: 'sBTC', + }, + }) + ); } // Handle swap assets onSetIsCrossChainSwap(false); - navigate(RouteUrls.Swap.replace(':base', baseAsset.name).replace(':quote', quote ?? '')); + navigate( + constructSwapRoute({ + chain: 'stacks', + route: RouteUrls.Swap, + params: { + base: baseAsset.name, + quote: quote ?? '', + }, + }) + ); }, [navigate, onSetIsCrossChainSwap, quote, setFieldValue] ); const onSelectQuoteAsset = useCallback( - (quoteAsset: SwapAsset) => { + (quoteAsset: SwapAsset, baseAsset?: SwapAsset) => { void setFieldValue('swapAssetQuote', quoteAsset); setFieldError('swapAssetQuote', undefined); - navigate(RouteUrls.Swap.replace(':base', base ?? '').replace(':quote', quoteAsset.name)); + // Handle bridge assets + if (baseAsset?.name === 'BTC') { + onSetIsCrossChainSwap(true); + return navigate( + constructSwapRoute({ + chain: 'bitcoin', + route: RouteUrls.Swap, + params: { + base: baseAsset.name, + quote: quoteAsset.name, + }, + }) + ); + } + // Handle swap assets + onSetIsCrossChainSwap(false); + navigate( + constructSwapRoute({ + chain: 'stacks', + route: RouteUrls.Swap, + params: { + base: base ?? '', + quote: quoteAsset.name, + }, + }) + ); }, - [base, navigate, setFieldError, setFieldValue] + [base, navigate, onSetIsCrossChainSwap, setFieldError, setFieldValue] ); const onFetchQuoteAmount = useCallback( async (baseAsset: SwapAsset, quoteAsset: SwapAsset) => { + onSetIsFetchingExchangeRate(true); const quoteAmount = await fetchQuoteAmount(baseAsset, quoteAsset, values.swapAmountBase); + onSetIsFetchingExchangeRate(false); // Handle race condition; make sure quote amount is 1:1 if (baseAsset.name === 'BTC') { void setFieldValue('swapAmountQuote', values.swapAmountBase); @@ -87,7 +129,13 @@ export function useSwapAssetList({ assets, type }: SwapAssetListProps) { void setFieldValue('swapAmountQuote', formatMoneyWithoutSymbol(quoteAmountAsMoney)); setFieldError('swapAmountQuote', undefined); }, - [fetchQuoteAmount, setFieldError, setFieldValue, values.swapAmountBase] + [ + fetchQuoteAmount, + onSetIsFetchingExchangeRate, + setFieldError, + setFieldValue, + values.swapAmountBase, + ] ); return { @@ -103,7 +151,7 @@ export function useSwapAssetList({ assets, type }: SwapAssetListProps) { if (isQuoteList) { baseAsset = values.swapAssetBase; quoteAsset = asset; - onSelectQuoteAsset(quoteAsset); + onSelectQuoteAsset(quoteAsset, baseAsset); } if (baseAsset && quoteAsset && values.swapAmountBase) { await onFetchQuoteAmount(baseAsset, quoteAsset); diff --git a/src/app/pages/swap/components/swap-asset-sheet/swap-asset-sheet-base.tsx b/src/app/pages/swap/components/swap-asset-sheet/swap-asset-sheet-base.tsx new file mode 100644 index 00000000000..3a990040149 --- /dev/null +++ b/src/app/pages/swap/components/swap-asset-sheet/swap-asset-sheet-base.tsx @@ -0,0 +1,42 @@ +import { Sheet, SheetHeader } from '@leather.io/ui'; + +import { RouteUrls } from '@shared/route-urls'; + +import { useSwapNavigate } from '../../hooks/use-swap-navigate'; +import { type BaseSwapContext, useSwapContext } from '../../swap.context'; +import { constructSwapRoute } from '../../swap.routes'; +import { SwapAssetList } from './components/swap-asset-list'; + +export function SwapAssetSheetBase>() { + const { swapData } = useSwapContext(); + const swapNavigate = useSwapNavigate(); + + function onClose() { + return swapNavigate( + constructSwapRoute({ + chain: swapData.chain, + route: RouteUrls.Swap, + }) + ); + } + + return ( + + Choose asset
to swap + + } + variant="large" + onClose={onClose} + /> + } + > + +
+ ); +} diff --git a/src/app/pages/swap/components/swap-asset-sheet/swap-asset-sheet-quote.tsx b/src/app/pages/swap/components/swap-asset-sheet/swap-asset-sheet-quote.tsx new file mode 100644 index 00000000000..1a08d1aa969 --- /dev/null +++ b/src/app/pages/swap/components/swap-asset-sheet/swap-asset-sheet-quote.tsx @@ -0,0 +1,42 @@ +import { Sheet, SheetHeader } from '@leather.io/ui'; + +import { RouteUrls } from '@shared/route-urls'; + +import { useSwapNavigate } from '../../hooks/use-swap-navigate'; +import { type BaseSwapContext, useSwapContext } from '../../swap.context'; +import { constructSwapRoute } from '../../swap.routes'; +import { SwapAssetList } from './components/swap-asset-list'; + +export function SwapAssetSheetQuote>() { + const { swapData } = useSwapContext(); + const swapNavigate = useSwapNavigate(); + + function onClose() { + return swapNavigate( + constructSwapRoute({ + chain: swapData.chain, + route: RouteUrls.Swap, + }) + ); + } + + return ( + + Choose asset
to receive + + } + variant="large" + onClose={onClose} + /> + } + > + +
+ ); +} 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..5365adeaedd 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 @@ -7,6 +7,7 @@ import { createMoneyFromDecimal, isUndefined } from '@leather.io/utils'; import type { SwapFormValues } from '@shared/models/form.model'; import { RouteUrls } from '@shared/route-urls'; +import { constructSwapRoute } from '../../swap.routes'; import { SwapAssetItemLayout } from './swap-asset-item.layout'; import { SwapAssetsPairLayout } from './swap-assets-pair.layout'; @@ -17,7 +18,17 @@ export function SwapAssetsPair() { const navigate = useNavigate(); if (isUndefined(swapAssetBase) || isUndefined(swapAssetQuote)) { - navigate(RouteUrls.Swap, { replace: true }); + navigate( + constructSwapRoute({ + chain: 'stacks', + route: RouteUrls.Swap, + params: { + base: 'STX', + quote: '', + }, + }), + { replace: true } + ); return null; } 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..910ac40c639 --- /dev/null +++ b/src/app/pages/swap/components/swap-details/bitcoin-swap-details.tsx @@ -0,0 +1,61 @@ +import { SwapSelectors } from '@tests/selectors/swap.selectors'; +import BigNumber from 'bignumber.js'; +import { useFormikContext } from 'formik'; +import { HStack, styled } from 'leather-styles/jsx'; + +import { ChevronRightIcon } from '@leather.io/ui'; +import { createMoneyFromDecimal, formatMoney, isUndefined, satToBtc } from '@leather.io/utils'; + +import type { SwapFormValues } from '@shared/models/form.model'; + +import { useSwapContext } from '@app/pages/swap/swap.context'; + +import type { BitcoinSwapContext } from '../../providers/bitcoin-swap-provider'; +import { SwapDetailLayout } from './swap-detail.layout'; +import { SwapDetailsLayout } from './swap-details.layout'; + +function RouterSwapRoute() { + return ( + + BTC + + sBTC + + ); +} + +export function BitcoinSwapDetails() { + const { swapData } = useSwapContext(); + const { values } = useFormikContext(); + + if (isUndefined(values.swapAssetBase) || isUndefined(values.swapAssetQuote)) return null; + + const maxSignerFee = satToBtc(swapData.maxSignerFee ?? 0); + const minToReceive = 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..ef504c1e694 --- /dev/null +++ b/src/app/pages/swap/components/swap-details/stacks-swap-details.tsx @@ -0,0 +1,93 @@ +import { SwapSelectors } from '@tests/selectors/swap.selectors'; +import BigNumber from 'bignumber.js'; +import { useFormikContext } from 'formik'; +import { HStack, styled } from 'leather-styles/jsx'; + +import type { SwapAsset } from '@leather.io/query'; +import { ChevronRightIcon } from '@leather.io/ui'; +import { createMoneyFromDecimal, formatMoney, isDefined, isUndefined } from '@leather.io/utils'; + +import type { SwapFormValues } from '@shared/models/form.model'; + +import { useSwapContext } from '@app/pages/swap/swap.context'; + +import type { StacksSwapContext } from '../../providers/stacks-swap-provider'; +import { toCommaSeparatedWithAnd } from '../../swap.utils'; +import { SwapDetailLayout } from './swap-detail.layout'; +import { SwapDetailsLayout } from './swap-details.layout'; +import { getStacksSwapDataFromRouteQuote } from './swap-details.utils'; + +function RouterSwapRoute(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 } = useSwapContext(); + const { values } = useFormikContext(); + + if (isUndefined(values.swapAssetBase) || isUndefined(values.swapAssetQuote)) return null; + + const routeQuoteDetails = + swapData.routeQuote && + getStacksSwapDataFromRouteQuote({ + routeQuote: swapData.routeQuote, + swapAssets: swapData.swappableAssetsBase, + }); + + const minToReceive = 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 f90d4fcc81c..00000000000 --- a/src/app/pages/swap/components/swap-details/swap-details.tsx +++ /dev/null @@ -1,114 +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, - formatMoney, - 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 sbtcMoreInfoUrl = 'https://github.com/stacks-network/sbtc-bridge'; -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 { isCrossChainSwap, swapSubmissionData } = useSwapContext(); - - if ( - isUndefined(swapSubmissionData) || - isUndefined(swapSubmissionData.swapAssetBase) || - isUndefined(swapSubmissionData.swapAssetQuote) - ) - return null; - - const maxSignerFee = satToBtc(swapSubmissionData.maxSignerFee ?? 0); - - const formattedMinToReceive = formatMoney( - 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..e5bf613acf4 --- /dev/null +++ b/src/app/pages/swap/components/swap-details/swap-details.utils.ts @@ -0,0 +1,28 @@ +import BigNumber from 'bignumber.js'; +import type { RouteQuote } from 'bitflow-sdk'; + +import type { SwapAsset } from '@leather.io/query'; +import { capitalize, isDefined } from '@leather.io/utils'; + +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..ec74b00c862 --- /dev/null +++ b/src/app/pages/swap/components/swap-review/bitcoin-swap-review.tsx @@ -0,0 +1,17 @@ +import { Callout } from '@leather.io/ui'; + +import { SwapAssetsPair } from '../swap-assets-pair/swap-assets-pair'; +import { BitcoinSwapDetails } from '../swap-details/bitcoin-swap-details'; +import { SwapReviewLayout } from './swap-review.layout'; + +export function BitcoinSwapReview() { + return ( + + + Note that bridging from sBTC back to BTC is currently unavailable. + + + + + ); +} 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..aba407c48f8 --- /dev/null +++ b/src/app/pages/swap/components/swap-review/stacks-swap-review.tsx @@ -0,0 +1,12 @@ +import { SwapAssetsPair } from '../swap-assets-pair/swap-assets-pair'; +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 53% 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..43400b3dbe4 100644 --- a/src/app/pages/swap/components/swap-review.tsx +++ b/src/app/pages/swap/components/swap-review/swap-review.layout.tsx @@ -1,18 +1,22 @@ import { Outlet } from 'react-router-dom'; import { SwapSelectors } from '@tests/selectors/swap.selectors'; +import { useFormikContext } from 'formik'; -import { Button, Callout } from '@leather.io/ui'; +import { Button } from '@leather.io/ui'; +import type { SwapFormValues } from '@shared/models/form.model'; + +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 BaseSwapContext, useSwapContext } from '../../swap.context'; -export function SwapReview() { - const { isCrossChainSwap, onSubmitSwap } = useSwapContext(); +export function SwapReviewLayout>({ children }: HasChildren) { + const { swapData } = useSwapContext(); + const { onSubmitSwap } = swapData; + const { values } = useFormikContext(); const { isLoading } = useLoading(LoadingKeys.SUBMIT_SWAP_TRANSACTION); return ( @@ -25,20 +29,14 @@ export function SwapReview() { disabled={isLoading} data-testid={SwapSelectors.SwapSubmitBtn} type="button" - onClick={onSubmitSwap} + onClick={() => onSubmitSwap({ values, swapData })} 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..11881a0177e --- /dev/null +++ b/src/app/pages/swap/containers/bitcoin-swap-container.tsx @@ -0,0 +1,18 @@ +import { BitcoinNativeSegwitAccountLoader } from '@app/components/loaders/bitcoin-account-loader'; + +import { BitcoinUtxosLoader } from '../loaders/bitcoin-utxos-loader'; +import { BitcoinSwapProvider } from '../providers/bitcoin-swap-provider'; + +export function BitcoinSwapContainer() { + return ( + + {signer => ( + + {utxos => { + return ; + }} + + )} + + ); +} 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..ef56db09459 --- /dev/null +++ b/src/app/pages/swap/containers/stacks-swap-container.tsx @@ -0,0 +1,12 @@ +import { StacksNonceLoader } from '../loaders/stacks-nonce-loader'; +import { StacksSwapProvider } from '../providers/stacks-swap-provider'; + +export function StacksSwapContainer() { + return ( + + {nonce => { + return ; + }} + + ); +} diff --git a/src/app/pages/swap/form/swap-form.layout.tsx b/src/app/pages/swap/form/swap-form.layout.tsx new file mode 100644 index 00000000000..099914120f0 --- /dev/null +++ b/src/app/pages/swap/form/swap-form.layout.tsx @@ -0,0 +1,18 @@ +import { Box } from 'leather-styles/jsx'; + +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 SwapFormLayout({ children }: HasChildren) { + return ( + + + + {children} + + + ); +} 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..06a5d1d9074 --- /dev/null +++ b/src/app/pages/swap/form/swap-form.tsx @@ -0,0 +1,46 @@ +import { Form, Formik } from 'formik'; + +import type { SwapFormValues } from '@shared/models/form.model'; +import { RouteUrls } from '@shared/route-urls'; + +import type { HasChildren } from '@app/common/has-children'; + +import { useSwapNavigate } from '../hooks/use-swap-navigate'; +import { type BaseSwapContext, useSwapContext } from '../swap.context'; +import { constructSwapRoute } from '../swap.routes'; +import { SwapFormLayout } from './swap-form.layout'; +import { useSwapForm } from './use-swap-form'; + +export function SwapForm>({ children }: HasChildren) { + const { initialValues, validationSchema } = useSwapForm(); + const { isSendingMax, onSetIsPreparingSwapReview, onSetSwapData, swapData } = useSwapContext(); + const { chain, onSubmitSwapForReview } = swapData; + const swapNavigate = useSwapNavigate(); + + async function onReviewSwap(values: SwapFormValues) { + onSetIsPreparingSwapReview(true); + const data = await onSubmitSwapForReview({ values, swapData, isSendingMax }); + data && onSetSwapData(data); + onSetIsPreparingSwapReview(false); + swapNavigate( + constructSwapRoute({ + chain, + route: RouteUrls.SwapReview, + }) + ); + } + + return ( + + +
{children}
+
+
+ ); +} diff --git a/src/app/pages/swap/hooks/use-swap-form.tsx b/src/app/pages/swap/form/use-swap-form.tsx similarity index 94% rename from src/app/pages/swap/hooks/use-swap-form.tsx rename to src/app/pages/swap/form/use-swap-form.tsx index 881c10f172f..30d94318d35 100644 --- a/src/app/pages/swap/hooks/use-swap-form.tsx +++ b/src/app/pages/swap/form/use-swap-form.tsx @@ -21,10 +21,10 @@ import { useGetSbtcLimits, } from '@app/query/sbtc/sbtc-limits.query'; -import { useSwapContext } from '../swap.context'; +import { type BaseSwapContext, useSwapContext } from '../swap.context'; -export function useSwapForm() { - const { isCrossChainSwap, isFetchingExchangeRate } = useSwapContext(); +export function useSwapForm>() { + const { isCrossChainSwap, isFetchingExchangeRate } = useSwapContext(); const { data: sBtcLimits } = useGetSbtcLimits(); const { data: supply } = useGetCurrentSbtcSupply(); @@ -33,7 +33,11 @@ export function useSwapForm() { if (!sBtcPegCap || !supply) return; const currentSupplyValue = supply?.result && cvToValue(hexToCV(supply?.result)); return convertAmountToFractionalUnit( - createMoney(new BigNumber(Number(sBtcPegCap - currentSupplyValue.value)), 'BTC', BTC_DECIMALS) + createMoney( + new BigNumber(Number(sBtcPegCap - currentSupplyValue?.value)), + 'BTC', + BTC_DECIMALS + ) ); }, [sBtcLimits?.pegCap, supply]); diff --git a/src/app/pages/swap/generate-swap-routes.tsx b/src/app/pages/swap/generate-swap-routes.tsx deleted file mode 100644 index b8084376a95..00000000000 --- a/src/app/pages/swap/generate-swap-routes.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { Route } from 'react-router-dom'; - -import { RouteUrls } from '@shared/route-urls'; - -import { ledgerBitcoinTxSigningRoutes } from '@app/features/ledger/flows/bitcoin-tx-signing/ledger-bitcoin-sign-tx-container'; -import { ledgerStacksTxSigningRoutes } from '@app/features/ledger/flows/stacks-tx-signing/ledger-sign-stacks-tx-container'; -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 { Swap } from './swap'; - -export function generateSwapRoutes(container: React.ReactNode) { - return ( - {container}}> - }> - } /> - } /> - - } /> - }> - {ledgerBitcoinTxSigningRoutes} - {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..4038c082aed --- /dev/null +++ b/src/app/pages/swap/hooks/use-all-swappable-assets.tsx @@ -0,0 +1,57 @@ +import { useMemo } from 'react'; + +import type { SwapAsset } from '@leather.io/query'; +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 { 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 sortedStacksSwapAssetsWithoutSbtc = useMemo( + () => migratePositiveAssetBalancesToTop(bitflowSwapAssets), + [bitflowSwapAssets] + ); + const sortedStacksSwapAssetsWithSbtc = useMemo( + () => getBitflowSwappableAssetsWithSbtcAtTop(bitflowSwapAssets), + [bitflowSwapAssets] + ); + + const allSwappableAssets = useMemo(() => { + if (!isSbtcEnabled) return sortedStacksSwapAssetsWithoutSbtc; + return [btcAsset, ...sortedStacksSwapAssetsWithSbtc]; + }, [btcAsset, isSbtcEnabled, sortedStacksSwapAssetsWithSbtc, sortedStacksSwapAssetsWithoutSbtc]); + + const bitcoinSwappableAssetsQuote = useMemo( + () => bitflowSwapAssets.filter(asset => asset.name === 'sBTC'), + [bitflowSwapAssets] + ); + const stacksSwappableAssetsQuote = useMemo( + () => (isSbtcEnabled ? sortedStacksSwapAssetsWithSbtc : bitflowSwapAssets), + [isSbtcEnabled, sortedStacksSwapAssetsWithSbtc, bitflowSwapAssets] + ); + + return { + allSwappableAssets, + bitcoinSwappableAssetsQuote, + stacksSwappableAssetsQuote, + }; +} diff --git a/src/app/pages/swap/hooks/use-btc-bridge-asset.tsx b/src/app/pages/swap/hooks/use-bitcoin-bridge-assets.tsx similarity index 65% rename from src/app/pages/swap/hooks/use-btc-bridge-asset.tsx rename to src/app/pages/swap/hooks/use-bitcoin-bridge-assets.tsx index ea578dd5a5e..09e95eb91a1 100644 --- a/src/app/pages/swap/hooks/use-btc-bridge-asset.tsx +++ b/src/app/pages/swap/hooks/use-bitcoin-bridge-assets.tsx @@ -1,19 +1,19 @@ -import { useCallback } from 'react'; +import { useMemo } 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'; +import { useCurrentAccountNativeSegwitIndexZeroSignerNullable } from '@app/store/accounts/blockchain/bitcoin/native-segwit-account.hooks'; -export function useBtcSwapAsset(btcSigner?: Signer) { - const currentBitcoinAddress = btcSigner?.address ?? ''; +export function useBtcSwapAsset() { + const nativeSegwitSigner = useCurrentAccountNativeSegwitIndexZeroSignerNullable(); + const currentBitcoinAddress = nativeSegwitSigner?.address ?? ''; const { balance } = useBtcCryptoAssetBalanceNativeSegwit(currentBitcoinAddress); const bitcoinMarketData = useCryptoCurrencyMarketDataMeanAverage('BTC'); - return useCallback((): SwapAsset => { + return useMemo((): SwapAsset => { return { balance: balance.availableBalance, tokenId: 'token-btc', 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-sbtc-deposit-transaction.tsx b/src/app/pages/swap/hooks/use-sbtc-deposit-transaction.tsx index be95634d0dd..5a121059c44 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,20 @@ 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 { BitcoinSwapContext } from '../providers/bitcoin-swap-provider'; +import type { SubmitSwapArgs } from '../swap.context'; // Also set as defaults in sbtc lib -const maxSignerFee = 80_000; -const reclaimLockTime = 12; +export const defaultMaxSignerFee = 80_000; +const reclaimLockTime = 144; -interface SbtcDeposit { +export interface SbtcDeposit { address: string; depositScript: string; reclaimScript: string; @@ -61,11 +61,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 +81,22 @@ export function useSbtcDepositTransaction(btcSigner?: Signer) { useBreakOnNonCompliantEntity(); return { - async onReviewDepositSbtc(swapData: SwapSubmissionData, isSendingMax: boolean) { - if (!stacksAccount || !utxos || !btcSigner) return; + async onReviewDepositSbtc({ + values, + swapData, + isSendingMax, + }: SubmitSwapArgs) { + 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: swapData.maxSignerFee, reclaimLockTime, - reclaimPublicKey: bytesToHex(btcSigner.publicKey).slice(2), + reclaimPublicKey: bytesToHex(signer.publicKey).slice(2), }); const determineUtxosArgs = { @@ -111,7 +114,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 +132,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: createMoney(fee, 'BTC') }; } 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..d5069520fb3 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(), }); }; @@ -40,7 +39,7 @@ export function useSponsorTransactionFees() { async (unsignedSponsoredTx: StacksTransaction) => { try { const signedSponsoredTx = await signTx(unsignedSponsoredTx); - if (!signedSponsoredTx) return logger.error('Unable to sign sponsored transaction!'); + if (!signedSponsoredTx) return logger.error('Unable to sign sponsored transaction'); const result = await submitSponsoredSbtcTransaction(sponsorshipApiUrl, signedSponsoredTx); if (!result.txid) { 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 deleted file mode 100644 index 8e2080a3282..00000000000 --- a/src/app/pages/swap/hooks/use-swap-assets-from-route.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { useEffect } from 'react'; -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'; - -export function useSwapAssetsFromRoute() { - const { onSetIsCrossChainSwap, swappableAssetsBase, swappableAssetsQuote } = useSwapContext(); - const { setFieldValue, values, validateForm } = useFormikContext(); - const { base, quote } = useParams(); - const navigate = useNavigate(); - - useEffect(() => { - // Handle if same asset selected; reset assets - // Should not happen bc of list filtering - if (base === quote) { - void setFieldValue('swapAssetQuote', undefined); - void setFieldValue('swapAmountQuote', ''); - return navigate(RouteUrls.Swap.replace(':base', 'STX').replace(':quote', '')); - } - if (base) - void setFieldValue( - 'swapAssetBase', - swappableAssetsBase.find(asset => asset.name === base) - ); - if (quote) - void setFieldValue( - 'swapAssetQuote', - swappableAssetsQuote.find(asset => asset.name === quote) - ); - if (base === 'BTC') onSetIsCrossChainSwap(true); - void validateForm(); - }, [ - base, - navigate, - onSetIsCrossChainSwap, - quote, - setFieldValue, - swappableAssetsBase, - swappableAssetsQuote, - validateForm, - values.swapAssetBase, - ]); -} diff --git a/src/app/pages/swap/hooks/use-swap-navigate.ts b/src/app/pages/swap/hooks/use-swap-navigate.ts index b981c90d67f..8f95eb2065c 100644 --- a/src/app/pages/swap/hooks/use-swap-navigate.ts +++ b/src/app/pages/swap/hooks/use-swap-navigate.ts @@ -1,13 +1,20 @@ import { useCallback } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; +import { replaceRouteParams } from '@shared/utils/replace-route-params'; + export function useSwapNavigate() { - const navigate = useNavigate(); const { base, quote } = useParams(); + const navigate = useNavigate(); return useCallback( (route: string) => { - navigate(route.replace(':base', base ?? '').replace(':quote', quote ?? '')); + navigate( + replaceRouteParams(route, { + base: base ?? '', + quote: quote ?? '', + }) + ); }, [base, navigate, quote] ); diff --git a/src/app/pages/swap/hooks/use-swap-route-params.ts b/src/app/pages/swap/hooks/use-swap-route-params.ts new file mode 100644 index 00000000000..792b3c37b57 --- /dev/null +++ b/src/app/pages/swap/hooks/use-swap-route-params.ts @@ -0,0 +1,29 @@ +import { useEffect } from 'react'; +import { useParams } from 'react-router-dom'; + +import { useFormikContext } from 'formik'; + +import type { SwapFormValues } from '@shared/models/form.model'; + +import { type BaseSwapContext, useSwapContext } from '../swap.context'; + +export function useSwapRouteParams>() { + const { isCrossChainSwap, onSetIsCrossChainSwap, swapData } = useSwapContext(); + const { setFieldValue, validateForm } = useFormikContext(); + const { base, quote } = useParams(); + + useEffect(() => { + if (!isCrossChainSwap && swapData.chain === 'bitcoin') onSetIsCrossChainSwap(true); + if (base) + void setFieldValue( + 'swapAssetBase', + swapData.swappableAssetsBase.find(asset => asset.name === base) + ); + if (quote) + void setFieldValue( + 'swapAssetQuote', + swapData.swappableAssetsQuote.find(asset => asset.name === quote) + ); + void validateForm(); + }, [base, isCrossChainSwap, onSetIsCrossChainSwap, quote, setFieldValue, swapData, validateForm]); +} 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..7b9c1fa73b9 --- /dev/null +++ b/src/app/pages/swap/loaders/bitcoin-utxos-loader.tsx @@ -0,0 +1,11 @@ +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(); + 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/providers/bitcoin-swap-provider.tsx b/src/app/pages/swap/providers/bitcoin-swap-provider.tsx new file mode 100644 index 00000000000..24e3d9519a5 --- /dev/null +++ b/src/app/pages/swap/providers/bitcoin-swap-provider.tsx @@ -0,0 +1,55 @@ +import { Outlet } from 'react-router-dom'; + +import type { P2Ret } from '@scure/btc-signer/payment'; +import BigNumber from 'bignumber.js'; + +import type { UtxoResponseItem } from '@leather.io/query'; +import { createMoney } from '@leather.io/utils'; + +import type { Signer } from '@app/store/accounts/blockchain/bitcoin/bitcoin-signer'; + +import { SwapForm } from '../form/swap-form'; +import { useAllSwappableAssets } from '../hooks/use-all-swappable-assets'; +import { type SbtcDeposit, defaultMaxSignerFee } from '../hooks/use-sbtc-deposit-transaction'; +import { SwapProvider } from '../swap-provider'; +import type { BaseSwapContext } from '../swap.context'; +import { useBitcoinSwap } from './use-bitcoin-swap'; + +export interface BitcoinSwapContext extends BaseSwapContext { + deposit?: SbtcDeposit; + maxSignerFee: number; + signer: Signer; + utxos: UtxoResponseItem[]; +} + +interface BitcoinSwapProviderProps { + signer: Signer; + utxos: UtxoResponseItem[]; +} +export function BitcoinSwapProvider({ signer, utxos }: BitcoinSwapProviderProps) { + const { allSwappableAssets, bitcoinSwappableAssetsQuote } = useAllSwappableAssets(); + const { fetchQuoteAmount, onSubmitSwapForReview, onSubmitSwap } = useBitcoinSwap(signer, utxos); + + return ( + + initialData={{ + chain: 'bitcoin', + fee: createMoney(new BigNumber(0), 'BTC'), + maxSignerFee: defaultMaxSignerFee, + protocol: 'sBTC Protocol', + signer, + swappableAssetsBase: allSwappableAssets, + swappableAssetsQuote: bitcoinSwappableAssetsQuote, + timestamp: new Date().toISOString(), + utxos, + fetchQuoteAmount, + onSubmitSwapForReview, + onSubmitSwap, + }} + > + + + + + ); +} diff --git a/src/app/pages/swap/providers/stacks-swap-provider.tsx b/src/app/pages/swap/providers/stacks-swap-provider.tsx new file mode 100644 index 00000000000..89c8d8de016 --- /dev/null +++ b/src/app/pages/swap/providers/stacks-swap-provider.tsx @@ -0,0 +1,54 @@ +import { Outlet } from 'react-router-dom'; + +import type { RouteQuote } from 'bitflow-sdk'; + +import { defaultSwapFee } from '@leather.io/query'; + +import type { + SbtcSponsorshipEligibility, + TransactionBase, +} from '@app/query/sbtc/sponsored-transactions.query'; + +import { SwapForm } from '../form/swap-form'; +import { useAllSwappableAssets } from '../hooks/use-all-swappable-assets'; +import { SwapProvider } from '../swap-provider'; +import type { BaseSwapContext } from '../swap.context'; +import { useStacksSwap } from './use-stacks-swap'; + +export interface StacksSwapContext extends BaseSwapContext { + nonce: number | string; + routeQuote?: RouteQuote; + slippage: number; + sponsorship?: SbtcSponsorshipEligibility; + unsignedTx?: TransactionBase; +} + +interface StacksSwapProviderProps { + nonce: number | string; +} +export function StacksSwapProvider({ nonce }: StacksSwapProviderProps) { + const { allSwappableAssets, stacksSwappableAssetsQuote } = useAllSwappableAssets(); + const { fetchQuoteAmount, onSubmitSwapForReview, onSubmitSwap } = useStacksSwap(nonce); + + return ( + + initialData={{ + chain: 'stacks', + fee: defaultSwapFee, + nonce, + protocol: 'Bitflow', + slippage: 0.04, + swappableAssetsBase: allSwappableAssets, + swappableAssetsQuote: stacksSwappableAssetsQuote, + timestamp: new Date().toISOString(), + fetchQuoteAmount, + onSubmitSwapForReview, + onSubmitSwap, + }} + > + + + + + ); +} diff --git a/src/app/pages/swap/providers/use-bitcoin-swap.tsx b/src/app/pages/swap/providers/use-bitcoin-swap.tsx new file mode 100644 index 00000000000..1b913d1c5f3 --- /dev/null +++ b/src/app/pages/swap/providers/use-bitcoin-swap.tsx @@ -0,0 +1,68 @@ +import { useCallback } from 'react'; + +import type { P2Ret } from '@scure/btc-signer/payment'; + +import type { SwapAsset, UtxoResponseItem } from '@leather.io/query'; +import { delay, isUndefined } from '@leather.io/utils'; + +import { logger } from '@shared/logger'; + +import { LoadingKeys, useLoading } from '@app/common/hooks/use-loading'; +import type { Signer } from '@app/store/accounts/blockchain/bitcoin/bitcoin-signer'; + +import { useSbtcDepositTransaction } from '../hooks/use-sbtc-deposit-transaction'; +import type { SubmitSwapArgs } from '../swap.context'; +import type { BitcoinSwapContext } from './bitcoin-swap-provider'; + +export function useBitcoinSwap(signer: Signer, utxos: UtxoResponseItem[]) { + const { setIsLoading, isLoading } = useLoading(LoadingKeys.SUBMIT_SWAP_TRANSACTION); + + const { onDepositSbtc, onReviewDepositSbtc } = useSbtcDepositTransaction(signer, utxos); + + const onSubmitSwapForReview = useCallback( + async ({ values, swapData, isSendingMax }: SubmitSwapArgs) => { + if (isUndefined(values.swapAssetBase) || isUndefined(values.swapAssetQuote)) { + return logger.error('Error submitting swap for review'); + } + + const depositData = await onReviewDepositSbtc({ values, swapData, isSendingMax }); + if (!depositData) return logger.error('No deposit to review'); + + return depositData; + }, + [onReviewDepositSbtc] + ); + + const onSubmitSwap = useCallback( + async ({ values, swapData }: SubmitSwapArgs) => { + if (isLoading) return; + + if (isUndefined(values.swapAssetBase) || isUndefined(values.swapAssetQuote)) { + logger.error('No assets selected to perform swap'); + return; + } + + setIsLoading(); + + return await onDepositSbtc(swapData.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; + await delay(100); // Simulate API call + // Return 1:1 rate for now + return baseAmount; + }, + [] + ); + + return { + fetchQuoteAmount, + onSubmitSwapForReview, + onSubmitSwap, + }; +} diff --git a/src/app/pages/swap/providers/use-stacks-swap.tsx b/src/app/pages/swap/providers/use-stacks-swap.tsx new file mode 100644 index 00000000000..230ea0a8c42 --- /dev/null +++ b/src/app/pages/swap/providers/use-stacks-swap.tsx @@ -0,0 +1,191 @@ +import { useCallback } 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 { type SwapAsset } 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 { 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 { useSponsorTransactionFees } from '../hooks/use-sponsor-tx-fees'; +import { useStacksBroadcastSwap } from '../hooks/use-stacks-broadcast-swap'; +import type { SubmitSwapArgs } from '../swap.context'; +import type { StacksSwapContext } from './stacks-swap-provider'; + +export function useStacksSwap(nonce: number | string) { + const { setIsLoading, setIsIdle, isLoading } = useLoading(LoadingKeys.SUBMIT_SWAP_TRANSACTION); + const currentAccount = useCurrentStacksAccount(); + const generateUnsignedTx = useGenerateStacksContractCallUnsignedTx(); + const signTx = useSignStacksTransaction(); + const broadcastStacksSwap = useStacksBroadcastSwap(); + const navigate = useNavigate(); + + 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 => { + const routeQuote = await fetchRouteQuote(base, quote, baseAmount); + if (!routeQuote) return; + return String(routeQuote.quote); + }, + [fetchRouteQuote] + ); + + const onSubmitSwapForReview = useCallback( + async ({ values, swapData }: SubmitSwapArgs) => { + 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, + swapData.slippage + ); + + 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: swapData.fee?.amount.toNumber(), + nonce, + }); + + if (!unsignedTx) + return logger.error('Attempted to generate unsigned tx, but tx is undefined'); + + const sponsorship = await checkEligibilityForSponsor(unsignedTx); + + return { routeQuote, sponsorship, unsignedTx }; + }, + [currentAccount, fetchRouteQuote, generateUnsignedTx, nonce, checkEligibilityForSponsor] + ); + + const onSubmitSwap = useCallback( + async ({ values, swapData }: SubmitSwapArgs) => { + 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 (swapData.sponsorship?.isEligible) + return await submitSponsoredTx(swapData.sponsorship.unsignedSponsoredTx!); + + if (!swapData.unsignedTx?.transaction) return logger.error('No unsigned tx to sign'); + + const signedTx = await signTx(swapData.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, + submitSponsoredTx, + ] + ); + + return { + fetchQuoteAmount, + onSubmitSwapForReview, + onSubmitSwap, + }; +} diff --git a/src/app/pages/swap/swap-provider.tsx b/src/app/pages/swap/swap-provider.tsx new file mode 100644 index 00000000000..b663965131f --- /dev/null +++ b/src/app/pages/swap/swap-provider.tsx @@ -0,0 +1,42 @@ +import { useState } from 'react'; + +import type { HasChildren } from '@app/common/has-children'; + +import { type BaseSwapContext, swapContext as SwapContext } from './swap.context'; + +interface SwapProviderProps extends HasChildren { + initialData: T; +} +export function SwapProvider>({ + children, + initialData, +}: SwapProviderProps) { + const [isCrossChainSwap, setIsCrossChainSwap] = useState(false); + const [isFetchingExchangeRate, setIsFetchingExchangeRate] = useState(false); + const [isPreparingSwapReview, setIsPreparingSwapReview] = useState(false); + const [isSendingMax, setIsSendingMax] = useState(false); + const [swapData, setSwapData] = useState(initialData); + + function onSetSwapData(data: Partial) { + setSwapData(prev => ({ ...prev, ...data })); + } + + return ( + setIsCrossChainSwap(value), + onSetIsFetchingExchangeRate: (value: boolean) => setIsFetchingExchangeRate(value), + onSetIsPreparingSwapReview: (value: boolean) => setIsPreparingSwapReview(value), + onSetIsSendingMax: (value: boolean) => setIsSendingMax(value), + }} + > + {children} + + ); +} diff --git a/src/app/pages/swap/swap.context.ts b/src/app/pages/swap/swap.context.ts index 2ff547145e6..485abb50a13 100644 --- a/src/app/pages/swap/swap.context.ts +++ b/src/app/pages/swap/swap.context.ts @@ -1,43 +1,49 @@ import { createContext, useContext } from 'react'; +import type { Blockchain, Money } from '@leather.io/models'; import type { SwapAsset } from '@leather.io/query'; import type { SwapFormValues } from '@shared/models/form.model'; -export interface SwapSubmissionData extends SwapFormValues { - liquidityFee: number; - maxSignerFee?: number; +export interface SubmitSwapArgs { + isSendingMax?: boolean; + swapData: T; + values: SwapFormValues; +} + +export interface BaseSwapContext { + chain: Blockchain; + fee: Money; protocol: string; - router: SwapAsset[]; - dexPath?: string[]; - slippage: number; - sponsored?: boolean; + swappableAssetsBase: SwapAsset[]; + swappableAssetsQuote: SwapAsset[]; timestamp: string; - txData?: Record; + fetchQuoteAmount(from: SwapAsset, to: SwapAsset, fromAmount: string): Promise; + onSubmitSwapForReview({ + values, + swapData, + isSendingMax, + }: SubmitSwapArgs): Promise | void>; + onSubmitSwap({ values, swapData }: SubmitSwapArgs): Promise | void>; } -export interface SwapContext { - fetchQuoteAmount(from: SwapAsset, to: SwapAsset, fromAmount: string): Promise; +interface SwapContext { isCrossChainSwap: boolean; isFetchingExchangeRate: boolean; - isSendingMax: boolean; isPreparingSwapReview: boolean; + isSendingMax: boolean; + swapData: T; onSetIsCrossChainSwap(value: boolean): void; onSetIsFetchingExchangeRate(value: boolean): void; + onSetIsPreparingSwapReview(value: boolean): void; onSetIsSendingMax(value: boolean): void; - onSubmitSwapForReview(values: SwapFormValues): Promise | void; - onSubmitSwap(): Promise | void; - swappableAssetsBase: SwapAsset[]; - swappableAssetsQuote: SwapAsset[]; - swapSubmissionData?: SwapSubmissionData; + onSetSwapData(data: Partial): 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.routes.tsx b/src/app/pages/swap/swap.routes.tsx new file mode 100644 index 00000000000..3e18cee8eaa --- /dev/null +++ b/src/app/pages/swap/swap.routes.tsx @@ -0,0 +1,62 @@ +import { Route } from 'react-router-dom'; + +import type { Blockchain } from '@leather.io/models'; + +import { RouteUrls } from '@shared/route-urls'; +import { replaceRouteParams } from '@shared/utils/replace-route-params'; + +import { ledgerBitcoinTxSigningRoutes } from '@app/features/ledger/flows/bitcoin-tx-signing/ledger-bitcoin-sign-tx-container'; +import { ledgerStacksTxSigningRoutes } from '@app/features/ledger/flows/stacks-tx-signing/ledger-sign-stacks-tx-container'; +import { AccountGate } from '@app/routes/account-gate'; + +import { SwapAssetSheetBase } from './components/swap-asset-sheet/swap-asset-sheet-base'; +import { SwapAssetSheetQuote } from './components/swap-asset-sheet/swap-asset-sheet-quote'; +import { SwapError } from './components/swap-error'; +import { BitcoinSwapReview } from './components/swap-review/bitcoin-swap-review'; +import { StacksSwapReview } from './components/swap-review/stacks-swap-review'; +import { BitcoinSwapContainer } from './containers/bitcoin-swap-container'; +import { StacksSwapContainer } from './containers/stacks-swap-container'; +import { Swap } from './swap'; + +interface ConstructSwapRouteArgs { + chain: Blockchain; + route: string; + params?: Record; +} +export function constructSwapRoute({ chain, route, params }: ConstructSwapRouteArgs) { + const baseRoute = route.replace('{chain}', `${chain}`); + if (!params) return baseRoute; + return replaceRouteParams(baseRoute, params); +} + +export const bitcoinSwapRoutes = generateSwapRoutes({ + chain: 'bitcoin', + container: , + review: , +}); +export const stacksSwapRoutes = generateSwapRoutes({ + chain: 'stacks', + container: , + review: , +}); + +interface GenerateSwapRoutesArgs { + chain: Blockchain; + container: React.ReactNode; + review: React.ReactNode; +} +function generateSwapRoutes({ chain, container, review }: GenerateSwapRoutesArgs) { + return ( + {container}}> + }> + } /> + } /> + + } /> + + {ledgerBitcoinTxSigningRoutes} + {ledgerStacksTxSigningRoutes} + + + ); +} diff --git a/src/app/pages/swap/swap.tsx b/src/app/pages/swap/swap.tsx index 5ec9c44116d..8042a1ba4af 100644 --- a/src/app/pages/swap/swap.tsx +++ b/src/app/pages/swap/swap.tsx @@ -13,14 +13,14 @@ 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 { useSwapAssetsFromRoute } from './hooks/use-swap-assets-from-route'; -import { useSwapContext } from './swap.context'; +import { useSwapRouteParams } from './hooks/use-swap-route-params'; +import { type BaseSwapContext, useSwapContext } from './swap.context'; -export function Swap() { - const { isFetchingExchangeRate, isPreparingSwapReview, onSubmitSwapForReview } = useSwapContext(); - const { dirty, isValid, values, submitForm } = useFormikContext(); +export function Swap>() { + const { isFetchingExchangeRate, isPreparingSwapReview } = useSwapContext(); + const { dirty, isValid, values } = useFormikContext(); - useSwapAssetsFromRoute(); + useSwapRouteParams(); if (isUndefined(values.swapAssetBase)) return ; @@ -32,10 +32,6 @@ export function Swap() { data-testid={SwapSelectors.SwapReviewBtn} aria-busy={isPreparingSwapReview} disabled={!(dirty && isValid) || isFetchingExchangeRate || isPreparingSwapReview} - onClick={async () => { - await submitForm(); // Validate form - await onSubmitSwapForReview(values); - }} type="submit" fullWidth > @@ -45,7 +41,6 @@ export function Swap() { > - ); 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..cac8a8548ef --- /dev/null +++ b/src/app/query/sbtc/sbtc-token.query.ts @@ -0,0 +1,38 @@ +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'; + +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) + ); +} + +function useSbtcTokenCryptoAssetInfo() { + const { contractId } = useConfigSbtc(); + const infoResults = useStacksFungibleTokensMetadata([contractId]); + return infoResults.map(query => query.data).filter(isDefined)[0]; +} + +// Remove if end up not needing, but we might want to use this in the future +// ts-unused-exports:disable-next-line +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..2148f326a22 100644 --- a/src/app/routes/app-routes.tsx +++ b/src/app/routes/app-routes.tsx @@ -43,7 +43,7 @@ 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, stacksSwapRoutes } from '@app/pages/swap/swap.routes'; 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 +200,8 @@ function useAppRoutes() { } /> - {bitflowSwapRoutes} + {bitcoinSwapRoutes} + {stacksSwapRoutes} {/* OnBoarding Routes */} { + async (payload: ContractCallPayload, values: Partial) => { if (!account) return; const options: GenerateUnsignedTransactionOptions = { diff --git a/src/background/messaging/rpc-methods/open-swap.ts b/src/background/messaging/rpc-methods/open-swap.ts index 61f7d857da1..2b91dd1b22b 100644 --- a/src/background/messaging/rpc-methods/open-swap.ts +++ b/src/background/messaging/rpc-methods/open-swap.ts @@ -2,6 +2,7 @@ import type { OpenSwapRequest } from '@leather.io/rpc'; import { RouteUrls } from '@shared/route-urls'; import { makeRpcSuccessResponse } from '@shared/rpc/rpc-methods'; +import { replaceRouteParams } from '@shared/utils/replace-route-params'; import { makeSearchParamsWithDefaults, triggerSwapWindowOpen } from '../messaging-utils'; import { trackRpcRequestSuccess } from '../rpc-message-handler'; @@ -10,8 +11,21 @@ export async function rpcSwap(message: OpenSwapRequest, port: chrome.runtime.Por const { urlParams, tabId } = makeSearchParamsWithDefaults(port, [['requestId', message.id]]); const { base = 'STX', quote } = message?.params || {}; + if (base === 'BTC') { + await triggerSwapWindowOpen( + replaceRouteParams(RouteUrls.Swap, { + base: base, + quote: quote ?? '', + }).replace('{chain}', 'bitcoin'), + urlParams + ); + } + await triggerSwapWindowOpen( - RouteUrls.Swap.replace(':base', base).replace(':quote', quote ?? ''), + replaceRouteParams(RouteUrls.Swap, { + base: base, + quote: quote ?? '', + }).replace('{chain}', 'stacks'), urlParams ); diff --git a/src/shared/route-urls.ts b/src/shared/route-urls.ts index e4226245efb..12cdcc88df2 100644 --- a/src/shared/route-urls.ts +++ b/src/shared/route-urls.ts @@ -72,11 +72,11 @@ export enum RouteUrls { SendOrdinalInscriptionError = 'error', // Swap routes - Swap = '/swap/:base/:quote?', + Swap = '/swap/{chain}/:base/:quote?', SwapAssetSelectBase = 'select-base', SwapAssetSelectQuote = 'select-quote', + SwapReview = '/swap/{chain}/:base/:quote/review', SwapError = '/swap/error', - SwapReview = '/swap/:base/:quote/review', // Legacy request routes ProfileUpdateRequest = '/update-profile', diff --git a/src/shared/utils/replace-route-params.ts b/src/shared/utils/replace-route-params.ts new file mode 100644 index 00000000000..91d42a4f685 --- /dev/null +++ b/src/shared/utils/replace-route-params.ts @@ -0,0 +1,14 @@ +type RouteParams = Record; + +/** + * Replaces route parameters in a given path with provided values. + * @param route - The route string containing parameters (e.g., "/swap/:base/:quote"). + * @param params - An object where keys are parameter names and values are their replacements. + * @returns The route string with parameters replaced. + */ +export function replaceRouteParams(route: string, params: RouteParams): string { + return Object.entries(params).reduce( + (path, [key, value]) => path.replace(`:${key}`, value?.toString() ?? ''), + route + ); +} diff --git a/tests/page-object-models/swap.page.ts b/tests/page-object-models/swap.page.ts index bc49da46c03..c42f967fde9 100644 --- a/tests/page-object-models/swap.page.ts +++ b/tests/page-object-models/swap.page.ts @@ -51,8 +51,5 @@ export class SwapPage { async selectQuoteAsset() { const swapAssetSelectors = await this.page.locator(this.selectAssetBtn).all(); await swapAssetSelectors[1].click(); - // await this.page.locator(this.chooseAssetList).waitFor(); - // const quoteAssets = await this.page.locator(this.chooseAssetListItem).all(); - // await quoteAssets[0].click(); } } diff --git a/tests/specs/swap/swap.spec.ts b/tests/specs/swap/swap.spec.ts index 8058b19f0a1..1798f4bd774 100644 --- a/tests/specs/swap/swap.spec.ts +++ b/tests/specs/swap/swap.spec.ts @@ -47,7 +47,7 @@ test.describe('Swaps', () => { await expect(toast).toBeVisible(); }); - test('that it preselects cross chain swap assets and restricts quote list', async ({ + test('that it preselects cross-chain swap assets and restricts quote list', async ({ swapPage, }) => { await swapPage.selectBtcAsBaseAsset();