diff --git a/package.json b/package.json index 5ab1836e548..74d81b95429 100644 --- a/package.json +++ b/package.json @@ -130,6 +130,7 @@ "@noble/secp256k1": "2.0.0", "@radix-ui/colors": "2.1.0", "@radix-ui/react-accessible-icon": "1.0.3", + "@radix-ui/react-dropdown-menu": "^2.0.6", "@radix-ui/react-switch": "1.0.3", "@radix-ui/react-tabs": "1.0.4", "@radix-ui/themes": "1.1.2", @@ -168,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/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/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/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..980088da5da 100644 --- a/src/app/pages/swap/components/selected-asset-field.tsx +++ b/src/app/pages/swap/components/selected-asset-field.tsx @@ -1,20 +1,13 @@ 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; } -export function SelectedAssetField({ - contentLeft, - contentRight, - icon, - name, -}: SelectedAssetFieldProps) { +export function SelectedAssetField({ contentLeft, contentRight, name }: 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..35969b58f34 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-assets-pair.tsx b/src/app/pages/swap/components/swap-assets-pair/swap-assets-pair.tsx index a0dfb47086f..74748b41cb4 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; } diff --git a/src/app/pages/swap/components/swap-content.layout.tsx b/src/app/pages/swap/components/swap-content.layout.tsx index dc535460247..d6e94e7d75d 100644 --- a/src/app/pages/swap/components/swap-content.layout.tsx +++ b/src/app/pages/swap/components/swap-content.layout.tsx @@ -11,8 +11,8 @@ export function SwapContentLayout({ children }: HasChildren) { flexDirection="column" maxHeight={['calc(100vh - 116px)', 'calc(85vh - 116px)']} overflowY="auto" - pb={['120px', '48px']} - pt={['space.04', '48px']} + pb={['60px', 'unset']} + pt="space.02" px="space.05" width="100%" > 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..8e8a78293f5 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 @@ -12,16 +12,16 @@ export function SwapDetailLayout({ title, tooltipLabel, value }: SwapDetailLayou return ( - {title} + {title} {tooltipLabel ? ( - + ) : null} - {value} + {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 20be51c0b9c..d830496e9cc 100644 --- a/src/app/pages/swap/components/swap-details/swap-details.tsx +++ b/src/app/pages/swap/components/swap-details/swap-details.tsx @@ -1,7 +1,5 @@ import { isUndefined } from '@shared/utils'; -import { convertToMoneyTypeWithDefaultOfZero } from '@app/common/money/calculate-money'; -import { formatMoney } from '@app/common/money/format-money'; import { getEstimatedConfirmationTime } from '@app/common/transactions/stacks/transaction.utils'; import { useSwapContext } from '@app/pages/swap/swap.context'; import { useStacksBlockTime } from '@app/query/stacks/info/info.hooks'; @@ -24,37 +22,33 @@ export function SwapDetails() { return ( + x.name).join(' > ')} /> - - - + {/* Alex transactions are sponsored */} + + ); } 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..acaa5d38177 100644 --- a/src/app/pages/swap/components/swap-selected-asset-from.tsx +++ b/src/app/pages/swap/components/swap-selected-asset-from.tsx @@ -1,14 +1,12 @@ -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 { SwapFormValues } from '../hooks/use-swap-form'; import { useSwapContext } from '../swap.context'; import { SwapAmountField } from './swap-amount-field'; import { SwapSelectedAssetLayout } from './swap-selected-asset.layout'; @@ -27,36 +25,26 @@ interface SwapSelectedAssetFromProps { 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 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) || isUndefined(swapAssetTo)) return; + onSetIsSendingMax(!isSendingMax); + await amountFieldHelpers.setValue(Number(formattedBalance)); + await amountFieldHelpers.setTouched(true); + const toAmount = await fetchToAmount(swapAssetFrom, swapAssetTo, formattedBalance); + await setFieldValue('swapAmountTo', Number(toAmount)); + setFieldError('swapAmountTo', undefined); } return ( @@ -67,11 +55,11 @@ export function SwapSelectedAssetFrom({ onChooseAsset, title }: SwapSelectedAsse name="swapAmountFrom" onChooseAsset={onChooseAsset} onClickHandler={onSetMaxBalanceAsAmountToSwap} - showError={!!showError} + showError={!!(showError && values.swapAssetTo)} swapAmountInput={ } diff --git a/src/app/pages/swap/components/swap-selected-asset-placeholder.tsx b/src/app/pages/swap/components/swap-selected-asset-placeholder.tsx index 651ccdec8a9..7727c076a52 100644 --- a/src/app/pages/swap/components/swap-selected-asset-placeholder.tsx +++ b/src/app/pages/swap/components/swap-selected-asset-placeholder.tsx @@ -14,9 +14,9 @@ export function SwapSelectedAssetPlaceholder({ return ( } + swapAmountInput={} symbol="Select asset" title={title} value="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..31b2f2fa184 100644 --- a/src/app/pages/swap/components/swap-selected-asset.layout.tsx +++ b/src/app/pages/swap/components/swap-selected-asset.layout.tsx @@ -50,32 +50,43 @@ export function SwapSelectedAssetLayout({ return ( - + {title} {showToggle && } + + {icon && } {symbol} - + } contentRight={swapAmountInput} - icon={icon} name={name} /> {caption ? ( - {error ?? caption} + {showError ? error : caption} {tooltipLabel ? ( - + @@ -83,7 +94,7 @@ export function SwapSelectedAssetLayout({ {value} diff --git a/src/app/pages/swap/components/swap-selected-assets.tsx b/src/app/pages/swap/components/swap-selected-assets.tsx index 2fbefce8720..38bf3f85741 100644 --- a/src/app/pages/swap/components/swap-selected-assets.tsx +++ b/src/app/pages/swap/components/swap-selected-assets.tsx @@ -5,7 +5,7 @@ import { useFormikContext } from 'formik'; 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 { SwapSelectedAssetFrom } from './swap-selected-asset-from'; import { SwapSelectedAssetPlaceholder } from './swap-selected-asset-placeholder'; import { SwapSelectedAssetTo } from './swap-selected-asset-to'; diff --git a/src/app/pages/swap/components/swap-status/swap-status.tsx b/src/app/pages/swap/components/swap-status/swap-status.tsx index 4dc40d2aeef..4c3ba4bbdf9 100644 --- a/src/app/pages/swap/components/swap-status/swap-status.tsx +++ b/src/app/pages/swap/components/swap-status/swap-status.tsx @@ -38,3 +38,19 @@ export function SwapStatus() { ); } + +// TODO: To be relocated/used as DotIcon when needed +// export function DotIcon(props: React.SVGProps) { +// return ( +// +// +// +// ); +// } diff --git a/src/app/pages/swap/components/swap-toggle-button.tsx b/src/app/pages/swap/components/swap-toggle-button.tsx index 2e44aaad484..86d7e40dfd4 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 } 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,12 +23,14 @@ 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-swap.tsx b/src/app/pages/swap/hooks/use-alex-swap.tsx index a71ecdc6380..1d8e5010f5e 100644 --- a/src/app/pages/swap/hooks/use-alex-swap.tsx +++ b/src/app/pages/swap/hooks/use-alex-swap.tsx @@ -10,7 +10,7 @@ import { createMoney } from '@shared/models/money.model'; import { useAllTransferableCryptoAssetBalances } from '@app/common/hooks/use-transferable-asset-balances.hooks'; import { SwapSubmissionData } from '../swap.context'; -import { SwapAsset } from './use-swap'; +import { SwapAsset } from './use-swap-form'; export const oneHundredMillion = 100_000_000; @@ -66,11 +66,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 index af7381e6540..4688de00a95 100644 --- a/src/app/pages/swap/hooks/use-amount-as-fiat.tsx +++ b/src/app/pages/swap/hooks/use-amount-as-fiat.tsx @@ -13,6 +13,6 @@ export function useAmountAsFiat(balance?: Money, value?: string) { 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-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..607d0c3ad71 100644 --- a/src/app/pages/swap/hooks/use-swap.tsx +++ b/src/app/pages/swap/hooks/use-swap-form.tsx @@ -1,13 +1,14 @@ 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; @@ -23,32 +24,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.isLessThanOrEqualTo(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..59480a7e817 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 @@ -5,7 +5,7 @@ import { Flag } from '@app/components/layout/flag'; export function SwapAssetItemLayout({ children, icon }: 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..5ff09c3974b 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 @@ -2,7 +2,7 @@ import { HStack, styled } from 'leather-styles/jsx'; import { formatMoneyWithoutSymbol } from '@app/common/money/format-money'; -import { SwapAsset } from '../../hooks/use-swap'; +import { SwapAsset } from '../../hooks/use-swap-form'; import { SwapAssetItemLayout } from './swap-asset-item.layout'; interface SwapAssetItemProps { 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..770d41fcc63 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 convert + + ) : ( + <> + 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..685408d1b56 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'; @@ -17,7 +17,8 @@ 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 { delay } from '@app/common/utils'; +import { NonceSetter } from '@app/components/nonce-setter'; 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'; @@ -25,10 +26,38 @@ import { useSignTransactionSoftwareWallet } from '@app/store/transactions/transa import { SwapContainerLayout } from './components/swap-container.layout'; import { SwapForm } from './components/swap-form'; import { oneHundredMillion, useAlexSwap } from './hooks/use-alex-swap'; -import { SwapAsset, SwapFormValues } from './hooks/use-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 currentAccount = useCurrentStacksAccount(); @@ -46,7 +75,10 @@ export function SwapContainer() { } = useAlexSwap(); const swappableAssets: SwapAsset[] = useMemo( - () => supportedCurrencies.map(getAssetFromAlexCurrency).filter(isDefined), + () => + sortSwappableAssetsBySymbol( + supportedCurrencies.map(getAssetFromAlexCurrency).filter(isDefined) + ), [getAssetFromAlexCurrency, supportedCurrencies] ); @@ -62,11 +94,11 @@ export function SwapContainer() { ]); onSetSwapSubmissionData({ - // Default to low fee for now - fee: stxToMicroStx('0.0025').toString(), + fee: '0', // Alex transactions are sponsored 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))) @@ -127,6 +159,7 @@ export function SwapContainer() { fee: swapSubmissionData.fee, feeCurrency: swapSubmissionData.feeCurrency, feeType: swapSubmissionData.feeType, + nonce: swapSubmissionData.nonce, }; const payload: ContractCallPayload = { @@ -152,7 +185,8 @@ export function SwapContainer() { try { const txId = await alexSDK.broadcastSponsoredTx(txRaw); setIsIdle(); - navigate(RouteUrls.SwapSummary, { state: { txId } }); + await delay(1000); + navigate(RouteUrls.SwapSummary, { state: { txLink: { blockchain: 'stacks', txId } } }); } catch (e) { setIsIdle(); navigate(RouteUrls.SwapError, { @@ -165,18 +199,23 @@ export function SwapContainer() { } 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.tsx b/src/app/pages/swap/swap-summary/swap-summary.tsx index 017c7c3b157..3f046516dc3 100644 --- a/src/app/pages/swap/swap-summary/swap-summary.tsx +++ b/src/app/pages/swap/swap-summary/swap-summary.tsx @@ -1,16 +1,16 @@ import toast from 'react-hot-toast'; -import { Outlet, useLocation } from 'react-router-dom'; +import { Outlet, useLocation, useNavigate } 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 { RouteUrls } from '@shared/route-urls'; import { isUndefined } from '@shared/utils'; import { useAnalytics } from '@app/common/hooks/analytics/use-analytics'; -import { useExplorerLink } from '@app/common/hooks/use-explorer-link'; +import { HandleOpenTxLinkArgs, 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'; @@ -28,14 +28,15 @@ import { SwapSummaryLayout } from './swap-summary.layout'; function useSwapSummaryState() { const location = useLocation(); return { - txId: get(location.state, 'txId') as string, + txLink: get(location.state, 'txLink') as HandleOpenTxLinkArgs, }; } export function SwapSummary() { const { swapSubmissionData } = useSwapContext(); - const { txId } = useSwapSummaryState(); + const { txLink } = useSwapSummaryState(); const analytics = useAnalytics(); + const navigate = useNavigate(); const { onCopy } = useClipboard(''); const { handleOpenTxLink } = useExplorerLink(); @@ -56,30 +57,17 @@ export function SwapSummary() { swapSymbolFrom: swapSubmissionData?.swapAssetFrom?.balance.symbol, swapSymbolTo: swapSubmissionData?.swapAssetTo?.balance.symbol, }); - handleOpenTxLink({ - blockchain: 'stacks', - txId, - }); + handleOpenTxLink(txLink); } - if (isUndefined(swapSubmissionData?.swapAssetTo)) { - logger.error('No asset selected for swap'); + if (isUndefined(swapSubmissionData) || isUndefined(swapSubmissionData.swapAssetTo)) { + navigate(RouteUrls.Swap, { replace: true }); return null; } return ( - - - All done - - - {swapSubmissionData?.swapAmountTo} {swapSubmissionData?.swapAssetTo.balance.symbol} - - - {amountAsFiat ? `~ ${amountAsFiat}` : '~ 0'} - diff --git a/src/app/pages/swap/swap.context.ts b/src/app/pages/swap/swap.context.ts index 046eb0bc331..89cf9a85440 100644 --- a/src/app/pages/swap/swap.context.ts +++ b/src/app/pages/swap/swap.context.ts @@ -1,6 +1,6 @@ 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; @@ -11,11 +11,14 @@ export interface SwapSubmissionData extends SwapFormValues { } 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.tsx b/src/app/pages/swap/swap.tsx index 48524959ae2..a999150812b 100644 --- a/src/app/pages/swap/swap.tsx +++ b/src/app/pages/swap/swap.tsx @@ -2,6 +2,9 @@ import { Outlet } from 'react-router-dom'; import { useFormikContext } from 'formik'; +import { isEmpty } from '@shared/utils'; + +import { FormErrorMessages } from '@app/common/error-messages'; import { useRouteHeader } from '@app/common/hooks/use-route-header'; import { LeatherButton } from '@app/components/button/button'; import { ModalHeader } from '@app/components/modal-header'; @@ -9,7 +12,7 @@ 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() { @@ -26,8 +29,8 @@ export function Swap() { { - handleSubmit(e); + onClick={async () => { + handleSubmit(); await onSubmitSwapForReview(values); }} width="100%" 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 29e5f9bcab9..0a96c6b14ef 100644 --- a/src/app/store/transactions/requests.hooks.ts +++ b/src/app/store/transactions/requests.hooks.ts @@ -1,7 +1,4 @@ import { useMemo } from 'react'; -import { useLocation } from 'react-router-dom'; - -import { TransactionPayload } from '@stacks/connect'; import { getPayloadFromToken } from '@shared/utils/requests'; @@ -12,16 +9,8 @@ export function useTransactionRequest() { } export function useTransactionRequestState() { - const location = useLocation(); const requestToken = useTransactionRequest(); return useMemo(() => { - if ( - typeof location.state === 'object' && - location.state != null && - 'txType' in location.state - ) { - return location.state as TransactionPayload; - } if (!requestToken) return null; return getPayloadFromToken(requestToken); }, [requestToken]); diff --git a/yarn.lock b/yarn.lock index 9933c80c6c4..d8e5d8ae7b7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2583,7 +2583,7 @@ "@radix-ui/react-use-callback-ref" "1.0.1" "@radix-ui/react-use-escape-keydown" "1.0.3" -"@radix-ui/react-dropdown-menu@^2.0.5": +"@radix-ui/react-dropdown-menu@^2.0.5", "@radix-ui/react-dropdown-menu@^2.0.6": version "2.0.6" resolved "https://registry.yarnpkg.com/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.0.6.tgz#cdf13c956c5e263afe4e5f3587b3071a25755b63" integrity sha512-i6TuFOoWmLWq+M/eCLGd/bQ2HfAX1RJgvrBQ6AQLmzfvsLdefxbWu8G9zczcPFfcSPehz9GcpF6K9QYreFV8hA== @@ -8251,7 +8251,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==