From e66dc2122a1c72187386849ae6aa8b2f9293a6e7 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 | 5 +- pnpm-lock.yaml | 99 ++++++++++++++--- src/app/common/asset-utils.ts | 6 - .../hooks/use-calculate-sip10-fiat-value.ts | 2 +- .../pages/home/components/account-actions.tsx | 11 +- .../pages/receive/components/receive-item.tsx | 2 +- .../receive/components/receive-tokens.tsx | 13 ++- src/app/pages/swap/bitflow-swap-container.tsx | 24 ++-- .../components/swap-asset-item.tsx | 14 ++- .../components/swap-asset-list.tsx | 67 +----------- .../components/use-swap-asset-list.tsx | 103 ++++++++++++++++++ .../select-asset-trigger-button.tsx | 9 +- .../components/swap-amount-field.tsx | 13 ++- .../swap-asset-select-quote.tsx | 2 +- .../swap-asset-item.layout.tsx | 13 ++- src/app/pages/swap/hooks/use-bitflow-swap.tsx | 11 +- .../hooks/use-bitflow-swappable-assets.tsx | 19 ++-- .../swap/hooks/use-sbtc-bridge-assets.tsx | 51 +++++++++ src/app/pages/swap/swap.tsx | 13 ++- .../btc-balance-native-segwit.hooks.ts | 1 - .../ordinals/brc20/brc20-tokens.hooks.ts | 11 +- 21 files changed, 351 insertions(+), 138 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 727049f3149..8274494fbad 100644 --- a/package.json +++ b/package.json @@ -150,11 +150,11 @@ "@leather.io/constants": "0.13.5", "@leather.io/crypto": "1.6.14", "@leather.io/models": "0.22.0", - "@leather.io/query": "2.23.3", + "@leather.io/query": "2.26.1", "@leather.io/stacks": "1.4.0", "@leather.io/tokens": "0.12.1", "@leather.io/ui": "1.39.0", - "@leather.io/utils": "0.20.0", + "@leather.io/utils": "0.21.1", "@ledgerhq/hw-transport-webusb": "6.27.19", "@noble/hashes": "1.5.0", "@noble/secp256k1": "2.1.0", @@ -193,7 +193,6 @@ "@types/lodash.uniqby": "4.7.7", "@typescript-eslint/eslint-plugin": "7.5.0", "@zondax/ledger-stacks": "1.0.4", - "alex-sdk": "2.1.4", "are-passive-events-supported": "1.1.1", "argon2-browser": "1.18.0", "assert": "2.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0e078677c61..34cad21d68a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -60,8 +60,8 @@ importers: specifier: 0.22.0 version: 0.22.0 '@leather.io/query': - specifier: 2.23.3 - version: 2.23.3(encoding@0.1.13)(react@18.3.1) + specifier: 2.26.1 + version: 2.26.1(encoding@0.1.13)(react@18.3.1) '@leather.io/stacks': specifier: 1.4.0 version: 1.4.0(encoding@0.1.13) @@ -72,8 +72,8 @@ importers: specifier: 1.39.0 version: 1.39.0(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@emotion/is-prop-valid@1.3.1)(@types/react-dom@18.3.0)(@types/react@18.3.10)(encoding@0.1.13)(expo-modules-autolinking@1.11.1) '@leather.io/utils': - specifier: 0.20.0 - version: 0.20.0 + specifier: 0.21.1 + version: 0.21.1 '@ledgerhq/hw-transport-webusb': specifier: 6.27.19 version: 6.27.19 @@ -188,9 +188,6 @@ importers: '@zondax/ledger-stacks': specifier: 1.0.4 version: 1.0.4(encoding@0.1.13) - alex-sdk: - specifier: 2.1.4 - version: 2.1.4(@stacks/network@6.13.0(encoding@0.1.13))(@stacks/transactions@6.17.0(encoding@0.1.13)) are-passive-events-supported: specifier: 1.1.1 version: 1.1.1 @@ -2680,32 +2677,47 @@ packages: '@leather.io/bitcoin@0.17.0': resolution: {integrity: sha512-Nc4Bl2HWmxWvgIXbpa6Gs8EpqR1UJHDzXPu+R+2TIyOsjxFoGXoIQwLohxySnQa4i0UodrCXBrqDOqOTHtwbMQ==} + '@leather.io/bitcoin@0.19.0': + resolution: {integrity: sha512-fqE1peFL3kgOnHcQgo8s/ClmLTIWRZrOBoFMWjM+8x8vcZQWvBXC5oTWDSGWsrxabzQhh0jKBcj8HXMOhcf3ZA==} + '@leather.io/constants@0.13.5': resolution: {integrity: sha512-FOh/F/g8WepB8HfoTXsMB/BYcm/F6INPEpyEZc3ljzaN0mLwVLO1kwgMTFU9Pq7tQlITvyWiyGHcB7OYovLoUQ==} + '@leather.io/constants@0.14.0': + resolution: {integrity: sha512-kSrrbxmt7uxX9MZKrzHu71XL2vF1oig8PoMlBC6Vh/xpU5TGTySmpOLhfEES2FUqh1mRa/JP/ZgOYX6dQvoboQ==} + '@leather.io/crypto@1.6.14': resolution: {integrity: sha512-D9Z0EgvXhdDSJdaQX+KCm2czqNCRDe3Kj7YJ3Hn1GiHet+wOy68sBJzG6yzESeC0Z7f1UBuDhmN9Edq5u1Zz2g==} + '@leather.io/crypto@1.6.17': + resolution: {integrity: sha512-CctP7QMqL6DvniYTCetG92H1vYzJeJCdbyuDhwe29v2oqmEnjwRJsX5X6ue0o2sEihtjJtvXJG1HPwd+D17XGw==} + '@leather.io/eslint-config@0.7.0': resolution: {integrity: sha512-4K7olfSC+mJnG90TSaLIlytp14yDprGXwe1+oP9TLQbuPFpJai3/+g5Bp/FeUC4NZ23UVbAlGXFCav2amBb77w==} '@leather.io/models@0.22.0': resolution: {integrity: sha512-MmFmWdKN3T+L9euo+rq8JCr5Ku0mNulzVa0mYqXclB9vLa4NyhUsGHA3lWz8e05cMW9CsrPNg+eWpVg6AKTkeQ==} + '@leather.io/models@0.24.0': + resolution: {integrity: sha512-WB4CI/RWag10unxQSqdN3wIlmLaww5we4KmIyVroX3QfQyRMSN98srs8b507RITc+deD2xrIGbS0M/HOPG85tw==} + '@leather.io/panda-preset@0.5.3': resolution: {integrity: sha512-3mhe83gNSOjHOh5q5wBuUZiyx1611FQDJE0iEEa1R7CC7+lt4HPf+nMn1oVgPBmil/qTgglsIv807SioVA/p9g==} '@leather.io/prettier-config@0.6.0': resolution: {integrity: sha512-QBKtLanfxFxXBlR58U/j8a6lBI0xzJzqqi36fXpGVp+9mJoEf6Ro6xrtFrixjW6seY6EOva4OApVnnPBsvOC/w==} - '@leather.io/query@2.23.3': - resolution: {integrity: sha512-pKjZOlXScOHaNbJbCCSL6hTg6h6nmtVYGrn0FKqGAPbbeHjsGtHXInhdQeudqlVr5vZ4/5Yztkza8+r2qIJ+fQ==} + '@leather.io/query@2.26.1': + resolution: {integrity: sha512-/F0ddwk552/XAoP0dN+Yk//7m1qjvkHMZ+v6N4KFkwbNSNDh+1p5ehZ3GNf6AX1zT6pFc+6OhD4LPR7dmKdlFw==} peerDependencies: react: '*' '@leather.io/rpc@2.1.20': resolution: {integrity: sha512-BE56W5yzdOPdVWHo2G+ZrXsEt3Jki47/noMxy+9On0sNW5B90M+pULm65szp5/vzqJuY7xIrQJ1AOyG22cd82g==} + '@leather.io/rpc@2.1.22': + resolution: {integrity: sha512-1LHCs251qDSOw9vWbkkhaJ7YiYbsZ8KtwDajpJFLJ9mcqBGqS8TJhqUBlWGZqe2LQEak2s7usMU6M19QR8NyqQ==} + '@leather.io/stacks@1.4.0': resolution: {integrity: sha512-vF3eQljr+dsfg8DhlEFgQKvr9NHn9CKwt8XT51kWnULTtZH6syrABiarHGwhtE/AZz9weg5n5q/+m8b4lN6bGw==} @@ -2718,6 +2730,9 @@ packages: '@leather.io/utils@0.20.0': resolution: {integrity: sha512-Ot0oOYMku4oy3218W3Tt0ip0xjMyegOxFONqOyt/WSZe9xzTiXXUq0u3D8jwa851ZEOSCB7TgOO5RMzWK0lkLg==} + '@leather.io/utils@0.21.1': + resolution: {integrity: sha512-l2epiM2cYqImWO0okuWMAF2Q13g8gNaz6PYlzMejccfgGvPwj/XDsRqCHPzNhKmQ7cTsirJQ8I5uJOpUPg8BIA==} + '@ledgerhq/devices@8.4.4': resolution: {integrity: sha512-sz/ryhe/R687RHtevIE9RlKaV8kkKykUV4k29e7GAVwzHX1gqG+O75cu1NCJUHLbp3eABV5FdvZejqRUlLis9A==} @@ -17367,10 +17382,39 @@ snapshots: transitivePeerDependencies: - encoding + '@leather.io/bitcoin@0.19.0(encoding@0.1.13)': + dependencies: + '@bitcoinerlab/secp256k1': 1.0.2 + '@leather.io/constants': 0.14.0 + '@leather.io/crypto': 1.6.17 + '@leather.io/models': 0.24.0 + '@leather.io/utils': 0.21.1 + '@noble/hashes': 1.5.0 + '@noble/secp256k1': 2.1.0 + '@scure/base': 1.1.9 + '@scure/bip32': 1.5.0 + '@scure/bip39': 1.4.0 + '@scure/btc-signer': 1.4.0 + '@stacks/common': 6.13.0 + '@stacks/transactions': 6.17.0(encoding@0.1.13) + bignumber.js: 9.1.2 + bip32: 4.0.0 + bitcoin-address-validation: 2.2.3 + bitcoinjs-lib: 6.1.5 + ecpair: 2.1.0 + just-memoize: 2.2.0 + varuint-bitcoin: 1.1.2 + transitivePeerDependencies: + - encoding + '@leather.io/constants@0.13.5': dependencies: '@leather.io/models': 0.22.0 + '@leather.io/constants@0.14.0': + dependencies: + '@leather.io/models': 0.24.0 + '@leather.io/crypto@1.6.14': dependencies: '@leather.io/utils': 0.20.0 @@ -17378,6 +17422,13 @@ snapshots: '@scure/bip39': 1.4.0 just-memoize: 2.2.0 + '@leather.io/crypto@1.6.17': + dependencies: + '@leather.io/utils': 0.21.1 + '@scure/bip32': 1.5.0 + '@scure/bip39': 1.4.0 + just-memoize: 2.2.0 + '@leather.io/eslint-config@0.7.0(typescript@5.4.5)': dependencies: '@typescript-eslint/eslint-plugin': 6.9.0(@typescript-eslint/parser@6.9.0(eslint@8.56.0)(typescript@5.4.5))(eslint@8.56.0)(typescript@5.4.5) @@ -17394,6 +17445,12 @@ snapshots: bignumber.js: 9.1.2 zod: 3.23.8 + '@leather.io/models@0.24.0': + dependencies: + '@stacks/stacks-blockchain-api-types': 7.8.2 + bignumber.js: 9.1.2 + zod: 3.23.8 + '@leather.io/panda-preset@0.5.3(jsdom@22.1.0)(typescript@5.4.5)': dependencies: '@pandacss/dev': 0.46.1(jsdom@22.1.0)(typescript@5.4.5) @@ -17409,15 +17466,15 @@ snapshots: - '@vue/compiler-sfc' - supports-color - '@leather.io/query@2.23.3(encoding@0.1.13)(react@18.3.1)': + '@leather.io/query@2.26.1(encoding@0.1.13)(react@18.3.1)': dependencies: '@fungible-systems/zone-file': 2.0.0 '@hirosystems/token-metadata-api-client': 1.2.0(encoding@0.1.13) - '@leather.io/bitcoin': 0.17.0(encoding@0.1.13) - '@leather.io/constants': 0.13.5 - '@leather.io/models': 0.22.0 - '@leather.io/rpc': 2.1.20 - '@leather.io/utils': 0.20.0 + '@leather.io/bitcoin': 0.19.0(encoding@0.1.13) + '@leather.io/constants': 0.14.0 + '@leather.io/models': 0.24.0 + '@leather.io/rpc': 2.1.22 + '@leather.io/utils': 0.21.1 '@noble/hashes': 1.5.0 '@scure/base': 1.1.9 '@scure/bip32': 1.5.0 @@ -17447,6 +17504,11 @@ snapshots: '@leather.io/models': 0.22.0 zod: 3.23.8 + '@leather.io/rpc@2.1.22': + dependencies: + '@leather.io/models': 0.24.0 + zod: 3.23.8 + '@leather.io/stacks@1.4.0(encoding@0.1.13)': dependencies: '@leather.io/constants': 0.13.5 @@ -17531,6 +17593,13 @@ snapshots: '@leather.io/rpc': 2.1.20 bignumber.js: 9.1.2 + '@leather.io/utils@0.21.1': + dependencies: + '@leather.io/constants': 0.14.0 + '@leather.io/models': 0.24.0 + '@leather.io/rpc': 2.1.22 + bignumber.js: 9.1.2 + '@ledgerhq/devices@8.4.4': dependencies: '@ledgerhq/errors': 6.19.1 diff --git a/src/app/common/asset-utils.ts b/src/app/common/asset-utils.ts index 5c9d1411390..211c8e390e0 100644 --- a/src/app/common/asset-utils.ts +++ b/src/app/common/asset-utils.ts @@ -5,12 +5,6 @@ import { isMoneyGreaterThanZero, } from '@leather.io/utils'; -export function migratePositiveAssetBalancesToTop(assets: T) { - const assetsWithPositiveBalance = assets.filter(asset => asset.balance.amount.isGreaterThan(0)); - const assetsWithZeroBalance = assets.filter(asset => asset.balance.amount.isEqualTo(0)); - return [...assetsWithPositiveBalance, ...assetsWithZeroBalance] as T; -} - export function convertAssetBalanceToFiat< T extends { balance: Money | null; marketData: MarketData | null }, >(asset: T) { 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/receive/components/receive-item.tsx b/src/app/pages/receive/components/receive-item.tsx index a1696dedfab..74d6ea51b76 100644 --- a/src/app/pages/receive/components/receive-item.tsx +++ b/src/app/pages/receive/components/receive-item.tsx @@ -4,7 +4,7 @@ import { truncateMiddle } from '@leather.io/utils'; interface ReceiveItemProps { address?: string; dataTestId?: string; - icon: React.JSX.Element; + icon: React.ReactNode; onCopyAddress(): void; onClickQrCode?(): void; title: string; diff --git a/src/app/pages/receive/components/receive-tokens.tsx b/src/app/pages/receive/components/receive-tokens.tsx index f769d05b0f3..71beef18f97 100644 --- a/src/app/pages/receive/components/receive-tokens.tsx +++ b/src/app/pages/receive/components/receive-tokens.tsx @@ -14,6 +14,7 @@ import { StxAvatarIcon, defaultFallbackDelay, } from '@leather.io/ui'; +import { isString } from '@leather.io/utils'; import { copyToClipboard } from '@app/common/utils/copy-to-clipboard'; import { useToast } from '@app/features/toasts/use-toast'; @@ -116,10 +117,14 @@ export function ReceiveTokens({ key={asset.name} address={asset.address} icon={ - - - {asset.fallback} - + isString(asset.icon) ? ( + + + {asset.fallback} + + ) : ( + asset.icon + ) } // onClickQrCode={() => null} onCopyAddress={async () => { diff --git a/src/app/pages/swap/bitflow-swap-container.tsx b/src/app/pages/swap/bitflow-swap-container.tsx index 7ac3e2523ee..131b599c749 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.tokenId === 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..d8c448f1d20 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 @@ -8,7 +8,7 @@ import { defaultFallbackDelay, getAvatarFallback, } from '@leather.io/ui'; -import { formatMoneyWithoutSymbol } from '@leather.io/utils'; +import { formatMoneyWithoutSymbol, isString } from '@leather.io/utils'; import { convertSwapAssetBalanceToFiat } from '@app/pages/swap/swap.utils'; @@ -28,10 +28,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..40fcd4eda29 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,79 +1,22 @@ -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 { 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 ( {selectableAssets.map(asset => ( - onSelectAsset(asset)} /> + onSelectAsset(asset)} /> ))} ); 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..4e15a608af3 --- /dev/null +++ b/src/app/pages/swap/components/swap-asset-dialog/components/use-swap-asset-list.tsx @@ -0,0 +1,103 @@ +import { useEffect, useState } from 'react'; +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, + formatMoneyWithoutSymbol, + isUndefined, +} from '@leather.io/utils'; + +import { RouteUrls } from '@shared/route-urls'; + +import type { SwapFormValues } from '@app/pages/swap/hooks/use-swap-form'; +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 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/components/swap-assets-pair/swap-asset-item.layout.tsx b/src/app/pages/swap/components/swap-assets-pair/swap-asset-item.layout.tsx index e8ab720c26f..41e3412d74f 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,18 +1,27 @@ +import type React from 'react'; + import { SwapSelectors } from '@tests/selectors/swap.selectors'; import { HStack, styled } from 'leather-styles/jsx'; import { Flag } from '@leather.io/ui'; +import { isString } from '@leather.io/utils'; interface SwapAssetItemLayoutProps { caption: string; - icon: string; + icon: React.ReactNode; symbol: string; value: string; } export function SwapAssetItemLayout({ caption, icon, symbol, value }: SwapAssetItemLayoutProps) { return ( } + img={ + isString(icon) ? ( + + ) : ( + icon + ) + } spacing="space.03" width="100%" > diff --git a/src/app/pages/swap/hooks/use-bitflow-swap.tsx b/src/app/pages/swap/hooks/use-bitflow-swap.tsx index 7a0a7bfeb04..ca7502c3c29 100644 --- a/src/app/pages/swap/hooks/use-bitflow-swap.tsx +++ b/src/app/pages/swap/hooks/use-bitflow-swap.tsx @@ -2,7 +2,7 @@ import { useState } from 'react'; import type { RouteQuote } from 'bitflow-sdk'; -import { type SwapAsset } from '@leather.io/query'; +import type { SwapAsset } from '@leather.io/query'; import { logger } from '@shared/logger'; import { bitflow } from '@shared/utils/bitflow-sdk'; @@ -17,7 +17,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, @@ -27,8 +27,8 @@ export function useBitflowSwap() { if (!baseAmount || !base || !quote) return; try { const result = await bitflow.getQuoteForRoute( - base.currency, - quote.currency, + base.tokenId, + quote.tokenId, Number(baseAmount) ); if (!result.bestRoute) { @@ -47,6 +47,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 +62,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..b88be77c571 100644 --- a/src/app/pages/swap/hooks/use-bitflow-swappable-assets.tsx +++ b/src/app/pages/swap/hooks/use-bitflow-swappable-assets.tsx @@ -1,11 +1,10 @@ import { useCallback } from 'react'; import { useQuery } from '@tanstack/react-query'; -import { Currency } from 'alex-sdk'; import BigNumber from 'bignumber.js'; import type { Token } from 'bitflow-sdk'; -import { createMarketData, createMarketPair } from '@leather.io/models'; +import { type Currency, createMarketData, createMarketPair } from '@leather.io/models'; import { type SwapAsset, useAlexSdkLatestPricesQuery, @@ -24,8 +23,9 @@ import { createGetBitflowAvailableTokensQueryOptions } from '@app/query/bitflow- import { sortSwapAssets } from '../swap.utils'; -const BITFLOW_STX_CURRENCY: Currency = 'token-stx' as Currency; -const USD_DECIMAL_PRECISION = 2; +const alexStxTokenId: Currency = 'token-wstx'; +const bitflowStxTokenId: Currency = 'token-stx'; +const usdDecimalPrecision = 2; function useCreateSwapAsset(address: string) { const { data: prices } = useAlexSdkLatestPricesQuery(); @@ -36,9 +36,11 @@ function useCreateSwapAsset(address: string) { return useCallback( (token?: Token): SwapAsset | undefined => { if (!prices || !token || !token.tokenContract) return; + const pricesKeyedByCurrency = prices as Record; + const stxPrice = pricesKeyedByCurrency[alexStxTokenId] ?? 0; const swapAsset = { - currency: token.tokenId as Currency, + tokenId: token.tokenId, fallback: token.symbol.slice(0, 2), icon: token.icon, name: token.symbol, @@ -46,11 +48,8 @@ function useCreateSwapAsset(address: string) { principal: token.tokenContract, }; - if (token.tokenId === BITFLOW_STX_CURRENCY) { - const price = convertAmountToFractionalUnit( - new BigNumber(prices[Currency.STX] ?? 0), - USD_DECIMAL_PRECISION - ); + if (token.tokenId === bitflowStxTokenId) { + const price = convertAmountToFractionalUnit(new BigNumber(stxPrice), usdDecimalPrecision); return { ...swapAsset, balance: availableUnlockedBalance, 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..854084ea347 --- /dev/null +++ b/src/app/pages/swap/hooks/use-sbtc-bridge-assets.tsx @@ -0,0 +1,51 @@ +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, + tokenId: 'token-btc', + 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), + tokenId: 'token-sbtc', + displayName: 'sBTC', + fallback: 'SB', + icon: ( + + + + + + ), + name: 'sBTC', + marketData: castBitcoinMarketDataToSbtcMarketData(bitcoinMarketData), + principal: getPrincipalFromContractId(token?.info.contractId ?? ''), + }; +} 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/query/bitcoin/balance/btc-balance-native-segwit.hooks.ts b/src/app/query/bitcoin/balance/btc-balance-native-segwit.hooks.ts index fd2a1cd2778..eaf046d3dce 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 @@ -11,7 +11,6 @@ import { useCurrentAccountNativeSegwitIndexZeroSigner } from '@app/store/account function createBtcCryptoAssetBalance(balance: Money): BtcCryptoAssetBalance { return { availableBalance: balance, - // TODO: Can we determine these here or are they nec? protectedBalance: createMoney(0, 'BTC'), uneconomicalBalance: createMoney(0, 'BTC'), }; diff --git a/src/app/query/bitcoin/ordinals/brc20/brc20-tokens.hooks.ts b/src/app/query/bitcoin/ordinals/brc20/brc20-tokens.hooks.ts index b5e57b51599..a9a8b7dfe0a 100644 --- a/src/app/query/bitcoin/ordinals/brc20/brc20-tokens.hooks.ts +++ b/src/app/query/bitcoin/ordinals/brc20/brc20-tokens.hooks.ts @@ -1,18 +1,13 @@ import BigNumber from 'bignumber.js'; -import { - type Brc20CryptoAssetInfo, - createCryptoAssetBalance, - createMarketData, - createMarketPair, -} from '@leather.io/models'; +import { type Brc20CryptoAssetInfo, createMarketData, createMarketPair } from '@leather.io/models'; import { isFetchedWithSuccess, useCalculateBitcoinFiatValue, useConfigOrdinalsbot, useGetBrc20TokensQuery, } from '@leather.io/query'; -import { createMoney, unitToFractionalUnit } from '@leather.io/utils'; +import { createBaseCryptoAssetBalance, createMoney, unitToFractionalUnit } from '@leather.io/utils'; import { useCurrentAccountNativeSegwitIndexZeroSigner } from '@app/store/accounts/blockchain/bitcoin/native-segwit-account.hooks'; import { useCurrentAccountTaprootSigner } from '@app/store/accounts/blockchain/bitcoin/taproot-account.hooks'; @@ -72,7 +67,7 @@ export function useBrc20Tokens() { createMoney(new BigNumber(token.balance.min_listed_unit_price ?? 0), 'BTC') ); return { - balance: createCryptoAssetBalance( + balance: createBaseCryptoAssetBalance( createMoney( unitToFractionalUnit(token.info.decimals)(new BigNumber(token.balance.overall_balance)), token.balance.ticker,