From 3703202054cb064d1504109f595cd73d13af7c21 Mon Sep 17 00:00:00 2001 From: Fara Woolf Date: Thu, 19 Dec 2024 13:49:09 -0500 Subject: [PATCH] fix: sbtc swap updates and bugs --- .../sbtc-deposit-status-item.tsx | 48 ++++++++++---- .../features/activity-list/activity-list.tsx | 28 ++++++-- .../activity-list/activity-list.utils.ts | 17 ++++- .../pending-transaction-list.tsx | 8 +-- .../transaction-list-item.tsx | 4 ++ .../transaction-list.model.ts | 18 +++-- .../transaction-list/transaction-list.tsx | 14 +++- .../transaction-list.utils.spec.ts | 27 ++++++-- .../transaction-list.utils.ts | 12 +++- src/app/pages/swap/bitflow-swap.utils.ts | 2 +- .../components/swap-amount-field.tsx | 31 ++++----- .../swap-details/swap-detail.layout.tsx | 8 +++ .../components/swap-details/swap-details.tsx | 8 ++- .../hooks/use-sbtc-deposit-transaction.tsx | 2 +- .../swap/hooks/use-swap-assets-from-route.ts | 4 +- src/app/pages/swap/hooks/use-swap-form.tsx | 13 ++++ src/app/query/sbtc/get-stacks-block.query.ts | 53 +++++++++++++++ src/app/query/sbtc/sbtc-deposits.query.ts | 66 ++++++++++++++++++- 18 files changed, 300 insertions(+), 63 deletions(-) create mode 100644 src/app/query/sbtc/get-stacks-block.query.ts diff --git a/src/app/components/sbtc-deposit-status-item/sbtc-deposit-status-item.tsx b/src/app/components/sbtc-deposit-status-item/sbtc-deposit-status-item.tsx index f89988aace0..630713e2a88 100644 --- a/src/app/components/sbtc-deposit-status-item/sbtc-deposit-status-item.tsx +++ b/src/app/components/sbtc-deposit-status-item/sbtc-deposit-status-item.tsx @@ -1,12 +1,14 @@ import SbtcAvatarIconSrc from '@assets/avatars/sbtc-avatar-icon.png'; +import { HStack } from 'leather-styles/jsx'; -import { Avatar, Caption, Title } from '@leather.io/ui'; +import { Avatar, Caption, Link, Title } from '@leather.io/ui'; import { truncateMiddle } from '@leather.io/utils'; import { analytics } from '@shared/utils/analytics'; import { useBitcoinExplorerLink } from '@app/common/hooks/use-bitcoin-explorer-link'; -import type { SbtcDepositInfo, SbtcStatus } from '@app/query/sbtc/sbtc-deposits.query'; +import { openInNewTab } from '@app/common/utils/open-in-new-tab'; +import { SbtcDeposit, SbtcStatus } from '@app/query/sbtc/sbtc-deposits.query'; import { TransactionItemLayout } from '../transaction-item/transaction-item.layout'; @@ -17,37 +19,61 @@ function getDepositStatus(status: SbtcStatus) { return 'Pending deposit'; case 'accepted': return 'Pending mint'; - case 'confirmed': - return 'Done'; case 'failed': return 'Failed'; + case 'confirmed': default: return ''; } } +function getDepositStatusTextColor(status: SbtcStatus) { + switch (status) { + case 'pending': + case 'reprocessing': + case 'accepted': + return 'yellow.action-primary-default'; + case 'failed': + return 'red.action-primary-default'; + case 'confirmed': + default: + return ''; + } +} + +const sbtcReclaimUrl = 'https://app.stacks.co/reclaim?depositTxId='; + interface SbtcDepositTransactionItemProps { - deposit: SbtcDepositInfo; + deposit: SbtcDeposit; } export function SbtcDepositTransactionItem({ deposit }: SbtcDepositTransactionItemProps) { const { handleOpenBitcoinTxLink: handleOpenTxLink } = useBitcoinExplorerLink(); + const { bitcoinTxid, status } = deposit; + const depositFailed = status === 'failed'; - const openTxLink = () => { + function openTxLink() { void analytics.track('view_bitcoin_transaction'); - handleOpenTxLink({ txid: deposit.bitcoinTxid }); - }; + handleOpenTxLink({ txid: bitcoinTxid }); + } + + function openReclaimLink() { + return openInNewTab(`${sbtcReclaimUrl}${bitcoinTxid}`); + } return ( {}} + txCaption={truncateMiddle(bitcoinTxid, 4)} txIcon={ } txStatus={ - {getDepositStatus(deposit.status)} + + {getDepositStatus(status)} + {depositFailed && Reclaim} + } txTitle={BTC → sBTC} // Api is only returning 0 right now diff --git a/src/app/features/activity-list/activity-list.tsx b/src/app/features/activity-list/activity-list.tsx index b9c50e337dc..4a92a035790 100644 --- a/src/app/features/activity-list/activity-list.tsx +++ b/src/app/features/activity-list/activity-list.tsx @@ -12,14 +12,22 @@ import { import { LoadingSpinner } from '@app/components/loading-spinner'; import { useConfigBitcoinEnabled } from '@app/query/common/remote-config/remote-config.query'; -import { useSbtcPendingDeposits } from '@app/query/sbtc/sbtc-deposits.query'; +import { + useSbtcConfirmedDeposits, + useSbtcFailedDeposits, + useSbtcPendingDeposits, +} from '@app/query/sbtc/sbtc-deposits.query'; import { useZeroIndexTaprootAddress } from '@app/store/accounts/blockchain/bitcoin/bitcoin.hooks'; import { useCurrentAccountNativeSegwitIndexZeroSigner } from '@app/store/accounts/blockchain/bitcoin/native-segwit-account.hooks'; import { useCurrentStacksAccountAddress } from '@app/store/accounts/blockchain/stacks/stacks-account.hooks'; import { useUpdateSubmittedTransactions } from '@app/store/submitted-transactions/submitted-transactions.hooks'; import { useSubmittedTransactions } from '@app/store/submitted-transactions/submitted-transactions.selectors'; -import { convertBitcoinTxsToListType, convertStacksTxsToListType } from './activity-list.utils'; +import { + convertBitcoinTxsToListType, + convertSbtcDepositToListType, + convertStacksTxsToListType, +} from './activity-list.utils'; import { NoAccountActivity } from './components/no-account-activity'; import { PendingTransactionList } from './components/pending-transaction-list/pending-transaction-list'; import { SubmittedTransactionList } from './components/submitted-transaction-list/submitted-transaction-list'; @@ -64,8 +72,12 @@ export function ActivityList() { [nsPendingTxs, trPendingTxs] ); - const { isLoading: isLoadingSbtcDeposits, pendingSbtcDeposits } = + const { isLoading: isLoadingSbtcPendingDeposits, pendingSbtcDeposits } = useSbtcPendingDeposits(stxAddress); + const { isLoading: isLoadingSbtcConfirmedDeposits, confirmedSbtcDeposits } = + useSbtcConfirmedDeposits(stxAddress); + const { isLoading: isLoadingSbtcFailedDeposits, failedSbtcDeposits } = + useSbtcFailedDeposits(stxAddress); const { isLoading: isLoadingStacksTransactions, data: stacksTransactionsWithTransfers } = useGetAccountTransactionsWithTransfersQuery(stxAddress); @@ -85,7 +97,9 @@ export function ActivityList() { isLoadingTrBitcoinTransactions || isLoadingStacksTransactions || isLoadingStacksPendingTransactions || - isLoadingSbtcDeposits; + isLoadingSbtcPendingDeposits || + isLoadingSbtcConfirmedDeposits || + isLoadingSbtcFailedDeposits; const transactionListBitcoinTxs = useMemo(() => { return convertBitcoinTxsToListType( @@ -135,7 +149,7 @@ export function ActivityList() { {hasPendingTransactions && ( )} @@ -143,6 +157,10 @@ export function ActivityList() { )} diff --git a/src/app/features/activity-list/activity-list.utils.ts b/src/app/features/activity-list/activity-list.utils.ts index c4d74fa71c8..7815d34b850 100644 --- a/src/app/features/activity-list/activity-list.utils.ts +++ b/src/app/features/activity-list/activity-list.utils.ts @@ -2,8 +2,11 @@ import { AddressTransactionWithTransfers } from '@stacks/stacks-blockchain-api-t import type { BitcoinTx } from '@leather.io/models'; -import { +import type { SbtcDeposit } from '@app/query/sbtc/sbtc-deposits.query'; + +import type { TransactionListBitcoinTx, + TransactionListSbtcDeposit, TransactionListStacksTx, } from './components/transaction-list/transaction-list.model'; @@ -21,6 +24,13 @@ function createStacksTxTypeWrapper(tx: AddressTransactionWithTransfers): Transac }; } +function createSbtcDepositTxTypeWrapper(deposit: SbtcDeposit): TransactionListSbtcDeposit { + return { + blockchain: 'bitcoin-stacks', + deposit, + }; +} + export function convertBitcoinTxsToListType(txs?: BitcoinTx[]) { if (!txs) return []; const confirmedTxs = txs.filter(tx => tx.status.confirmed); @@ -31,3 +41,8 @@ export function convertStacksTxsToListType(txs?: AddressTransactionWithTransfers if (!txs) return []; return txs.map(tx => createStacksTxTypeWrapper(tx)); } + +export function convertSbtcDepositToListType(deposits?: SbtcDeposit[]) { + if (!deposits) return []; + return deposits.map(deposit => createSbtcDepositTxTypeWrapper(deposit)); +} diff --git a/src/app/features/activity-list/components/pending-transaction-list/pending-transaction-list.tsx b/src/app/features/activity-list/components/pending-transaction-list/pending-transaction-list.tsx index 3174080c244..de9bf7ca521 100644 --- a/src/app/features/activity-list/components/pending-transaction-list/pending-transaction-list.tsx +++ b/src/app/features/activity-list/components/pending-transaction-list/pending-transaction-list.tsx @@ -5,18 +5,18 @@ import type { BitcoinTx } from '@leather.io/models'; import { BitcoinTransactionItem } from '@app/components/bitcoin-transaction-item/bitcoin-transaction-item'; import { SbtcDepositTransactionItem } from '@app/components/sbtc-deposit-status-item/sbtc-deposit-status-item'; import { StacksTransactionItem } from '@app/components/stacks-transaction-item/stacks-transaction-item'; -import type { SbtcDepositInfo } from '@app/query/sbtc/sbtc-deposits.query'; +import type { SbtcDeposit } from '@app/query/sbtc/sbtc-deposits.query'; import { PendingTransactionListLayout } from './pending-transaction-list.layout'; interface PendingTransactionListProps { bitcoinTxs: BitcoinTx[]; - sBtcDeposits: SbtcDepositInfo[]; + sbtcDeposits: SbtcDeposit[]; stacksTxs: MempoolTransaction[]; } export function PendingTransactionList({ bitcoinTxs, - sBtcDeposits, + sbtcDeposits, stacksTxs, }: PendingTransactionListProps) { return ( @@ -24,7 +24,7 @@ export function PendingTransactionList({ {bitcoinTxs.map(tx => ( ))} - {sBtcDeposits.map(deposit => ( + {sbtcDeposits.map(deposit => ( ))} {stacksTxs.map(tx => ( diff --git a/src/app/features/activity-list/components/transaction-list/transaction-list-item.tsx b/src/app/features/activity-list/components/transaction-list/transaction-list-item.tsx index af3debc6c94..849727f4e64 100644 --- a/src/app/features/activity-list/components/transaction-list/transaction-list-item.tsx +++ b/src/app/features/activity-list/components/transaction-list/transaction-list-item.tsx @@ -1,3 +1,5 @@ +import { SbtcDepositTransactionItem } from '@app/components/sbtc-deposit-status-item/sbtc-deposit-status-item'; + import { BitcoinTransaction } from './bitcoin-transaction/bitcoin-transaction'; import { StacksTransaction } from './stacks-transaction/stacks-transaction'; import { TransactionListTxs } from './transaction-list.model'; @@ -11,6 +13,8 @@ export function TransactionListItem({ tx }: TransactionListItemProps) { return ; case 'stacks': return ; + case 'bitcoin-stacks': + return ; default: return null; } diff --git a/src/app/features/activity-list/components/transaction-list/transaction-list.model.ts b/src/app/features/activity-list/components/transaction-list/transaction-list.model.ts index bf21bcaf303..01dfbc7d7be 100644 --- a/src/app/features/activity-list/components/transaction-list/transaction-list.model.ts +++ b/src/app/features/activity-list/components/transaction-list/transaction-list.model.ts @@ -1,15 +1,25 @@ import { AddressTransactionWithTransfers } from '@stacks/stacks-blockchain-api-types'; -import type { BitcoinTx, Blockchain } from '@leather.io/models'; +import type { BitcoinTx } from '@leather.io/models'; + +import type { SbtcDeposit } from '@app/query/sbtc/sbtc-deposits.query'; export interface TransactionListBitcoinTx { - blockchain: Extract; + blockchain: 'bitcoin'; transaction: BitcoinTx; } export interface TransactionListStacksTx { - blockchain: Extract; + blockchain: 'stacks'; transaction: AddressTransactionWithTransfers; } -export type TransactionListTxs = TransactionListBitcoinTx | TransactionListStacksTx; +export interface TransactionListSbtcDeposit { + blockchain: 'bitcoin-stacks'; + deposit: SbtcDeposit; +} + +export type TransactionListTxs = + | TransactionListBitcoinTx + | TransactionListStacksTx + | TransactionListSbtcDeposit; diff --git a/src/app/features/activity-list/components/transaction-list/transaction-list.tsx b/src/app/features/activity-list/components/transaction-list/transaction-list.tsx index 66fd66605bd..d145490bc8c 100644 --- a/src/app/features/activity-list/components/transaction-list/transaction-list.tsx +++ b/src/app/features/activity-list/components/transaction-list/transaction-list.tsx @@ -5,19 +5,25 @@ import { Box } from 'leather-styles/jsx'; import { useTransactionListRender } from './hooks/use-transaction-list-render'; import { TransactionListItem } from './transaction-list-item'; import { TransactionListLayout } from './transaction-list.layout'; -import { TransactionListBitcoinTx, TransactionListStacksTx } from './transaction-list.model'; +import type { + TransactionListBitcoinTx, + TransactionListSbtcDeposit, + TransactionListStacksTx, +} from './transaction-list.model'; import { createTxDateFormatList, getTransactionId } from './transaction-list.utils'; import { TransactionsByDateLayout } from './transactions-by-date.layout'; interface TransactionListProps { bitcoinTxs: TransactionListBitcoinTx[]; stacksTxs: TransactionListStacksTx[]; + sbtcDeposits: TransactionListSbtcDeposit[]; currentBitcoinAddress: string; } export function TransactionList({ bitcoinTxs, stacksTxs, + sbtcDeposits, currentBitcoinAddress, }: TransactionListProps) { const { intersectionSentinel, visibleTxsNum } = useTransactionListRender({ @@ -25,8 +31,10 @@ export function TransactionList({ }); const txsGroupedByDate = useMemo( () => - bitcoinTxs.length || stacksTxs.length ? createTxDateFormatList(bitcoinTxs, stacksTxs) : [], - [bitcoinTxs, stacksTxs] + bitcoinTxs.length || stacksTxs.length || sbtcDeposits.length + ? createTxDateFormatList(bitcoinTxs, stacksTxs, sbtcDeposits) + : [], + [bitcoinTxs, sbtcDeposits, stacksTxs] ); const groupedByDateTxsLength = useMemo(() => { diff --git a/src/app/features/activity-list/components/transaction-list/transaction-list.utils.spec.ts b/src/app/features/activity-list/components/transaction-list/transaction-list.utils.spec.ts index 015402f3d2d..1f6b1c7fd03 100644 --- a/src/app/features/activity-list/components/transaction-list/transaction-list.utils.spec.ts +++ b/src/app/features/activity-list/components/transaction-list/transaction-list.utils.spec.ts @@ -1,7 +1,14 @@ import { AddressTransactionWithTransfers, Transaction } from '@stacks/stacks-blockchain-api-types'; import dayjs from 'dayjs'; -import { TransactionListBitcoinTx, TransactionListStacksTx } from './transaction-list.model'; +import type { StacksBlock } from '@app/query/sbtc/get-stacks-block.query'; +import type { SbtcDeposit } from '@app/query/sbtc/sbtc-deposits.query'; + +import type { + TransactionListBitcoinTx, + TransactionListSbtcDeposit, + TransactionListStacksTx, +} from './transaction-list.model'; import { createTxDateFormatList } from './transaction-list.utils'; function createFakeTx(tx: Partial) { @@ -17,6 +24,13 @@ function createFakeTx(tx: Partial) { } as TransactionListStacksTx; } +function createFakeDeposit(block: Partial) { + return { + blockchain: 'bitcoin-stacks', + deposit: { block } as Partial, + } as TransactionListSbtcDeposit; +} + describe(createTxDateFormatList.name, () => { test('grouping by date', () => { const mockBitcoinTx = { @@ -26,7 +40,7 @@ describe(createTxDateFormatList.name, () => { const mockStacksTx = createFakeTx({ burn_block_time_iso: '1991-02-08T13:48:04.699Z', }); - expect(createTxDateFormatList([mockBitcoinTx], [mockStacksTx])).toEqual([ + expect(createTxDateFormatList([mockBitcoinTx], [mockStacksTx], [])).toEqual([ { date: '1991-02-08', displayDate: 'Feb 8th, 1991', @@ -42,7 +56,8 @@ describe(createTxDateFormatList.name, () => { transaction: { status: { confirmed: true, block_time: dayjs().unix() } }, } as TransactionListBitcoinTx; const mockStacksTx = createFakeTx({ burn_block_time_iso: today }); - const result = createTxDateFormatList([mockBitcoinTx], [mockStacksTx]); + const mockSbtcDeposit = createFakeDeposit({ burn_block_time_iso: today }); + const result = createTxDateFormatList([mockBitcoinTx], [mockStacksTx], [mockSbtcDeposit]); expect(result[0].date).toEqual(today.split('T')[0]); expect(result[0].displayDate).toEqual('Today'); }); @@ -55,7 +70,8 @@ describe(createTxDateFormatList.name, () => { transaction: { status: { confirmed: true, block_time: dayjs().subtract(1, 'day').unix() } }, } as TransactionListBitcoinTx; const mockStacksTx = createFakeTx({ burn_block_time_iso: yesterday.toISOString() }); - const result = createTxDateFormatList([mockBitcoinTx], [mockStacksTx]); + const mockSbtcDeposit = createFakeDeposit({ burn_block_time_iso: yesterday.toISOString() }); + const result = createTxDateFormatList([mockBitcoinTx], [mockStacksTx], [mockSbtcDeposit]); expect(result[0].date).toEqual(yesterday.toISOString().split('T')[0]); expect(result[0].displayDate).toEqual('Yesterday'); }); @@ -65,7 +81,8 @@ describe(createTxDateFormatList.name, () => { date.setFullYear(date.getFullYear()); date.setMonth(6); const mockStacksTx = createFakeTx({ burn_block_time_iso: date.toISOString() }); - const result = createTxDateFormatList([], [mockStacksTx]); + const mockSbtcDeposit = createFakeDeposit({ burn_block_time_iso: date.toISOString() }); + const result = createTxDateFormatList([], [mockStacksTx], [mockSbtcDeposit]); expect(result[0].date).toEqual(date.toISOString().split('T')[0]); expect(result[0].displayDate).not.toContain(new Date().getFullYear().toString()); }); diff --git a/src/app/features/activity-list/components/transaction-list/transaction-list.utils.ts b/src/app/features/activity-list/components/transaction-list/transaction-list.utils.ts index 1f8a2e204b5..acaec945683 100644 --- a/src/app/features/activity-list/components/transaction-list/transaction-list.utils.ts +++ b/src/app/features/activity-list/components/transaction-list/transaction-list.utils.ts @@ -3,8 +3,9 @@ import dayjs from 'dayjs'; import { isUndefined } from '@leather.io/utils'; import { displayDate, isoDateToLocalDateSafe, todaysIsoDate } from '@app/common/date-utils'; -import { +import type { TransactionListBitcoinTx, + TransactionListSbtcDeposit, TransactionListStacksTx, TransactionListTxs, } from '@app/features/activity-list/components/transaction-list/transaction-list.model'; @@ -30,6 +31,8 @@ function getTransactionTime(listTx: TransactionListTxs) { listTx.transaction.tx.burn_block_time_iso || listTx.transaction.tx.parent_burn_block_time_iso ); + case 'bitcoin-stacks': + return listTx.deposit.block?.burn_block_time_iso; default: return undefined; } @@ -51,6 +54,8 @@ function getTransactionBlockHeight(listTx: TransactionListTxs) { return listTx.transaction.status.block_height; case 'stacks': return listTx.transaction.tx.block_height; + case 'bitcoin-stacks': + return listTx.deposit.block?.height ?? listTx.deposit.lastUpdateHeight; default: return undefined; } @@ -141,10 +146,11 @@ function sortGroupedTransactions( export function createTxDateFormatList( bitcoinTxs: TransactionListBitcoinTx[], - stacksTxs: TransactionListStacksTx[] + stacksTxs: TransactionListStacksTx[], + sbtcDeposits: TransactionListSbtcDeposit[] ) { const formattedTxs = formatTxDateMapAsList( - groupTxsByDateMap([...bitcoinTxs, ...filterDuplicateStacksTxs(stacksTxs)]) + groupTxsByDateMap([...bitcoinTxs, ...filterDuplicateStacksTxs(stacksTxs), ...sbtcDeposits]) ); return sortGroupedTransactions(formattedTxs); } diff --git a/src/app/pages/swap/bitflow-swap.utils.ts b/src/app/pages/swap/bitflow-swap.utils.ts index 22b7cbf2e3d..3a593b4b241 100644 --- a/src/app/pages/swap/bitflow-swap.utils.ts +++ b/src/app/pages/swap/bitflow-swap.utils.ts @@ -57,7 +57,7 @@ export function getCrossChainSwapSubmissionData(values: SwapFormValues): SwapSub feeType: BtcFeeType.Standard, liquidityFee: 0, maxSignerFee: 0, - protocol: 'Bitcoin L2 Labs', + protocol: 'sBTC Protocol', dexPath: [], router: [values.swapAssetBase, values.swapAssetQuote].filter(isDefined), slippage: 0, diff --git a/src/app/pages/swap/components/swap-asset-select/components/swap-amount-field.tsx b/src/app/pages/swap/components/swap-asset-select/components/swap-amount-field.tsx index d87d2907cca..51b6d2b0673 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, useEffect } from 'react'; +import { ChangeEvent } from 'react'; import { SwapSelectors } from '@tests/selectors/swap.selectors'; import BigNumber from 'bignumber.js'; @@ -6,8 +6,7 @@ import { useField, useFormikContext } from 'formik'; import { Stack, styled } from 'leather-styles/jsx'; import { - convertAmountToFractionalUnit, - createMoney, + createMoneyFromDecimal, formatMoneyWithoutSymbol, isDefined, isUndefined, @@ -31,37 +30,33 @@ interface SwapAmountFieldProps { name: string; } export function SwapAmountField({ amountAsFiat, isDisabled, name }: SwapAmountFieldProps) { - const { fetchQuoteAmount, isFetchingExchangeRate, onSetIsSendingMax } = useSwapContext(); + const { fetchQuoteAmount, isCrossChainSwap, isFetchingExchangeRate, onSetIsSendingMax } = + useSwapContext(); const { setFieldError, setFieldValue, values } = useFormikContext(); 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', ''); - } - }, [setFieldValue, values]); - async function onBlur(event: ChangeEvent) { const { swapAssetBase, swapAssetQuote } = values; if (isUndefined(swapAssetBase) || isUndefined(swapAssetQuote)) return; onSetIsSendingMax(false); const value = event.currentTarget.value; const toAmount = await fetchQuoteAmount(swapAssetBase, swapAssetQuote, value); - if (isUndefined(toAmount)) { + const valueLengthAsDecimals = value.length - 1; + if (isUndefined(toAmount) || valueLengthAsDecimals > swapAssetBase.balance.decimals) { await setFieldValue('swapAmountQuote', ''); return; } - const toAmountAsMoney = createMoney( - convertAmountToFractionalUnit( - new BigNumber(toAmount), - values.swapAssetQuote?.balance.decimals - ), + const toAmountAsMoney = createMoneyFromDecimal( + new BigNumber(toAmount), values.swapAssetQuote?.balance.symbol ?? '', values.swapAssetQuote?.balance.decimals ); - await setFieldValue('swapAmountQuote', formatMoneyWithoutSymbol(toAmountAsMoney)); + + await setFieldValue( + 'swapAmountQuote', + isCrossChainSwap ? toAmount : formatMoneyWithoutSymbol(toAmountAsMoney) + ); setFieldError('swapAmountQuote', undefined); } diff --git a/src/app/pages/swap/components/swap-details/swap-detail.layout.tsx b/src/app/pages/swap/components/swap-details/swap-detail.layout.tsx index 02dddfb649b..3683acb9d14 100644 --- a/src/app/pages/swap/components/swap-details/swap-detail.layout.tsx +++ b/src/app/pages/swap/components/swap-details/swap-detail.layout.tsx @@ -4,16 +4,19 @@ import { Box, HStack, styled } from 'leather-styles/jsx'; import { InfoCircleIcon } from '@leather.io/ui'; +import { openInNewTab } from '@app/common/utils/open-in-new-tab'; import { BasicTooltip } from '@app/ui/components/tooltip/basic-tooltip'; interface SwapDetailLayoutProps { dataTestId?: string; + moreInfoUrl?: string; title: string; tooltipLabel?: string; value: ReactNode; } export function SwapDetailLayout({ dataTestId, + moreInfoUrl, title, tooltipLabel, value, @@ -31,6 +34,11 @@ export function SwapDetailLayout({ ) : null} + {moreInfoUrl ? ( + openInNewTab(moreInfoUrl)} type="button"> + + + ) : null} {value} 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 c7ae3980cb5..f90d4fcc81c 100644 --- a/src/app/pages/swap/components/swap-details/swap-details.tsx +++ b/src/app/pages/swap/components/swap-details/swap-details.tsx @@ -7,7 +7,7 @@ import { convertAmountToBaseUnit, createMoney, createMoneyFromDecimal, - formatMoneyPadded, + formatMoney, isDefined, isUndefined, satToBtc, @@ -31,11 +31,12 @@ function RouteNames(props: { swapSubmissionData: SwapSubmissionData }) { }); } +const sbtcMoreInfoUrl = 'https://github.com/stacks-network/sbtc-bridge'; const sponsoredFeeLabel = 'Sponsorship may not apply when you have pending transactions. In such cases, if you choose to proceed, the associated costs will be deducted from your balance.'; export function SwapDetails() { - const { swapSubmissionData } = useSwapContext(); + const { isCrossChainSwap, swapSubmissionData } = useSwapContext(); if ( isUndefined(swapSubmissionData) || @@ -46,7 +47,7 @@ export function SwapDetails() { const maxSignerFee = satToBtc(swapSubmissionData.maxSignerFee ?? 0); - const formattedMinToReceive = formatMoneyPadded( + const formattedMinToReceive = formatMoney( createMoneyFromDecimal( new BigNumber(swapSubmissionData.swapAmountQuote) .times(1 - swapSubmissionData.slippage) @@ -69,6 +70,7 @@ export function SwapDetails() { diff --git a/src/app/pages/swap/hooks/use-sbtc-deposit-transaction.tsx b/src/app/pages/swap/hooks/use-sbtc-deposit-transaction.tsx index c85c274d51c..be95634d0dd 100644 --- a/src/app/pages/swap/hooks/use-sbtc-deposit-transaction.tsx +++ b/src/app/pages/swap/hooks/use-sbtc-deposit-transaction.tsx @@ -37,7 +37,7 @@ import type { SwapSubmissionData } from '../swap.context'; // Also set as defaults in sbtc lib const maxSignerFee = 80_000; -const reclaimLockTime = 144; +const reclaimLockTime = 12; interface SbtcDeposit { address: string; diff --git a/src/app/pages/swap/hooks/use-swap-assets-from-route.ts b/src/app/pages/swap/hooks/use-swap-assets-from-route.ts index dfb3c4dedc2..8e2080a3282 100644 --- a/src/app/pages/swap/hooks/use-swap-assets-from-route.ts +++ b/src/app/pages/swap/hooks/use-swap-assets-from-route.ts @@ -9,7 +9,7 @@ import { RouteUrls } from '@shared/route-urls'; import { useSwapContext } from '../swap.context'; export function useSwapAssetsFromRoute() { - const { swappableAssetsBase, swappableAssetsQuote } = useSwapContext(); + const { onSetIsCrossChainSwap, swappableAssetsBase, swappableAssetsQuote } = useSwapContext(); const { setFieldValue, values, validateForm } = useFormikContext(); const { base, quote } = useParams(); const navigate = useNavigate(); @@ -32,10 +32,12 @@ export function useSwapAssetsFromRoute() { 'swapAssetQuote', swappableAssetsQuote.find(asset => asset.name === quote) ); + if (base === 'BTC') onSetIsCrossChainSwap(true); void validateForm(); }, [ base, navigate, + onSetIsCrossChainSwap, quote, setFieldValue, swappableAssetsBase, diff --git a/src/app/pages/swap/hooks/use-swap-form.tsx b/src/app/pages/swap/hooks/use-swap-form.tsx index 49c77602e11..881c10f172f 100644 --- a/src/app/pages/swap/hooks/use-swap-form.tsx +++ b/src/app/pages/swap/hooks/use-swap-form.tsx @@ -78,6 +78,19 @@ export function useSwapForm() { return true; }, }) + .test({ + message: 'Decimal precision not supported', + test(value) { + if (!value || isFetchingExchangeRate) return true; + const { swapAssetBase } = this.parent; + const numStr = value.toString(); + if (numStr.includes('e')) { + const exponent = Math.abs(parseInt(value.toExponential().split('e')[1], 10)); + if (exponent > swapAssetBase.balance.decimals) return false; + } + return true; + }, + }) .test({ message: `Min amount is ${convertAmountToBaseUnit(sBtcDepositCapMin).toString()} BTC`, test(value) { diff --git a/src/app/query/sbtc/get-stacks-block.query.ts b/src/app/query/sbtc/get-stacks-block.query.ts new file mode 100644 index 00000000000..c52652959f7 --- /dev/null +++ b/src/app/query/sbtc/get-stacks-block.query.ts @@ -0,0 +1,53 @@ +import { useQueries } from '@tanstack/react-query'; +import axios from 'axios'; + +import { getHiroApiRateLimiter } from '@leather.io/query'; + +import { useCurrentNetwork } from '@app/store/networks/networks.selectors'; + +export interface StacksBlock { + canonical: boolean; + height: number; + hash: string; + block_time: number; + block_time_iso: string; + index_block_hash: string; + parent_block_hash: string; + parent_index_block_hash: string; + burn_block_time: number; + burn_block_time_iso: string; + burn_block_hash: string; + burn_block_height: number; + miner_txid: string; + tx_count: number; + execution_cost_read_count: number; + execution_cost_read_length: number; + execution_cost_runtime: number; + execution_cost_write_count: number; + execution_cost_write_length: number; +} + +async function getStacksBlock(basePath: string, block: number): Promise { + const rateLimiter = getHiroApiRateLimiter(basePath); + const resp = await rateLimiter.add(() => axios.get(`${basePath}/extended/v2/blocks/${block}`), { + priority: 3, + throwOnTimeout: true, + }); + return resp.data; +} + +function makeGetStacksBlockQuery(basePath: string, block: number) { + return { + queryKey: ['get-stacks-block', block], + async queryFn() { + return getStacksBlock(basePath, block); + }, + }; +} + +export function useGetStacksBlocks(blocks: number[]) { + const network = useCurrentNetwork(); + return useQueries({ + queries: blocks.map(block => makeGetStacksBlockQuery(network.chain.stacks.url, block)), + }); +} diff --git a/src/app/query/sbtc/sbtc-deposits.query.ts b/src/app/query/sbtc/sbtc-deposits.query.ts index 44599d1816c..00fd5f70b9a 100644 --- a/src/app/query/sbtc/sbtc-deposits.query.ts +++ b/src/app/query/sbtc/sbtc-deposits.query.ts @@ -3,7 +3,10 @@ import { BytesReader, addressToString, deserializeAddress } from '@stacks/transa import { useQuery } from '@tanstack/react-query'; import axios from 'axios'; +import { isDefined } from '@leather.io/utils'; + import { useConfigSbtc } from '../common/remote-config/remote-config.query'; +import { type StacksBlock, useGetStacksBlocks } from './get-stacks-block.query'; export enum SbtcStatus { Pending = 'pending', @@ -13,7 +16,7 @@ export enum SbtcStatus { Failed = 'failed', } -export interface SbtcDepositInfo { +interface SbtcDepositInfo { amount: number; bitcoinTxOutputIndex: number; bitcoinTxid: string; @@ -54,6 +57,29 @@ function useGetSbtcDeposits(stxAddress: string, status: string) { }); } +export interface SbtcDeposit extends SbtcDepositInfo { + block?: StacksBlock; +} + +function useSbtcDeposits(deposits: SbtcDepositInfo[]) { + const blockResults = useGetStacksBlocks(deposits.map(deposit => deposit.lastUpdateHeight)); + const isLoadingBlocks = blockResults.some(query => query.isLoading); + + return { + isLoadingBlocks, + deposits: deposits.map(deposit => { + const block = blockResults + .map(query => query.data) + .filter(isDefined) + .find(block => block.height === deposit.lastUpdateHeight); + return { + ...deposit, + block, + }; + }), + }; +} + export function useSbtcPendingDeposits(stxAddress: string) { const { data: pendingDeposits = [], isLoading: isLoadingStatusPending } = useGetSbtcDeposits( stxAddress, @@ -66,8 +92,42 @@ export function useSbtcPendingDeposits(stxAddress: string) { 'accepted' ); + const { isLoadingBlocks, deposits } = useSbtcDeposits([ + ...pendingDeposits, + ...reprocessingDeposits, + ...acceptedDeposits, + ]); + + return { + isLoading: + isLoadingStatusPending || + isLoadingStatusReprocessing || + isLoadingStatusAccepted || + isLoadingBlocks, + pendingSbtcDeposits: deposits, + }; +} + +export function useSbtcConfirmedDeposits(stxAddress: string) { + const { data: confirmedSbtcDeposits = [], isLoading: isLoadingStatusConfirmed } = + useGetSbtcDeposits(stxAddress, 'confirmed'); + const { isLoadingBlocks, deposits } = useSbtcDeposits(confirmedSbtcDeposits); + + return { + isLoading: isLoadingStatusConfirmed || isLoadingBlocks, + confirmedSbtcDeposits: deposits, + }; +} + +export function useSbtcFailedDeposits(stxAddress: string) { + const { data: failedSbtcDeposits = [], isLoading: isLoadingStatusFailed } = useGetSbtcDeposits( + stxAddress, + 'failed' + ); + const { isLoadingBlocks, deposits } = useSbtcDeposits(failedSbtcDeposits); + return { - isLoading: isLoadingStatusPending || isLoadingStatusReprocessing || isLoadingStatusAccepted, - pendingSbtcDeposits: [...pendingDeposits, ...reprocessingDeposits, ...acceptedDeposits], + isLoading: isLoadingStatusFailed || isLoadingBlocks, + failedSbtcDeposits: deposits, }; }