From 69a66fe71674f0ffc9f3940df26b3b0dcc8f4f0d Mon Sep 17 00:00:00 2001 From: Fara Woolf Date: Fri, 6 Dec 2024 11:13:29 -0500 Subject: [PATCH] feat: cross chain swap review --- package.json | 2 +- pnpm-lock.yaml | 10 +- public/assets/avatars/btc-avatar-icon.png | Bin 0 -> 2771 bytes public/assets/avatars/sbtc-avatar-icon.png | Bin 0 -> 1695 bytes src/app/components/nonce-setter.tsx | 3 +- src/app/pages/swap/bitflow-swap-container.tsx | 130 ++++++++++-------- src/app/pages/swap/bitflow-swap.utils.ts | 59 +++++++- .../components/use-swap-asset-list.tsx | 115 +++++++++------- .../components/swap-amount-field.tsx | 6 +- .../components/swap-details/swap-details.tsx | 7 +- src/app/pages/swap/components/swap-form.tsx | 5 +- src/app/pages/swap/components/swap-review.tsx | 9 +- ...sets.tsx => use-bitcoin-bridge-assets.tsx} | 65 +++++---- src/app/pages/swap/hooks/use-bitflow-swap.tsx | 88 +++++++----- .../hooks/use-sbtc-deposit-transaction.tsx | 104 ++++++++++++++ .../swap/hooks/use-swap-assets-from-route.ts | 46 +++++++ src/app/pages/swap/hooks/use-swap-form.tsx | 13 +- src/app/pages/swap/hooks/use-swap-navigate.ts | 1 + src/app/pages/swap/swap.context.ts | 6 +- src/app/pages/swap/swap.tsx | 52 ++----- src/app/pages/test-deposit-sbtc/deposit.tsx | 95 +++++++++++++ .../test-deposit-sbtc/test-deposit-sbtc.tsx | 24 ++-- 22 files changed, 588 insertions(+), 252 deletions(-) create mode 100644 public/assets/avatars/btc-avatar-icon.png create mode 100644 public/assets/avatars/sbtc-avatar-icon.png rename src/app/pages/swap/hooks/{use-sbtc-bridge-assets.tsx => use-bitcoin-bridge-assets.tsx} (52%) create mode 100644 src/app/pages/swap/hooks/use-sbtc-deposit-transaction.tsx create mode 100644 src/app/pages/swap/hooks/use-swap-assets-from-route.ts create mode 100644 src/app/pages/test-deposit-sbtc/deposit.tsx diff --git a/package.json b/package.json index 8274494fbad..7c7473b6ccb 100644 --- a/package.json +++ b/package.json @@ -248,7 +248,7 @@ "redux-persist": "6.0.0", "remark-gfm": "4.0.0", "rxjs": "7.8.1", - "sbtc": "0.2.2", + "sbtc": "0.2.4", "style-loader": "3.3.4", "ts-debounce": "4.0.0", "url": "0.11.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 34cad21d68a..8f83c1a70bf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -354,8 +354,8 @@ importers: specifier: 7.8.1 version: 7.8.1 sbtc: - specifier: 0.2.2 - version: 0.2.2(encoding@0.1.13) + specifier: 0.2.4 + version: 0.2.4(encoding@0.1.13) style-loader: specifier: 3.3.4 version: 3.3.4(webpack@5.94.0(@swc/core@1.9.3)(esbuild@0.24.0)(webpack-cli@5.1.4(webpack-bundle-analyzer@4.10.2)(webpack-dev-server@4.15.1)(webpack@5.94.0))) @@ -13239,8 +13239,8 @@ packages: resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} engines: {node: '>=v12.22.7'} - sbtc@0.2.2: - resolution: {integrity: sha512-TsRXLR5crL2ORfJxiEA/CQANtTDu53pT5HjkTG0cJyC/rwl2wxtRV+mF4FFLj8bH7aLMRAr9SM68ihHxUOpmgw==} + sbtc@0.2.4: + resolution: {integrity: sha512-mLc5jZT3m4MrHqpc3H7L4DpJAGaXIS9/2XOD7toZZzj4YWkbTWKcz8Yy5Xp12YI1s0AC+K/WJNzDdyI4tpsM2A==} sc-errors@3.0.0: resolution: {integrity: sha512-rIqv2HTPb9DVreZwK/DV0ytRUqyw2DbDcoB9XTKjEQL7oMEQKsfPA8V8dGGr7p8ZYfmvaRIGZ4Wu5qwvs/hGDA==} @@ -31394,7 +31394,7 @@ snapshots: dependencies: xmlchars: 2.2.0 - sbtc@0.2.2(encoding@0.1.13): + sbtc@0.2.4(encoding@0.1.13): dependencies: '@btc-helpers/rpc': 2.0.0(encoding@0.1.13) '@noble/secp256k1': 2.1.0 diff --git a/public/assets/avatars/btc-avatar-icon.png b/public/assets/avatars/btc-avatar-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..a5a60b100dce5467a80bc7064090fde56cf8308a GIT binary patch literal 2771 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 Z&MyoyTEYM(fujHb002ovPDHLkV1h1^XHWnD literal 0 HcmV?d00001 diff --git a/public/assets/avatars/sbtc-avatar-icon.png b/public/assets/avatars/sbtc-avatar-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..2f3671720f7c4e808b9b66e2e180d16c3eb85a73 GIT binary patch literal 1695 zcmV;Q24MM#P)-=q!QIRmFW{0DpQ4e zeV-(f*vtSRLxBW|$6^;G=U0fJ2vXGkcV~9?F)N^?ILKfCEh)X05U>p(n*gGT*N&9Z zp<5zv=?Niv_?k=Cc&{rZTnb@zD;4PW5GWx6N&*%5a5&t<%QyJ7+E|cHP>SdZA-i~S zVcT{G^>CpC7!`mDz2EoVVZnThPurlE4z{L`ro}KKz}A-21{fZI{z8xAR0#Xy%}wtV zG^0&`QEj&@_@Lgrr~zAqiw}o4H#aAs2`vI_Zoc?{X67fDcATX|rx1k-K&D0r@3^cr zei1r^u%Q6Z+B7Sb>M?d5e}N^b^)b+Q->cW_%|@f~<^KL&c~@4s0MDO4|K9T?IkOh5 z4j7L}65{Id@v#dEQ6>O6*-EAMGhR}y-+9D3)= zYU|TNz5(NhT@~?z1v%N=+-wa7!_Vtd_yi7dV1*l=)Bg@R$t3{UdmL-|49o3kGeIKI z|4wB9Imqk-sBe!CzXP*Zt!`ZP`wyQW1DON}1FPSF@j;*PHmeA!1qhoQ*Zc@10(Mhf z?52K6^}aMWlhX-pJ`)4ZU+mo7-6f6{5<6k)YFd{42QYKN3VKdumH7T7HwBX=-dc4v z^AN@fJ4t5Y68V52!j*40oJEr3i9hTF%$C_wJs-rja*y)s9D7hk#dtATn& zIIH$*)@w6OP*D51@Kr$rcQ}0W3*g#c z9J74ElNNxre~$~lILH@lZEdwDe`iH*$ThpeBtaZJc{aMBs278VufP-zI^&lkXFP5J z81=S+(ZZxN9t*(cM<3yG{NS2?u=&x?$PD={fX$D7!ree)6Z8fc-S}nh3m~OfrG>Q1 zLYsbwU^n9MUtqLDe!!xwpuiYmFc`Ef+J_5_5u~(Q77h?KfiVJ=Jvw2z4U8cq39x=k z%gckXTWHIl7^42#{yKV)(J3L7?~epEC8D!ZJbT!j)+Xi$U|TXMF{)k$@Az9yiK(@ z7H(4|JP(e5DT*lzGt~mZu_4;7#LHC_5|^-JDq|KUrLI=14>&%=DasUr0_@Mqyw2%r zG>|%74I!oX9vD@`_AX-u2s1qK7*S5J_Mgl<0%h*PESxad2VOUnnJ|Co+}LVv+DbYz zrCzW9gO_{2q=p>t_4RdU-aQNRhLY#5)oL&C<6Z-k0(pdHd||#-PF-98X;K#_TAWKC zWZTv*us>MH#Q8Fb%8Jw1E2gmY^#pw|D$Mag3{*C03ZF=Qf(AKd9ICkDp^B$q&EH3- zoBCvtOlQdS`~6QW!v9d=W6jTWrcUNllt=`O5olrWTE_@vcCys`@%tesre zX}SL<7$8WK@25&GOAyzgRe?pCnmCLIFd{goY}gLPXoFrlv?5$*ZJ&iv0VWxVftM(V z_y%jF(jHV0DEd2rE@(rF$wHr`Gy+5kR_F~{l-e=K?C2*KbSTAInB^-PXV7NDUXXf$ pcK?e@+J{S9K`WI?w=4x7#{ literal 0 HcmV?d00001 diff --git a/src/app/components/nonce-setter.tsx b/src/app/components/nonce-setter.tsx index 530cba5f41f..b8ae2352bd9 100644 --- a/src/app/components/nonce-setter.tsx +++ b/src/app/components/nonce-setter.tsx @@ -6,11 +6,12 @@ import { useNextNonce } from '@leather.io/query'; import { StacksSendFormValues, StacksTransactionFormValues } from '@shared/models/form.model'; +import type { SwapFormValues } from '@app/pages/swap/hooks/use-swap-form'; import { useCurrentStacksAccountAddress } from '@app/store/accounts/blockchain/stacks/stacks-account.hooks'; export function NonceSetter() { const { setFieldValue, touched, values } = useFormikContext< - StacksSendFormValues | StacksTransactionFormValues + StacksSendFormValues | StacksTransactionFormValues | SwapFormValues >(); const stxAddress = useCurrentStacksAccountAddress(); const { data: nextNonce } = useNextNonce(stxAddress); diff --git a/src/app/pages/swap/bitflow-swap-container.tsx b/src/app/pages/swap/bitflow-swap-container.tsx index 131b599c749..9ac9a7368d7 100644 --- a/src/app/pages/swap/bitflow-swap-container.tsx +++ b/src/app/pages/swap/bitflow-swap-container.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useCallback, useState } from 'react'; import { Outlet, useNavigate } from 'react-router-dom'; import { bytesToHex } from '@stacks/common'; @@ -10,13 +10,7 @@ import { serializePostCondition, } from '@stacks/transactions'; -import { defaultSwapFee } from '@leather.io/query'; -import { - isDefined, - isError, - isUndefined, - migratePositiveAssetBalancesToTop, -} from '@leather.io/utils'; +import { isError, isUndefined } from '@leather.io/utils'; import { logger } from '@shared/logger'; import { RouteUrls } from '@shared/route-urls'; @@ -29,11 +23,11 @@ import { useCurrentStacksAccount } from '@app/store/accounts/blockchain/stacks/s import { useGenerateStacksContractCallUnsignedTx } from '@app/store/transactions/contract-call.hooks'; import { useSignStacksTransaction } from '@app/store/transactions/transaction.hooks'; -import { estimateLiquidityFee, formatDexPathItem } from './bitflow-swap.utils'; +import { getCrossChainSwapSubmissionData, getSwapSubmissionData } from './bitflow-swap.utils'; import { SwapForm } from './components/swap-form'; import { generateSwapRoutes } from './generate-swap-routes'; import { useBitflowSwap } from './hooks/use-bitflow-swap'; -import { useBtcSwapAsset, useSBtcSwapAsset } from './hooks/use-sbtc-bridge-assets'; +import { useSBtcDepositTransaction } from './hooks/use-sbtc-deposit-transaction'; import { useStacksBroadcastSwap } from './hooks/use-stacks-broadcast-swap'; import { SwapFormValues } from './hooks/use-swap-form'; import { useSwapNavigate } from './hooks/use-swap-navigate'; @@ -51,63 +45,66 @@ function BitflowSwapContainer() { const generateUnsignedTx = useGenerateStacksContractCallUnsignedTx(); const signTx = useSignStacksTransaction(); const broadcastStacksSwap = useStacksBroadcastSwap(); - - // Bridge assets - const btcAsset = useBtcSwapAsset(); - const sBtcAsset = useSBtcSwapAsset(); + const { onDepositSBtc } = useSBtcDepositTransaction(); const { fetchRouteQuote, fetchQuoteAmount, + isCrossChainSwap, isFetchingExchangeRate, + onSetIsCrossChainSwap, onSetIsFetchingExchangeRate, onSetSwapSubmissionData, slippage, bitflowSwapAssets, + swappableAssetsBase, + swappableAssetsQuote, swapSubmissionData, } = useBitflowSwap(); - async function onSubmitSwapForReview(values: SwapFormValues) { - try { - setIsPreparingSwapReview(true); - if (isUndefined(values.swapAssetBase) || isUndefined(values.swapAssetQuote)) { - logger.error('Error submitting swap for review'); - return; + const onSubmitSwapForReview = useCallback( + async (values: SwapFormValues) => { + try { + setIsPreparingSwapReview(true); + if (isUndefined(values.swapAssetBase) || isUndefined(values.swapAssetQuote)) { + logger.error('Error submitting swap for review'); + return; + } + + // TODO: Handle cross-chain swaps + if (isCrossChainSwap) { + onSetSwapSubmissionData(getCrossChainSwapSubmissionData(values)); + swapNavigate(RouteUrls.SwapReview); + return; + } + + const routeQuote = await fetchRouteQuote( + values.swapAssetBase, + values.swapAssetQuote, + values.swapAmountBase + ); + + if (!routeQuote) return; + + onSetSwapSubmissionData( + getSwapSubmissionData({ bitflowSwapAssets, routeQuote, slippage, values }) + ); + swapNavigate(RouteUrls.SwapReview); + } finally { + setIsPreparingSwapReview(false); } + }, + [ + bitflowSwapAssets, + fetchRouteQuote, + isCrossChainSwap, + onSetSwapSubmissionData, + slippage, + swapNavigate, + ] + ); - const routeQuote = await fetchRouteQuote( - values.swapAssetBase, - values.swapAssetQuote, - values.swapAmountBase - ); - if (!routeQuote) return; - - onSetSwapSubmissionData({ - fee: defaultSwapFee.amount.toString(), - feeCurrency: values.feeCurrency, - feeType: values.feeType, - 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, - sponsored: false, - swapAmountBase: values.swapAmountBase, - swapAmountQuote: values.swapAmountQuote, - swapAssetBase: values.swapAssetBase, - swapAssetQuote: values.swapAssetQuote, - timestamp: new Date().toISOString(), - }); - swapNavigate(RouteUrls.SwapReview); - } finally { - setIsPreparingSwapReview(false); - } - } - - async function onSubmitSwap() { + const onSubmitSwap = useCallback(async () => { if (isLoading) return; if (isUndefined(currentAccount) || isUndefined(swapSubmissionData)) { @@ -125,12 +122,18 @@ function BitflowSwapContainer() { setIsLoading(); + // TODO: Handle cross-chain swaps + if (isCrossChainSwap) { + return await onDepositSBtc(); + } + try { const routeQuote = await fetchRouteQuote( swapSubmissionData.swapAssetBase, swapSubmissionData.swapAssetQuote, swapSubmissionData.swapAmountBase ); + if (!routeQuote) return; const swapExecutionData = { @@ -184,19 +187,34 @@ function BitflowSwapContainer() { } finally { setIsIdle(); } - } + }, [ + broadcastStacksSwap, + currentAccount, + fetchRouteQuote, + generateUnsignedTx, + isCrossChainSwap, + isLoading, + navigate, + onDepositSBtc, + setIsIdle, + setIsLoading, + signTx, + swapSubmissionData, + ]); const swapContextValue: SwapContext = { fetchQuoteAmount, + isCrossChainSwap, isFetchingExchangeRate, isSendingMax, isPreparingSwapReview, + onSetIsCrossChainSwap, onSetIsFetchingExchangeRate, onSetIsSendingMax: value => setIsSendingMax(value), onSubmitSwapForReview, onSubmitSwap, - swappableAssetsBase: [...[btcAsset], ...migratePositiveAssetBalancesToTop(bitflowSwapAssets)], - swappableAssetsQuote: [...[sBtcAsset], ...bitflowSwapAssets], + swappableAssetsBase, + swappableAssetsQuote, swapSubmissionData, }; diff --git a/src/app/pages/swap/bitflow-swap.utils.ts b/src/app/pages/swap/bitflow-swap.utils.ts index a8643b224f9..acf92a8c78b 100644 --- a/src/app/pages/swap/bitflow-swap.utils.ts +++ b/src/app/pages/swap/bitflow-swap.utils.ts @@ -1,6 +1,13 @@ import BigNumber from 'bignumber.js'; +import type { RouteQuote } from 'bitflow-sdk'; +import { P } from 'pino'; -import { capitalize } from '@leather.io/utils'; +import { BtcFeeType, FeeTypes } from '@leather.io/models'; +import { type SwapAsset, defaultSwapFee } from '@leather.io/query'; +import { capitalize, isDefined, microStxToStx } from '@leather.io/utils'; + +import type { SwapFormValues } from './hooks/use-swap-form'; +import type { SwapSubmissionData } from './swap.context'; export function estimateLiquidityFee(dexPath: string[]) { return new BigNumber(dexPath.length).times(0.3).toNumber(); @@ -10,3 +17,53 @@ export function formatDexPathItem(dex: string) { const name = dex.split('_')[0]; return name === 'ALEX' ? name : capitalize(name.toLowerCase()); } + +interface GetSwapSubmissionDataArgs { + bitflowSwapAssets: SwapAsset[]; + routeQuote: RouteQuote; + slippage: number; + values: SwapFormValues; +} +export function getSwapSubmissionData({ + bitflowSwapAssets, + routeQuote, + slippage, + values, +}: GetSwapSubmissionDataArgs): SwapSubmissionData { + return { + fee: microStxToStx(defaultSwapFee.amount).toNumber(), + 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, + protocol: 'sBTC', + 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/use-swap-asset-list.tsx b/src/app/pages/swap/components/swap-asset-dialog/components/use-swap-asset-list.tsx index 4e15a608af3..9502298ffd2 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,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useCallback } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import BigNumber from 'bignumber.js'; @@ -20,65 +20,80 @@ import { useSwapContext } from '@app/pages/swap/swap.context'; import type { SwapAssetListProps } from './swap-asset-list'; export function useSwapAssetList({ assets, type }: SwapAssetListProps) { - const [selectableAssets, setSelectableAssets] = useState(assets); const { setFieldError, setFieldValue, values } = useFormikContext(); - const { fetchQuoteAmount } = useSwapContext(); + const { fetchQuoteAmount, onSetIsCrossChainSwap } = useSwapContext(); const navigate = useNavigate(); const { base, quote } = useParams(); const isBaseList = type === 'base'; const isQuoteList = type === 'quote'; - useEffect(() => { - setSelectableAssets( - assets.filter( - asset => - (isBaseList && asset.name !== values.swapAssetQuote?.name) || - (isQuoteList && asset.name !== values.swapAssetBase?.name) - ) - ); - }, [assets, isBaseList, isQuoteList, values.swapAssetBase?.name, values.swapAssetQuote?.name]); + const selectableAssets = assets.filter( + asset => + (isBaseList && asset.name !== values.swapAssetQuote?.name) || + (isQuoteList && asset.name !== values.swapAssetBase?.name) + ); - function onSelectBaseAsset(baseAsset: SwapAsset, quoteAsset?: SwapAsset) { - void setFieldValue('swapAssetBase', baseAsset); - // Handle bridge assets - if (baseAsset.name === 'BTC') - return navigate(RouteUrls.Swap.replace(':base', baseAsset.name).replace(':quote', 'sBTC')); - if (quoteAsset?.name === 'sBTC') { - void setFieldValue('swapAssetQuote', undefined); - return navigate(RouteUrls.Swap.replace(':base', baseAsset.name).replace(':quote', '')); - } - // Handle swap assets - navigate(RouteUrls.Swap.replace(':base', baseAsset.name).replace(':quote', quote ?? '')); - } + const onSelectBaseAsset = useCallback( + (baseAsset: SwapAsset, quoteAsset?: SwapAsset) => { + void setFieldValue('swapAssetBase', baseAsset); + // Handle bridge assets + if (baseAsset.name === 'BTC') { + onSetIsCrossChainSwap(true); + return navigate(RouteUrls.Swap.replace(':base', baseAsset.name).replace(':quote', 'sBTC')); + } + if (quoteAsset?.name === 'sBTC') { + void setFieldValue('swapAssetQuote', undefined); + return navigate(RouteUrls.Swap.replace(':base', baseAsset.name).replace(':quote', '')); + } + // Handle swap assets + onSetIsCrossChainSwap(false); + navigate(RouteUrls.Swap.replace(':base', baseAsset.name).replace(':quote', quote ?? '')); + }, + [navigate, onSetIsCrossChainSwap, quote, setFieldValue] + ); - function onSelectQuoteAsset(quoteAsset: SwapAsset, baseAsset?: SwapAsset) { - void setFieldValue('swapAssetQuote', quoteAsset); - setFieldError('swapAssetQuote', undefined); - // Handle bridge assets - if (isQuoteList && quoteAsset.name === 'sBTC') - return navigate(RouteUrls.Swap.replace(':base', 'BTC').replace(':quote', quoteAsset.name)); - if (isQuoteList && baseAsset?.name === 'BTC') { - return navigate(RouteUrls.Swap.replace(':base', 'STX').replace(':quote', quoteAsset.name)); - } - // Handle swap assets - navigate(RouteUrls.Swap.replace(':base', base ?? '').replace(':quote', quoteAsset.name)); - } + const onSelectQuoteAsset = useCallback( + (quoteAsset: SwapAsset, baseAsset?: SwapAsset) => { + void setFieldValue('swapAssetQuote', quoteAsset); + setFieldError('swapAssetQuote', undefined); + // Handle bridge assets + if (isQuoteList && quoteAsset.name === 'sBTC') { + onSetIsCrossChainSwap(true); + return navigate(RouteUrls.Swap.replace(':base', 'BTC').replace(':quote', quoteAsset.name)); + } + if (isQuoteList && baseAsset?.name === 'BTC') { + return navigate(RouteUrls.Swap.replace(':base', 'STX').replace(':quote', quoteAsset.name)); + } + // Handle swap assets + onSetIsCrossChainSwap(false); + navigate(RouteUrls.Swap.replace(':base', base ?? '').replace(':quote', quoteAsset.name)); + }, + [base, isQuoteList, navigate, onSetIsCrossChainSwap, setFieldError, setFieldValue] + ); - async function onFetchQuoteAmount(baseAsset: SwapAsset, quoteAsset: SwapAsset) { - const quoteAmount = await fetchQuoteAmount(baseAsset, quoteAsset, values.swapAmountBase); - if (isUndefined(quoteAmount)) { - await setFieldValue('swapAmountQuote', ''); - return; - } - const quoteAmountAsMoney = createMoney( - convertAmountToFractionalUnit(new BigNumber(quoteAmount), quoteAsset?.balance.decimals), - quoteAsset?.balance.symbol ?? '', - quoteAsset?.balance.decimals - ); - await setFieldValue('swapAmountQuote', formatMoneyWithoutSymbol(quoteAmountAsMoney)); - setFieldError('swapAmountQuote', undefined); - } + const onFetchQuoteAmount = useCallback( + async (baseAsset: SwapAsset, quoteAsset: SwapAsset) => { + const quoteAmount = await fetchQuoteAmount(baseAsset, quoteAsset, values.swapAmountBase); + // Handle race condition; make sure quote amount is 1:1 + if (baseAsset.name === 'BTC' || quoteAsset.name === 'sBTC') { + void setFieldValue('swapAmountQuote', values.swapAmountBase); + return; + } + if (isUndefined(quoteAmount)) { + void setFieldValue('swapAmountQuote', ''); + return; + } + const quoteAmountAsMoney = createMoney( + convertAmountToFractionalUnit(new BigNumber(quoteAmount), quoteAsset?.balance.decimals), + quoteAsset?.balance.symbol ?? '', + quoteAsset?.balance.decimals + ); + void setFieldValue('swapAmountQuote', formatMoneyWithoutSymbol(quoteAmountAsMoney)); + setFieldError('swapAmountQuote', undefined); + }, + [fetchQuoteAmount, setFieldError, setFieldValue, values.swapAmountBase] + ); return { selectableAssets, 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 d257a6ccc1e..3dfb3c9a313 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 @@ -40,7 +40,7 @@ export function SwapAmountField({ amountAsFiat, isDisabled, name }: SwapAmountFi if (isUndefined(values.swapAssetQuote)) { void setFieldValue('swapAmountQuote', ''); } - }, [name, setFieldValue, values]); + }, [setFieldValue, values]); async function onBlur(event: ChangeEvent) { const { swapAssetBase, swapAssetQuote } = values; @@ -49,7 +49,7 @@ export function SwapAmountField({ amountAsFiat, isDisabled, name }: SwapAmountFi const value = event.currentTarget.value; const toAmount = await fetchQuoteAmount(swapAssetBase, swapAssetQuote, value); if (isUndefined(toAmount)) { - void setFieldValue('swapAmountQuote', ''); + await setFieldValue('swapAmountQuote', ''); return; } const toAmountAsMoney = createMoney( @@ -60,7 +60,7 @@ export function SwapAmountField({ amountAsFiat, isDisabled, name }: SwapAmountFi values.swapAssetQuote?.balance.symbol ?? '', values.swapAssetQuote?.balance.decimals ); - void setFieldValue('swapAmountQuote', formatMoneyWithoutSymbol(toAmountAsMoney)); + await setFieldValue('swapAmountQuote', formatMoneyWithoutSymbol(toAmountAsMoney)); setFieldError('swapAmountQuote', undefined); } diff --git a/src/app/pages/swap/components/swap-details/swap-details.tsx b/src/app/pages/swap/components/swap-details/swap-details.tsx index 591d39b5e83..c8d047e28c0 100644 --- a/src/app/pages/swap/components/swap-details/swap-details.tsx +++ b/src/app/pages/swap/components/swap-details/swap-details.tsx @@ -8,7 +8,6 @@ import { formatMoneyPadded, isDefined, isUndefined, - microStxToStx, } from '@leather.io/utils'; import { SwapSubmissionData, useSwapContext } from '@app/pages/swap/swap.context'; @@ -89,10 +88,12 @@ export function SwapDetails() { value={ swapSubmissionData.sponsored ? 'Sponsored' - : `${microStxToStx(swapSubmissionData.fee).toString()} STX` + : `${swapSubmissionData.fee.toString()} ${swapSubmissionData.feeCurrency}` } /> - + {Number(swapSubmissionData?.nonce) >= 0 ? ( + + ) : null} ); } diff --git a/src/app/pages/swap/components/swap-form.tsx b/src/app/pages/swap/components/swap-form.tsx index 150c862776b..4faefe3572e 100644 --- a/src/app/pages/swap/components/swap-form.tsx +++ b/src/app/pages/swap/components/swap-form.tsx @@ -5,16 +5,15 @@ import { HasChildren } from '@app/common/has-children'; import { NonceSetter } from '@app/components/nonce-setter'; import { useSwapForm } from '../hooks/use-swap-form'; -import { useSwapContext } from '../swap.context'; export function SwapForm({ children }: HasChildren) { const { initialValues, validationSchema } = useSwapForm(); - const { onSubmitSwapForReview } = useSwapContext(); return ( {}} validateOnChange={false} validateOnMount validationSchema={validationSchema} diff --git a/src/app/pages/swap/components/swap-review.tsx b/src/app/pages/swap/components/swap-review.tsx index 347045b207d..0f55a275206 100644 --- a/src/app/pages/swap/components/swap-review.tsx +++ b/src/app/pages/swap/components/swap-review.tsx @@ -2,7 +2,7 @@ import { Outlet } from 'react-router-dom'; import { SwapSelectors } from '@tests/selectors/swap.selectors'; -import { Button } from '@leather.io/ui'; +import { Button, Callout } from '@leather.io/ui'; import { LoadingKeys, useLoading } from '@app/common/hooks/use-loading'; import { Card } from '@app/components/layout'; @@ -12,7 +12,7 @@ import { SwapAssetsPair } from './swap-assets-pair/swap-assets-pair'; import { SwapDetails } from './swap-details/swap-details'; export function SwapReview() { - const { onSubmitSwap } = useSwapContext(); + const { isCrossChainSwap, onSubmitSwap } = useSwapContext(); const { isLoading } = useLoading(LoadingKeys.SUBMIT_SWAP_TRANSACTION); return ( @@ -32,6 +32,11 @@ export function SwapReview() { } > + {isCrossChainSwap && ( + + Note that bridging from sBTC back to BTC is currently unavailable. + + )} diff --git a/src/app/pages/swap/hooks/use-sbtc-bridge-assets.tsx b/src/app/pages/swap/hooks/use-bitcoin-bridge-assets.tsx similarity index 52% rename from src/app/pages/swap/hooks/use-sbtc-bridge-assets.tsx rename to src/app/pages/swap/hooks/use-bitcoin-bridge-assets.tsx index 854084ea347..e5a3ed5af33 100644 --- a/src/app/pages/swap/hooks/use-sbtc-bridge-assets.tsx +++ b/src/app/pages/swap/hooks/use-bitcoin-bridge-assets.tsx @@ -1,6 +1,14 @@ +import { useCallback } from 'react'; + +import BtcAvatarIconSrc from '@assets/avatars/btc-avatar-icon.png'; +import SBtcAvatarIconSrc from '@assets/avatars/sbtc-avatar-icon.png'; + import { BTC_DECIMALS } from '@leather.io/constants'; -import { useCryptoCurrencyMarketDataMeanAverage, useSip10Token } from '@leather.io/query'; -import { Avatar, BtcAvatarIcon, PlaceholderIcon } from '@leather.io/ui'; +import { + type SwapAsset, + useCryptoCurrencyMarketDataMeanAverage, + useSip10Token, +} from '@leather.io/query'; import { createMoney, getPrincipalFromContractId } from '@leather.io/utils'; import { castBitcoinMarketDataToSbtcMarketData } from '@app/common/hooks/use-calculate-sip10-fiat-value'; @@ -13,17 +21,21 @@ export function useBtcSwapAsset() { const currentBitcoinAddress = nativeSegwitSigner.address; const { balance } = useBtcCryptoAssetBalanceNativeSegwit(currentBitcoinAddress); const bitcoinMarketData = useCryptoCurrencyMarketDataMeanAverage('BTC'); - return { - balance: balance.availableBalance, - tokenId: 'token-btc', - displayName: 'Bitcoin', - fallback: 'BT', - icon: , - name: 'BTC', - marketData: bitcoinMarketData, - principal: '', - }; + + return useCallback((): SwapAsset => { + return { + balance: balance.availableBalance, + tokenId: 'token-btc', + displayName: 'Bitcoin', + fallback: 'BT', + icon: BtcAvatarIconSrc, + name: 'BTC', + marketData: bitcoinMarketData, + principal: '', + }; + }, [balance.availableBalance, bitcoinMarketData]); } + // Testnet only const tempContractIdForSBtcTesting = 'SNGWPN3XDAQE673MXYXF81016M50NHF5X5PWWM70.sbtc-token::sbtc-token'; @@ -32,20 +44,17 @@ export function useSBtcSwapAsset() { const stxAddress = useCurrentStacksAccountAddress(); const token = useSip10Token(stxAddress, tempContractIdForSBtcTesting); const bitcoinMarketData = useCryptoCurrencyMarketDataMeanAverage('BTC'); - return { - balance: token?.balance.availableBalance ?? createMoney(0, 'sBTC', BTC_DECIMALS), - tokenId: 'token-sbtc', - displayName: 'sBTC', - fallback: 'SB', - icon: ( - - - - - - ), - name: 'sBTC', - marketData: castBitcoinMarketDataToSbtcMarketData(bitcoinMarketData), - principal: getPrincipalFromContractId(token?.info.contractId ?? ''), - }; + + return useCallback((): SwapAsset => { + return { + balance: token?.balance.availableBalance ?? createMoney(0, 'sBTC', BTC_DECIMALS), + tokenId: 'token-sbtc', + displayName: 'sBTC', + fallback: 'SB', + icon: SBtcAvatarIconSrc, + name: 'sBTC', + marketData: castBitcoinMarketDataToSbtcMarketData(bitcoinMarketData), + principal: getPrincipalFromContractId(token?.info.contractId ?? ''), + }; + }, [bitcoinMarketData, token?.balance.availableBalance, token?.info.contractId]); } diff --git a/src/app/pages/swap/hooks/use-bitflow-swap.tsx b/src/app/pages/swap/hooks/use-bitflow-swap.tsx index ca7502c3c29..0a9fbb0a888 100644 --- a/src/app/pages/swap/hooks/use-bitflow-swap.tsx +++ b/src/app/pages/swap/hooks/use-bitflow-swap.tsx @@ -1,8 +1,9 @@ -import { useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import type { RouteQuote } from 'bitflow-sdk'; import type { SwapAsset } from '@leather.io/query'; +import { migratePositiveAssetBalancesToTop } from '@leather.io/utils'; import { logger } from '@shared/logger'; import { bitflow } from '@shared/utils/bitflow-sdk'; @@ -10,59 +11,80 @@ import { bitflow } from '@shared/utils/bitflow-sdk'; import { useCurrentStacksAccountAddress } from '@app/store/accounts/blockchain/stacks/stacks-account.hooks'; import { SwapSubmissionData } from '../swap.context'; +import { useBtcSwapAsset, useSBtcSwapAsset } from './use-bitcoin-bridge-assets'; import { useBitflowSwappableAssets } from './use-bitflow-swappable-assets'; export function useBitflowSwap() { + 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); - async function fetchRouteQuote( - 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'); + // Bridge assets + const createBtcAsset = useBtcSwapAsset(); + const createSBtcAsset = useSBtcSwapAsset(); + + const swappableAssetsBase = useMemo( + () => [createBtcAsset(), ...migratePositiveAssetBalancesToTop(bitflowSwapAssets)], + [bitflowSwapAssets, createBtcAsset] + ); + const swappableAssetsQuote = useMemo( + () => [createSBtcAsset(), ...bitflowSwapAssets], + [bitflowSwapAssets, createSBtcAsset] + ); + + 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; } - return result.bestRoute; - } catch (e) { - logger.error('Error fetching exchange rate from Bitflow', e); - return; - } - } + }, + [isCrossChainSwap] + ); - async function fetchQuoteAmount( - base: SwapAsset, - quote: SwapAsset, - baseAmount: string - ): Promise { - if (base.name === 'BTC' || quote.name === 'sBTC') return baseAmount; - setIsFetchingExchangeRate(true); - const routeQuote = await fetchRouteQuote(base, quote, baseAmount); - setIsFetchingExchangeRate(false); - if (!routeQuote) return; - return String(routeQuote.quote); - } + const fetchQuoteAmount = useCallback( + async (base: SwapAsset, quote: SwapAsset, baseAmount: string): Promise => { + setIsFetchingExchangeRate(true); + const routeQuote = await fetchRouteQuote(base, quote, baseAmount); + setIsFetchingExchangeRate(false); + if (isCrossChainSwap) return baseAmount; // 1:1 swap + if (!routeQuote) return; + return String(routeQuote.quote); + }, + [fetchRouteQuote, isCrossChainSwap] + ); return { fetchRouteQuote, fetchQuoteAmount, + isCrossChainSwap, isFetchingExchangeRate, + onSetIsCrossChainSwap: (value: boolean) => setIsCrossChainSwap(value), onSetIsFetchingExchangeRate: (value: boolean) => setIsFetchingExchangeRate(value), onSetSwapSubmissionData: (value: SwapSubmissionData) => setSwapSubmissionData(value), slippage, bitflowSwapAssets, + swappableAssetsBase, + swappableAssetsQuote, swapSubmissionData, }; } diff --git a/src/app/pages/swap/hooks/use-sbtc-deposit-transaction.tsx b/src/app/pages/swap/hooks/use-sbtc-deposit-transaction.tsx new file mode 100644 index 00000000000..7ffee2cd737 --- /dev/null +++ b/src/app/pages/swap/hooks/use-sbtc-deposit-transaction.tsx @@ -0,0 +1,104 @@ +/* eslint-disable */ +import { useNavigate } from 'react-router-dom'; + +import * as btc from '@scure/btc-signer'; +import { REGTEST, SbtcApiClientTestnet, buildSbtcDepositTx } from 'sbtc'; + +import { useAverageBitcoinFeeRates } from '@leather.io/query'; +import { createMoney } from '@leather.io/utils'; + +import { RouteUrls } from '@shared/route-urls'; + +import { LoadingKeys, useLoading } from '@app/common/hooks/use-loading'; +import { determineUtxosForSpend } 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 { useBitcoinScureLibNetworkConfig } from '@app/store/accounts/blockchain/bitcoin/bitcoin-keychain'; +import { useCurrentAccountNativeSegwitIndexZeroSigner } from '@app/store/accounts/blockchain/bitcoin/native-segwit-account.hooks'; +import { useCurrentStacksAccount } from '@app/store/accounts/blockchain/stacks/stacks-account.hooks'; + +const client = new SbtcApiClientTestnet({ + sbtcApiUrl: 'https://beta.sbtc-emily.com/deposit', + btcApiUrl: 'https://beta.sbtc-mempool.tech/api/proxy', + stxApiUrl: 'https://api.testnet.hiro.so', + sbtcContract: 'SNGWPN3XDAQE673MXYXF81016M50NHF5X5PWWM70', +}); + +export function useSBtcDepositTransaction() { + const toast = useToast(); + const { setIsIdle } = useLoading(LoadingKeys.SUBMIT_SWAP_TRANSACTION); + const stacksAccount = useCurrentStacksAccount(); + const { data: utxos } = useCurrentNativeSegwitUtxos(); + const { data: feeRates } = useAverageBitcoinFeeRates(); + const signer = useCurrentAccountNativeSegwitIndexZeroSigner(); + const networkMode = useBitcoinScureLibNetworkConfig(); + const navigate = useNavigate(); + + return { + async onDepositSBtc() { + if (!stacksAccount) throw new Error('no stacks account'); + if (!utxos) throw new Error('no utxos'); + + try { + const deposit = buildSbtcDepositTx({ + amountSats: 100_000, + network: REGTEST, + stacksAddress: stacksAccount.address, + signersPublicKey: await client.fetchSignersPublicKey(), + maxSignerFee: 80_000, + reclaimLockTime: 6_000, + }); + + const { inputs, outputs } = determineUtxosForSpend({ + feeRate: feeRates?.halfHourFee.toNumber() ?? 0, + recipients: [ + { + address: deposit.address, + amount: createMoney(Number(deposit.transaction.getOutput(0).amount), 'BTC'), + }, + ], + utxos, + }); + console.log('inputs', inputs); + console.log('outputs', outputs); + const p2wpkh = btc.p2wpkh(signer.publicKey, networkMode); + + for (const input of inputs) { + deposit.transaction.addInput({ + txid: input.txid, + index: input.vout, + sequence: 0, + witnessUtxo: { + // script = 0014 + pubKeyHash + script: p2wpkh.script, + amount: BigInt(input.value), + }, + }); + } + + outputs.forEach(output => { + // Add change output + if (!output.address) { + deposit.transaction.addOutputAddress(signer.address, BigInt(output.value), networkMode); + return; + } + }); + + signer.sign(deposit.transaction); + deposit.transaction.finalize(); + + console.log('deposit tx', deposit.transaction); + console.log('tx hex', deposit.transaction.hex); + + const txid = await client.broadcastTx(deposit.transaction); + console.log('broadcasted tx', txid); + await client.notifySbtc(deposit); + toast.success('Transaction submitted!'); + setIsIdle(); + navigate(RouteUrls.Activity); + } catch (error) { + console.error(error); + } + }, + }; +} 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 new file mode 100644 index 00000000000..d19cd02a87a --- /dev/null +++ b/src/app/pages/swap/hooks/use-swap-assets-from-route.ts @@ -0,0 +1,46 @@ +import { useEffect } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; + +import { useFormikContext } from 'formik'; + +import { RouteUrls } from '@shared/route-urls'; + +import { useSwapContext } from '../swap.context'; +import type { SwapFormValues } from './use-swap-form'; + +export function useSwapAssetsFromRoute() { + const { swappableAssetsBase, swappableAssetsQuote } = useSwapContext(); + const { setFieldValue, values, validateForm } = useFormikContext(); + const { base, quote } = useParams(); + const navigate = useNavigate(); + + useEffect(() => { + // Handle if same asset selected; reset assets + // Should not happen bc of list filtering + if (base === quote) { + void setFieldValue('swapAssetQuote', undefined); + void setFieldValue('swapAmountQuote', ''); + return navigate(RouteUrls.Swap.replace(':base', 'STX').replace(':quote', '')); + } + if (base) + void setFieldValue( + 'swapAssetBase', + swappableAssetsBase.find(asset => asset.name === base) + ); + if (quote) + void setFieldValue( + 'swapAssetQuote', + swappableAssetsQuote.find(asset => asset.name === quote) + ); + void validateForm(); + }, [ + base, + navigate, + quote, + setFieldValue, + swappableAssetsBase, + swappableAssetsQuote, + validateForm, + values.swapAssetBase, + ]); +} diff --git a/src/app/pages/swap/hooks/use-swap-form.tsx b/src/app/pages/swap/hooks/use-swap-form.tsx index 597834daa93..d407f68a43b 100644 --- a/src/app/pages/swap/hooks/use-swap-form.tsx +++ b/src/app/pages/swap/hooks/use-swap-form.tsx @@ -1,15 +1,12 @@ import BigNumber from 'bignumber.js'; import * as yup from 'yup'; -import { FeeTypes } from '@leather.io/models'; -import { type SwapAsset, useNextNonce } from '@leather.io/query'; +import { type SwapAsset } from '@leather.io/query'; import { convertAmountToFractionalUnit, createMoney } from '@leather.io/utils'; import { FormErrorMessages } from '@shared/error-messages'; import { StacksTransactionFormValues } from '@shared/models/form.model'; -import { useCurrentStacksAccountAddress } from '@app/store/accounts/blockchain/stacks/stacks-account.hooks'; - import { useSwapContext } from '../swap.context'; export interface SwapFormValues extends StacksTransactionFormValues { @@ -21,14 +18,12 @@ export interface SwapFormValues extends StacksTransactionFormValues { export function useSwapForm() { const { isFetchingExchangeRate } = useSwapContext(); - const stxAddress = useCurrentStacksAccountAddress(); - const { data: nextNonce } = useNextNonce(stxAddress); const initialValues: SwapFormValues = { fee: '0', - feeCurrency: 'STX', - feeType: FeeTypes[FeeTypes.Middle], - nonce: nextNonce?.nonce, + feeCurrency: '', + feeType: '', + nonce: 0, swapAmountBase: '', swapAmountQuote: '', swapAssetBase: undefined, diff --git a/src/app/pages/swap/hooks/use-swap-navigate.ts b/src/app/pages/swap/hooks/use-swap-navigate.ts index 72890163875..b981c90d67f 100644 --- a/src/app/pages/swap/hooks/use-swap-navigate.ts +++ b/src/app/pages/swap/hooks/use-swap-navigate.ts @@ -4,6 +4,7 @@ import { useNavigate, useParams } from 'react-router-dom'; export function useSwapNavigate() { const navigate = useNavigate(); const { base, quote } = useParams(); + return useCallback( (route: string) => { navigate(route.replace(':base', base ?? '').replace(':quote', quote ?? '')); diff --git a/src/app/pages/swap/swap.context.ts b/src/app/pages/swap/swap.context.ts index bdabc0d7493..0b9cbd2c38f 100644 --- a/src/app/pages/swap/swap.context.ts +++ b/src/app/pages/swap/swap.context.ts @@ -8,17 +8,19 @@ export interface SwapSubmissionData extends SwapFormValues { liquidityFee: number; protocol: string; router: SwapAsset[]; - dexPath: string[]; + dexPath?: string[]; slippage: number; - sponsored: boolean; + sponsored?: boolean; timestamp: string; } export interface SwapContext { fetchQuoteAmount(from: SwapAsset, to: SwapAsset, fromAmount: string): Promise; + isCrossChainSwap: boolean; isFetchingExchangeRate: boolean; isSendingMax: boolean; isPreparingSwapReview: boolean; + onSetIsCrossChainSwap(value: boolean): void; onSetIsFetchingExchangeRate(value: boolean): void; onSetIsSendingMax(value: boolean): void; onSubmitSwapForReview(values: SwapFormValues): Promise | void; diff --git a/src/app/pages/swap/swap.tsx b/src/app/pages/swap/swap.tsx index c5a8734aa91..058b0e0530e 100644 --- a/src/app/pages/swap/swap.tsx +++ b/src/app/pages/swap/swap.tsx @@ -1,5 +1,4 @@ -import { useEffect } from 'react'; -import { Outlet, useNavigate, useParams } from 'react-router-dom'; +import { Outlet } from 'react-router-dom'; import { SwapSelectors } from '@tests/selectors/swap.selectors'; import { useFormikContext } from 'formik'; @@ -7,57 +6,20 @@ import { useFormikContext } from 'formik'; import { Button } from '@leather.io/ui'; import { isUndefined } from '@leather.io/utils'; -import { RouteUrls } from '@shared/route-urls'; - 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 { useSwapAssetsFromRoute } from './hooks/use-swap-assets-from-route'; import { SwapFormValues } from './hooks/use-swap-form'; import { useSwapContext } from './swap.context'; export function Swap() { - const { - isFetchingExchangeRate, - isPreparingSwapReview, - swappableAssetsBase, - swappableAssetsQuote, - } = useSwapContext(); - const { dirty, isValid, setFieldValue, values, validateForm } = - useFormikContext(); - const { base, quote } = useParams(); - const navigate = useNavigate(); + const { isFetchingExchangeRate, isPreparingSwapReview, onSubmitSwapForReview } = useSwapContext(); + const { dirty, isValid, values, submitForm } = useFormikContext(); - useEffect(() => { - // Handle if same asset selected; reset assets - // Should not happen bc of list filtering - if (base === quote) { - void setFieldValue('swapAssetQuote', undefined); - void setFieldValue('swapAmountQuote', ''); - return navigate(RouteUrls.Swap.replace(':base', 'STX').replace(':quote', '')); - } - if (base) - void setFieldValue( - 'swapAssetBase', - swappableAssetsBase.find(asset => asset.name === base) - ); - if (quote) - void setFieldValue( - 'swapAssetQuote', - swappableAssetsQuote.find(asset => asset.name === quote) - ); - void validateForm(); - }, [ - base, - navigate, - quote, - setFieldValue, - swappableAssetsBase, - swappableAssetsQuote, - validateForm, - values.swapAssetBase, - ]); + useSwapAssetsFromRoute(); if (isUndefined(values.swapAssetBase)) return ; @@ -69,6 +31,10 @@ export function Swap() { data-testid={SwapSelectors.SwapReviewBtn} aria-busy={isPreparingSwapReview} disabled={!(dirty && isValid) || isFetchingExchangeRate || isPreparingSwapReview} + onClick={async () => { + await submitForm(); // Validate form + await onSubmitSwapForReview(values); + }} type="submit" fullWidth > diff --git a/src/app/pages/test-deposit-sbtc/deposit.tsx b/src/app/pages/test-deposit-sbtc/deposit.tsx new file mode 100644 index 00000000000..de103706923 --- /dev/null +++ b/src/app/pages/test-deposit-sbtc/deposit.tsx @@ -0,0 +1,95 @@ +/* eslint-disable */ +// const emilyReqPayload = { +// bitcoinTxid: txId, +// bitcoinTxOutputIndex: 0, +// reclaimScript: reclaimScriptHex, +// depositScript: depositScriptHexPreHash, +// url: emilyUrl, +// }; + +// const response = await fetch('/api/emilyDeposit', { +// method: 'POST', +// headers: { +// 'Content-Type': 'application/json', +// }, +// body: JSON.stringify(emilyReqPayload), +// }); + +// export async function POST(req: NextRequest) { +// try { +// const body: CreateDepositRequestBody = await req.json(); + +// const paramsBody = { +// bitcoinTxid: body.bitcoinTxid, +// bitcoinTxOutputIndex: body.bitcoinTxOutputIndex, +// reclaimScript: body.reclaimScript, +// depositScript: body.depositScript, +// }; +// // Forward the request to the Rust server +// const response = await fetch(`${body.url}/deposit`, { +// method: 'POST', +// headers: { +// 'Content-Type': 'application/json', +// 'x-api-key': env.EMILY_API_KEY || '', +// }, +// body: JSON.stringify(paramsBody), +// }); + +// // If Rust server responds with an error status +// if (!response.ok) { +// const errorResponse = await response.json(); +// return NextResponse.json({ error: errorResponse }, { status: response.status }); +// } + +// // Return the success response from Rust server +// const responseData = await response.json(); +// return NextResponse.json(responseData, { status: 201 }); +// } catch (error) { +// console.error('Error forwarding request to Rust server:', error); +// return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); +// } +// } + +const emilyUrl = 'https://beta.sbtc-emily.com/deposit'; + +export async function notifySbtc({ + depositScript, + reclaimScript, + vout = 0, + ...tx +}: { + depositScript: string; + reclaimScript: string; + /** Optional, output index (defaults to `0`) */ + vout?: number; +} & ({ txid: string } | { transaction: { id: string } })) { + return (await fetch(emilyUrl, { + headers: { + 'Content-Type': 'application/json', + 'x-api-key': '', + }, + method: 'POST', + body: JSON.stringify({ + bitcoinTxid: 'txid' in tx ? tx.txid : tx.transaction.id, + bitcoinTxOutputIndex: vout, + depositScript, + reclaimScript, + emilyUrl, + }), + }).then(res => res.json())) as { + bitcoinTxid: string; + bitcoinTxOutputIndex: number; + recipient: string; + amount: number; + lastUpdateHeight: number; + lastUpdateBlockHash: string; + status: string; + statusMessage: string; + parameters: { + maxFee: number; + lockTime: number; + }; + reclaimScript: string; + depositScript: string; + }; +} diff --git a/src/app/pages/test-deposit-sbtc/test-deposit-sbtc.tsx b/src/app/pages/test-deposit-sbtc/test-deposit-sbtc.tsx index 487e7607295..cf71644089f 100644 --- a/src/app/pages/test-deposit-sbtc/test-deposit-sbtc.tsx +++ b/src/app/pages/test-deposit-sbtc/test-deposit-sbtc.tsx @@ -13,17 +13,16 @@ import { useBitcoinScureLibNetworkConfig } from '@app/store/accounts/blockchain/ import { useCurrentAccountNativeSegwitIndexZeroSigner } from '@app/store/accounts/blockchain/bitcoin/native-segwit-account.hooks'; import { useCurrentStacksAccount } from '@app/store/accounts/blockchain/stacks/stacks-account.hooks'; -const client = new SbtcApiClientTestnet(); +// import { notifySbtc } from './deposit'; -const sBtcApiUrl = 'https://beta.sbtc-mempool.tech/api/proxy'; -// Temporary function to broadcast the transaction, not sure about correct api? -// It does return a txid, but the notify function returns an error -async function broadcastTx(tx: btc.Transaction): Promise { - return await fetch(`${sBtcApiUrl}/tx`, { - method: 'POST', - body: tx.hex, - }).then(res => res.text()); -} +// const client = new SbtcApiClientTestnet({ +// sbtcApiUrl: 'https://beta.sbtc-emily.com/deposit', +// btcApiUrl: 'https://beta.sbtc-mempool.tech/api/proxy', +// stxApiUrl: 'https://api.testnet.hiro.so', +// sbtcContract: 'SNGWPN3XDAQE673MXYXF81016M50NHF5X5PWWM70', +// }); + +const client = new SbtcApiClientTestnet(); // Demo component to create the swap deposit transaction export function TestDepositSbtc() { @@ -88,9 +87,10 @@ export function TestDepositSbtc() { console.log('deposit tx', deposit.transaction); console.log('tx hex', deposit.transaction.hex); - const txid = await broadcastTx(deposit.transaction); + const txid = await client.broadcastTx(deposit.transaction); console.log('broadcasted tx', txid); - await client.notifySbtc(deposit); + const registeredDeposit = await client.notifySbtc(deposit); + console.log('registered deposit', registeredDeposit); } catch (error) { console.error(error); }