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