From 08c6808b09f5922f87c6ce86c0f093fa2007694f Mon Sep 17 00:00:00 2001 From: Fara Woolf Date: Mon, 2 Dec 2024 20:20:49 -0500 Subject: [PATCH] feat: btc to sbtc swap --- package.json | 2 +- pnpm-lock.yaml | 57 +++++++++- .../hooks/use-calculate-sip10-fiat-value.ts | 2 +- .../pages/home/components/account-actions.tsx | 11 +- src/app/pages/swap/bitflow-swap-container.tsx | 24 +++-- .../components/swap-asset-item.tsx | 17 +-- .../components/swap-asset-list.tsx | 67 +----------- .../components/use-swap-asset-list.tsx | 102 ++++++++++++++++++ .../select-asset-trigger-button.tsx | 9 +- .../components/swap-amount-field.tsx | 13 ++- .../swap-asset-select-quote.tsx | 2 +- src/app/pages/swap/hooks/use-bitflow-swap.tsx | 9 +- .../hooks/use-bitflow-swappable-assets.tsx | 4 +- .../swap/hooks/use-sbtc-bridge-assets.tsx | 53 +++++++++ src/app/pages/swap/hooks/use-swap-form.tsx | 4 +- src/app/pages/swap/swap.context.ts | 16 ++- src/app/pages/swap/swap.tsx | 13 ++- src/app/pages/swap/swap.utils.ts | 3 +- src/app/store/networks/networks.hooks.ts | 36 ++++--- 19 files changed, 331 insertions(+), 113 deletions(-) create mode 100644 src/app/pages/swap/components/swap-asset-dialog/components/use-swap-asset-list.tsx create mode 100644 src/app/pages/swap/hooks/use-sbtc-bridge-assets.tsx diff --git a/package.json b/package.json index 88e593a7383..87c810a740e 100644 --- a/package.json +++ b/package.json @@ -174,7 +174,7 @@ "@stacks/auth": "6.15.0", "@stacks/blockchain-api-client": "6.3.4", "@stacks/common": "6.13.0", - "@stacks/connect": "7.4.0", + "@stacks/connect": "7.9.0", "@stacks/connect-ui": "6.1.1", "@stacks/encryption": "6.15.0", "@stacks/network": "7.0.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 99bb5ab4ad0..06238a82550 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -132,8 +132,8 @@ importers: specifier: 6.13.0 version: 6.13.0 '@stacks/connect': - specifier: 7.4.0 - version: 7.4.0(encoding@0.1.13) + specifier: 7.9.0 + version: 7.9.0(encoding@0.1.13) '@stacks/connect-ui': specifier: 6.1.1 version: 6.1.1 @@ -4551,6 +4551,9 @@ packages: '@stacks/auth@6.15.0': resolution: {integrity: sha512-foL5tXWGhOxtU8t/sGnQB+mFPYL22Zy+kZvdhce/qwev+whx/DhJJtwdF9xnFk3ZZ9XE0dQGwxiddE/q7GZ7Pw==} + '@stacks/auth@7.0.2': + resolution: {integrity: sha512-1N0ylkK9mz6RqIH3SbuIvoUG4eTgSkun7hHiPirScGCyXvmugjereGdTowSRSrEnl32/qrnquAAaO7a1XF6TMA==} + '@stacks/blockchain-api-client@6.3.4': resolution: {integrity: sha512-4O9qe7m2XKG8PNZ9n5cvhji95IDZ29WO1X2ICgeBPdGc5Y0WGmo0wgIFAROh37pGSkBJsuJjy15ICdP47iy8+w==} @@ -4575,9 +4578,15 @@ packages: '@stacks/connect-ui@6.1.1': resolution: {integrity: sha512-iSo57djIynmqt0jGlFkRFu2nHY/Nk0LmXKdRf/Whw1w/YbZD+CQJweHRh77XQOtAVbXZ1+e/klszxABevcPtPg==} + '@stacks/connect-ui@6.5.0': + resolution: {integrity: sha512-iXSpl2NxrjERBqtGgkZp0tX1uJgdWZXmsNo3I0cJYYTTbieSAE/Al9nTYc1wLTPW5w5oVvZEkQKo90WIrHR8Rw==} + '@stacks/connect@7.4.0': resolution: {integrity: sha512-2jhTHL6Wi7Y/B1AwUuumUUE5F+/X7AvtbJ3BzsNVP7yB+yswmtjC3ZO3jYEohBcuAay5ysfNWUYdjfiXvp0NDQ==} + '@stacks/connect@7.9.0': + resolution: {integrity: sha512-UPv2UQpZwnNPYodL4bf+6Pu3kHY9BcRabAgAbmDajn3RFWvDrnOMmvLqJRGOvo5fEm8vSwxAGY8R7BSOPmlLdg==} + '@stacks/encryption@6.15.0': resolution: {integrity: sha512-506BdBvWhbXY1jxCdUcdbBzcSJctO2nzgzfenQwUuoBABSc1N/MFwQdlR9ZusY+E31zBxQPLfbr36V05/p2cfQ==} @@ -4602,6 +4611,9 @@ packages: '@stacks/profile@6.15.0': resolution: {integrity: sha512-+m11HYHU45+f98FySsVmofeLFWxnhnwZ5jbREoD2f53fmBulsSbJpDUVg3w4aPdj6hg4+o7rkg09gbirIXNWBw==} + '@stacks/profile@7.0.2': + resolution: {integrity: sha512-BJis1ZAP2yzv0IFaJcm4mZFtauizcB1zBVpAeOSX06BDEUgM8h0L8uRvAbfTvSuSjsveNgblucZouZMSEsQMGA==} + '@stacks/rpc-client@1.0.3': resolution: {integrity: sha512-lao7MKCq39VA86v2rJzmgjHKG5bg9LWdLSzvktuEy3lfatVki/hRm6sitkmNhYVcdUVp3YV9gyW6mvu7U9weWw==} @@ -20541,6 +20553,18 @@ snapshots: transitivePeerDependencies: - encoding + '@stacks/auth@7.0.2(encoding@0.1.13)': + dependencies: + '@noble/secp256k1': 1.7.1 + '@stacks/common': 7.0.2 + '@stacks/encryption': 7.0.2 + '@stacks/network': 7.0.2(encoding@0.1.13) + '@stacks/profile': 7.0.2(encoding@0.1.13) + cross-fetch: 3.1.8(encoding@0.1.13) + jsontokens: 4.0.1 + transitivePeerDependencies: + - encoding + '@stacks/blockchain-api-client@6.3.4(encoding@0.1.13)': dependencies: '@stacks/stacks-blockchain-api-types': 7.8.2 @@ -20587,6 +20611,10 @@ snapshots: dependencies: '@stencil/core': 2.22.3 + '@stacks/connect-ui@6.5.0': + dependencies: + '@stencil/core': 2.22.3 + '@stacks/connect@7.4.0(encoding@0.1.13)': dependencies: '@stacks/auth': 6.15.0(encoding@0.1.13) @@ -20598,6 +20626,20 @@ snapshots: transitivePeerDependencies: - encoding + '@stacks/connect@7.9.0(encoding@0.1.13)': + dependencies: + '@stacks/auth': 7.0.2(encoding@0.1.13) + '@stacks/common': 7.0.2 + '@stacks/connect-ui': 6.5.0 + '@stacks/network': 7.0.2(encoding@0.1.13) + '@stacks/network-v6': '@stacks/network@6.17.0(encoding@0.1.13)' + '@stacks/profile': 7.0.2(encoding@0.1.13) + '@stacks/transactions': 7.0.2(encoding@0.1.13) + '@stacks/transactions-v6': '@stacks/transactions@6.17.0(encoding@0.1.13)' + jsontokens: 4.0.1 + transitivePeerDependencies: + - encoding + '@stacks/encryption@6.15.0': dependencies: '@noble/hashes': 1.1.5 @@ -20672,6 +20714,17 @@ snapshots: transitivePeerDependencies: - encoding + '@stacks/profile@7.0.2(encoding@0.1.13)': + dependencies: + '@stacks/common': 7.0.2 + '@stacks/network': 7.0.2(encoding@0.1.13) + '@stacks/transactions': 7.0.2(encoding@0.1.13) + jsontokens: 4.0.1 + schema-inspector: 2.0.2 + zone-file: 2.0.0-beta.3 + transitivePeerDependencies: + - encoding + '@stacks/rpc-client@1.0.3(encoding@0.1.13)': dependencies: '@blockstack/stacks-transactions': 0.7.0(encoding@0.1.13) 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/pages/home/components/account-actions.tsx b/src/app/pages/home/components/account-actions.tsx index 03ed3ae8481..8a83182c27a 100644 --- a/src/app/pages/home/components/account-actions.tsx +++ b/src/app/pages/home/components/account-actions.tsx @@ -74,7 +74,16 @@ export function AccountActions() { ), - [ChainID.Testnet]: null, + // Temporary for sBTC testing + [ChainID.Testnet]: ( + } + label="Swap" + onClick={() => navigate(RouteUrls.Swap.replace(':base', 'STX').replace(':quote', ''))} + /> + ), })} ); diff --git a/src/app/pages/swap/bitflow-swap-container.tsx b/src/app/pages/swap/bitflow-swap-container.tsx index 7ac3e2523ee..36d32ffe481 100644 --- a/src/app/pages/swap/bitflow-swap-container.tsx +++ b/src/app/pages/swap/bitflow-swap-container.tsx @@ -11,13 +11,17 @@ import { } from '@stacks/transactions'; import { defaultSwapFee } from '@leather.io/query'; -import { isDefined, isError, isUndefined } from '@leather.io/utils'; +import { + isDefined, + isError, + isUndefined, + migratePositiveAssetBalancesToTop, +} from '@leather.io/utils'; import { logger } from '@shared/logger'; import { RouteUrls } from '@shared/route-urls'; import { bitflow } from '@shared/utils/bitflow-sdk'; -import { migratePositiveAssetBalancesToTop } from '@app/common/asset-utils'; import { LoadingKeys, useLoading } from '@app/common/hooks/use-loading'; import { Content, Page } from '@app/components/layout'; import { PageHeader } from '@app/features/container/headers/page.header'; @@ -29,6 +33,7 @@ import { estimateLiquidityFee, formatDexPathItem } 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 { useStacksBroadcastSwap } from './hooks/use-stacks-broadcast-swap'; import { SwapFormValues } from './hooks/use-swap-form'; import { useSwapNavigate } from './hooks/use-swap-navigate'; @@ -38,6 +43,7 @@ export const bitflowSwapRoutes = generateSwapRoutes(); function BitflowSwapContainer() { 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); @@ -45,7 +51,11 @@ function BitflowSwapContainer() { const generateUnsignedTx = useGenerateStacksContractCallUnsignedTx(); const signTx = useSignStacksTransaction(); const broadcastStacksSwap = useStacksBroadcastSwap(); - const [isPreparingSwapReview, setIsPreparingSwapReview] = useState(false); + + // Bridge assets + const btcAsset = useBtcSwapAsset(); + const sBtcAsset = useSBtcSwapAsset(); + const { fetchRouteQuote, fetchQuoteAmount, @@ -53,7 +63,7 @@ function BitflowSwapContainer() { onSetIsFetchingExchangeRate, onSetSwapSubmissionData, slippage, - swapAssets, + bitflowSwapAssets, swapSubmissionData, } = useBitflowSwap(); @@ -81,7 +91,7 @@ function BitflowSwapContainer() { protocol: 'Bitflow', dexPath: routeQuote.route.dex_path.map(formatDexPathItem), router: routeQuote.route.token_path - .map(x => swapAssets.find(asset => asset.currency === x)) + .map(x => bitflowSwapAssets.find(asset => asset.currency === x)) .filter(isDefined), slippage, sponsored: false, @@ -185,8 +195,8 @@ function BitflowSwapContainer() { onSetIsSendingMax: value => setIsSendingMax(value), onSubmitSwapForReview, onSubmitSwap, - swappableAssetsBase: migratePositiveAssetBalancesToTop(swapAssets), - swappableAssetsQuote: swapAssets, + swappableAssetsBase: [...[btcAsset], ...migratePositiveAssetBalancesToTop(bitflowSwapAssets)], + swappableAssetsQuote: [...[sBtcAsset], ...bitflowSwapAssets], swapSubmissionData, }; 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 6bb9d1d3cff..bc21d74f2c6 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,6 +1,6 @@ import { SwapSelectors } from '@tests/selectors/swap.selectors'; -import { type SwapAsset, isFtAsset, useGetFungibleTokenMetadataQuery } from '@leather.io/query'; +import { isFtAsset, useGetFungibleTokenMetadataQuery } from '@leather.io/query'; import { Avatar, ItemLayout, @@ -8,8 +8,9 @@ import { defaultFallbackDelay, getAvatarFallback, } from '@leather.io/ui'; -import { formatMoneyWithoutSymbol } from '@leather.io/utils'; +import { formatMoneyWithoutSymbol, isString } from '@leather.io/utils'; +import type { SwapAsset } from '@app/pages/swap/swap.context'; import { convertSwapAssetBalanceToFiat } from '@app/pages/swap/swap.utils'; interface SwapAssetItemProps { @@ -28,10 +29,14 @@ export function SwapAssetItem({ asset, onClick }: SwapAssetItemProps) { - - {fallback} - + isString(asset.icon) ? ( + + + {fallback} + + ) : ( + asset.icon + ) } titleLeft={displayName} captionLeft={asset.name} 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 6e613c7d929..81569de725a 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,74 +1,17 @@ -import { useNavigate, useParams } from 'react-router-dom'; - import { SwapSelectors } from '@tests/selectors/swap.selectors'; -import BigNumber from 'bignumber.js'; -import { useFormikContext } from 'formik'; import { Stack } from 'leather-styles/jsx'; -import type { SwapAsset } from '@leather.io/query'; -import { - convertAmountToFractionalUnit, - createMoney, - formatMoneyWithoutSymbol, - isUndefined, -} from '@leather.io/utils'; - -import { RouteUrls } from '@shared/route-urls'; +import { type SwapAsset } from '@app/pages/swap/swap.context'; -import { useSwapContext } from '@app/pages/swap/swap.context'; - -import { SwapFormValues } from '../../../hooks/use-swap-form'; import { SwapAssetItem } from './swap-asset-item'; +import { useSwapAssetList } from './use-swap-asset-list'; -interface SwapAssetList { +export interface SwapAssetListProps { assets: SwapAsset[]; type: string; } -export function SwapAssetList({ assets, type }: SwapAssetList) { - const { fetchQuoteAmount } = useSwapContext(); - const { setFieldError, setFieldValue, values } = useFormikContext(); - const navigate = useNavigate(); - const { base, quote } = useParams(); - const isBaseList = type === 'base'; - const isQuoteList = type === 'quote'; - - const selectableAssets = assets.filter( - asset => - (isBaseList && asset.name !== values.swapAssetQuote?.name) || - (isQuoteList && asset.name !== values.swapAssetBase?.name) - ); - - async function onSelectAsset(asset: SwapAsset) { - let baseAsset: SwapAsset | undefined; - let quoteAsset: SwapAsset | undefined; - if (isBaseList) { - baseAsset = asset; - quoteAsset = values.swapAssetQuote; - await setFieldValue('swapAssetBase', asset); - navigate(RouteUrls.Swap.replace(':base', baseAsset.name).replace(':quote', quote ?? '')); - } else if (isQuoteList) { - baseAsset = values.swapAssetBase; - quoteAsset = asset; - await setFieldValue('swapAssetQuote', asset); - setFieldError('swapAssetQuote', undefined); - navigate(RouteUrls.Swap.replace(':base', base ?? '').replace(':quote', quoteAsset.name)); - } - - if (baseAsset && quoteAsset && values.swapAmountBase) { - 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); - } - } +export function SwapAssetList({ assets, type }: SwapAssetListProps) { + const { selectableAssets, onSelectAsset } = useSwapAssetList({ assets, type }); return ( 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 new file mode 100644 index 00000000000..5b85a3a84dd --- /dev/null +++ b/src/app/pages/swap/components/swap-asset-dialog/components/use-swap-asset-list.tsx @@ -0,0 +1,102 @@ +import { useEffect, useState } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; + +import BigNumber from 'bignumber.js'; +import { useFormikContext } from 'formik'; + +import { + convertAmountToFractionalUnit, + createMoney, + formatMoneyWithoutSymbol, + isUndefined, +} from '@leather.io/utils'; + +import { RouteUrls } from '@shared/route-urls'; + +import type { SwapFormValues } from '@app/pages/swap/hooks/use-swap-form'; +import { type SwapAsset, 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 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]); + + 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 ?? '')); + } + + 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)); + } + + 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); + } + + return { + selectableAssets, + async onSelectAsset(asset: SwapAsset) { + let baseAsset: SwapAsset | undefined; + let quoteAsset: SwapAsset | undefined; + if (isBaseList) { + baseAsset = asset; + quoteAsset = values.swapAssetQuote; + onSelectBaseAsset(baseAsset, quoteAsset); + } + if (isQuoteList) { + baseAsset = values.swapAssetBase; + quoteAsset = asset; + onSelectQuoteAsset(quoteAsset, baseAsset); + } + if (baseAsset && quoteAsset && values.swapAmountBase) { + await onFetchQuoteAmount(baseAsset, quoteAsset); + } + }, + }; +} 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 43702abb1da..2be96d1decd 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,3 +1,5 @@ +import type React from 'react'; + import { SwapSelectors } from '@tests/selectors/swap.selectors'; import { useField } from 'formik'; import { HStack, styled } from 'leather-styles/jsx'; @@ -9,9 +11,10 @@ import { defaultFallbackDelay, getAvatarFallback, } from '@leather.io/ui'; +import { isString } from '@leather.io/utils'; interface SelectAssetTriggerButtonProps { - icon?: string; + icon?: React.ReactNode; name: string; onSelectAsset(): void; symbol: string; @@ -34,11 +37,13 @@ export function SelectAssetTriggerButton({ {...field} > - {icon && ( + {icon && isString(icon) ? ( {fallback} + ) : ( + icon )} {symbol} 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 7320e1a8f23..d257a6ccc1e 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,4 +1,4 @@ -import { ChangeEvent } from 'react'; +import { ChangeEvent, useEffect } from 'react'; import { SwapSelectors } from '@tests/selectors/swap.selectors'; import BigNumber from 'bignumber.js'; @@ -35,6 +35,13 @@ export function SwapAmountField({ amountAsFiat, isDisabled, name }: SwapAmountFi const [field] = useField(name); const showError = useShowFieldError(name) && name === 'swapAmountBase' && values.swapAssetQuote; + useEffect(() => { + // Clear quote amount if quote asset is reset + if (isUndefined(values.swapAssetQuote)) { + void setFieldValue('swapAmountQuote', ''); + } + }, [name, setFieldValue, values]); + async function onBlur(event: ChangeEvent) { const { swapAssetBase, swapAssetQuote } = values; if (isUndefined(swapAssetBase) || isUndefined(swapAssetQuote)) return; @@ -42,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)) { - await setFieldValue('swapAmountQuote', ''); + void setFieldValue('swapAmountQuote', ''); return; } const toAmountAsMoney = createMoney( @@ -53,7 +60,7 @@ export function SwapAmountField({ amountAsFiat, isDisabled, name }: SwapAmountFi values.swapAssetQuote?.balance.symbol ?? '', values.swapAssetQuote?.balance.decimals ); - await setFieldValue('swapAmountQuote', formatMoneyWithoutSymbol(toAmountAsMoney)); + void setFieldValue('swapAmountQuote', formatMoneyWithoutSymbol(toAmountAsMoney)); setFieldError('swapAmountQuote', undefined); } 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 0793cae9287..f5a8b276836 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 @@ -37,7 +37,7 @@ export function SwapAssetSelectQuote() { icon={assetField.value?.icon} name="swapAmountQuote" onSelectAsset={() => navigate(RouteUrls.SwapAssetSelectQuote)} - showToggle + showToggle={assetField.value?.name !== 'sBTC'} swapAmountInput={ isFetchingExchangeRate ? ( diff --git a/src/app/pages/swap/hooks/use-bitflow-swap.tsx b/src/app/pages/swap/hooks/use-bitflow-swap.tsx index 7a0a7bfeb04..44fecf303d9 100644 --- a/src/app/pages/swap/hooks/use-bitflow-swap.tsx +++ b/src/app/pages/swap/hooks/use-bitflow-swap.tsx @@ -2,14 +2,12 @@ import { useState } from 'react'; import type { RouteQuote } from 'bitflow-sdk'; -import { type SwapAsset } from '@leather.io/query'; - import { logger } from '@shared/logger'; import { bitflow } from '@shared/utils/bitflow-sdk'; import { useCurrentStacksAccountAddress } from '@app/store/accounts/blockchain/stacks/stacks-account.hooks'; -import { SwapSubmissionData } from '../swap.context'; +import { type SwapAsset, SwapSubmissionData } from '../swap.context'; import { useBitflowSwappableAssets } from './use-bitflow-swappable-assets'; export function useBitflowSwap() { @@ -17,7 +15,7 @@ export function useBitflowSwap() { const [slippage, _setSlippage] = useState(0.04); const [isFetchingExchangeRate, setIsFetchingExchangeRate] = useState(false); const address = useCurrentStacksAccountAddress(); - const { data: swapAssets = [] } = useBitflowSwappableAssets(address); + const { data: bitflowSwapAssets = [] } = useBitflowSwappableAssets(address); async function fetchRouteQuote( base: SwapAsset, @@ -47,6 +45,7 @@ export function useBitflowSwap() { 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); @@ -61,7 +60,7 @@ export function useBitflowSwap() { onSetIsFetchingExchangeRate: (value: boolean) => setIsFetchingExchangeRate(value), onSetSwapSubmissionData: (value: SwapSubmissionData) => setSwapSubmissionData(value), slippage, - swapAssets, + bitflowSwapAssets, 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 0c781dd1bf6..345c0988e77 100644 --- a/src/app/pages/swap/hooks/use-bitflow-swappable-assets.tsx +++ b/src/app/pages/swap/hooks/use-bitflow-swappable-assets.tsx @@ -7,7 +7,6 @@ import type { Token } from 'bitflow-sdk'; import { createMarketData, createMarketPair } from '@leather.io/models'; import { - type SwapAsset, useAlexSdkLatestPricesQuery, useStxAvailableUnlockedBalance, useTransferableSip10Tokens, @@ -22,9 +21,10 @@ 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 '../swap.context'; import { sortSwapAssets } from '../swap.utils'; -const BITFLOW_STX_CURRENCY: Currency = 'token-stx' as Currency; +export const BITFLOW_STX_CURRENCY: Currency = 'token-stx' as Currency; const USD_DECIMAL_PRECISION = 2; function useCreateSwapAsset(address: string) { diff --git a/src/app/pages/swap/hooks/use-sbtc-bridge-assets.tsx b/src/app/pages/swap/hooks/use-sbtc-bridge-assets.tsx new file mode 100644 index 00000000000..70751a43d9d --- /dev/null +++ b/src/app/pages/swap/hooks/use-sbtc-bridge-assets.tsx @@ -0,0 +1,53 @@ +import type { Currency } from 'alex-sdk'; + +import { BTC_DECIMALS } from '@leather.io/constants'; +import { useCryptoCurrencyMarketDataMeanAverage, useSip10Token } from '@leather.io/query'; +import { Avatar, BtcAvatarIcon, PlaceholderIcon } from '@leather.io/ui'; +import { createMoney, 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 { useCurrentAccountNativeSegwitIndexZeroSigner } from '@app/store/accounts/blockchain/bitcoin/native-segwit-account.hooks'; +import { useCurrentStacksAccountAddress } from '@app/store/accounts/blockchain/stacks/stacks-account.hooks'; + +export function useBtcSwapAsset() { + const nativeSegwitSigner = useCurrentAccountNativeSegwitIndexZeroSigner(); + const currentBitcoinAddress = nativeSegwitSigner.address; + const { balance } = useBtcCryptoAssetBalanceNativeSegwit(currentBitcoinAddress); + const bitcoinMarketData = useCryptoCurrencyMarketDataMeanAverage('BTC'); + return { + balance: balance.availableBalance, + currency: 'token-btc' as Currency, + displayName: 'Bitcoin', + fallback: 'BT', + icon: , + name: 'BTC', + marketData: bitcoinMarketData, + principal: '', + }; +} +// Testnet only +const tempContractIdForSBtcTesting = + 'SNGWPN3XDAQE673MXYXF81016M50NHF5X5PWWM70.sbtc-token::sbtc-token'; + +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), + currency: 'token-sbtc', + displayName: 'sBTC', + fallback: 'SB', + icon: ( + + + + + + ), + name: 'sBTC', + marketData: castBitcoinMarketDataToSbtcMarketData(bitcoinMarketData), + principal: getPrincipalFromContractId(token?.info.contractId ?? ''), + }; +} diff --git a/src/app/pages/swap/hooks/use-swap-form.tsx b/src/app/pages/swap/hooks/use-swap-form.tsx index 597834daa93..58bfb57d171 100644 --- a/src/app/pages/swap/hooks/use-swap-form.tsx +++ b/src/app/pages/swap/hooks/use-swap-form.tsx @@ -2,7 +2,7 @@ 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 { useNextNonce } from '@leather.io/query'; import { convertAmountToFractionalUnit, createMoney } from '@leather.io/utils'; import { FormErrorMessages } from '@shared/error-messages'; @@ -10,7 +10,7 @@ import { StacksTransactionFormValues } from '@shared/models/form.model'; import { useCurrentStacksAccountAddress } from '@app/store/accounts/blockchain/stacks/stacks-account.hooks'; -import { useSwapContext } from '../swap.context'; +import { type SwapAsset, useSwapContext } from '../swap.context'; export interface SwapFormValues extends StacksTransactionFormValues { swapAmountBase: string; diff --git a/src/app/pages/swap/swap.context.ts b/src/app/pages/swap/swap.context.ts index bdabc0d7493..f5b2e44555f 100644 --- a/src/app/pages/swap/swap.context.ts +++ b/src/app/pages/swap/swap.context.ts @@ -1,9 +1,21 @@ -import { createContext, useContext } from 'react'; +import React, { createContext, useContext } from 'react'; -import type { SwapAsset } from '@leather.io/query'; +import type { Currency, MarketData, Money } from '@leather.io/models'; import { SwapFormValues } from './hooks/use-swap-form'; +export interface SwapAsset { + address?: string; + balance: Money; + currency: Currency | string; + displayName?: string; + fallback: string; + icon: React.ReactNode; + name: string; + marketData: MarketData | null; + principal: string; +} + export interface SwapSubmissionData extends SwapFormValues { liquidityFee: number; protocol: string; diff --git a/src/app/pages/swap/swap.tsx b/src/app/pages/swap/swap.tsx index 7320294e540..c5a8734aa91 100644 --- a/src/app/pages/swap/swap.tsx +++ b/src/app/pages/swap/swap.tsx @@ -1,5 +1,5 @@ import { useEffect } from 'react'; -import { Outlet, useParams } from 'react-router-dom'; +import { Outlet, useNavigate, useParams } from 'react-router-dom'; import { SwapSelectors } from '@tests/selectors/swap.selectors'; import { useFormikContext } from 'formik'; @@ -7,6 +7,8 @@ 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'; @@ -25,8 +27,16 @@ export function Swap() { const { dirty, isValid, 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', @@ -40,6 +50,7 @@ export function Swap() { void validateForm(); }, [ base, + navigate, quote, setFieldValue, swappableAssetsBase, diff --git a/src/app/pages/swap/swap.utils.ts b/src/app/pages/swap/swap.utils.ts index 7cff499e1aa..ec349e85516 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 './swap.context'; + export function convertSwapAssetBalanceToFiat(asset: SwapAsset) { if ( !asset.marketData || diff --git a/src/app/store/networks/networks.hooks.ts b/src/app/store/networks/networks.hooks.ts index b049ef3c0cb..ddc6acbf823 100644 --- a/src/app/store/networks/networks.hooks.ts +++ b/src/app/store/networks/networks.hooks.ts @@ -1,15 +1,14 @@ import { useMemo } from 'react'; import { useSelector } from 'react-redux'; -import { StacksNetwork } from '@stacks/network'; -import { ChainID, TransactionVersion } from '@stacks/transactions'; +import { STACKS_MAINNET, STACKS_TESTNET, StacksNetwork } from '@stacks/network'; +import { ChainID } from '@stacks/transactions'; import { type BitcoinNetworkModes, HIRO_API_BASE_URL_NAKAMOTO_TESTNET, bitcoinNetworkToNetworkMode, } from '@leather.io/models'; -import { whenStacksChainId } from '@leather.io/stacks'; import { useAppDispatch } from '@app/store'; @@ -35,19 +34,28 @@ export function useCurrentStacksNetworkState(): StacksNetwork { return useMemo(() => { if (!currentNetwork) throw new Error('No current network'); - // todo: these params could be added to the constructor in stacks.js - const stacksNetwork = new StacksNetwork({ url: currentNetwork.chain.stacks.url }); - stacksNetwork.version = whenStacksChainId(currentNetwork.chain.stacks.chainId)({ - [ChainID.Mainnet]: TransactionVersion.Mainnet, - [ChainID.Testnet]: TransactionVersion.Testnet, - }); + switch (currentNetwork.chain.stacks.chainId) { + case ChainID.Mainnet: + return STACKS_MAINNET; + case ChainID.Testnet: + return STACKS_TESTNET; + default: + return STACKS_MAINNET; + } - // Use actual chainId on network object, since it's used for signing - stacksNetwork.chainId = - currentNetwork.chain.stacks.subnetChainId ?? currentNetwork.chain.stacks.chainId; + // // todo: these params could be added to the constructor in stacks.js + // const stacksNetwork = new StacksNetwork({ url: currentNetwork.chain.stacks.url }); + // stacksNetwork.version = whenStacksChainId(currentNetwork.chain.stacks.chainId)({ + // [ChainID.Mainnet]: TransactionVersion.Mainnet, + // [ChainID.Testnet]: TransactionVersion.Testnet, + // }); - stacksNetwork.bnsLookupUrl = currentNetwork.chain.stacks.url || ''; - return stacksNetwork; + // // Use actual chainId on network object, since it's used for signing + // stacksNetwork.chainId = + // currentNetwork.chain.stacks.subnetChainId ?? currentNetwork.chain.stacks.chainId; + + // stacksNetwork.bnsLookupUrl = currentNetwork.chain.stacks.url || ''; + // return stacksNetwork; }, [currentNetwork]); }