Skip to content

Commit

Permalink
feat: btc to sbtc swap
Browse files Browse the repository at this point in the history
  • Loading branch information
fbwoolf committed Dec 4, 2024
1 parent dff2e4f commit 23d1e16
Show file tree
Hide file tree
Showing 16 changed files with 253 additions and 96 deletions.
2 changes: 1 addition & 1 deletion src/app/common/hooks/use-calculate-sip10-fiat-value.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
11 changes: 10 additions & 1 deletion src/app/pages/home/components/account-actions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,16 @@ export function AccountActions() {
</Box>
</BasicTooltip>
),
[ChainID.Testnet]: null,
// Temporary for sBTC testing
[ChainID.Testnet]: (
<IconButton
data-testid={HomePageSelectors.SwapBtn}
disabled={swapsBtnDisabled}
icon={<ArrowsRepeatLeftRightIcon />}
label="Swap"
onClick={() => navigate(RouteUrls.Swap.replace(':base', 'STX').replace(':quote', ''))}
/>
),
})}
</Flex>
);
Expand Down
24 changes: 17 additions & 7 deletions src/app/pages/swap/bitflow-swap-container.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand All @@ -38,22 +43,27 @@ export const bitflowSwapRoutes = generateSwapRoutes(<BitflowSwapContainer />);

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);
const currentAccount = useCurrentStacksAccount();
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,
isFetchingExchangeRate,
onSetIsFetchingExchangeRate,
onSetSwapSubmissionData,
slippage,
swapAssets,
bitflowSwapAssets,
swapSubmissionData,
} = useBitflowSwap();

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
};

Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
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,
Pressable,
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 {
Expand All @@ -28,10 +29,14 @@ export function SwapAssetItem({ asset, onClick }: SwapAssetItemProps) {
<Pressable data-testid={SwapSelectors.SwapAssetListItem} onClick={onClick} my="space.02">
<ItemLayout
img={
<Avatar.Root>
<Avatar.Image alt={fallback} src={asset.icon} />
<Avatar.Fallback delayMs={defaultFallbackDelay}>{fallback}</Avatar.Fallback>
</Avatar.Root>
isString(asset.icon) ? (
<Avatar.Root>
<Avatar.Image alt={fallback} src={asset.icon} />
<Avatar.Fallback delayMs={defaultFallbackDelay}>{fallback}</Avatar.Fallback>
</Avatar.Root>
) : (
asset.icon
)
}
titleLeft={displayName}
captionLeft={asset.name}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<SwapFormValues>();
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 (
<Stack mb="space.05" p="space.05" width="100%" data-testid={SwapSelectors.SwapAssetList}>
Expand Down
Original file line number Diff line number Diff line change
@@ -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<SwapAsset[]>(assets);
const { setFieldError, setFieldValue, values } = useFormikContext<SwapFormValues>();
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);
}
},
};
}
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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;
Expand All @@ -34,11 +37,13 @@ export function SelectAssetTriggerButton({
{...field}
>
<HStack>
{icon && (
{icon && isString(icon) ? (
<Avatar.Root>
<Avatar.Image alt={fallback} src={icon} />
<Avatar.Fallback delayMs={defaultFallbackDelay}>{fallback}</Avatar.Fallback>
</Avatar.Root>
) : (
icon
)}
<styled.span textStyle="label.01">{symbol}</styled.span>
<ChevronDownIcon variant="small" />
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -35,14 +35,21 @@ 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<HTMLInputElement>) {
const { swapAssetBase, swapAssetQuote } = values;
if (isUndefined(swapAssetBase) || isUndefined(swapAssetQuote)) return;
onSetIsSendingMax(false);
const value = event.currentTarget.value;
const toAmount = await fetchQuoteAmount(swapAssetBase, swapAssetQuote, value);
if (isUndefined(toAmount)) {
await setFieldValue('swapAmountQuote', '');
void setFieldValue('swapAmountQuote', '');
return;
}
const toAmountAsMoney = createMoney(
Expand All @@ -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);
}

Expand Down
Loading

0 comments on commit 23d1e16

Please sign in to comment.