From 3379335c161bb64be61db69fd63adc5be6ffae6f Mon Sep 17 00:00:00 2001 From: fbwoolf Date: Mon, 18 Sep 2023 13:52:04 -0500 Subject: [PATCH] refactor: swap qa changes --- package.json | 2 +- src/app/common/date-utils.ts | 5 - src/app/common/hooks/use-bitcoin-contracts.ts | 2 +- .../hooks/use-convert-to-fiat-amount.ts | 14 ++- src/app/common/math/helpers.ts | 7 +- src/app/common/money/calculate-money.ts | 10 +- src/app/components/icons/dot-icon.tsx | 15 --- src/app/components/icons/swap-icon.tsx | 2 +- src/app/components/nonce-setter.tsx | 6 +- .../edit-nonce-drawer/edit-nonce-drawer.tsx | 2 +- .../pages/rpc-sign-psbt/use-rpc-sign-psbt.tsx | 2 +- .../swap/components/selected-asset-field.tsx | 38 ++++--- .../swap/components/swap-amount-field.tsx | 67 +++++++----- .../swap-asset-item.layout.tsx | 10 +- .../swap-assets-pair.layout.tsx | 6 +- .../swap-assets-pair/swap-assets-pair.tsx | 11 +- .../swap/components/swap-content.layout.tsx | 4 +- .../swap-details/swap-detail.layout.tsx | 16 ++- .../swap-details/swap-details.layout.tsx | 13 +-- .../components/swap-details/swap-details.tsx | 46 ++++---- src/app/pages/swap/components/swap-form.tsx | 5 +- .../components/swap-selected-asset-from.tsx | 64 +++++------ .../swap-selected-asset-placeholder.tsx | 25 ----- .../components/swap-selected-asset-to.tsx | 14 ++- .../components/swap-selected-asset.layout.tsx | 51 +++++---- .../swap/components/swap-selected-assets.tsx | 23 ++-- .../swap-status/swap-status-item.layout.tsx | 23 ---- .../swap-status/swap-status.layout.tsx | 7 -- .../components/swap-status/swap-status.tsx | 40 ------- .../swap/components/swap-toggle-button.tsx | 26 +++-- .../swap/hooks/use-alex-broadcast-swap.ts | 36 +++++++ src/app/pages/swap/hooks/use-alex-swap.tsx | 47 ++++---- .../pages/swap/hooks/use-amount-as-fiat.tsx | 18 ---- src/app/pages/swap/hooks/use-fiat-price.tsx | 36 +++++++ .../swap/hooks/use-stacks-broadcast-swap.tsx | 78 +++++--------- .../hooks/{use-swap.tsx => use-swap-form.tsx} | 42 +++++--- .../components/swap-asset-item.layout.tsx | 8 +- .../components/swap-asset-item.tsx | 16 ++- .../components/swap-asset-list.layout.tsx | 2 +- .../components/swap-asset-list.tsx | 33 ++++-- .../swap-choose-asset/swap-choose-asset.tsx | 39 ++++++- src/app/pages/swap/swap-container.tsx | 83 +++++++++++---- .../swap-summary-action-button.tsx | 21 ---- .../swap/swap-summary/swap-summary-tabs.tsx | 42 -------- .../swap/swap-summary/swap-summary.layout.tsx | 11 -- .../pages/swap/swap-summary/swap-summary.tsx | 100 ------------------ src/app/pages/swap/swap.context.ts | 10 +- src/app/pages/swap/swap.routes.tsx | 7 -- src/app/pages/swap/swap.tsx | 22 +++- .../alex-swaps/swappable-currency.query.ts | 16 +++ .../common/market-data/market-data.hooks.ts | 6 ++ src/app/query/stacks/fees/fees.hooks.ts | 2 +- .../stacks/nonce/account-nonces.utils.ts | 5 +- .../store/transactions/contract-call.hooks.ts | 2 +- src/app/store/transactions/requests.hooks.ts | 4 +- src/shared/route-urls.ts | 2 - theme/semantic-tokens.ts | 5 +- yarn.lock | 2 +- 58 files changed, 596 insertions(+), 655 deletions(-) delete mode 100644 src/app/components/icons/dot-icon.tsx delete mode 100644 src/app/pages/swap/components/swap-selected-asset-placeholder.tsx delete mode 100644 src/app/pages/swap/components/swap-status/swap-status-item.layout.tsx delete mode 100644 src/app/pages/swap/components/swap-status/swap-status.layout.tsx delete mode 100644 src/app/pages/swap/components/swap-status/swap-status.tsx create mode 100644 src/app/pages/swap/hooks/use-alex-broadcast-swap.ts delete mode 100644 src/app/pages/swap/hooks/use-amount-as-fiat.tsx create mode 100644 src/app/pages/swap/hooks/use-fiat-price.tsx rename src/app/pages/swap/hooks/{use-swap.tsx => use-swap-form.tsx} (51%) delete mode 100644 src/app/pages/swap/swap-summary/swap-summary-action-button.tsx delete mode 100644 src/app/pages/swap/swap-summary/swap-summary-tabs.tsx delete mode 100644 src/app/pages/swap/swap-summary/swap-summary.layout.tsx delete mode 100644 src/app/pages/swap/swap-summary/swap-summary.tsx create mode 100644 src/app/query/common/alex-swaps/swappable-currency.query.ts diff --git a/package.json b/package.json index 401f6404728..f00e4cf3091 100644 --- a/package.json +++ b/package.json @@ -169,7 +169,7 @@ "@typescript-eslint/eslint-plugin": "6.7.4", "@vkontakte/vk-qr": "2.0.13", "@zondax/ledger-stacks": "1.0.4", - "alex-sdk": "^0.1.22", + "alex-sdk": "0.1.22", "are-passive-events-supported": "1.1.1", "argon2-browser": "1.18.0", "assert": "2.0.0", diff --git a/src/app/common/date-utils.ts b/src/app/common/date-utils.ts index 4d9db9df0bb..952f4ad50f0 100644 --- a/src/app/common/date-utils.ts +++ b/src/app/common/date-utils.ts @@ -31,11 +31,6 @@ export function displayDate(txDate: string): string { return date.format('MMM Do, YYYY'); } -export function displayTime(txDate: string) { - const date = dayjs(txDate); - return date.format('h:mm A'); -} - export function isoDateToLocalDateSafe(isoDate: string) { try { return isoDateToLocalDate(isoDate); diff --git a/src/app/common/hooks/use-bitcoin-contracts.ts b/src/app/common/hooks/use-bitcoin-contracts.ts index 0526bee6110..facb420a331 100644 --- a/src/app/common/hooks/use-bitcoin-contracts.ts +++ b/src/app/common/hooks/use-bitcoin-contracts.ts @@ -203,7 +203,7 @@ export function useBitcoinContracts() { const txMoney = createMoneyFromDecimal(bitcoinValue, 'BTC'); const txFiatValue = i18nFormatCurrency(calculateFiatValue(txMoney)).toString(); const txFiatValueSymbol = bitcoinMarketData.price.symbol; - const txLink = { blockchain: 'bitcoin', txid: txId }; + const txLink = { blockchain: 'bitcoin', txId }; return { txId, diff --git a/src/app/common/hooks/use-convert-to-fiat-amount.ts b/src/app/common/hooks/use-convert-to-fiat-amount.ts index ac64254c446..df83b0039c5 100644 --- a/src/app/common/hooks/use-convert-to-fiat-amount.ts +++ b/src/app/common/hooks/use-convert-to-fiat-amount.ts @@ -3,7 +3,10 @@ import { useCallback } from 'react'; import { CryptoCurrencies } from '@shared/models/currencies.model'; import type { Money } from '@shared/models/money.model'; -import { useCryptoCurrencyMarketData } from '@app/query/common/market-data/market-data.hooks'; +import { + useAlexMarketData, + useCryptoCurrencyMarketData, +} from '@app/query/common/market-data/market-data.hooks'; import { baseCurrencyAmountInQuote } from '../money/calculate-money'; @@ -15,3 +18,12 @@ export function useConvertCryptoCurrencyToFiatAmount(currency: CryptoCurrencies) [cryptoCurrencyMarketData] ); } + +export function useConvertAlexSwapCurrencyToFiatAmount(currency: CryptoCurrencies, price: Money) { + const alexCurrencyMarketData = useAlexMarketData(currency, price); + + return useCallback( + (value: Money) => baseCurrencyAmountInQuote(value, alexCurrencyMarketData), + [alexCurrencyMarketData] + ); +} diff --git a/src/app/common/math/helpers.ts b/src/app/common/math/helpers.ts index b38a33a9319..84746b97f80 100644 --- a/src/app/common/math/helpers.ts +++ b/src/app/common/math/helpers.ts @@ -1,7 +1,10 @@ import BigNumber from 'bignumber.js'; -export function initBigNumber(num: string | number | BigNumber) { - return BigNumber.isBigNumber(num) ? num : new BigNumber(num); +import { isBigInt } from '@shared/utils'; + +export function initBigNumber(num: string | number | BigNumber | bigint) { + if (BigNumber.isBigNumber(num)) return num; + return isBigInt(num) ? new BigNumber(num.toString()) : new BigNumber(num); } export function sumNumbers(nums: number[]) { diff --git a/src/app/common/money/calculate-money.ts b/src/app/common/money/calculate-money.ts index 8086de577bb..8063e2d0ce1 100644 --- a/src/app/common/money/calculate-money.ts +++ b/src/app/common/money/calculate-money.ts @@ -2,9 +2,9 @@ import { BigNumber } from 'bignumber.js'; import { MarketData, formatMarketPair } from '@shared/models/market.model'; import { Money, NumType, createMoney } from '@shared/models/money.model'; -import { isBigInt, isNumber } from '@shared/utils'; +import { isNumber } from '@shared/utils'; -import { sumNumbers } from '../math/helpers'; +import { initBigNumber, sumNumbers } from '../math/helpers'; import { formatMoney } from './format-money'; import { isMoney } from './is-money'; @@ -36,11 +36,7 @@ export function convertToMoneyTypeWithDefaultOfZero( num?: NumType, decimals?: number ) { - return createMoney( - isBigInt(num) ? new BigNumber(num.toString()) : new BigNumber(num ?? 0), - symbol.toUpperCase(), - decimals - ); + return createMoney(initBigNumber(num ?? 0), symbol.toUpperCase(), decimals); } // ts-unused-exports:disable-next-line diff --git a/src/app/components/icons/dot-icon.tsx b/src/app/components/icons/dot-icon.tsx deleted file mode 100644 index 49b8aa170ad..00000000000 --- a/src/app/components/icons/dot-icon.tsx +++ /dev/null @@ -1,15 +0,0 @@ -// ts-unused-exports:disable-next-line -export function DotIcon(props: React.SVGProps) { - return ( - - - - ); -} diff --git a/src/app/components/icons/swap-icon.tsx b/src/app/components/icons/swap-icon.tsx index 2e7413ca217..c7da54524fd 100644 --- a/src/app/components/icons/swap-icon.tsx +++ b/src/app/components/icons/swap-icon.tsx @@ -11,7 +11,7 @@ export function SwapIcon(props: React.SVGProps) { diff --git a/src/app/components/nonce-setter.tsx b/src/app/components/nonce-setter.tsx index 6fd0e5acb41..68a7400ed27 100644 --- a/src/app/components/nonce-setter.tsx +++ b/src/app/components/nonce-setter.tsx @@ -2,6 +2,7 @@ import { useEffect } from 'react'; import { useFormikContext } from 'formik'; +import { logger } from '@shared/logger'; import { StacksSendFormValues, StacksTransactionFormValues } from '@shared/models/form.model'; import { useNextNonce } from '@app/query/stacks/nonce/account-nonces.hooks'; @@ -13,8 +14,9 @@ export function NonceSetter() { const { data: nextNonce } = useNextNonce(); useEffect(() => { - if (nextNonce && !touched.nonce && values.nonce !== nextNonce.nonce) - setFieldValue('nonce', nextNonce.nonce); + const setAsyncFieldValue = async (nonce: number) => await setFieldValue('nonce', nonce); + if (nextNonce?.nonce && !touched.nonce && values.nonce !== nextNonce.nonce) + setAsyncFieldValue(nextNonce.nonce).catch(e => logger.error(e)); // eslint-disable-next-line react-hooks/exhaustive-deps }, [nextNonce?.nonce]); diff --git a/src/app/features/edit-nonce-drawer/edit-nonce-drawer.tsx b/src/app/features/edit-nonce-drawer/edit-nonce-drawer.tsx index 938c2f98910..5f8c7c220d0 100644 --- a/src/app/features/edit-nonce-drawer/edit-nonce-drawer.tsx +++ b/src/app/features/edit-nonce-drawer/edit-nonce-drawer.tsx @@ -42,7 +42,7 @@ export function EditNonceDrawer() { const onBlur = useCallback(() => validateField('nonce'), [validateField]); const onSubmit = useCallback(async () => { - validateField('nonce'); + await validateField('nonce'); if (!errors.nonce) onGoBack(); }, [errors.nonce, onGoBack, validateField]); diff --git a/src/app/pages/rpc-sign-psbt/use-rpc-sign-psbt.tsx b/src/app/pages/rpc-sign-psbt/use-rpc-sign-psbt.tsx index ad742d71b55..2147e63648a 100644 --- a/src/app/pages/rpc-sign-psbt/use-rpc-sign-psbt.tsx +++ b/src/app/pages/rpc-sign-psbt/use-rpc-sign-psbt.tsx @@ -64,7 +64,7 @@ export function useRpcSignPsbt() { txId: txid, txLink: { blockchain: 'bitcoin', - txid: txid || '', + txId: txid || '', }, txValue: formatMoney(transferTotalAsMoney), }; diff --git a/src/app/pages/swap/components/selected-asset-field.tsx b/src/app/pages/swap/components/selected-asset-field.tsx index b5eef81350c..e72645f5866 100644 --- a/src/app/pages/swap/components/selected-asset-field.tsx +++ b/src/app/pages/swap/components/selected-asset-field.tsx @@ -1,46 +1,44 @@ import { Field } from 'formik'; -import { Box, Flex, HStack, styled } from 'leather-styles/jsx'; - -import { Flag } from '@app/components/layout/flag'; +import { Box, Flex, HStack } from 'leather-styles/jsx'; interface SelectedAssetFieldProps { contentLeft: React.JSX.Element; contentRight: React.JSX.Element; - icon?: string; name: string; + showError?: boolean; } export function SelectedAssetField({ contentLeft, contentRight, - icon, name, + showError, }: SelectedAssetFieldProps) { return ( - : null - } - spacing="tight" - > - - {contentLeft} - {contentRight} - - + + {contentLeft} + {contentRight} + diff --git a/src/app/pages/swap/components/swap-amount-field.tsx b/src/app/pages/swap/components/swap-amount-field.tsx index 808f5e56016..95324ba00d2 100644 --- a/src/app/pages/swap/components/swap-amount-field.tsx +++ b/src/app/pages/swap/components/swap-amount-field.tsx @@ -1,62 +1,73 @@ import { ChangeEvent } from 'react'; -import { Input, Stack, color } from '@stacks/ui'; import { useField, useFormikContext } from 'formik'; +import { Stack, styled } from 'leather-styles/jsx'; + +import { isDefined, isUndefined } from '@shared/utils'; import { useShowFieldError } from '@app/common/form-utils'; -import { Caption } from '@app/components/typography'; -import { SwapFormValues } from '../hooks/use-swap'; +import { SwapFormValues } from '../hooks/use-swap-form'; import { useSwapContext } from '../swap.context'; +function getPlaceholderValue(name: string, values: SwapFormValues) { + if (name === 'swapAmountFrom' && isDefined(values.swapAssetFrom)) return '0'; + if (name === 'swapAmountTo' && isDefined(values.swapAssetTo)) return '0'; + return '-'; +} + interface SwapAmountFieldProps { amountAsFiat: string; isDisabled?: boolean; name: string; } export function SwapAmountField({ amountAsFiat, isDisabled, name }: SwapAmountFieldProps) { - const { fetchToAmount } = useSwapContext(); - const { setFieldValue, values } = useFormikContext(); + const { fetchToAmount, onSetIsSendingMax } = useSwapContext(); + const { setErrors, setFieldValue, values } = useFormikContext(); const [field] = useField(name); - const showError = useShowFieldError(name); + const showError = useShowFieldError(name) && name === 'swapAmountFrom' && values.swapAssetTo; async function onChange(event: ChangeEvent) { - field.onChange(event); - const value = event.currentTarget.value; const { swapAssetFrom, swapAssetTo } = values; - if (swapAssetFrom != null && swapAssetTo && !isNaN(Number(value))) { - await setFieldValue('swapAmountTo', ''); - const toAmount = await fetchToAmount(swapAssetFrom, swapAssetTo, value); - await setFieldValue('swapAmountTo', toAmount); - } + if (isUndefined(swapAssetFrom) || isUndefined(swapAssetTo)) return; + onSetIsSendingMax(false); + const value = event.currentTarget.value; + const toAmount = await fetchToAmount(swapAssetFrom, swapAssetTo, value); + await setFieldValue('swapAmountTo', Number(toAmount)); + field.onChange(event); + setErrors({}); } return ( - - + + ); } 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 2cdd5fbe80d..19fe903475b 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 @@ -3,18 +3,22 @@ import { HStack, styled } from 'leather-styles/jsx'; import { Flag } from '@app/components/layout/flag'; interface SwapAssetItemLayoutProps { + caption: string; icon: string; symbol: string; value: string; } -export function SwapAssetItemLayout({ icon, symbol, value }: SwapAssetItemLayoutProps) { +export function SwapAssetItemLayout({ caption, icon, symbol, value }: SwapAssetItemLayoutProps) { return ( } - spacing="tight" + img={} + spacing="space.03" width="100%" > + + {caption} + {symbol} {value} diff --git a/src/app/pages/swap/components/swap-assets-pair/swap-assets-pair.layout.tsx b/src/app/pages/swap/components/swap-assets-pair/swap-assets-pair.layout.tsx index 9e678a4a785..97066d1727d 100644 --- a/src/app/pages/swap/components/swap-assets-pair/swap-assets-pair.layout.tsx +++ b/src/app/pages/swap/components/swap-assets-pair/swap-assets-pair.layout.tsx @@ -11,15 +11,15 @@ export function SwapAssetsPairLayout({ swapAssetFrom, swapAssetTo }: SwapAssetsP {swapAssetFrom} - + {swapAssetTo} diff --git a/src/app/pages/swap/components/swap-assets-pair/swap-assets-pair.tsx b/src/app/pages/swap/components/swap-assets-pair/swap-assets-pair.tsx index a0dfb47086f..cda96afe328 100644 --- a/src/app/pages/swap/components/swap-assets-pair/swap-assets-pair.tsx +++ b/src/app/pages/swap/components/swap-assets-pair/swap-assets-pair.tsx @@ -1,18 +1,21 @@ +import { useNavigate } from 'react-router-dom'; + import { useFormikContext } from 'formik'; -import { logger } from '@shared/logger'; +import { RouteUrls } from '@shared/route-urls'; import { isUndefined } from '@shared/utils'; -import { SwapFormValues } from '../../hooks/use-swap'; +import { SwapFormValues } from '../../hooks/use-swap-form'; import { SwapAssetItemLayout } from './swap-asset-item.layout'; import { SwapAssetsPairLayout } from './swap-assets-pair.layout'; export function SwapAssetsPair() { const { values } = useFormikContext(); const { swapAmountFrom, swapAmountTo, swapAssetFrom, swapAssetTo } = values; + const navigate = useNavigate(); if (isUndefined(swapAssetFrom) || isUndefined(swapAssetTo)) { - logger.error('No asset selected to swap'); + navigate(RouteUrls.Swap, { replace: true }); return null; } @@ -20,6 +23,7 @@ export function SwapAssetsPair() { 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 1e44ce2dcb4..c2cf222318e 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 @@ -1,3 +1,5 @@ +import { ReactNode } from 'react'; + import { Box, HStack, styled } from 'leather-styles/jsx'; import { InfoIcon } from '@app/components/icons/info-icon'; @@ -6,22 +8,26 @@ import { Tooltip } from '@app/components/tooltip'; interface SwapDetailLayoutProps { title: string; tooltipLabel?: string; - value: string; + value: ReactNode; } export function SwapDetailLayout({ title, tooltipLabel, value }: SwapDetailLayoutProps) { return ( - + - {title} + + {title} + {tooltipLabel ? ( - + ) : null} - {value} + + {value} + ); } diff --git a/src/app/pages/swap/components/swap-details/swap-details.layout.tsx b/src/app/pages/swap/components/swap-details/swap-details.layout.tsx index 3fd7a3514ff..337e8d47e47 100644 --- a/src/app/pages/swap/components/swap-details/swap-details.layout.tsx +++ b/src/app/pages/swap/components/swap-details/swap-details.layout.tsx @@ -1,18 +1,11 @@ -import { Box, Stack, styled } from 'leather-styles/jsx'; +import { Stack } from 'leather-styles/jsx'; import { HasChildren } from '@app/common/has-children'; export function SwapDetailsLayout({ children }: HasChildren) { return ( - - - Swap details - - - - {children} - - + + {children} ); } 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 20be51c0b9c..4cd493adc83 100644 --- a/src/app/pages/swap/components/swap-details/swap-details.tsx +++ b/src/app/pages/swap/components/swap-details/swap-details.tsx @@ -1,8 +1,10 @@ +import { HStack, styled } from 'leather-styles/jsx'; + import { isUndefined } from '@shared/utils'; -import { convertToMoneyTypeWithDefaultOfZero } from '@app/common/money/calculate-money'; -import { formatMoney } from '@app/common/money/format-money'; +import { microStxToStx } from '@app/common/money/unit-conversion'; import { getEstimatedConfirmationTime } from '@app/common/transactions/stacks/transaction.utils'; +import { ChevronUpIcon } from '@app/components/icons/chevron-up-icon'; import { useSwapContext } from '@app/pages/swap/swap.context'; import { useStacksBlockTime } from '@app/query/stacks/info/info.hooks'; import { useCurrentNetworkState } from '@app/store/networks/networks.hooks'; @@ -24,37 +26,45 @@ export function SwapDetails() { return ( + x.name).join(' > ')} - /> - + {swapSubmissionData.router[0].name} + + {swapSubmissionData.router[1].name} + + } /> - - + + ); } diff --git a/src/app/pages/swap/components/swap-form.tsx b/src/app/pages/swap/components/swap-form.tsx index 50f631a5cda..6610dd169bd 100644 --- a/src/app/pages/swap/components/swap-form.tsx +++ b/src/app/pages/swap/components/swap-form.tsx @@ -5,17 +5,16 @@ import { noop } from '@shared/utils'; import { HasChildren } from '@app/common/has-children'; -import { useSwap } from '../hooks/use-swap'; +import { useSwapForm } from '../hooks/use-swap-form'; export function SwapForm({ children }: HasChildren) { - const { initialValues, validationSchema } = useSwap(); + const { initialValues, validationSchema } = useSwapForm(); return ( diff --git a/src/app/pages/swap/components/swap-selected-asset-from.tsx b/src/app/pages/swap/components/swap-selected-asset-from.tsx index c7efe8ef582..196b6b1406c 100644 --- a/src/app/pages/swap/components/swap-selected-asset-from.tsx +++ b/src/app/pages/swap/components/swap-selected-asset-from.tsx @@ -1,84 +1,72 @@ -import { useRef } from 'react'; - import { useField, useFormikContext } from 'formik'; -import { isDefined, isUndefined } from '@shared/utils'; +import { isUndefined } from '@shared/utils'; import { useShowFieldError } from '@app/common/form-utils'; import { formatMoneyWithoutSymbol } from '@app/common/money/format-money'; -import { useAmountAsFiat } from '../hooks/use-amount-as-fiat'; -import { SwapFormValues } from '../hooks/use-swap'; +import { useAmountAsFiat } from '../hooks/use-fiat-price'; +import { SwapFormValues } from '../hooks/use-swap-form'; import { useSwapContext } from '../swap.context'; import { SwapAmountField } from './swap-amount-field'; import { SwapSelectedAssetLayout } from './swap-selected-asset.layout'; -const sendingMaxCaption = 'Using max available'; -const sendingMaxTooltip = 'When sending max, this amount is affected by the fee you choose.'; - -const maxAvailableCaption = 'Max available in your balance'; +const availableBalanceCaption = 'Available balance'; const maxAvailableTooltip = 'Amount of funds that is immediately available for use, after taking into account any pending transactions or holds placed on your account by the protocol.'; - -const sendAnyValue = 'Send any value'; - +const sendingMaxTooltip = 'When sending max, this amount is affected by the fee you choose.'; interface SwapSelectedAssetFromProps { onChooseAsset(): void; title: string; } export function SwapSelectedAssetFrom({ onChooseAsset, title }: SwapSelectedAssetFromProps) { - const { fetchToAmount } = useSwapContext(); - const { setFieldValue, validateForm, values } = useFormikContext(); + const { fetchToAmount, isSendingMax, onSetIsSendingMax } = useSwapContext(); + const { setFieldValue, setFieldError, values } = useFormikContext(); const [amountField, amountFieldMeta, amountFieldHelpers] = useField('swapAmountFrom'); const showError = useShowFieldError('swapAmountFrom'); const [assetField] = useField('swapAssetFrom'); - const amountAsFiat = useAmountAsFiat(assetField.value.balance, amountField.value); - + const amountAsFiat = useAmountAsFiat( + assetField.value.balance, + assetField.value.price, + amountField.value + ); const formattedBalance = formatMoneyWithoutSymbol(assetField.value.balance); - - const isSendingMax = formattedBalance === values.swapAmountFrom; - - const previousFromValue = useRef(''); + const isSwapAssetFromBalanceGreaterThanZero = + values.swapAssetFrom?.balance.amount.isGreaterThan(0); async function onSetMaxBalanceAsAmountToSwap() { const { swapAssetFrom, swapAssetTo } = values; - - if (isSendingMax) { - await amountFieldHelpers.setValue(previousFromValue.current); - } else { - previousFromValue.current = values.swapAmountFrom; - await amountFieldHelpers.setValue(formattedBalance); - } - - if (isDefined(swapAssetTo) && isDefined(swapAssetFrom)) { - await setFieldValue('swapAmountTo', ''); - const toAmount = await fetchToAmount(swapAssetFrom, swapAssetTo, formattedBalance); - await setFieldValue('swapAmountTo', toAmount); - await validateForm(); - } + if (isUndefined(swapAssetFrom)) return; + onSetIsSendingMax(!isSendingMax); + await amountFieldHelpers.setValue(Number(formattedBalance)); + await amountFieldHelpers.setTouched(true); + if (isUndefined(swapAssetTo)) return; + const toAmount = await fetchToAmount(swapAssetFrom, swapAssetTo, formattedBalance); + await setFieldValue('swapAmountTo', Number(toAmount)); + setFieldError('swapAmountTo', undefined); } return ( } symbol={assetField.value.balance.symbol} title={title} tooltipLabel={isSendingMax ? sendingMaxTooltip : maxAvailableTooltip} - value={isSendingMax ? sendAnyValue : formattedBalance} + value={formattedBalance} /> ); } diff --git a/src/app/pages/swap/components/swap-selected-asset-placeholder.tsx b/src/app/pages/swap/components/swap-selected-asset-placeholder.tsx deleted file mode 100644 index 651ccdec8a9..00000000000 --- a/src/app/pages/swap/components/swap-selected-asset-placeholder.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { SwapAmountField } from './swap-amount-field'; -import { SwapSelectedAssetLayout } from './swap-selected-asset.layout'; - -interface SwapSelectedAssetPlaceholderProps { - onChooseAsset(): void; - showToggle?: boolean; - title: string; -} -export function SwapSelectedAssetPlaceholder({ - onChooseAsset, - showToggle, - title, -}: SwapSelectedAssetPlaceholderProps) { - return ( - } - symbol="Select asset" - title={title} - value="0" - /> - ); -} diff --git a/src/app/pages/swap/components/swap-selected-asset-to.tsx b/src/app/pages/swap/components/swap-selected-asset-to.tsx index fdd3809a8bd..1fd766c87c6 100644 --- a/src/app/pages/swap/components/swap-selected-asset-to.tsx +++ b/src/app/pages/swap/components/swap-selected-asset-to.tsx @@ -2,7 +2,7 @@ import { useField } from 'formik'; import { formatMoneyWithoutSymbol } from '@app/common/money/format-money'; -import { useAmountAsFiat } from '../hooks/use-amount-as-fiat'; +import { useAmountAsFiat } from '../hooks/use-fiat-price'; import { SwapAmountField } from './swap-amount-field'; import { SwapSelectedAssetLayout } from './swap-selected-asset.layout'; @@ -14,21 +14,25 @@ export function SwapSelectedAssetTo({ onChooseAsset, title }: SwapSelectedAssetT const [amountField] = useField('swapAmountTo'); const [assetField] = useField('swapAssetTo'); - const amountAsFiat = useAmountAsFiat(assetField.value.balance, amountField.value); + const amountAsFiat = useAmountAsFiat( + assetField.value?.balance, + assetField.value?.price, + amountField.value + ); return ( } - symbol={assetField.value.balance.symbol} + symbol={assetField.value?.balance.symbol ?? 'Select asset'} title={title} - value={formatMoneyWithoutSymbol(assetField.value.balance)} + value={assetField.value?.balance ? formatMoneyWithoutSymbol(assetField.value?.balance) : '0'} /> ); } diff --git a/src/app/pages/swap/components/swap-selected-asset.layout.tsx b/src/app/pages/swap/components/swap-selected-asset.layout.tsx index 4ce083fd6f0..24956ee90ed 100644 --- a/src/app/pages/swap/components/swap-selected-asset.layout.tsx +++ b/src/app/pages/swap/components/swap-selected-asset.layout.tsx @@ -4,7 +4,6 @@ import { noop } from '@shared/utils'; import { LeatherButton } from '@app/components/button/button'; import { ChevronDownIcon } from '@app/components/icons/chevron-down-icon'; -import { InfoIcon } from '@app/components/icons/info-icon'; import { Tooltip } from '@app/components/tooltip'; import { SelectedAssetField } from './selected-asset-field'; @@ -50,44 +49,54 @@ export function SwapSelectedAssetLayout({ return ( - + {title} {showToggle && } + + {icon && } {symbol} - + } contentRight={swapAmountInput} - icon={icon} name={name} + showError={showError} /> {caption ? ( - - - {error ?? caption} + + + {showError ? error : caption} - {tooltipLabel ? ( - - - - - - ) : null} - + - - {value} - + {value} ) : null} diff --git a/src/app/pages/swap/components/swap-selected-assets.tsx b/src/app/pages/swap/components/swap-selected-assets.tsx index 2fbefce8720..40937a6d73e 100644 --- a/src/app/pages/swap/components/swap-selected-assets.tsx +++ b/src/app/pages/swap/components/swap-selected-assets.tsx @@ -5,13 +5,14 @@ import { useFormikContext } from 'formik'; import { RouteUrls } from '@shared/route-urls'; import { isUndefined } from '@shared/utils'; -import { SwapFormValues } from '../hooks/use-swap'; +import { LoadingSpinner } from '@app/components/loading-spinner'; + +import { SwapFormValues } from '../hooks/use-swap-form'; import { SwapSelectedAssetFrom } from './swap-selected-asset-from'; -import { SwapSelectedAssetPlaceholder } from './swap-selected-asset-placeholder'; import { SwapSelectedAssetTo } from './swap-selected-asset-to'; -const titleFrom = 'Convert'; -const titleTo = 'To'; +const titleFrom = 'You pay'; +const titleTo = 'You receive'; export function SwapSelectedAssets() { const { values } = useFormikContext(); @@ -25,18 +26,12 @@ export function SwapSelectedAssets() { navigate(RouteUrls.SwapChooseAsset, { state: { swap: 'to' } }); } + if (isUndefined(values.swapAssetFrom)) return ; + return ( <> - {isUndefined(values.swapAssetFrom) ? ( - - ) : ( - - )} - {isUndefined(values.swapAssetTo) ? ( - - ) : ( - - )} + + ); } diff --git a/src/app/pages/swap/components/swap-status/swap-status-item.layout.tsx b/src/app/pages/swap/components/swap-status/swap-status-item.layout.tsx deleted file mode 100644 index a4b74e58f44..00000000000 --- a/src/app/pages/swap/components/swap-status/swap-status-item.layout.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { HStack, Stack, styled } from 'leather-styles/jsx'; - -import { ArrowUpIcon } from '@app/components/icons/arrow-up-icon'; -import { Flag } from '@app/components/layout/flag'; - -interface SwapStatusItemLayoutProps { - icon: React.JSX.Element; - text: string; - timestamp?: string; -} -export function SwapStatusItemLayout({ icon, text, timestamp }: SwapStatusItemLayoutProps) { - return ( - - - - {timestamp ? {timestamp} : null} - {text} - - - - - ); -} diff --git a/src/app/pages/swap/components/swap-status/swap-status.layout.tsx b/src/app/pages/swap/components/swap-status/swap-status.layout.tsx deleted file mode 100644 index 01ba1c941ac..00000000000 --- a/src/app/pages/swap/components/swap-status/swap-status.layout.tsx +++ /dev/null @@ -1,7 +0,0 @@ -import { Stack } from 'leather-styles/jsx'; - -import { HasChildren } from '@app/common/has-children'; - -export function SwapStatusLayout({ children }: HasChildren) { - return {children}; -} diff --git a/src/app/pages/swap/components/swap-status/swap-status.tsx b/src/app/pages/swap/components/swap-status/swap-status.tsx deleted file mode 100644 index 4dc40d2aeef..00000000000 --- a/src/app/pages/swap/components/swap-status/swap-status.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { displayDate, displayTime } from '@app/common/date-utils'; -import { CheckmarkIcon } from '@app/components/icons/checkmark-icon'; - -import { useSwapContext } from '../../swap.context'; -import { SwapStatusItemLayout } from './swap-status-item.layout'; -import { SwapStatusLayout } from './swap-status.layout'; - -export function SwapStatus() { - const { swapSubmissionData } = useSwapContext(); - - if (!swapSubmissionData) return null; - - return ( - - } - text="You submitted your swap" - timestamp={`${displayDate(swapSubmissionData.timestamp)} at ${displayTime( - swapSubmissionData.timestamp - )}`} - /> - {/* TODO: Use status updates with future protocols - leaving as examples from designs */} - {/* } - text="You set up your swap" - timestamp="Today at 10:14 PM" - /> - - } - text="We received your BTC" - timestamp="Today at 10:14 PM" - /> - - } text="We escrow your transaction" /> - - } text="We add your xBTC to your balance" /> */} - - ); -} diff --git a/src/app/pages/swap/components/swap-toggle-button.tsx b/src/app/pages/swap/components/swap-toggle-button.tsx index 2e44aaad484..e0ed5194d1c 100644 --- a/src/app/pages/swap/components/swap-toggle-button.tsx +++ b/src/app/pages/swap/components/swap-toggle-button.tsx @@ -1,16 +1,20 @@ import { useFormikContext } from 'formik'; import { styled } from 'leather-styles/jsx'; +import { isDefined, isUndefined } from '@shared/utils'; + import { SwapIcon } from '@app/components/icons/swap-icon'; -import { SwapFormValues } from '../hooks/use-swap'; +import { SwapFormValues } from '../hooks/use-swap-form'; import { useSwapContext } from '../swap.context'; export function SwapToggleButton() { - const { fetchToAmount } = useSwapContext(); - const { setFieldValue, values } = useFormikContext(); + const { fetchToAmount, onSetIsSendingMax } = useSwapContext(); + const { setFieldValue, validateForm, values } = useFormikContext(); async function onToggleSwapAssets() { + onSetIsSendingMax(false); + const prevAmountFrom = values.swapAmountFrom; const prevAmountTo = values.swapAmountTo; const prevAssetFrom = values.swapAssetFrom; @@ -19,16 +23,22 @@ export function SwapToggleButton() { await setFieldValue('swapAssetFrom', prevAssetTo); await setFieldValue('swapAssetTo', prevAssetFrom); await setFieldValue('swapAmountFrom', prevAmountTo); - if (prevAssetFrom != null && prevAssetTo != null && !isNaN(Number(prevAmountTo))) { - const to = await fetchToAmount(prevAssetTo, prevAssetFrom, prevAmountTo); - await setFieldValue('swapAmountTo', to); + + if (isDefined(prevAssetFrom) && isDefined(prevAssetTo)) { + const toAmount = await fetchToAmount(prevAssetTo, prevAssetFrom, prevAmountTo); + await setFieldValue('swapAmountTo', Number(toAmount)); } else { - await setFieldValue('swapAmountTo', prevAmountFrom); + await setFieldValue('swapAmountTo', Number(prevAmountFrom)); } + await validateForm(); } return ( - + ); diff --git a/src/app/pages/swap/hooks/use-alex-broadcast-swap.ts b/src/app/pages/swap/hooks/use-alex-broadcast-swap.ts new file mode 100644 index 00000000000..b3fa5bde8ed --- /dev/null +++ b/src/app/pages/swap/hooks/use-alex-broadcast-swap.ts @@ -0,0 +1,36 @@ +import { useCallback } from 'react'; +import { useNavigate } from 'react-router-dom'; + +import { AlexSDK, SponsoredTxError } from 'alex-sdk'; + +import { logger } from '@shared/logger'; +import { RouteUrls } from '@shared/route-urls'; + +import { LoadingKeys, useLoading } from '@app/common/hooks/use-loading'; +import { delay } from '@app/common/utils'; + +export function useAlexBroadcastSwap(alexSDK: AlexSDK) { + const { setIsIdle } = useLoading(LoadingKeys.SUBMIT_SWAP_TRANSACTION); + const navigate = useNavigate(); + + return useCallback( + async (txRaw: string) => { + try { + const txId = await alexSDK.broadcastSponsoredTx(txRaw); + logger.info('transaction:', txId); + await delay(1000); + setIsIdle(); + navigate(RouteUrls.Activity); + } catch (e) { + setIsIdle(); + navigate(RouteUrls.SwapError, { + state: { + message: e instanceof (Error || SponsoredTxError) ? e.message : 'Unknown error', + title: 'Failed to broadcast', + }, + }); + } + }, + [alexSDK, navigate, setIsIdle] + ); +} diff --git a/src/app/pages/swap/hooks/use-alex-swap.tsx b/src/app/pages/swap/hooks/use-alex-swap.tsx index a71ecdc6380..7f993bf7d36 100644 --- a/src/app/pages/swap/hooks/use-alex-swap.tsx +++ b/src/app/pages/swap/hooks/use-alex-swap.tsx @@ -1,6 +1,6 @@ import { useCallback, useState } from 'react'; +import { useAsync } from 'react-async-hook'; -import { useQuery } from '@tanstack/react-query'; import { AlexSDK, Currency, TokenInfo } from 'alex-sdk'; import BigNumber from 'bignumber.js'; @@ -8,9 +8,11 @@ import { logger } from '@shared/logger'; import { createMoney } from '@shared/models/money.model'; import { useAllTransferableCryptoAssetBalances } from '@app/common/hooks/use-transferable-asset-balances.hooks'; +import { convertAmountToFractionalUnit } from '@app/common/money/calculate-money'; +import { useSwappableCurrencyQuery } from '@app/query/common/alex-swaps/swappable-currency.query'; import { SwapSubmissionData } from '../swap.context'; -import { SwapAsset } from './use-swap'; +import { SwapAsset } from './use-swap-form'; export const oneHundredMillion = 100_000_000; @@ -19,46 +21,51 @@ export function useAlexSwap() { const [swapSubmissionData, setSwapSubmissionData] = useState(); const [slippage, _setSlippage] = useState(0.04); const allTransferableCryptoAssetBalances = useAllTransferableCryptoAssetBalances(); - // TODO: Relocate query - const { data: supportedCurrencies = [] } = useQuery( - ['alex-supported-swap-currencies'], - async () => alexSDK.fetchSwappableCurrency() - ); + const { data: supportedCurrencies = [] } = useSwappableCurrencyQuery(alexSDK); + const { result: prices } = useAsync(async () => await alexSDK.getLatestPrices(), [alexSDK]); const getAssetFromAlexCurrency = useCallback( (tokenInfo?: TokenInfo) => { + if (!prices) return; if (!tokenInfo) { logger.error('No token data found to swap'); return; } const currency = tokenInfo.id as Currency; + const price = convertAmountToFractionalUnit(new BigNumber(prices[currency] ?? 0), 2); if (currency === Currency.STX) { - const balance = allTransferableCryptoAssetBalances.find( - x => x.type === 'crypto-currency' && x.blockchain === 'stacks' && x.asset.symbol === 'STX' - )?.balance; + const balance = + allTransferableCryptoAssetBalances.find( + x => + x.type === 'crypto-currency' && x.blockchain === 'stacks' && x.asset.symbol === 'STX' + )?.balance ?? createMoney(0, tokenInfo.name, tokenInfo.decimals); return { + balance: balance, currency, icon: tokenInfo.icon, name: tokenInfo.name, - balance: balance ?? createMoney(0, tokenInfo.name, tokenInfo.decimals), + price: createMoney(price, 'USD'), }; } - const balance = allTransferableCryptoAssetBalances.find( - x => x.type === 'fungible-token' && alexSDK.getAddressFrom(currency) === x.asset.contractId - )?.balance; + const balance = + allTransferableCryptoAssetBalances.find( + x => + x.type === 'fungible-token' && alexSDK.getAddressFrom(currency) === x.asset.contractId + )?.balance ?? createMoney(0, tokenInfo.name, tokenInfo.decimals); return { + balance: balance, currency, icon: tokenInfo.icon, name: tokenInfo.name, - balance: balance ?? createMoney(0, tokenInfo.name, tokenInfo.decimals), + price: createMoney(price, 'USD'), }; }, - [alexSDK, allTransferableCryptoAssetBalances] + [alexSDK, allTransferableCryptoAssetBalances, prices] ); async function fetchToAmount( @@ -66,11 +73,9 @@ export function useAlexSwap() { to: SwapAsset, fromAmount: string ): Promise { - const result = await alexSDK.getAmountTo( - from.currency, - BigInt(new BigNumber(fromAmount).multipliedBy(oneHundredMillion).dp(0).toString()), - to.currency - ); + const amount = new BigNumber(fromAmount).multipliedBy(oneHundredMillion).dp(0).toString(); + const amountAsBigInt = isNaN(Number(amount)) ? BigInt(0) : BigInt(amount); + const result = await alexSDK.getAmountTo(from.currency, amountAsBigInt, to.currency); return new BigNumber(Number(result)).dividedBy(oneHundredMillion).toString(); } diff --git a/src/app/pages/swap/hooks/use-amount-as-fiat.tsx b/src/app/pages/swap/hooks/use-amount-as-fiat.tsx deleted file mode 100644 index af7381e6540..00000000000 --- a/src/app/pages/swap/hooks/use-amount-as-fiat.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { Money, createMoney } from '@shared/models/money.model'; -import { isUndefined } from '@shared/utils'; - -import { useConvertCryptoCurrencyToFiatAmount } from '@app/common/hooks/use-convert-to-fiat-amount'; -import { i18nFormatCurrency } from '@app/common/money/format-money'; -import { unitToFractionalUnit } from '@app/common/money/unit-conversion'; - -export function useAmountAsFiat(balance?: Money, value?: string) { - const convertCryptoCurrencyToUsd = useConvertCryptoCurrencyToFiatAmount(balance?.symbol ?? ''); - - if (isUndefined(balance) || isUndefined(value)) return ''; - - const convertedAmountAsMoney = convertCryptoCurrencyToUsd( - createMoney(unitToFractionalUnit(balance.decimals)(value), balance.symbol, balance.decimals) - ); - // TODO: Remove this when using live data bc amounts won't be null? - return convertedAmountAsMoney.amount.isNaN() ? '' : i18nFormatCurrency(convertedAmountAsMoney); -} diff --git a/src/app/pages/swap/hooks/use-fiat-price.tsx b/src/app/pages/swap/hooks/use-fiat-price.tsx new file mode 100644 index 00000000000..4e3f819c1bc --- /dev/null +++ b/src/app/pages/swap/hooks/use-fiat-price.tsx @@ -0,0 +1,36 @@ +import { Money, createMoney } from '@shared/models/money.model'; +import { isUndefined } from '@shared/utils'; + +import { useConvertAlexSwapCurrencyToFiatAmount } from '@app/common/hooks/use-convert-to-fiat-amount'; +import { i18nFormatCurrency } from '@app/common/money/format-money'; +import { unitToFractionalUnit } from '@app/common/money/unit-conversion'; + +export function useAmountAsFiat(balance?: Money, price?: Money, value?: string) { + const convertAlexSwapCurrencyToUsd = useConvertAlexSwapCurrencyToFiatAmount( + balance?.symbol ?? '', + price ?? createMoney(0, 'USD') + ); + + if (isUndefined(balance) || isUndefined(price) || isUndefined(value)) return ''; + + const convertedAmountAsMoney = convertAlexSwapCurrencyToUsd( + createMoney(unitToFractionalUnit(balance.decimals)(value), balance.symbol, balance.decimals) + ); + + return convertedAmountAsMoney.amount.isNaN() ? '' : i18nFormatCurrency(convertedAmountAsMoney); +} + +export function useBalanceAsFiat(balance?: Money, price?: Money) { + const convertAlexSwapCurrencyToUsd = useConvertAlexSwapCurrencyToFiatAmount( + balance?.symbol ?? '', + price ?? createMoney(0, 'USD') + ); + + if (isUndefined(balance) || isUndefined(price)) return ''; + + const convertedBalanceAsMoney = convertAlexSwapCurrencyToUsd( + createMoney(balance.amount, balance.symbol, balance.decimals) + ); + + return convertedBalanceAsMoney.amount.isNaN() ? '' : i18nFormatCurrency(convertedBalanceAsMoney); +} diff --git a/src/app/pages/swap/hooks/use-stacks-broadcast-swap.tsx b/src/app/pages/swap/hooks/use-stacks-broadcast-swap.tsx index 4f56076559a..551eb4598b3 100644 --- a/src/app/pages/swap/hooks/use-stacks-broadcast-swap.tsx +++ b/src/app/pages/swap/hooks/use-stacks-broadcast-swap.tsx @@ -1,4 +1,4 @@ -import { useCallback, useState } from 'react'; +import { useCallback } from 'react'; import toast from 'react-hot-toast'; import { useNavigate } from 'react-router-dom'; @@ -8,15 +8,11 @@ import { logger } from '@shared/logger'; import { RouteUrls } from '@shared/route-urls'; import { isString } from '@shared/utils'; -import { LoadingKeys } from '@app/common/hooks/use-loading'; +import { LoadingKeys, useLoading } from '@app/common/hooks/use-loading'; import { useSubmitTransactionCallback } from '@app/common/hooks/use-submit-stx-transaction'; -import { useSignTransactionSoftwareWallet } from '@app/store/transactions/transaction.hooks'; -// TODO: Remove if end up not needing -// ts-unused-exports:disable-next-line export function useStacksBroadcastSwap() { - const signSoftwareWalletTx = useSignTransactionSoftwareWallet(); - const [isBroadcasting, setIsBroadcasting] = useState(false); + const { setIsIdle } = useLoading(LoadingKeys.SUBMIT_SWAP_TRANSACTION); const navigate = useNavigate(); const broadcastTransactionFn = useSubmitTransactionCallback({ @@ -24,50 +20,34 @@ export function useStacksBroadcastSwap() { }); return useCallback( - (unsignedTx: StacksTransaction) => { - function handlePreviewSuccess(signedTx: StacksTransaction, txId: string) { - navigate(RouteUrls.SwapSummary, { state: { signedTx, txId } }); + async (signedTx: StacksTransaction) => { + if (!signedTx) { + logger.error('Cannot broadcast transaction, no tx in state'); + toast.error('Unable to broadcast transaction'); + return; } - - async function broadcastTransactionAction(signedTx: StacksTransaction) { - if (!signedTx) { - logger.error('Cannot broadcast transaction, no tx in state'); - toast.error('Unable to broadcast transaction'); - return; - } - try { - setIsBroadcasting(true); - await broadcastTransactionFn({ - onError(e: Error | string) { - const message = isString(e) ? e : e.message; - navigate(RouteUrls.TransactionBroadcastError, { state: { message } }); - }, - onSuccess(txId) { - handlePreviewSuccess(signedTx, txId); - }, - replaceByFee: false, - })(signedTx); - } catch (e) { - navigate(RouteUrls.TransactionBroadcastError, { - state: { message: e instanceof Error ? e.message : 'Unknown error' }, - }); - } finally { - setIsBroadcasting(false); - } - } - - async function broadcastTransaction() { - if (!unsignedTx) return; - const signedTx = signSoftwareWalletTx(unsignedTx); - if (!signedTx) return; - await broadcastTransactionAction(signedTx); + try { + await broadcastTransactionFn({ + onError(e: Error | string) { + setIsIdle(); + const message = isString(e) ? e : e.message; + navigate(RouteUrls.TransactionBroadcastError, { state: { message } }); + }, + onSuccess() { + setIsIdle(); + navigate(RouteUrls.Activity); + }, + replaceByFee: false, + })(signedTx); + } catch (e) { + setIsIdle(); + navigate(RouteUrls.TransactionBroadcastError, { + state: { message: e instanceof Error ? e.message : 'Unknown error' }, + }); + } finally { + setIsIdle(); } - - return { - stacksBroadcastTransaction: broadcastTransaction, - isBroadcasting, - }; }, - [broadcastTransactionFn, navigate, signSoftwareWalletTx, isBroadcasting] + [broadcastTransactionFn, setIsIdle, navigate] ); } diff --git a/src/app/pages/swap/hooks/use-swap.tsx b/src/app/pages/swap/hooks/use-swap-form.tsx similarity index 51% rename from src/app/pages/swap/hooks/use-swap.tsx rename to src/app/pages/swap/hooks/use-swap-form.tsx index b2e40bcd293..b506650391b 100644 --- a/src/app/pages/swap/hooks/use-swap.tsx +++ b/src/app/pages/swap/hooks/use-swap-form.tsx @@ -1,19 +1,21 @@ import { Currency } from 'alex-sdk'; +import BigNumber from 'bignumber.js'; import * as yup from 'yup'; import { FeeTypes } from '@shared/models/fees/fees.model'; import { StacksTransactionFormValues } from '@shared/models/form.model'; -import { Money } from '@shared/models/money.model'; +import { Money, createMoney } from '@shared/models/money.model'; import { FormErrorMessages } from '@app/common/error-messages'; -// import { tokenAmountValidator } from '@app/common/validation/forms/amount-validators'; -import { currencyAmountValidator } from '@app/common/validation/forms/currency-validators'; +import { convertAmountToFractionalUnit } from '@app/common/money/calculate-money'; +import { useNextNonce } from '@app/query/stacks/nonce/account-nonces.hooks'; export interface SwapAsset { balance: Money; currency: Currency; icon: string; name: string; + price: Money; } export interface SwapFormValues extends StacksTransactionFormValues { @@ -23,32 +25,48 @@ export interface SwapFormValues extends StacksTransactionFormValues { swapAssetTo?: SwapAsset; } -export function useSwap() { +export function useSwapForm() { + const { data: nextNonce } = useNextNonce(); + const initialValues: SwapFormValues = { fee: '0', feeCurrency: 'STX', feeType: FeeTypes[FeeTypes.Middle], + nonce: nextNonce?.nonce, swapAmountFrom: '', swapAmountTo: '', swapAssetFrom: undefined, swapAssetTo: undefined, }; - // TODO: Need to add insufficient balance validation - // Validate directly on Field once asset is selected? const validationSchema = yup.object({ + swapAssetFrom: yup.object().required(), + swapAssetTo: yup.object().required(), swapAmountFrom: yup .number() + .test({ + message: 'Insufficient balance', + test(value) { + const { swapAssetFrom } = this.parent; + const valueInFractionalUnit = convertAmountToFractionalUnit( + createMoney( + new BigNumber(Number(value)), + swapAssetFrom.balance.symbol, + swapAssetFrom.balance.decimals + ) + ); + if (swapAssetFrom.balance.amount.isLessThan(valueInFractionalUnit)) return false; + return true; + }, + }) .required(FormErrorMessages.AmountRequired) - .concat(currencyAmountValidator()), - // .concat(tokenAmountValidator(balance)), + .typeError(FormErrorMessages.MustBeNumber) + .positive(FormErrorMessages.MustBePositive), swapAmountTo: yup .number() .required(FormErrorMessages.AmountRequired) - .concat(currencyAmountValidator()), - // .concat(tokenAmountValidator(balance)), - swapAssetFrom: yup.object().required(), - swapAssetTo: yup.object().required(), + .typeError(FormErrorMessages.MustBeNumber) + .positive(FormErrorMessages.MustBePositive), }); return { diff --git a/src/app/pages/swap/swap-choose-asset/components/swap-asset-item.layout.tsx b/src/app/pages/swap/swap-choose-asset/components/swap-asset-item.layout.tsx index f858e2b8e12..6f85aeafa2c 100644 --- a/src/app/pages/swap/swap-choose-asset/components/swap-asset-item.layout.tsx +++ b/src/app/pages/swap/swap-choose-asset/components/swap-asset-item.layout.tsx @@ -3,9 +3,13 @@ import { Stack } from 'leather-styles/jsx'; import { HasChildren } from '@app/common/has-children'; import { Flag } from '@app/components/layout/flag'; -export function SwapAssetItemLayout({ children, icon }: HasChildren & { icon: React.JSX.Element }) { +export function SwapAssetItemLayout({ + children, + icon, + ...rest +}: HasChildren & { icon: React.JSX.Element }) { return ( - + {children} ); diff --git a/src/app/pages/swap/swap-choose-asset/components/swap-asset-item.tsx b/src/app/pages/swap/swap-choose-asset/components/swap-asset-item.tsx index c0af94974f8..79f9d313e60 100644 --- a/src/app/pages/swap/swap-choose-asset/components/swap-asset-item.tsx +++ b/src/app/pages/swap/swap-choose-asset/components/swap-asset-item.tsx @@ -1,23 +1,35 @@ import { HStack, styled } from 'leather-styles/jsx'; import { formatMoneyWithoutSymbol } from '@app/common/money/format-money'; +import { usePressable } from '@app/components/item-hover'; -import { SwapAsset } from '../../hooks/use-swap'; +import { useBalanceAsFiat } from '../../hooks/use-fiat-price'; +import { SwapAsset } from '../../hooks/use-swap-form'; import { SwapAssetItemLayout } from './swap-asset-item.layout'; interface SwapAssetItemProps { asset: SwapAsset; } export function SwapAssetItem({ asset }: SwapAssetItemProps) { + const [component, bind] = usePressable(true); + const balanceAsFiat = useBalanceAsFiat(asset.balance, asset.price); + return ( } + {...bind} > {asset.name} {formatMoneyWithoutSymbol(asset.balance)} - {asset.balance.symbol} + + {asset.balance.symbol} + + {balanceAsFiat} + + + {component} ); } diff --git a/src/app/pages/swap/swap-choose-asset/components/swap-asset-list.layout.tsx b/src/app/pages/swap/swap-choose-asset/components/swap-asset-list.layout.tsx index 55c3712a1f4..e7feee61700 100644 --- a/src/app/pages/swap/swap-choose-asset/components/swap-asset-list.layout.tsx +++ b/src/app/pages/swap/swap-choose-asset/components/swap-asset-list.layout.tsx @@ -2,7 +2,7 @@ import { Stack, StackProps } from 'leather-styles/jsx'; export function SwapAssetListLayout({ children }: StackProps) { return ( - + {children} ); diff --git a/src/app/pages/swap/swap-choose-asset/components/swap-asset-list.tsx b/src/app/pages/swap/swap-choose-asset/components/swap-asset-list.tsx index ebde46ace5c..6369953aa61 100644 --- a/src/app/pages/swap/swap-choose-asset/components/swap-asset-list.tsx +++ b/src/app/pages/swap/swap-choose-asset/components/swap-asset-list.tsx @@ -1,12 +1,12 @@ -import { useLocation, useNavigate } from 'react-router-dom'; +import { useNavigate } from 'react-router-dom'; import { useFormikContext } from 'formik'; import { styled } from 'leather-styles/jsx'; -import get from 'lodash.get'; import { useSwapContext } from '@app/pages/swap/swap.context'; -import { SwapAsset, SwapFormValues } from '../../hooks/use-swap'; +import { SwapAsset, SwapFormValues } from '../../hooks/use-swap-form'; +import { useSwapChooseAssetState } from '../swap-choose-asset'; import { SwapAssetItem } from './swap-asset-item'; import { SwapAssetListLayout } from './swap-asset-list.layout'; @@ -15,37 +15,48 @@ interface SwapAssetList { } export function SwapAssetList({ assets }: SwapAssetList) { const { fetchToAmount } = useSwapContext(); - const { setFieldValue, values } = useFormikContext(); - const location = useLocation(); + const { swapListType } = useSwapChooseAssetState(); + const { setFieldError, setFieldValue, values } = useFormikContext(); const navigate = useNavigate(); + const isFromList = swapListType === 'from'; + const isToList = swapListType === 'to'; + + const selectableAssets = assets.filter( + asset => + (isFromList && asset.name !== values.swapAssetTo?.name) || + (isToList && asset.name !== values.swapAssetFrom?.name) + ); + async function onChooseAsset(asset: SwapAsset) { let from: SwapAsset | undefined; let to: SwapAsset | undefined; - if (get(location.state, 'swap') === 'from') { + if (isFromList) { from = asset; to = values.swapAssetTo; await setFieldValue('swapAssetFrom', asset); - } else if (get(location.state, 'swap') === 'to') { + } else if (isToList) { from = values.swapAssetFrom; to = asset; await setFieldValue('swapAssetTo', asset); + setFieldError('swapAssetTo', undefined); } navigate(-1); - if (values.swapAmountFrom && from && to) { - await setFieldValue('swapAmountTo', ''); + if (from && to && values.swapAmountFrom) { const toAmount = await fetchToAmount(from, to, values.swapAmountFrom); - await setFieldValue('swapAmountTo', toAmount); + await setFieldValue('swapAmountTo', Number(toAmount)); + setFieldError('swapAmountTo', undefined); } } return ( - {assets.map(asset => ( + {selectableAssets.map(asset => ( onChooseAsset(asset)} textAlign="left" + type="button" > diff --git a/src/app/pages/swap/swap-choose-asset/swap-choose-asset.tsx b/src/app/pages/swap/swap-choose-asset/swap-choose-asset.tsx index 52c79073b3e..b997b5c4df5 100644 --- a/src/app/pages/swap/swap-choose-asset/swap-choose-asset.tsx +++ b/src/app/pages/swap/swap-choose-asset/swap-choose-asset.tsx @@ -1,17 +1,48 @@ -import { useNavigate } from 'react-router-dom'; +import { useLocation, useNavigate } from 'react-router-dom'; + +import { Box, styled } from 'leather-styles/jsx'; +import get from 'lodash.get'; import { BaseDrawer } from '@app/components/drawer/base-drawer'; import { useSwapContext } from '../swap.context'; import { SwapAssetList } from './components/swap-asset-list'; +export function useSwapChooseAssetState() { + const location = useLocation(); + const swapListType = get(location.state, 'swap') as string; + return { swapListType }; +} + export function SwapChooseAsset() { - const { swappableAssets } = useSwapContext(); + const { swappableAssetsFrom, swappableAssetsTo } = useSwapContext(); + const { swapListType } = useSwapChooseAssetState(); const navigate = useNavigate(); + const isFromList = swapListType === 'from'; + + const title = isFromList ? ( + <> + Choose asset +
+ to swap + + ) : ( + <> + Choose asset +
+ to receive + + ); + return ( - navigate(-1)}> - + navigate(-1)}> + + + {title} + + + ); } diff --git a/src/app/pages/swap/swap-container.tsx b/src/app/pages/swap/swap-container.tsx index 435e03132a5..f055bfe3584 100644 --- a/src/app/pages/swap/swap-container.tsx +++ b/src/app/pages/swap/swap-container.tsx @@ -1,4 +1,4 @@ -import { useMemo } from 'react'; +import { useMemo, useState } from 'react'; import { Outlet, useNavigate } from 'react-router-dom'; import { bytesToHex } from '@stacks/common'; @@ -9,7 +9,6 @@ import { serializeCV, serializePostCondition, } from '@stacks/transactions'; -import { SponsoredTxError } from 'alex-sdk'; import BigNumber from 'bignumber.js'; import { logger } from '@shared/logger'; @@ -17,23 +16,58 @@ import { RouteUrls } from '@shared/route-urls'; import { isDefined, isUndefined } from '@shared/utils'; import { LoadingKeys, useLoading } from '@app/common/hooks/use-loading'; -import { stxToMicroStx } from '@app/common/money/unit-conversion'; +import { NonceSetter } from '@app/components/nonce-setter'; +import { defaultFeesMinValues } from '@app/query/stacks/fees/fees.hooks'; +import { useStacksPendingTransactions } from '@app/query/stacks/mempool/mempool.hooks'; import { useCurrentStacksAccount } from '@app/store/accounts/blockchain/stacks/stacks-account.hooks'; import { useGenerateStacksContractCallUnsignedTx } from '@app/store/transactions/contract-call.hooks'; import { useSignTransactionSoftwareWallet } from '@app/store/transactions/transaction.hooks'; import { SwapContainerLayout } from './components/swap-container.layout'; import { SwapForm } from './components/swap-form'; +import { useAlexBroadcastSwap } from './hooks/use-alex-broadcast-swap'; import { oneHundredMillion, useAlexSwap } from './hooks/use-alex-swap'; -import { SwapAsset, SwapFormValues } from './hooks/use-swap'; +import { useStacksBroadcastSwap } from './hooks/use-stacks-broadcast-swap'; +import { SwapAsset, SwapFormValues } from './hooks/use-swap-form'; import { SwapContext, SwapProvider } from './swap.context'; +function sortSwappableAssetsBySymbol(swappableAssets: SwapAsset[]) { + return swappableAssets + .sort((a, b) => { + if (a.name < b.name) return -1; + if (a.name > b.name) return 1; + return 0; + }) + .sort((a, b) => { + if (a.name === 'STX') return -1; + if (b.name !== 'STX') return 1; + return 0; + }) + .sort((a, b) => { + if (a.name === 'BTC') return -1; + if (b.name !== 'BTC') return 1; + return 0; + }); +} + +function migratePositiveBalancesToTop(swappableAssets: SwapAsset[]) { + const assetsWithPositiveBalance = swappableAssets.filter(asset => + asset.balance.amount.isGreaterThan(0) + ); + const assetsWithZeroBalance = swappableAssets.filter(asset => asset.balance.amount.isEqualTo(0)); + return [...assetsWithPositiveBalance, ...assetsWithZeroBalance]; +} + export function SwapContainer() { + const [isSendingMax, setIsSendingMax] = useState(false); const navigate = useNavigate(); - const { setIsLoading, setIsIdle } = useLoading(LoadingKeys.SUBMIT_SWAP_TRANSACTION); + const { setIsLoading } = useLoading(LoadingKeys.SUBMIT_SWAP_TRANSACTION); const currentAccount = useCurrentStacksAccount(); const generateUnsignedTx = useGenerateStacksContractCallUnsignedTx(); const signSoftwareWalletTx = useSignTransactionSoftwareWallet(); + const { transactions: pendingTransactions } = useStacksPendingTransactions(); + + const isSponsoredByAlex = !pendingTransactions.length; const { alexSDK, @@ -45,8 +79,14 @@ export function SwapContainer() { swapSubmissionData, } = useAlexSwap(); + const broadcastAlexSwap = useAlexBroadcastSwap(alexSDK); + const broadcastStacksSwap = useStacksBroadcastSwap(); + const swappableAssets: SwapAsset[] = useMemo( - () => supportedCurrencies.map(getAssetFromAlexCurrency).filter(isDefined), + () => + sortSwappableAssetsBySymbol( + supportedCurrencies.map(getAssetFromAlexCurrency).filter(isDefined) + ), [getAssetFromAlexCurrency, supportedCurrencies] ); @@ -62,16 +102,17 @@ export function SwapContainer() { ]); onSetSwapSubmissionData({ - // Default to low fee for now - fee: stxToMicroStx('0.0025').toString(), + fee: isSponsoredByAlex ? '0' : defaultFeesMinValues[1].amount.toString(), feeCurrency: values.feeCurrency, feeType: values.feeType, liquidityFee: new BigNumber(Number(lpFee)).dividedBy(oneHundredMillion).toNumber(), + nonce: values.nonce, protocol: 'ALEX', router: router .map(x => getAssetFromAlexCurrency(supportedCurrencies.find(y => y.id === x))) .filter(isDefined), slippage, + sponsored: isSponsoredByAlex, swapAmountFrom: values.swapAmountFrom, swapAmountTo: values.swapAmountTo, swapAssetFrom: values.swapAssetFrom, @@ -127,6 +168,7 @@ export function SwapContainer() { fee: swapSubmissionData.fee, feeCurrency: swapSubmissionData.feeCurrency, feeType: swapSubmissionData.feeType, + nonce: swapSubmissionData.nonce, }; const payload: ContractCallPayload = { @@ -138,7 +180,7 @@ export function SwapContainer() { postConditionMode: PostConditionMode.Deny, postConditions: tx.postConditions.map(pc => bytesToHex(serializePostCondition(pc))), publicKey: currentAccount?.stxPublicKey, - sponsored: true, + sponsored: swapSubmissionData.sponsored, txType: TransactionTypes.ContractCall, }; @@ -149,33 +191,28 @@ export function SwapContainer() { if (!signedTx) return logger.error('Attempted to generate raw tx, but signed tx is undefined'); const txRaw = bytesToHex(signedTx.serialize()); - try { - const txId = await alexSDK.broadcastSponsoredTx(txRaw); - setIsIdle(); - navigate(RouteUrls.SwapSummary, { state: { txId } }); - } catch (e) { - setIsIdle(); - navigate(RouteUrls.SwapError, { - state: { - message: e instanceof (Error || SponsoredTxError) ? e.message : 'Unknown error', - title: 'Failed to broadcast', - }, - }); + if (isSponsoredByAlex) { + return await broadcastAlexSwap(txRaw); } + return await broadcastStacksSwap(unsignedTx); } const swapContextValue: SwapContext = { - swapSubmissionData, fetchToAmount, + isSendingMax, + onSetIsSendingMax: value => setIsSendingMax(value), onSubmitSwapForReview, onSubmitSwap, - swappableAssets: swappableAssets, + swappableAssetsFrom: migratePositiveBalancesToTop(swappableAssets), + swappableAssetsTo: swappableAssets, + swapSubmissionData, }; return ( + diff --git a/src/app/pages/swap/swap-summary/swap-summary-action-button.tsx b/src/app/pages/swap/swap-summary/swap-summary-action-button.tsx deleted file mode 100644 index 684e3d5c9fd..00000000000 --- a/src/app/pages/swap/swap-summary/swap-summary-action-button.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { Box, Flex, styled } from 'leather-styles/jsx'; - -import { LeatherButton } from '@app/components/button/button'; - -interface SwapSummaryActionButtonProps { - icon: React.JSX.Element; - label: string; - onClick: () => void; -} -export function SwapSummaryActionButton({ icon, label, onClick }: SwapSummaryActionButtonProps) { - return ( - - - - {label} - - {icon} - - - ); -} diff --git a/src/app/pages/swap/swap-summary/swap-summary-tabs.tsx b/src/app/pages/swap/swap-summary/swap-summary-tabs.tsx deleted file mode 100644 index ef652acaf71..00000000000 --- a/src/app/pages/swap/swap-summary/swap-summary-tabs.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { Suspense, useCallback, useMemo } from 'react'; -import { useLocation, useNavigate } from 'react-router-dom'; - -import { Box, Stack } from 'leather-styles/jsx'; - -import { RouteUrls } from '@shared/route-urls'; - -import { HasChildren } from '@app/common/has-children'; -import { LoadingSpinner } from '@app/components/loading-spinner'; -import { Tabs } from '@app/components/tabs'; - -export function SwapSummaryTabs({ children }: HasChildren) { - const navigate = useNavigate(); - const { pathname } = useLocation(); - - const tabs = useMemo( - () => [ - { slug: RouteUrls.SwapSummary, label: 'Status' }, - { slug: RouteUrls.SwapSummaryDetails, label: 'Swap details' }, - ], - [] - ); - - const getActiveTab = useCallback( - () => tabs.findIndex(tab => tab.slug === pathname), - [tabs, pathname] - ); - - const setActiveTab = useCallback( - (index: number) => navigate(tabs[index]?.slug), - [navigate, tabs] - ); - - return ( - - - }> - {children} - - - ); -} diff --git a/src/app/pages/swap/swap-summary/swap-summary.layout.tsx b/src/app/pages/swap/swap-summary/swap-summary.layout.tsx deleted file mode 100644 index fcadd615109..00000000000 --- a/src/app/pages/swap/swap-summary/swap-summary.layout.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import { Flex } from 'leather-styles/jsx'; - -import { HasChildren } from '@app/common/has-children'; - -export function SwapSummaryLayout({ children }: HasChildren) { - return ( - - {children} - - ); -} diff --git a/src/app/pages/swap/swap-summary/swap-summary.tsx b/src/app/pages/swap/swap-summary/swap-summary.tsx deleted file mode 100644 index 017c7c3b157..00000000000 --- a/src/app/pages/swap/swap-summary/swap-summary.tsx +++ /dev/null @@ -1,100 +0,0 @@ -import toast from 'react-hot-toast'; -import { Outlet, useLocation } from 'react-router-dom'; - -import WaxSeal from '@assets/illustrations/wax-seal.png'; -import { useClipboard } from '@stacks/ui'; -import { HStack, styled } from 'leather-styles/jsx'; -import get from 'lodash.get'; - -import { logger } from '@shared/logger'; -import { isUndefined } from '@shared/utils'; - -import { useAnalytics } from '@app/common/hooks/analytics/use-analytics'; -import { useExplorerLink } from '@app/common/hooks/use-explorer-link'; -import { useRouteHeader } from '@app/common/hooks/use-route-header'; -import { CopyIcon } from '@app/components/icons/copy-icon'; -import { ExternalLinkIcon } from '@app/components/icons/external-link-icon'; -import { ModalHeader } from '@app/components/modal-header'; - -import { SwapAssetsPair } from '../components/swap-assets-pair/swap-assets-pair'; -import { SwapContentLayout } from '../components/swap-content.layout'; -import { SwapFooterLayout } from '../components/swap-footer.layout'; -import { useAmountAsFiat } from '../hooks/use-amount-as-fiat'; -import { useSwapContext } from '../swap.context'; -import { SwapSummaryActionButton } from './swap-summary-action-button'; -import { SwapSummaryTabs } from './swap-summary-tabs'; -import { SwapSummaryLayout } from './swap-summary.layout'; - -function useSwapSummaryState() { - const location = useLocation(); - return { - txId: get(location.state, 'txId') as string, - }; -} - -export function SwapSummary() { - const { swapSubmissionData } = useSwapContext(); - const { txId } = useSwapSummaryState(); - const analytics = useAnalytics(); - const { onCopy } = useClipboard(''); - const { handleOpenTxLink } = useExplorerLink(); - - useRouteHeader(, true); - - const amountAsFiat = useAmountAsFiat( - swapSubmissionData?.swapAssetTo?.balance, - swapSubmissionData?.swapAmountTo - ); - - function onClickCopy() { - onCopy(); - toast.success('ID copied!'); - } - - function onClickLink() { - void analytics.track('view_swap_transaction_confirmation', { - swapSymbolFrom: swapSubmissionData?.swapAssetFrom?.balance.symbol, - swapSymbolTo: swapSubmissionData?.swapAssetTo?.balance.symbol, - }); - handleOpenTxLink({ - blockchain: 'stacks', - txId, - }); - } - - if (isUndefined(swapSubmissionData?.swapAssetTo)) { - logger.error('No asset selected for swap'); - return null; - } - - return ( - - - - - All done - - - {swapSubmissionData?.swapAmountTo} {swapSubmissionData?.swapAssetTo.balance.symbol} - - - {amountAsFiat ? `~ ${amountAsFiat}` : '~ 0'} - - - - - - - - - } - label="View details" - onClick={onClickLink} - /> - } label="Copy ID" onClick={onClickCopy} /> - - - - ); -} diff --git a/src/app/pages/swap/swap.context.ts b/src/app/pages/swap/swap.context.ts index 046eb0bc331..a74d7d12cdf 100644 --- a/src/app/pages/swap/swap.context.ts +++ b/src/app/pages/swap/swap.context.ts @@ -1,21 +1,25 @@ import { createContext, useContext } from 'react'; -import { SwapAsset, SwapFormValues } from './hooks/use-swap'; +import { SwapAsset, SwapFormValues } from './hooks/use-swap-form'; export interface SwapSubmissionData extends SwapFormValues { liquidityFee: number; protocol: string; router: SwapAsset[]; slippage: number; + sponsored: boolean; timestamp: string; } export interface SwapContext { - swapSubmissionData?: SwapSubmissionData; fetchToAmount(from: SwapAsset, to: SwapAsset, fromAmount: string): Promise; + isSendingMax: boolean; + onSetIsSendingMax(value: boolean): void; onSubmitSwapForReview(values: SwapFormValues): Promise | void; onSubmitSwap(): Promise | void; - swappableAssets: SwapAsset[]; + swappableAssetsFrom: SwapAsset[]; + swappableAssetsTo: SwapAsset[]; + swapSubmissionData?: SwapSubmissionData; } const swapContext = createContext(null); diff --git a/src/app/pages/swap/swap.routes.tsx b/src/app/pages/swap/swap.routes.tsx index d8967828fdf..110d488276f 100644 --- a/src/app/pages/swap/swap.routes.tsx +++ b/src/app/pages/swap/swap.routes.tsx @@ -4,14 +4,11 @@ import { RouteUrls } from '@shared/route-urls'; import { AccountGate } from '@app/routes/account-gate'; -import { SwapDetails } from './components/swap-details/swap-details'; -import { SwapStatus } from './components/swap-status/swap-status'; import { Swap } from './swap'; import { SwapChooseAsset } from './swap-choose-asset/swap-choose-asset'; import { SwapContainer } from './swap-container'; import { SwapError } from './swap-error/swap-error'; import { SwapReview } from './swap-review/swap-review'; -import { SwapSummary } from './swap-summary/swap-summary'; export const swapRoutes = ( } /> } /> - }> - } /> - } /> - ); diff --git a/src/app/pages/swap/swap.tsx b/src/app/pages/swap/swap.tsx index 48524959ae2..bef5d475474 100644 --- a/src/app/pages/swap/swap.tsx +++ b/src/app/pages/swap/swap.tsx @@ -1,7 +1,11 @@ +import { useEffect } from 'react'; import { Outlet } from 'react-router-dom'; import { useFormikContext } from 'formik'; +import { logger } from '@shared/logger'; +import { isUndefined } from '@shared/utils'; + import { useRouteHeader } from '@app/common/hooks/use-route-header'; import { LeatherButton } from '@app/components/button/button'; import { ModalHeader } from '@app/components/modal-header'; @@ -9,15 +13,23 @@ import { ModalHeader } from '@app/components/modal-header'; import { SwapContentLayout } from './components/swap-content.layout'; import { SwapFooterLayout } from './components/swap-footer.layout'; import { SwapSelectedAssets } from './components/swap-selected-assets'; -import { SwapFormValues } from './hooks/use-swap'; +import { SwapFormValues } from './hooks/use-swap-form'; import { useSwapContext } from './swap.context'; export function Swap() { - const { onSubmitSwapForReview } = useSwapContext(); - const { dirty, handleSubmit, isValid, values } = useFormikContext(); + const { onSubmitSwapForReview, swappableAssetsFrom } = useSwapContext(); + const { dirty, handleSubmit, isValid, setFieldValue, values } = + useFormikContext(); useRouteHeader(, true); + useEffect(() => { + const setDefaultAsset = async () => + await setFieldValue('swapAssetFrom', swappableAssetsFrom[0]); + + if (isUndefined(values.swapAssetFrom)) setDefaultAsset().catch(e => logger.error(e)); + }, [setFieldValue, swappableAssetsFrom, values.swapAssetFrom]); + return ( <> @@ -26,8 +38,8 @@ export function Swap() { { - handleSubmit(e); + onClick={async () => { + handleSubmit(); await onSubmitSwapForReview(values); }} width="100%" diff --git a/src/app/query/common/alex-swaps/swappable-currency.query.ts b/src/app/query/common/alex-swaps/swappable-currency.query.ts new file mode 100644 index 00000000000..415bbcc09df --- /dev/null +++ b/src/app/query/common/alex-swaps/swappable-currency.query.ts @@ -0,0 +1,16 @@ +import { useQuery } from '@tanstack/react-query'; +import { AlexSDK } from 'alex-sdk'; + +export function useSwappableCurrencyQuery(alexSDK: AlexSDK) { + return useQuery( + ['alex-supported-swap-currencies'], + async () => alexSDK.fetchSwappableCurrency(), + { + refetchOnMount: false, + refetchOnReconnect: false, + refetchOnWindowFocus: false, + retryDelay: 1000 * 60, + staleTime: 1000 * 60 * 10, + } + ); +} diff --git a/src/app/query/common/market-data/market-data.hooks.ts b/src/app/query/common/market-data/market-data.hooks.ts index 506bc916320..298617c9082 100644 --- a/src/app/query/common/market-data/market-data.hooks.ts +++ b/src/app/query/common/market-data/market-data.hooks.ts @@ -53,6 +53,12 @@ export function useCryptoCurrencyMarketData(currency: CryptoCurrencies): MarketD }, [binance, coincap, coingecko, currency]); } +export function useAlexMarketData(currency: CryptoCurrencies, price: Money): MarketData { + return useMemo(() => { + return createMarketData(createMarketPair(currency, 'USD'), price); + }, [currency, price]); +} + export function useCalculateBitcoinFiatValue() { const btcMarketData = useCryptoCurrencyMarketData('BTC'); diff --git a/src/app/query/stacks/fees/fees.hooks.ts b/src/app/query/stacks/fees/fees.hooks.ts index 75478e0dc23..766de8ab51c 100644 --- a/src/app/query/stacks/fees/fees.hooks.ts +++ b/src/app/query/stacks/fees/fees.hooks.ts @@ -27,7 +27,7 @@ const defaultFeesMaxValues = [ createMoney(750000, 'STX'), createMoney(2000000, 'STX'), ]; -const defaultFeesMinValues = [ +export const defaultFeesMinValues = [ createMoney(2500, 'STX'), createMoney(3000, 'STX'), createMoney(3500, 'STX'), diff --git a/src/app/query/stacks/nonce/account-nonces.utils.ts b/src/app/query/stacks/nonce/account-nonces.utils.ts index b646fa04b47..bd98a53fe74 100644 --- a/src/app/query/stacks/nonce/account-nonces.utils.ts +++ b/src/app/query/stacks/nonce/account-nonces.utils.ts @@ -97,7 +97,10 @@ export function parseAccountNoncesResponse({ const lastConfirmedTxNonceIncremented = confirmedTxsNonces.length && confirmedTxsNonces[0] + 1; const lastPendingTxNonceIncremented = lastPendingTxNonce + 1; const pendingTxsNoncesIncludesApiPossibleNextNonce = pendingTxsNonces.includes(possibleNextNonce); - const pendingTxsMissingNonces = findAnyMissingPendingTxsNonces(pendingTxsNonces); + // Make sure any pending tx nonces are not already confirmed + const pendingTxsMissingNonces = findAnyMissingPendingTxsNonces(pendingTxsNonces).filter( + nonce => !confirmedTxsNonces.includes(nonce) + ); const firstPendingMissingNonce = pendingTxsMissingNonces.sort()[0]; const hasApiMissingNonces = detectedMissingNonces?.length > 0; diff --git a/src/app/store/transactions/contract-call.hooks.ts b/src/app/store/transactions/contract-call.hooks.ts index d8058a79af8..c29e3974ab3 100644 --- a/src/app/store/transactions/contract-call.hooks.ts +++ b/src/app/store/transactions/contract-call.hooks.ts @@ -24,7 +24,7 @@ export function useGenerateStacksContractCallUnsignedTx() { const options: GenerateUnsignedTransactionOptions = { publicKey: account.stxPublicKey, - nonce: nextNonce?.nonce, + nonce: Number(values?.nonce) ?? nextNonce?.nonce, fee: values.fee ?? 0, txData: { ...payload, network }, }; diff --git a/src/app/store/transactions/requests.hooks.ts b/src/app/store/transactions/requests.hooks.ts index 1adf80e6b16..44254f8d534 100644 --- a/src/app/store/transactions/requests.hooks.ts +++ b/src/app/store/transactions/requests.hooks.ts @@ -12,9 +12,7 @@ export function useTransactionRequestState() { const requestToken = useTransactionRequest(); return useMemo(() => { - if (!requestToken) { - return null; - } + if (!requestToken) return null; return getPayloadFromToken(requestToken); }, [requestToken]); } diff --git a/src/shared/route-urls.ts b/src/shared/route-urls.ts index fd7cdb225be..e218d0d6ee4 100644 --- a/src/shared/route-urls.ts +++ b/src/shared/route-urls.ts @@ -88,8 +88,6 @@ export enum RouteUrls { SwapChooseAsset = '/swap/choose-asset', SwapError = '/swap/error', SwapReview = '/swap/review', - SwapSummary = '/swap/summary', - SwapSummaryDetails = '/swap/summary/details', // Legacy request routes ProfileUpdateRequest = '/update-profile', diff --git a/theme/semantic-tokens.ts b/theme/semantic-tokens.ts index cad026eb629..dc82e29664c 100644 --- a/theme/semantic-tokens.ts +++ b/theme/semantic-tokens.ts @@ -37,7 +37,7 @@ export const semanticTokens = defineSemanticTokens({ value: { base: '{colors.lightModeBrown.12}', _dark: '{colors.darkModeBrown.12}' }, }, 'text-subdued': { - value: { base: '{colors.lightModeBrown.11}', _dark: '{colors.darkModeBrown.11}' }, + value: { base: '{colors.lightModeBrown.8}', _dark: '{colors.darkModeBrown.8}' }, }, 'action-primary-hover': { value: { base: '{colors.lightModeBrown.10}', _dark: '{colors.darkModeBrown.10}' }, @@ -72,6 +72,9 @@ export const semanticTokens = defineSemanticTokens({ disabled: { value: { base: '{colors.blue.100}', _dark: '{colors.blue.100}' }, }, + focused: { + value: { base: '{colors.blue.500}', _dark: '{colors.blue.500}' }, + }, warning: { value: { base: '{colors.yellow.100}', _dark: '{colors.yellow.100}' }, }, diff --git a/yarn.lock b/yarn.lock index f787c37b76c..56a8420bb51 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8339,7 +8339,7 @@ ajv@^6.12.2, ajv@^6.12.3, ajv@^6.12.4, ajv@^6.12.5, ajv@^6.7.0: json-schema-traverse "^0.4.1" uri-js "^4.2.2" -alex-sdk@^0.1.22: +alex-sdk@0.1.22: version "0.1.22" resolved "https://registry.yarnpkg.com/alex-sdk/-/alex-sdk-0.1.22.tgz#ea94f2ebbb962c402ee485e10c5de5b5b66240af" integrity sha512-g8sQN5Cs8mbkbOb0sHFN//lYVlJq6jG452LGOtNSOeoP7I5WNWgkwn0OrW8jqxjanbQCgoouP4xutXky1JDIGQ==