diff --git a/package.json b/package.json index e580783339e..245d6720e5c 100644 --- a/package.json +++ b/package.json @@ -169,6 +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", "are-passive-events-supported": "1.1.1", "argon2-browser": "1.18.0", "assert": "2.0.0", diff --git a/src/app/common/error-messages.ts b/src/app/common/error-messages.ts index 0e89903387f..e5a25ce7348 100644 --- a/src/app/common/error-messages.ts +++ b/src/app/common/error-messages.ts @@ -13,7 +13,7 @@ export enum FormErrorMessages { InsufficientFunds = 'Insufficient funds', MemoExceedsLimit = 'Memo must be less than 34-bytes', MustBeNumber = 'Amount must be a number', - MustBePositive = 'Amount must be positive', + MustBePositive = 'Amount must be greater than zero', MustSelectAsset = 'Select a valid token to transfer', SameAddress = 'Cannot send to yourself', TooMuchPrecision = 'Token can only have {decimals} decimals', diff --git a/src/app/common/hooks/use-bitcoin-contracts.ts b/src/app/common/hooks/use-bitcoin-contracts.ts index aec2a6700a7..ed785a13f70 100644 --- a/src/app/common/hooks/use-bitcoin-contracts.ts +++ b/src/app/common/hooks/use-bitcoin-contracts.ts @@ -252,7 +252,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..f38c9dae623 100644 --- a/src/app/common/hooks/use-convert-to-fiat-amount.ts +++ b/src/app/common/hooks/use-convert-to-fiat-amount.ts @@ -1,6 +1,7 @@ -import { useCallback } from 'react'; +import { useCallback, useMemo } from 'react'; import { CryptoCurrencies } from '@shared/models/currencies.model'; +import { createMarketData, createMarketPair } from '@shared/models/market.model'; import type { Money } from '@shared/models/money.model'; import { useCryptoCurrencyMarketData } from '@app/query/common/market-data/market-data.hooks'; @@ -15,3 +16,15 @@ export function useConvertCryptoCurrencyToFiatAmount(currency: CryptoCurrencies) [cryptoCurrencyMarketData] ); } + +export function useConvertAlexSdkCurrencyToFiatAmount(currency: CryptoCurrencies, price: Money) { + const alexCurrencyMarketData = useMemo( + () => createMarketData(createMarketPair(currency, 'USD'), price), + [currency, price] + ); + + return useCallback( + (value: Money) => baseCurrencyAmountInQuote(value, alexCurrencyMarketData), + [alexCurrencyMarketData] + ); +} diff --git a/src/app/common/hooks/use-explorer-link.ts b/src/app/common/hooks/use-explorer-link.ts index cd446dc1c7b..f118b1adb6d 100644 --- a/src/app/common/hooks/use-explorer-link.ts +++ b/src/app/common/hooks/use-explorer-link.ts @@ -10,13 +10,13 @@ import { openInNewTab } from '../utils/open-in-new-tab'; export interface HandleOpenTxLinkArgs { blockchain: Blockchains; suffix?: string; - txid: string; + txId: string; } export function useExplorerLink() { const { mode } = useCurrentNetworkState(); const handleOpenTxLink = useCallback( - ({ blockchain, suffix, txid }: HandleOpenTxLinkArgs) => - openInNewTab(makeTxExplorerLink({ blockchain, mode, suffix, txid })), + ({ blockchain, suffix, txId }: HandleOpenTxLinkArgs) => + openInNewTab(makeTxExplorerLink({ blockchain, mode, suffix, txId })), [mode] ); diff --git a/src/app/common/hooks/use-loading.ts b/src/app/common/hooks/use-loading.ts index 991b232bb4c..3d48ef662f6 100644 --- a/src/app/common/hooks/use-loading.ts +++ b/src/app/common/hooks/use-loading.ts @@ -1,10 +1,10 @@ import { useLoadingState } from '@app/store/ui/ui.hooks'; export enum LoadingKeys { - CONFIRM_DRAWER = 'loading/CONFIRM_DRAWER', INCREASE_FEE_DRAWER = 'loading/INCREASE_FEE_DRAWER', - SUBMIT_TRANSACTION = 'loading/SUBMIT_TRANSACTION', - SUBMIT_SIGNATURE = 'loading/SUBMIT_SIGNATURE', + SUBMIT_SEND_FORM_TRANSACTION = 'loading/SUBMIT_SEND_FORM_TRANSACTION', + SUBMIT_SWAP_TRANSACTION = 'loading/SUBMIT_SWAP_TRANSACTION', + SUBMIT_TRANSACTION_REQUEST = 'loading/SUBMIT_TRANSACTION_REQUEST', } export function useLoading(key: string) { 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 6a4a9d6943b..8063e2d0ce1 100644 --- a/src/app/common/money/calculate-money.ts +++ b/src/app/common/money/calculate-money.ts @@ -1,10 +1,10 @@ import { BigNumber } from 'bignumber.js'; import { MarketData, formatMarketPair } from '@shared/models/market.model'; -import { Money, createMoney } from '@shared/models/money.model'; +import { Money, NumType, createMoney } from '@shared/models/money.model'; 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'; @@ -31,6 +31,14 @@ export function convertAmountToFractionalUnit(num: Money | BigNumber, decimals?: return num.shiftedBy(decimals); } +export function convertToMoneyTypeWithDefaultOfZero( + symbol: string, + num?: NumType, + decimals?: number +) { + return createMoney(initBigNumber(num ?? 0), symbol.toUpperCase(), decimals); +} + // ts-unused-exports:disable-next-line export function convertAmountToBaseUnit(num: Money | BigNumber, decimals?: number) { if (isMoney(num)) return num.amount.shiftedBy(-num.decimals); diff --git a/src/app/common/transactions/stacks/transaction.utils.ts b/src/app/common/transactions/stacks/transaction.utils.ts index e27989125e7..d74dac8d624 100644 --- a/src/app/common/transactions/stacks/transaction.utils.ts +++ b/src/app/common/transactions/stacks/transaction.utils.ts @@ -2,6 +2,7 @@ import { bytesToHex } from '@stacks/common'; import { TransactionTypes } from '@stacks/connect'; import { CoinbaseTransaction, + NetworkBlockTimesResponse, TransactionEventFungibleAsset, } from '@stacks/stacks-blockchain-api-types'; import { @@ -126,3 +127,16 @@ export function getTxSenderAddress(tx: StacksTransaction): string | undefined { ); return txSender; } + +export function getEstimatedConfirmationTime( + isTestnet: boolean, + blockTime?: NetworkBlockTimesResponse +) { + const arrivesIn = isTestnet + ? blockTime?.testnet.target_block_time + : blockTime?.mainnet.target_block_time; + + if (!arrivesIn) return '~10 – 20 min'; + + return `~${arrivesIn / 60} min`; +} diff --git a/src/app/common/utils.ts b/src/app/common/utils.ts index 2f8314ea828..a715451fe58 100644 --- a/src/app/common/utils.ts +++ b/src/app/common/utils.ts @@ -44,19 +44,19 @@ interface MakeTxExplorerLinkArgs { blockchain: Blockchains; mode: BitcoinNetworkModes; suffix?: string; - txid: string; + txId: string; } export function makeTxExplorerLink({ blockchain, mode, suffix = '', - txid, + txId, }: MakeTxExplorerLinkArgs) { switch (blockchain) { case 'bitcoin': - return `https://mempool.space/${mode !== 'mainnet' ? mode + '/' : ''}tx/${txid}`; + return `https://mempool.space/${mode !== 'mainnet' ? mode + '/' : ''}tx/${txId}`; case 'stacks': - return `https://explorer.hiro.so/txid/${txid}?chain=${mode}${suffix}`; + return `https://explorer.hiro.so/txid/${txId}?chain=${mode}${suffix}`; default: return ''; } diff --git a/src/app/components/bitcoin-contract-entry-point/bitcoin-contract-entry-point-layout.tsx b/src/app/components/bitcoin-contract-entry-point/bitcoin-contract-entry-point-layout.tsx index e45c3a21295..3a9dd85f6ab 100644 --- a/src/app/components/bitcoin-contract-entry-point/bitcoin-contract-entry-point-layout.tsx +++ b/src/app/components/bitcoin-contract-entry-point/bitcoin-contract-entry-point-layout.tsx @@ -11,7 +11,7 @@ import { Flag } from '@app/components/layout/flag'; import { Tooltip } from '@app/components/tooltip'; import { Caption, Text } from '@app/components/typography'; -import { SmallLoadingSpinner } from '../loading-spinner'; +import { LoadingSpinner } from '../loading-spinner'; interface BitcoinContractEntryPointLayoutProps extends StackProps { balance: Money; @@ -48,7 +48,7 @@ export const BitcoinContractEntryPointLayout = forwardRefWithAs( fontVariantNumeric="tabular-nums" textAlign="right" > - {isLoading ? : formattedBalance.value} + {isLoading ? : formattedBalance.value} diff --git a/src/app/components/bitcoin-transaction-item/bitcoin-transaction-item.tsx b/src/app/components/bitcoin-transaction-item/bitcoin-transaction-item.tsx index 33ca25356e3..4bc58d0c060 100644 --- a/src/app/components/bitcoin-transaction-item/bitcoin-transaction-item.tsx +++ b/src/app/components/bitcoin-transaction-item/bitcoin-transaction-item.tsx @@ -79,7 +79,7 @@ export function BitcoinTransactionItem({ transaction, ...rest }: BitcoinTransact } handleOpenTxLink({ blockchain: 'bitcoin', - txid: transaction?.txid || '', + txId: transaction?.txid || '', }); }; diff --git a/src/app/components/generic-error/generic-error.layout.tsx b/src/app/components/generic-error/generic-error.layout.tsx index 61b67bf816c..c588c78bda6 100644 --- a/src/app/components/generic-error/generic-error.layout.tsx +++ b/src/app/components/generic-error/generic-error.layout.tsx @@ -1,7 +1,6 @@ import { ReactNode } from 'react'; import GenericError from '@assets/images/generic-error.png'; -import { Box, Text, color } from '@stacks/ui'; import { Flex, FlexProps, HStack, styled } from 'leather-styles/jsx'; import { openInNewTab } from '@app/common/utils/open-in-new-tab'; @@ -22,16 +21,8 @@ export function GenericErrorLayout(props: GenericErrorProps) { const { body, helpTextList, onClose, title, ...rest } = props; return ( - - - - + + {title} @@ -44,32 +35,30 @@ export function GenericErrorLayout(props: GenericErrorProps) { > {body} - {helpTextList} - + - Reach out to our support team - openInNewTab(supportUrl)}> + Reach out to our support team + openInNewTab(supportUrl)}> - + - - + + Close window diff --git a/src/app/components/generic-error/generic-error.tsx b/src/app/components/generic-error/generic-error.tsx index c514d3c163f..6928ff7283b 100644 --- a/src/app/components/generic-error/generic-error.tsx +++ b/src/app/components/generic-error/generic-error.tsx @@ -1,5 +1,7 @@ import { ReactNode } from 'react'; +import { FlexProps } from 'leather-styles/jsx'; + import { closeWindow } from '@shared/utils'; import { useRouteHeader } from '@app/common/hooks/use-route-header'; @@ -7,18 +9,24 @@ import { Header } from '@app/components/header'; import { GenericErrorLayout } from './generic-error.layout'; -interface GenericErrorProps { +interface GenericErrorProps extends FlexProps { body: string; helpTextList: ReactNode[]; onClose?(): void; title: string; } export function GenericError(props: GenericErrorProps) { - const { body, helpTextList, onClose = () => closeWindow(), title } = props; + const { body, helpTextList, onClose = () => closeWindow(), title, ...rest } = props; useRouteHeader(
); return ( - + ); } diff --git a/src/app/components/icons/dot-icon.tsx b/src/app/components/icons/dot-icon.tsx deleted file mode 100644 index 5d15cfb556c..00000000000 --- a/src/app/components/icons/dot-icon.tsx +++ /dev/null @@ -1,14 +0,0 @@ -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/loading-spinner.tsx b/src/app/components/loading-spinner.tsx index 1e195f10006..7eeb2633e1b 100644 --- a/src/app/components/loading-spinner.tsx +++ b/src/app/components/loading-spinner.tsx @@ -1,17 +1,12 @@ -import { Flex, FlexProps, Spinner, color } from '@stacks/ui'; +import { Spinner, SpinnerSize } from '@stacks/ui'; +import { Flex, FlexProps } from 'leather-styles/jsx'; +import { token } from 'leather-styles/tokens'; -export function LoadingSpinner(props: FlexProps) { +export function LoadingSpinner(props: { size?: SpinnerSize } & FlexProps) { + const { size = 'lg' } = props; return ( - - - ); -} - -export function SmallLoadingSpinner(props: FlexProps) { - return ( - - + ); } diff --git a/src/app/components/nonce-setter.tsx b/src/app/components/nonce-setter.tsx index 6fd0e5acb41..e528ed5c4f0 100644 --- a/src/app/components/nonce-setter.tsx +++ b/src/app/components/nonce-setter.tsx @@ -1,4 +1,4 @@ -import { useEffect } from 'react'; +import { useAsync } from 'react-async-hook'; import { useFormikContext } from 'formik'; @@ -12,10 +12,10 @@ export function NonceSetter() { >(); const { data: nextNonce } = useNextNonce(); - useEffect(() => { - if (nextNonce && !touched.nonce && values.nonce !== nextNonce.nonce) - setFieldValue('nonce', nextNonce.nonce); - // eslint-disable-next-line react-hooks/exhaustive-deps + useAsync(async () => { + if (nextNonce?.nonce && !touched.nonce && values.nonce !== nextNonce.nonce) + return await setFieldValue('nonce', nextNonce?.nonce); + return; }, [nextNonce?.nonce]); return <>; diff --git a/src/app/components/stacks-transaction-item/stacks-transaction-item.tsx b/src/app/components/stacks-transaction-item/stacks-transaction-item.tsx index 8a491299dc3..5235f4bfe84 100644 --- a/src/app/components/stacks-transaction-item/stacks-transaction-item.tsx +++ b/src/app/components/stacks-transaction-item/stacks-transaction-item.tsx @@ -52,7 +52,7 @@ export function StacksTransactionItem({ void analytics.track('view_transaction'); handleOpenTxLink({ blockchain: 'stacks', - txid: transaction?.tx_id || transferDetails?.link || '', + txId: transaction?.tx_id || transferDetails?.link || '', }); }; diff --git a/src/app/features/activity-list/components/submitted-transaction-list/submitted-transaction-item.tsx b/src/app/features/activity-list/components/submitted-transaction-list/submitted-transaction-item.tsx index 188c4fdfc15..2c458e762de 100644 --- a/src/app/features/activity-list/components/submitted-transaction-list/submitted-transaction-item.tsx +++ b/src/app/features/activity-list/components/submitted-transaction-list/submitted-transaction-item.tsx @@ -42,7 +42,7 @@ export function SubmittedTransactionItem(props: SubmittedTransactionItemProps) { handleOpenTxLink({ blockchain: 'stacks', suffix: `&submitted=true`, - txid: txId, + txId, }) } position="relative" 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..14fb48d4827 100644 --- a/src/app/features/edit-nonce-drawer/edit-nonce-drawer.tsx +++ b/src/app/features/edit-nonce-drawer/edit-nonce-drawer.tsx @@ -37,12 +37,15 @@ export function EditNonceDrawer() { useOnMount(() => setLoadedNextNonce(values.nonce)); - const onGoBack = useCallback(() => navigate('..' + search), [navigate, search]); + const onGoBack = useCallback( + () => navigate('..' + search, { replace: true }), + [navigate, search] + ); 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/features/psbt-signer/components/psbt-inputs-and-outputs/components/psbt-input-output-item.layout.tsx b/src/app/features/psbt-signer/components/psbt-inputs-and-outputs/components/psbt-input-output-item.layout.tsx index 62476e21690..8e74243b475 100644 --- a/src/app/features/psbt-signer/components/psbt-inputs-and-outputs/components/psbt-input-output-item.layout.tsx +++ b/src/app/features/psbt-signer/components/psbt-inputs-and-outputs/components/psbt-input-output-item.layout.tsx @@ -57,7 +57,7 @@ export function PsbtInputOutputItemLayout({ onClick={() => handleOpenTxLink({ blockchain: 'bitcoin', - txid: txIdHoverLabel ?? '', + txId: txIdHoverLabel ?? '', }) } variant="text" diff --git a/src/app/features/psbt-signer/components/psbt-inputs-outputs-totals/psbt-inputs-outputs-totals.tsx b/src/app/features/psbt-signer/components/psbt-inputs-outputs-totals/psbt-inputs-outputs-totals.tsx index 7ff20f7a228..de0b0cbc2f6 100644 --- a/src/app/features/psbt-signer/components/psbt-inputs-outputs-totals/psbt-inputs-outputs-totals.tsx +++ b/src/app/features/psbt-signer/components/psbt-inputs-outputs-totals/psbt-inputs-outputs-totals.tsx @@ -32,9 +32,7 @@ export function PsbtInputsOutputsTotals() { ) : null} - {showDivider ? ( - - ) : null} + {showDivider ? : null} {isReceiving ? ( diff --git a/src/app/features/psbt-signer/components/psbt-request-details-section.layout.tsx b/src/app/features/psbt-signer/components/psbt-request-details-section.layout.tsx index 4d8d63e29e9..e9e5a7299a6 100644 --- a/src/app/features/psbt-signer/components/psbt-request-details-section.layout.tsx +++ b/src/app/features/psbt-signer/components/psbt-request-details-section.layout.tsx @@ -4,15 +4,7 @@ import { HasChildren } from '@app/common/has-children'; export function PsbtRequestDetailsSectionLayout({ children, ...props }: HasChildren & StackProps) { return ( - + {children} ); diff --git a/src/app/features/stacks-transaction-request/contract-call-details/contract-call-details.tsx b/src/app/features/stacks-transaction-request/contract-call-details/contract-call-details.tsx index b71e4d8b44b..4e834fe4595 100644 --- a/src/app/features/stacks-transaction-request/contract-call-details/contract-call-details.tsx +++ b/src/app/features/stacks-transaction-request/contract-call-details/contract-call-details.tsx @@ -38,7 +38,7 @@ function ContractCallDetailsSuspense() { onClick={() => handleOpenTxLink({ blockchain: 'stacks', - txid: formatContractId(contractAddress, contractName), + txId: formatContractId(contractAddress, contractName), }) } contractAddress={contractAddress} diff --git a/src/app/features/stacks-transaction-request/submit-action.tsx b/src/app/features/stacks-transaction-request/submit-action.tsx index 98040c53568..54a8e0ad59e 100644 --- a/src/app/features/stacks-transaction-request/submit-action.tsx +++ b/src/app/features/stacks-transaction-request/submit-action.tsx @@ -21,7 +21,7 @@ function BaseConfirmButton(props: ButtonProps): React.JSX.Element { export function SubmitAction() { const { handleSubmit, values, validateForm } = useFormikContext(); const { isShowingHighFeeConfirmation, setIsShowingHighFeeConfirmation } = useDrawers(); - const { isLoading } = useLoading(LoadingKeys.SUBMIT_TRANSACTION); + const { isLoading } = useLoading(LoadingKeys.SUBMIT_TRANSACTION_REQUEST); const error = useTransactionError(); const isDisabled = !!error || Number(values.fee) < 0; diff --git a/src/app/pages/bitcoin-contract-list/components/bitcoin-contract-list-item-layout.tsx b/src/app/pages/bitcoin-contract-list/components/bitcoin-contract-list-item-layout.tsx index ed41f2ec49f..5f94bf2acf8 100644 --- a/src/app/pages/bitcoin-contract-list/components/bitcoin-contract-list-item-layout.tsx +++ b/src/app/pages/bitcoin-contract-list/components/bitcoin-contract-list-item-layout.tsx @@ -45,7 +45,7 @@ export function BitcoinContractListItemLayout({ handleOpenTxLink({ blockchain: 'bitcoin', suffix: `&submitted=true`, - txid: txId, + txId, }) } > diff --git a/src/app/pages/home/components/account-actions.tsx b/src/app/pages/home/components/account-actions.tsx index 04ab3120e9f..eca16e3585d 100644 --- a/src/app/pages/home/components/account-actions.tsx +++ b/src/app/pages/home/components/account-actions.tsx @@ -3,9 +3,9 @@ import { useLocation, useNavigate } from 'react-router-dom'; import { HomePageSelectors } from '@tests/selectors/home.selectors'; import { Flex, FlexProps } from 'leather-styles/jsx'; -import { SWAP_ENABLED } from '@shared/environment'; import { RouteUrls } from '@shared/route-urls'; +import { useWalletType } from '@app/common/use-wallet-type'; import { ArrowDown } from '@app/components/icons/arrow-down'; import { Plus2 } from '@app/components/icons/plus2'; import { SwapIcon } from '@app/components/icons/swap-icon'; @@ -18,6 +18,8 @@ export function AccountActions(props: FlexProps) { const navigate = useNavigate(); const location = useLocation(); const isBitcoinEnabled = useConfigBitcoinEnabled(); + const { whenWallet } = useWalletType(); + const receivePath = isBitcoinEnabled ? RouteUrls.Receive : `${RouteUrls.Home}${RouteUrls.ReceiveStx}`; @@ -44,14 +46,17 @@ export function AccountActions(props: FlexProps) { label="Buy" onClick={() => navigate(RouteUrls.Fund)} /> - {SWAP_ENABLED ? ( - } - label="Swap" - onClick={() => navigate(RouteUrls.Swap)} - /> - ) : null} + {whenWallet({ + software: ( + } + label="Swap" + onClick={() => navigate(RouteUrls.Swap)} + /> + ), + ledger: null, + })} ); } 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 e21974bf969..f955fd83d51 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 @@ -65,7 +65,7 @@ export function useRpcSignPsbt() { txId: txid, txLink: { blockchain: 'bitcoin', - txid: txid || '', + txId: txid || '', }, txValue: formatMoney(transferTotalAsMoney), }; diff --git a/src/app/pages/send/ordinal-inscription/sent-inscription-summary.tsx b/src/app/pages/send/ordinal-inscription/sent-inscription-summary.tsx index 09ac0b56bec..ad6111d1b37 100644 --- a/src/app/pages/send/ordinal-inscription/sent-inscription-summary.tsx +++ b/src/app/pages/send/ordinal-inscription/sent-inscription-summary.tsx @@ -41,7 +41,7 @@ export function SendInscriptionSummary() { const navigate = useNavigate(); const txLink = { blockchain: 'bitcoin' as Blockchains, - txid: txId || '', + txId, }; const { onCopy } = useClipboard(txId || ''); diff --git a/src/app/pages/send/send-crypto-asset-form/components/confirmation/send-form-confirmation.utils.tsx b/src/app/pages/send/send-crypto-asset-form/components/confirmation/send-form-confirmation.utils.tsx deleted file mode 100644 index 7f7efccf5a3..00000000000 --- a/src/app/pages/send/send-crypto-asset-form/components/confirmation/send-form-confirmation.utils.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import BigNumber from 'bignumber.js'; - -import { NumType, createMoney } from '@shared/models/money.model'; -import { isBigInt } from '@shared/utils'; - -export function convertToMoneyTypeWithDefaultOfZero( - symbol: string, - num?: NumType, - decimals?: number -) { - return createMoney( - isBigInt(num) ? new BigNumber(num.toString()) : new BigNumber(num ?? 0), - symbol.toUpperCase(), - decimals - ); -} diff --git a/src/app/pages/send/send-crypto-asset-form/family/stacks/hooks/use-stacks-broadcast-transaction.tsx b/src/app/pages/send/send-crypto-asset-form/family/stacks/hooks/use-stacks-broadcast-transaction.tsx index 6f735bfb0b4..0f48f98487d 100644 --- a/src/app/pages/send/send-crypto-asset-form/family/stacks/hooks/use-stacks-broadcast-transaction.tsx +++ b/src/app/pages/send/send-crypto-asset-form/family/stacks/hooks/use-stacks-broadcast-transaction.tsx @@ -26,7 +26,7 @@ export function useStacksBroadcastTransaction( const navigate = useNavigate(); const broadcastTransactionFn = useSubmitTransactionCallback({ - loadingKey: LoadingKeys.CONFIRM_DRAWER, + loadingKey: LoadingKeys.SUBMIT_SEND_FORM_TRANSACTION, }); return useMemo(() => { diff --git a/src/app/pages/send/send-crypto-asset-form/family/stacks/hooks/use-stacks-transaction-summary.ts b/src/app/pages/send/send-crypto-asset-form/family/stacks/hooks/use-stacks-transaction-summary.ts index 7d99d1c2205..baed2213cf2 100644 --- a/src/app/pages/send/send-crypto-asset-form/family/stacks/hooks/use-stacks-transaction-summary.ts +++ b/src/app/pages/send/send-crypto-asset-form/family/stacks/hooks/use-stacks-transaction-summary.ts @@ -11,15 +11,18 @@ import BigNumber from 'bignumber.js'; import { CryptoCurrencies } from '@shared/models/currencies.model'; import { createMoney } from '@shared/models/money.model'; +import { removeTrailingNullCharacters } from '@shared/utils'; -import { baseCurrencyAmountInQuote } from '@app/common/money/calculate-money'; +import { + baseCurrencyAmountInQuote, + convertToMoneyTypeWithDefaultOfZero, +} from '@app/common/money/calculate-money'; import { formatMoney, i18nFormatCurrency } from '@app/common/money/format-money'; +import { getEstimatedConfirmationTime } from '@app/common/transactions/stacks/transaction.utils'; import { useCryptoCurrencyMarketData } from '@app/query/common/market-data/market-data.hooks'; import { useStacksBlockTime } from '@app/query/stacks/info/info.hooks'; import { useCurrentNetworkState } from '@app/store/networks/networks.hooks'; -import { convertToMoneyTypeWithDefaultOfZero } from '../../../components/confirmation/send-form-confirmation.utils'; - export function useStacksTransactionSummary(token: CryptoCurrencies) { const tokenMarketData = useCryptoCurrencyMarketData(token); const { isTestnet } = useCurrentNetworkState(); @@ -54,7 +57,7 @@ export function useStacksTransactionSummary(token: CryptoCurrencies) { recipient: addressToString(payload.recipient.address), fee: formatMoney(convertToMoneyTypeWithDefaultOfZero('STX', Number(fee))), totalSpend: formatMoney(convertToMoneyTypeWithDefaultOfZero('STX', Number(txValue + fee))), - arrivesIn: getArrivesInTime(), + arrivesIn: getEstimatedConfirmationTime(isTestnet, blockTime), symbol: 'STX', txValue: microStxToStx(Number(txValue)), sendingValue: formatMoney(convertToMoneyTypeWithDefaultOfZero('STX', Number(txValue))), @@ -91,7 +94,7 @@ export function useStacksTransactionSummary(token: CryptoCurrencies) { return { recipient: cvToString(payload.functionArgs[2]), - arrivesIn: getArrivesInTime(), + arrivesIn: getEstimatedConfirmationTime(isTestnet, blockTime), txValue: new BigNumber(txValue).shiftedBy(-decimals).toString(), nonce: String(tx.auth.spendingCondition.nonce), fee: feeValue, @@ -104,22 +107,6 @@ export function useStacksTransactionSummary(token: CryptoCurrencies) { }; } - function getArrivesInTime() { - let arrivesIn = isTestnet - ? blockTime?.testnet.target_block_time - : blockTime?.mainnet.target_block_time; - if (!arrivesIn) { - return '~10 – 20 min'; - } - - arrivesIn = arrivesIn / 60; - return `~${arrivesIn} min`; - } - - function removeTrailingNullCharacters(s: string) { - return s.replace(/\0*$/g, ''); - } - return { formSentSummaryTxState, formReviewTxSummary, diff --git a/src/app/pages/swap/components/selected-asset-field.tsx b/src/app/pages/swap/components/selected-asset-field.tsx index b5eef81350c..aa47dbe4784 100644 --- a/src/app/pages/swap/components/selected-asset-field.tsx +++ b/src/app/pages/swap/components/selected-asset-field.tsx @@ -1,48 +1,40 @@ import { Field } from 'formik'; -import { Box, Flex, HStack, styled } from 'leather-styles/jsx'; - -import { Flag } from '@app/components/layout/flag'; +import { Box, HStack, styled } 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 afd3fbb19cd..50a2e44dd60 100644 --- a/src/app/pages/swap/components/swap-amount-field.tsx +++ b/src/app/pages/swap/components/swap-amount-field.tsx @@ -1,58 +1,83 @@ import { ChangeEvent } from 'react'; -import { Input, Stack, color } from '@stacks/ui'; +import BigNumber from 'bignumber.js'; import { useField, useFormikContext } from 'formik'; +import { Stack, styled } from 'leather-styles/jsx'; + +import { createMoney } from '@shared/models/money.model'; +import { isDefined, isUndefined } from '@shared/utils'; import { useShowFieldError } from '@app/common/form-utils'; -import { Caption } from '@app/components/typography'; +import { convertAmountToFractionalUnit } from '@app/common/money/calculate-money'; +import { formatMoneyWithoutSymbol } from '@app/common/money/format-money'; -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 { exchangeRate, onSetIsSendingMax } = useSwapContext(); - const { setFieldValue } = useFormikContext(); + const { fetchToAmount, isFetchingExchangeRate, onSetIsSendingMax } = useSwapContext(); + const { setFieldError, setFieldValue, values } = useFormikContext(); const [field] = useField(name); - const showError = useShowFieldError(name); + const showError = useShowFieldError(name) && name === 'swapAmountFrom' && values.swapAssetTo; - async function onChange(event: ChangeEvent) { + async function onBlur(event: ChangeEvent) { + const { swapAssetFrom, swapAssetTo } = values; + if (isUndefined(swapAssetFrom) || isUndefined(swapAssetTo)) return; onSetIsSendingMax(false); const value = event.currentTarget.value; - await setFieldValue('swapAmountTo', Number(value) * exchangeRate); - field.onChange(event); + const toAmount = await fetchToAmount(swapAssetFrom, swapAssetTo, value); + if (isUndefined(toAmount)) { + await setFieldValue('swapAmountTo', ''); + return; + } + const toAmountAsMoney = createMoney( + convertAmountToFractionalUnit(new BigNumber(toAmount), values.swapAssetTo?.balance.decimals), + values.swapAssetTo?.balance.symbol ?? '', + values.swapAssetTo?.balance.decimals + ); + await setFieldValue('swapAmountTo', formatMoneyWithoutSymbol(toAmountAsMoney)); + setFieldError('swapAmountTo', undefined); } return ( - - - {name} - - + - - {amountAsFiat} - + {amountAsFiat ? ( + + {amountAsFiat} + + ) : null} ); } 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..4ef675deb52 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 @@ -9,17 +9,16 @@ interface SwapAssetsPairLayoutProps { export function SwapAssetsPairLayout({ swapAssetFrom, swapAssetTo }: SwapAssetsPairLayoutProps) { return ( {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 b6377926500..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-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..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 27c5fe64eab..eee3d91f041 100644 --- a/src/app/pages/swap/components/swap-details/swap-details.tsx +++ b/src/app/pages/swap/components/swap-details/swap-details.tsx @@ -1,14 +1,87 @@ +import BigNumber from 'bignumber.js'; +import { HStack, styled } from 'leather-styles/jsx'; + +import { createMoney } from '@shared/models/money.model'; +import { isDefined, isUndefined } from '@shared/utils'; + +import { formatMoneyPadded } 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 { SwapSubmissionData, useSwapContext } from '@app/pages/swap/swap.context'; +import { useStacksBlockTime } from '@app/query/stacks/info/info.hooks'; +import { useCurrentNetworkState } from '@app/store/networks/networks.hooks'; + import { SwapDetailLayout } from './swap-detail.layout'; import { SwapDetailsLayout } from './swap-details.layout'; -// TODO: Replace with live data +function RouteNames(props: { swapSubmissionData: SwapSubmissionData }) { + return props.swapSubmissionData.router.map((route, i) => { + const insertIcon = isDefined(props.swapSubmissionData.router[i + 1]); + return ( + <> + {route.name} + {insertIcon && } + + ); + }); +} + export function SwapDetails() { + const { swapSubmissionData } = useSwapContext(); + const { isTestnet } = useCurrentNetworkState(); + const { data: blockTime } = useStacksBlockTime(); + + if ( + isUndefined(swapSubmissionData) || + isUndefined(swapSubmissionData.swapAssetFrom) || + isUndefined(swapSubmissionData.swapAssetTo) + ) + return null; + + const formattedMinToReceive = formatMoneyPadded( + createMoney( + new BigNumber(swapSubmissionData.swapAmountTo).times(1 - swapSubmissionData.slippage), + swapSubmissionData.swapAssetTo.balance.symbol, + swapSubmissionData.swapAssetTo.balance.decimals + ) + ); + return ( - - - - + + + + + } + /> + + + + + + ); } diff --git a/src/app/pages/swap/components/swap-form.tsx b/src/app/pages/swap/components/swap-form.tsx index 50f631a5cda..90915b4bdfd 100644 --- a/src/app/pages/swap/components/swap-form.tsx +++ b/src/app/pages/swap/components/swap-form.tsx @@ -1,21 +1,21 @@ import { Form, Formik } from 'formik'; import { Box } from 'leather-styles/jsx'; -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'; +import { useSwapContext } from '../swap.context'; export function SwapForm({ children }: HasChildren) { - const { initialValues, validationSchema } = useSwap(); + const { initialValues, validationSchema } = useSwapForm(); + const { onSubmitSwapForReview } = useSwapContext(); 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 71a781ea15d..3a493dadef4 100644 --- a/src/app/pages/swap/components/swap-selected-asset-from.tsx +++ b/src/app/pages/swap/components/swap-selected-asset-from.tsx @@ -1,69 +1,85 @@ +import BigNumber from 'bignumber.js'; import { useField, useFormikContext } from 'formik'; +import { createMoney } from '@shared/models/money.model'; import { isUndefined } from '@shared/utils'; import { useShowFieldError } from '@app/common/form-utils'; +import { convertAmountToFractionalUnit } from '@app/common/money/calculate-money'; import { formatMoneyWithoutSymbol } from '@app/common/money/format-money'; -import { useAmountAsFiat } from '../hooks/use-amount-as-fiat'; -import { SwapFormValues } from '../hooks/use-swap'; +import { useAlexSdkAmountAsFiat } from '../hooks/use-alex-sdk-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'; - + 'Amount of funds that are immediately available for use, after taking into account any pending transactions or holds placed on your account by the protocol.'; +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 { exchangeRate, isSendingMax, onSetIsSendingMax } = useSwapContext(); - const { setFieldValue, validateForm, values } = useFormikContext(); + const { fetchToAmount, isFetchingExchangeRate, 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(amountField.value, assetField.value.balance); - + const amountAsFiat = useAlexSdkAmountAsFiat( + assetField.value.balance, + assetField.value.price, + amountField.value + ); const formattedBalance = formatMoneyWithoutSymbol(assetField.value.balance); + const isSwapAssetFromBalanceGreaterThanZero = + values.swapAssetFrom?.balance.amount.isGreaterThan(0); async function onSetMaxBalanceAsAmountToSwap() { - if (isUndefined(values.swapAssetTo)) return; + const { swapAssetFrom, swapAssetTo } = values; + if (isFetchingExchangeRate || isUndefined(swapAssetFrom)) return; onSetIsSendingMax(!isSendingMax); - const value = isSendingMax ? '' : formattedBalance; - await amountFieldHelpers.setValue(value); - await setFieldValue('swapAmountTo', Number(value) * exchangeRate); - await validateForm(); + await amountFieldHelpers.setValue(Number(formattedBalance)); + await amountFieldHelpers.setTouched(true); + if (isUndefined(swapAssetTo)) return; + const toAmount = await fetchToAmount(swapAssetFrom, swapAssetTo, formattedBalance); + if (isUndefined(toAmount)) { + await setFieldValue('swapAmountTo', ''); + return; + } + const toAmountAsMoney = createMoney( + convertAmountToFractionalUnit(new BigNumber(toAmount), values.swapAssetTo?.balance.decimals), + values.swapAssetTo?.balance.symbol ?? '', + values.swapAssetTo?.balance.decimals + ); + await setFieldValue('swapAmountTo', formatMoneyWithoutSymbol(toAmountAsMoney)); + setFieldError('swapAmountTo', undefined); } return ( } - symbol={assetField.value.balance.symbol} + symbol={assetField.value.name} 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 19472289e7b..7e2c94f7049 100644 --- a/src/app/pages/swap/components/swap-selected-asset-to.tsx +++ b/src/app/pages/swap/components/swap-selected-asset-to.tsx @@ -1,8 +1,10 @@ import { useField } from 'formik'; import { formatMoneyWithoutSymbol } from '@app/common/money/format-money'; +import { LoadingSpinner } from '@app/components/loading-spinner'; -import { useAmountAsFiat } from '../hooks/use-amount-as-fiat'; +import { useAlexSdkAmountAsFiat } from '../hooks/use-alex-sdk-fiat-price'; +import { useSwapContext } from '../swap.context'; import { SwapAmountField } from './swap-amount-field'; import { SwapSelectedAssetLayout } from './swap-selected-asset.layout'; @@ -11,24 +13,33 @@ interface SwapSelectedAssetToProps { title: string; } export function SwapSelectedAssetTo({ onChooseAsset, title }: SwapSelectedAssetToProps) { + const { isFetchingExchangeRate } = useSwapContext(); const [amountField] = useField('swapAmountTo'); const [assetField] = useField('swapAssetTo'); - const amountAsFiat = useAmountAsFiat(amountField.value, assetField.value.balance); + const amountAsFiat = useAlexSdkAmountAsFiat( + assetField.value?.balance, + assetField.value?.price, + amountField.value + ); return ( + isFetchingExchangeRate ? ( + + ) : ( + + ) } - symbol={assetField.value.balance.symbol} + symbol={assetField.value?.name ?? '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..7def032844c 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,48 @@ 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..3c685bc4597 100644 --- a/src/app/pages/swap/components/swap-selected-assets.tsx +++ b/src/app/pages/swap/components/swap-selected-assets.tsx @@ -1,20 +1,14 @@ import { useNavigate } from 'react-router-dom'; -import { useFormikContext } from 'formik'; - import { RouteUrls } from '@shared/route-urls'; -import { isUndefined } from '@shared/utils'; -import { SwapFormValues } from '../hooks/use-swap'; 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(); const navigate = useNavigate(); function onChooseAssetFrom() { @@ -27,16 +21,8 @@ export function SwapSelectedAssets() { 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 af47184b6ea..00000000000 --- a/src/app/pages/swap/components/swap-status/swap-status.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { DashedHr } from '@app/components/hr'; -import { CheckmarkIcon } from '@app/components/icons/checkmark-icon'; -import { DotIcon } from '@app/components/icons/dot-icon'; - -import { SwapStatusItemLayout } from './swap-status-item.layout'; -import { SwapStatusLayout } from './swap-status.layout'; - -// TODO: Replace with live data -export function SwapStatus() { - return ( - - } - 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 150d7fca3e5..6ffafcff1e4 100644 --- a/src/app/pages/swap/components/swap-toggle-button.tsx +++ b/src/app/pages/swap/components/swap-toggle-button.tsx @@ -1,30 +1,48 @@ 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 { onSetIsSendingMax } = useSwapContext(); - const { setFieldValue, values } = useFormikContext(); + const { fetchToAmount, isFetchingExchangeRate, onSetIsSendingMax } = useSwapContext(); + const { setFieldValue, validateForm, values } = useFormikContext(); async function onToggleSwapAssets() { onSetIsSendingMax(false); + const prevAmountFrom = values.swapAmountFrom; const prevAmountTo = values.swapAmountTo; const prevAssetFrom = values.swapAssetFrom; const prevAssetTo = values.swapAssetTo; - await setFieldValue('swapAmountFrom', prevAmountTo); - await setFieldValue('swapAmountTo', prevAmountFrom); await setFieldValue('swapAssetFrom', prevAssetTo); await setFieldValue('swapAssetTo', prevAssetFrom); + await setFieldValue('swapAmountFrom', prevAmountTo); + + if (isDefined(prevAssetFrom) && isDefined(prevAssetTo)) { + const toAmount = await fetchToAmount(prevAssetTo, prevAssetFrom, prevAmountTo); + if (isUndefined(toAmount)) { + await setFieldValue('swapAmountTo', ''); + return; + } + await setFieldValue('swapAmountTo', Number(toAmount)); + } else { + 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-sdk-fiat-price.tsx b/src/app/pages/swap/hooks/use-alex-sdk-fiat-price.tsx new file mode 100644 index 00000000000..8869d579507 --- /dev/null +++ b/src/app/pages/swap/hooks/use-alex-sdk-fiat-price.tsx @@ -0,0 +1,36 @@ +import { Money, createMoney } from '@shared/models/money.model'; +import { isUndefined } from '@shared/utils'; + +import { useConvertAlexSdkCurrencyToFiatAmount } 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 useAlexSdkAmountAsFiat(balance?: Money, price?: Money, value?: string) { + const convertAlexSdkCurrencyToUsd = useConvertAlexSdkCurrencyToFiatAmount( + balance?.symbol ?? '', + price ?? createMoney(0, 'USD') + ); + + if (isUndefined(balance) || isUndefined(price) || isUndefined(value)) return ''; + + const convertedAmountAsMoney = convertAlexSdkCurrencyToUsd( + createMoney(unitToFractionalUnit(balance.decimals)(value), balance.symbol, balance.decimals) + ); + + return convertedAmountAsMoney.amount.isNaN() ? '' : i18nFormatCurrency(convertedAmountAsMoney); +} + +export function useAlexSdkBalanceAsFiat(balance?: Money, price?: Money) { + const convertAlexSdkCurrencyToUsd = useConvertAlexSdkCurrencyToFiatAmount( + balance?.symbol ?? '', + price ?? createMoney(0, 'USD') + ); + + if (isUndefined(balance) || isUndefined(price)) return ''; + + const convertedBalanceAsMoney = convertAlexSdkCurrencyToUsd( + createMoney(balance.amount, balance.symbol, balance.decimals) + ); + + return convertedBalanceAsMoney.amount.isNaN() ? '' : i18nFormatCurrency(convertedBalanceAsMoney); +} diff --git a/src/app/pages/swap/hooks/use-alex-swap.tsx b/src/app/pages/swap/hooks/use-alex-swap.tsx new file mode 100644 index 00000000000..eee4fdb2a2a --- /dev/null +++ b/src/app/pages/swap/hooks/use-alex-swap.tsx @@ -0,0 +1,100 @@ +import { useCallback, useState } from 'react'; +import { useAsync } from 'react-async-hook'; + +import { AlexSDK, Currency, TokenInfo } from 'alex-sdk'; +import BigNumber from 'bignumber.js'; + +import { logger } from '@shared/logger'; +import { createMoney } from '@shared/models/money.model'; + +import { useStxBalance } from '@app/common/hooks/balance/stx/use-stx-balance'; +import { convertAmountToFractionalUnit } from '@app/common/money/calculate-money'; +import { useSwappableCurrencyQuery } from '@app/query/common/alex-swaps/swappable-currency.query'; +import { useTransferableStacksFungibleTokenAssetBalances } from '@app/query/stacks/balance/stacks-ft-balances.hooks'; +import { useCurrentStacksAccount } from '@app/store/accounts/blockchain/stacks/stacks-account.hooks'; + +import { SwapSubmissionData } from '../swap.context'; +import { SwapAsset } from './use-swap-form'; + +export const oneHundredMillion = 100_000_000; + +export function useAlexSwap() { + const alexSDK = useState(() => new AlexSDK())[0]; + const [swapSubmissionData, setSwapSubmissionData] = useState(); + const [slippage, _setSlippage] = useState(0.04); + const [isFetchingExchangeRate, setIsFetchingExchangeRate] = useState(false); + const { data: supportedCurrencies = [] } = useSwappableCurrencyQuery(alexSDK); + const { result: prices } = useAsync(async () => await alexSDK.getLatestPrices(), [alexSDK]); + const { availableBalance: availableStxBalance } = useStxBalance(); + const account = useCurrentStacksAccount(); + const stacksFtAssetBalances = useTransferableStacksFungibleTokenAssetBalances( + account?.address ?? '' + ); + + const createSwapAssetFromAlexCurrency = 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); + const swapAsset = { + currency, + icon: tokenInfo.icon, + name: tokenInfo.name, + price: createMoney(price, 'USD'), + }; + + if (currency === Currency.STX) { + return { + ...swapAsset, + balance: availableStxBalance, + }; + } + + const fungibleTokenBalance = + stacksFtAssetBalances.find(x => alexSDK.getAddressFrom(currency) === x.asset.contractId) + ?.balance ?? createMoney(0, tokenInfo.name, tokenInfo.decimals); + + return { + ...swapAsset, + balance: fungibleTokenBalance, + }; + }, + [alexSDK, availableStxBalance, prices, stacksFtAssetBalances] + ); + + async function fetchToAmount( + from: SwapAsset, + to: SwapAsset, + fromAmount: string + ): Promise { + const amount = new BigNumber(fromAmount).multipliedBy(oneHundredMillion).dp(0).toString(); + const amountAsBigInt = isNaN(Number(amount)) ? BigInt(0) : BigInt(amount); + try { + setIsFetchingExchangeRate(true); + const result = await alexSDK.getAmountTo(from.currency, amountAsBigInt, to.currency); + setIsFetchingExchangeRate(false); + return new BigNumber(Number(result)).dividedBy(oneHundredMillion).toString(); + } catch (e) { + logger.error('Error fetching exchange rate from ALEX', e); + setIsFetchingExchangeRate(false); + return; + } + } + + return { + alexSDK, + fetchToAmount, + createSwapAssetFromAlexCurrency, + isFetchingExchangeRate, + onSetIsFetchingExchangeRate: (value: boolean) => setIsFetchingExchangeRate(value), + onSetSwapSubmissionData: (value: SwapSubmissionData) => setSwapSubmissionData(value), + slippage, + supportedCurrencies, + swapSubmissionData, + }; +} 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 efd8614a748..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(value: string, balance?: Money) { - const convertCryptoCurrencyToUsd = useConvertCryptoCurrencyToFiatAmount(balance?.symbol ?? ''); - - if (isUndefined(balance)) 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-stacks-broadcast-swap.tsx b/src/app/pages/swap/hooks/use-stacks-broadcast-swap.tsx new file mode 100644 index 00000000000..551eb4598b3 --- /dev/null +++ b/src/app/pages/swap/hooks/use-stacks-broadcast-swap.tsx @@ -0,0 +1,53 @@ +import { useCallback } from 'react'; +import toast from 'react-hot-toast'; +import { useNavigate } from 'react-router-dom'; + +import { StacksTransaction } from '@stacks/transactions'; + +import { logger } from '@shared/logger'; +import { RouteUrls } from '@shared/route-urls'; +import { isString } from '@shared/utils'; + +import { LoadingKeys, useLoading } from '@app/common/hooks/use-loading'; +import { useSubmitTransactionCallback } from '@app/common/hooks/use-submit-stx-transaction'; + +export function useStacksBroadcastSwap() { + const { setIsIdle } = useLoading(LoadingKeys.SUBMIT_SWAP_TRANSACTION); + const navigate = useNavigate(); + + const broadcastTransactionFn = useSubmitTransactionCallback({ + loadingKey: LoadingKeys.SUBMIT_SWAP_TRANSACTION, + }); + + return useCallback( + async (signedTx: StacksTransaction) => { + if (!signedTx) { + logger.error('Cannot broadcast transaction, no tx in state'); + toast.error('Unable to broadcast transaction'); + return; + } + 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(); + } + }, + [broadcastTransactionFn, setIsIdle, navigate] + ); +} diff --git a/src/app/pages/swap/hooks/use-swap-form.tsx b/src/app/pages/swap/hooks/use-swap-form.tsx new file mode 100644 index 00000000000..b506650391b --- /dev/null +++ b/src/app/pages/swap/hooks/use-swap-form.tsx @@ -0,0 +1,76 @@ +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, createMoney } from '@shared/models/money.model'; + +import { FormErrorMessages } from '@app/common/error-messages'; +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 { + swapAmountFrom: string; + swapAmountTo: string; + swapAssetFrom?: SwapAsset; + swapAssetTo?: SwapAsset; +} + +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, + }; + + 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) + .typeError(FormErrorMessages.MustBeNumber) + .positive(FormErrorMessages.MustBePositive), + swapAmountTo: yup + .number() + .required(FormErrorMessages.AmountRequired) + .typeError(FormErrorMessages.MustBeNumber) + .positive(FormErrorMessages.MustBePositive), + }); + + return { + initialValues, + validationSchema, + }; +} diff --git a/src/app/pages/swap/hooks/use-swap.tsx b/src/app/pages/swap/hooks/use-swap.tsx deleted file mode 100644 index 26cb0e3d84d..00000000000 --- a/src/app/pages/swap/hooks/use-swap.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import * as yup from 'yup'; - -import { Money } 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'; - -export interface SwapAsset { - balance: Money; - icon: string; - name: string; -} - -export interface SwapFormValues { - swapAmountFrom: string; - swapAmountTo: string; - swapAssetFrom?: SwapAsset; - swapAssetTo?: SwapAsset; -} - -export function useSwap() { - const initialValues: SwapFormValues = { - 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({ - swapAmountFrom: yup - .number() - .required(FormErrorMessages.AmountRequired) - .concat(currencyAmountValidator()), - // .concat(tokenAmountValidator(balance)), - swapAmountTo: yup - .number() - .required(FormErrorMessages.AmountRequired) - .concat(currencyAmountValidator()), - // .concat(tokenAmountValidator(balance)), - swapAssetFrom: yup.object().required(), - swapAssetTo: yup.object().required(), - }); - - return { - initialValues, - validationSchema, - }; -} 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..42e4d9baea0 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 { useAlexSdkBalanceAsFiat } from '../../hooks/use-alex-sdk-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 = useAlexSdkBalanceAsFiat(asset.balance, asset.price); + return ( } + {...bind} > {asset.name} {formatMoneyWithoutSymbol(asset.balance)} - {asset.balance.symbol} + + {asset.name} + + {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 286ec4d5385..eee860036c8 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,10 +1,18 @@ -import { useLocation, useNavigate } from 'react-router-dom'; +import { useNavigate } from 'react-router-dom'; +import BigNumber from 'bignumber.js'; import { useFormikContext } from 'formik'; import { styled } from 'leather-styles/jsx'; -import get from 'lodash.get'; -import { SwapAsset, SwapFormValues } from '../../hooks/use-swap'; +import { createMoney } from '@shared/models/money.model'; +import { isUndefined } from '@shared/utils'; + +import { convertAmountToFractionalUnit } from '@app/common/money/calculate-money'; +import { formatMoneyWithoutSymbol } from '@app/common/money/format-money'; +import { useSwapContext } from '@app/pages/swap/swap.context'; + +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'; @@ -12,23 +20,58 @@ interface SwapAssetList { assets: SwapAsset[]; } export function SwapAssetList({ assets }: SwapAssetList) { - const { setFieldValue } = useFormikContext(); - const location = useLocation(); + const { fetchToAmount } = useSwapContext(); + 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) { - if (get(location.state, 'swap') === 'from') await setFieldValue('swapAssetFrom', asset); - if (get(location.state, 'swap') === 'to') await setFieldValue('swapAssetTo', asset); + let from: SwapAsset | undefined; + let to: SwapAsset | undefined; + if (isFromList) { + from = asset; + to = values.swapAssetTo; + await setFieldValue('swapAssetFrom', asset); + } else if (isToList) { + from = values.swapAssetFrom; + to = asset; + await setFieldValue('swapAssetTo', asset); + setFieldError('swapAssetTo', undefined); + } navigate(-1); + if (from && to && values.swapAmountFrom) { + const toAmount = await fetchToAmount(from, to, values.swapAmountFrom); + if (isUndefined(toAmount)) { + await setFieldValue('swapAmountTo', ''); + return; + } + const toAmountAsMoney = createMoney( + convertAmountToFractionalUnit(new BigNumber(toAmount), to?.balance.decimals), + to?.balance.symbol ?? '', + to?.balance.decimals + ); + await setFieldValue('swapAmountTo', formatMoneyWithoutSymbol(toAmountAsMoney)); + 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 32f07ec3e89..d3596acef43 100644 --- a/src/app/pages/swap/swap-container.tsx +++ b/src/app/pages/swap/swap-container.tsx @@ -1,71 +1,196 @@ -import { useState } from 'react'; +import { useMemo, useState } from 'react'; import { Outlet, useNavigate } from 'react-router-dom'; -import BtcIcon from '@assets/images/btc-icon.png'; -import XBtcIcon from '@assets/images/xbtc-icon.png'; +import { bytesToHex } from '@stacks/common'; +import { ContractCallPayload, TransactionTypes } from '@stacks/connect'; +import { + AnchorMode, + PostConditionMode, + serializeCV, + serializePostCondition, +} from '@stacks/transactions'; import BigNumber from 'bignumber.js'; -import { createMoney } from '@shared/models/money.model'; +import { logger } from '@shared/logger'; import { RouteUrls } from '@shared/route-urls'; +import { isDefined, isUndefined } from '@shared/utils'; -import { useNativeSegwitBalance } from '@app/query/bitcoin/balance/btc-native-segwit-balance.hooks'; -import { useCurrentAccountNativeSegwitIndexZeroSigner } from '@app/store/accounts/blockchain/bitcoin/native-segwit-account.hooks'; +import { LoadingKeys, useLoading } from '@app/common/hooks/use-loading'; +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 { SwapAsset, SwapFormValues } from './hooks/use-swap'; +import { useAlexBroadcastSwap } from './hooks/use-alex-broadcast-swap'; +import { oneHundredMillion, useAlexSwap } from './hooks/use-alex-swap'; +import { useStacksBroadcastSwap } from './hooks/use-stacks-broadcast-swap'; +import { SwapAsset, SwapFormValues } from './hooks/use-swap-form'; import { SwapContext, SwapProvider } from './swap.context'; - -// TODO: Remove and set to initial state to 0 with live data -const tempExchangeRate = 0.5; +import { migratePositiveBalancesToTop, sortSwappableAssetsBySymbol } from './swap.utils'; export function SwapContainer() { - const [exchangeRate, setExchangeRate] = useState(tempExchangeRate); const [isSendingMax, setIsSendingMax] = useState(false); const navigate = useNavigate(); - const { address } = useCurrentAccountNativeSegwitIndexZeroSigner(); - const { balance: btcBalance } = useNativeSegwitBalance(address); - // TODO: Filter these assets for list to swap, not sure if need? - // const allTransferableCryptoAssetBalances = useAllTransferableCryptoAssetBalances(); - - // TODO: Replace with live asset list - const tempSwapAssetFrom: SwapAsset = { - balance: btcBalance, - icon: BtcIcon, - name: 'Bitcoin', - }; + const { setIsLoading } = useLoading(LoadingKeys.SUBMIT_SWAP_TRANSACTION); + const currentAccount = useCurrentStacksAccount(); + const generateUnsignedTx = useGenerateStacksContractCallUnsignedTx(); + const signSoftwareWalletTx = useSignTransactionSoftwareWallet(); + const { transactions: pendingTransactions } = useStacksPendingTransactions(); - const tempSwapAssetTo: SwapAsset = { - balance: createMoney(new BigNumber(0), 'xBTC', 0), - icon: XBtcIcon, - name: 'Wrapped Bitcoin', - }; + const isSponsoredByAlex = !pendingTransactions.length; + + const { + alexSDK, + fetchToAmount, + createSwapAssetFromAlexCurrency, + isFetchingExchangeRate, + onSetIsFetchingExchangeRate, + onSetSwapSubmissionData, + slippage, + supportedCurrencies, + swapSubmissionData, + } = useAlexSwap(); + + const broadcastAlexSwap = useAlexBroadcastSwap(alexSDK); + const broadcastStacksSwap = useStacksBroadcastSwap(); - function onSubmitSwapForReview(values: SwapFormValues) { - navigate(RouteUrls.SwapReview, { - state: { ...values }, + const swappableAssets: SwapAsset[] = useMemo( + () => + sortSwappableAssetsBySymbol( + supportedCurrencies.map(createSwapAssetFromAlexCurrency).filter(isDefined) + ), + [createSwapAssetFromAlexCurrency, supportedCurrencies] + ); + + async function onSubmitSwapForReview(values: SwapFormValues) { + if (isUndefined(values.swapAssetFrom) || isUndefined(values.swapAssetTo)) { + logger.error('Error submitting swap for review'); + return; + } + + const [router, lpFee] = await Promise.all([ + alexSDK.getRouter(values.swapAssetFrom.currency, values.swapAssetTo.currency), + alexSDK.getFeeRate(values.swapAssetFrom.currency, values.swapAssetTo.currency), + ]); + + onSetSwapSubmissionData({ + 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 => createSwapAssetFromAlexCurrency(supportedCurrencies.find(y => y.id === x))) + .filter(isDefined), + slippage, + sponsored: isSponsoredByAlex, + swapAmountFrom: values.swapAmountFrom, + swapAmountTo: values.swapAmountTo, + swapAssetFrom: values.swapAssetFrom, + swapAssetTo: values.swapAssetTo, + timestamp: new Date().toISOString(), }); + + navigate(RouteUrls.SwapReview); } - // TODO: Generate/broadcast transaction > pass real tx data - function onSubmitSwap() { - navigate(RouteUrls.SwapSummary); + async function onSubmitSwap() { + if (isUndefined(currentAccount) || isUndefined(swapSubmissionData)) { + logger.error('Error submitting swap data to sign'); + return; + } + + if ( + isUndefined(swapSubmissionData.swapAssetFrom) || + isUndefined(swapSubmissionData.swapAssetTo) + ) { + logger.error('No assets selected to perform swap'); + return; + } + + setIsLoading(); + + const fromAmount = BigInt( + new BigNumber(swapSubmissionData.swapAmountFrom) + .multipliedBy(oneHundredMillion) + .dp(0) + .toString() + ); + + const minToAmount = BigInt( + new BigNumber(swapSubmissionData.swapAmountTo) + .multipliedBy(oneHundredMillion) + .multipliedBy(1 - slippage) + .dp(0) + .toString() + ); + + const tx = alexSDK.runSwap( + currentAccount?.address, + swapSubmissionData.swapAssetFrom.currency, + swapSubmissionData.swapAssetTo.currency, + fromAmount, + minToAmount, + swapSubmissionData.router.map(x => x.currency) + ); + + // TODO: Add choose fee step + const tempFormValues = { + fee: swapSubmissionData.fee, + feeCurrency: swapSubmissionData.feeCurrency, + feeType: swapSubmissionData.feeType, + nonce: swapSubmissionData.nonce, + }; + + const payload: ContractCallPayload = { + anchorMode: AnchorMode.Any, + contractAddress: tx.contractAddress, + contractName: tx.contractName, + functionName: tx.functionName, + functionArgs: tx.functionArgs.map(x => bytesToHex(serializeCV(x))), + postConditionMode: PostConditionMode.Deny, + postConditions: tx.postConditions.map(pc => bytesToHex(serializePostCondition(pc))), + publicKey: currentAccount?.stxPublicKey, + sponsored: swapSubmissionData.sponsored, + txType: TransactionTypes.ContractCall, + }; + + const unsignedTx = await generateUnsignedTx(payload, tempFormValues); + if (!unsignedTx) return logger.error('Attempted to generate unsigned tx, but tx is undefined'); + + const signedTx = signSoftwareWalletTx(unsignedTx); + if (!signedTx) return logger.error('Attempted to generate raw tx, but signed tx is undefined'); + const txRaw = bytesToHex(signedTx.serialize()); + + if (isSponsoredByAlex) { + return await broadcastAlexSwap(txRaw); + } + return await broadcastStacksSwap(unsignedTx); } const swapContextValue: SwapContext = { - exchangeRate, + fetchToAmount, + isFetchingExchangeRate, isSendingMax, - onSetExchangeRate: value => setExchangeRate(value), + onSetIsFetchingExchangeRate, onSetIsSendingMax: value => setIsSendingMax(value), onSubmitSwapForReview, onSubmitSwap, - swappableAssets: [tempSwapAssetFrom, tempSwapAssetTo], + swappableAssetsFrom: migratePositiveBalancesToTop(swappableAssets), + swappableAssetsTo: swappableAssets, + swapSubmissionData, }; return ( + diff --git a/src/app/pages/swap/swap-error/swap-error.tsx b/src/app/pages/swap/swap-error/swap-error.tsx new file mode 100644 index 00000000000..bc38890803c --- /dev/null +++ b/src/app/pages/swap/swap-error/swap-error.tsx @@ -0,0 +1,31 @@ +import { useLocation, useNavigate } from 'react-router-dom'; + +import { styled } from 'leather-styles/jsx'; +import get from 'lodash.get'; + +import { RouteUrls } from '@shared/route-urls'; + +import { GenericError } from '@app/components/generic-error/generic-error'; + +const helpTextList = [ + + Please report issue to swap protocol + , +]; + +export function SwapError() { + const location = useLocation(); + const navigate = useNavigate(); + const message = get(location.state, 'message') as string; + const title = get(location.state, 'title') as string; + + return ( + navigate(RouteUrls.Home)} + title={title} + /> + ); +} diff --git a/src/app/pages/swap/swap-review/swap-review.tsx b/src/app/pages/swap/swap-review/swap-review.tsx index ddd0d61d493..4c756543d68 100644 --- a/src/app/pages/swap/swap-review/swap-review.tsx +++ b/src/app/pages/swap/swap-review/swap-review.tsx @@ -1,3 +1,4 @@ +import { LoadingKeys, useLoading } from '@app/common/hooks/use-loading'; import { useRouteHeader } from '@app/common/hooks/use-route-header'; import { LeatherButton } from '@app/components/button/button'; import { ModalHeader } from '@app/components/modal-header'; @@ -11,6 +12,7 @@ import { SwapReviewLayout } from './swap-review.layout'; export function SwapReview() { const { onSubmitSwap } = useSwapContext(); + const { isLoading } = useLoading(LoadingKeys.SUBMIT_SWAP_TRANSACTION); useRouteHeader(, true); @@ -21,7 +23,7 @@ export function SwapReview() { - + Swap 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 acc48533c0e..00000000000 --- a/src/app/pages/swap/swap-summary/swap-summary.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import toast from 'react-hot-toast'; -import { Outlet } from 'react-router-dom'; - -import WaxSeal from '@assets/illustrations/wax-seal.png'; -import { useFormikContext } from 'formik'; -import { HStack, styled } from 'leather-styles/jsx'; - -import { logger } from '@shared/logger'; -import { isUndefined } from '@shared/utils'; - -import { useAnalytics } from '@app/common/hooks/analytics/use-analytics'; -import { useClipboard } from '@app/common/hooks/use-copy-to-clipboard'; -// 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 { SwapFormValues } from '../hooks/use-swap'; -import { SwapSummaryActionButton } from './swap-summary-action-button'; -import { SwapSummaryTabs } from './swap-summary-tabs'; -import { SwapSummaryLayout } from './swap-summary.layout'; - -// TODO: Pass/replace state with tx data where needed and handle click events -// Commented code left here to use with tx data -export function SwapSummary() { - const { values } = useFormikContext(); - const analytics = useAnalytics(); - const { onCopy } = useClipboard(''); - // const { handleOpenTxLink } = useExplorerLink(); - - useRouteHeader(, true); - - const amountAsFiat = useAmountAsFiat(values.swapAmountTo, values.swapAssetTo?.balance); - - function onClickCopy() { - onCopy(); - toast.success('ID copied!'); - } - - function onClickLink() { - void analytics.track('view_swap_transaction_confirmation', { - swapSymbolFrom: values.swapAssetFrom?.balance.symbol, - swapSymbolTo: values.swapAssetTo?.balance.symbol, - }); - // handleOpenTxLink(txLink); - } - - if (isUndefined(values.swapAssetTo)) { - logger.error('No asset selected for swap'); - return null; - } - - return ( - - - - - All done - - - {values.swapAmountTo} {values.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 4b757f6964d..0634881a577 100644 --- a/src/app/pages/swap/swap.context.ts +++ b/src/app/pages/swap/swap.context.ts @@ -1,15 +1,27 @@ 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 { - exchangeRate: number; + fetchToAmount(from: SwapAsset, to: SwapAsset, fromAmount: string): Promise; + isFetchingExchangeRate: boolean; isSendingMax: boolean; - onSetExchangeRate(value: number): void; + onSetIsFetchingExchangeRate(value: boolean): void; 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 a2aeeaa54c4..110d488276f 100644 --- a/src/app/pages/swap/swap.routes.tsx +++ b/src/app/pages/swap/swap.routes.tsx @@ -4,13 +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'; -import { Swap } from './swap/swap'; export const swapRoutes = ( }> } /> + } /> } /> - }> - } /> - } /> - ); diff --git a/src/app/pages/swap/swap.tsx b/src/app/pages/swap/swap.tsx new file mode 100644 index 00000000000..1f270291d39 --- /dev/null +++ b/src/app/pages/swap/swap.tsx @@ -0,0 +1,46 @@ +import { useAsync } from 'react-async-hook'; +import { Outlet } from 'react-router-dom'; + +import { useFormikContext } from 'formik'; + +import { isUndefined } from '@shared/utils'; + +import { useRouteHeader } from '@app/common/hooks/use-route-header'; +import { LeatherButton } from '@app/components/button/button'; +import { LoadingSpinner } from '@app/components/loading-spinner'; +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-form'; +import { useSwapContext } from './swap.context'; + +export function Swap() { + const { isFetchingExchangeRate, swappableAssetsFrom } = useSwapContext(); + const { dirty, isValid, setFieldValue, values } = useFormikContext(); + + useRouteHeader(, true); + + useAsync(async () => { + if (isUndefined(values.swapAssetFrom)) + return await setFieldValue('swapAssetFrom', swappableAssetsFrom[0]); + return; + }, [swappableAssetsFrom, values.swapAssetFrom]); + + if (isUndefined(values.swapAssetFrom)) return ; + + return ( + <> + + + + + + Review and swap + + + + + ); +} diff --git a/src/app/pages/swap/swap.utils.ts b/src/app/pages/swap/swap.utils.ts new file mode 100644 index 00000000000..181458ac6d7 --- /dev/null +++ b/src/app/pages/swap/swap.utils.ts @@ -0,0 +1,28 @@ +import { SwapAsset } from './hooks/use-swap-form'; + +export 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; + }); +} + +export 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]; +} diff --git a/src/app/pages/swap/swap/swap.tsx b/src/app/pages/swap/swap/swap.tsx deleted file mode 100644 index ac2c6608d6e..00000000000 --- a/src/app/pages/swap/swap/swap.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { Outlet } from 'react-router-dom'; - -import { useFormikContext } from 'formik'; - -import { useRouteHeader } from '@app/common/hooks/use-route-header'; -import { LeatherButton } from '@app/components/button/button'; -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 { useSwapContext } from '../swap.context'; - -export function Swap() { - const { onSubmitSwapForReview } = useSwapContext(); - const { dirty, handleSubmit, isValid, values } = useFormikContext(); - - useRouteHeader(, true); - - return ( - <> - - - - - { - handleSubmit(e); - await onSubmitSwapForReview(values); - }} - width="100%" - > - Review and swap - - - - - ); -} diff --git a/src/app/pages/transaction-request/transaction-request.tsx b/src/app/pages/transaction-request/transaction-request.tsx index c86651f30ab..330016ef3d7 100644 --- a/src/app/pages/transaction-request/transaction-request.tsx +++ b/src/app/pages/transaction-request/transaction-request.tsx @@ -46,7 +46,7 @@ import { function TransactionRequestBase() { const transactionRequest = useTransactionRequestState(); - const { setIsLoading, setIsIdle } = useLoading(LoadingKeys.SUBMIT_TRANSACTION); + const { setIsLoading, setIsIdle } = useLoading(LoadingKeys.SUBMIT_TRANSACTION_REQUEST); const handleBroadcastTransaction = useSoftwareWalletTransactionRequestBroadcast(); const unsignedTx = useUnsignedStacksTransactionBaseState(); const { data: stxFees } = useCalculateStacksTxFees(unsignedTx.transaction); 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/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.query.ts b/src/app/query/stacks/nonce/account-nonces.query.ts index e45dbf109f4..e4d29d913c2 100644 --- a/src/app/query/stacks/nonce/account-nonces.query.ts +++ b/src/app/query/stacks/nonce/account-nonces.query.ts @@ -1,4 +1,3 @@ -import type { AddressNonces } from '@stacks/blockchain-api-client/lib/generated'; import { useQuery } from '@tanstack/react-query'; import { AppUseQueryConfig } from '@app/query/query-config'; @@ -21,7 +20,7 @@ function fetchAccountNonces(client: StacksClient, limiter: RateLimiter) { await limiter.removeTokens(1); return client.accountsApi.getAccountNonces({ principal, - }) as Promise; + }); }; } 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 new file mode 100644 index 00000000000..c29e3974ab3 --- /dev/null +++ b/src/app/store/transactions/contract-call.hooks.ts @@ -0,0 +1,35 @@ +import { useCallback } from 'react'; + +import { ContractCallPayload } from '@stacks/connect'; + +import { StacksTransactionFormValues } from '@shared/models/form.model'; + +import { + GenerateUnsignedTransactionOptions, + generateUnsignedTransaction, +} from '@app/common/transactions/stacks/generate-unsigned-txs'; +import { useNextNonce } from '@app/query/stacks/nonce/account-nonces.hooks'; + +import { useCurrentStacksAccount } from '../accounts/blockchain/stacks/stacks-account.hooks'; +import { useCurrentStacksNetworkState } from '../networks/networks.hooks'; + +export function useGenerateStacksContractCallUnsignedTx() { + const { data: nextNonce } = useNextNonce(); + const network = useCurrentStacksNetworkState(); + const account = useCurrentStacksAccount(); + + return useCallback( + async (payload: ContractCallPayload, values: StacksTransactionFormValues) => { + if (!account) return; + + const options: GenerateUnsignedTransactionOptions = { + publicKey: account.stxPublicKey, + nonce: Number(values?.nonce) ?? nextNonce?.nonce, + fee: values.fee ?? 0, + txData: { ...payload, network }, + }; + return generateUnsignedTransaction(options); + }, + [account, network, nextNonce?.nonce] + ); +} 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/logger-storage.ts b/src/shared/logger-storage.ts index 5d0ca41a671..5609acf2c76 100644 --- a/src/shared/logger-storage.ts +++ b/src/shared/logger-storage.ts @@ -6,7 +6,9 @@ const maxLogLength = 2_000; const logStorageKey = 'logs'; -const storageAdapter = chrome.storage.local; +function getStorageAdapter() { + return chrome.storage.local; +} function truncateLogToMaxSize(logs: LogItem[]) { if (logs.length <= maxLogLength) return logs; @@ -15,17 +17,17 @@ function truncateLogToMaxSize(logs: LogItem[]) { export async function getLogSizeInBytes(): Promise { return new Promise(resolve => - storageAdapter.getBytesInUse([logStorageKey], bytes => resolve(bytes)) + getStorageAdapter().getBytesInUse([logStorageKey], bytes => resolve(bytes)) ); } export async function clearBrowserStorageLogs(): Promise { - return new Promise(resolve => storageAdapter.set({ [logStorageKey]: [] }, () => resolve())); + return new Promise(resolve => getStorageAdapter().set({ [logStorageKey]: [] }, () => resolve())); } export async function getLogsFromBrowserStorage(): Promise { return new Promise(resolve => - storageAdapter.get([logStorageKey], ({ logs }) => resolve(Array.isArray(logs) ? logs : [])) + getStorageAdapter().get([logStorageKey], ({ logs }) => resolve(Array.isArray(logs) ? logs : [])) ); } @@ -39,8 +41,9 @@ export async function appendLogToBrowserStorage(logEvent: pino.LogEvent): Promis const { ts, level, messages } = logEvent; const formattedLogItem = [new Date(ts).toISOString(), level.label, ...messages] as LogItem; return new Promise(resolve => - storageAdapter.set({ [logStorageKey]: truncateLogToMaxSize([formattedLogItem, ...logs]) }, () => - resolve(formattedLogItem) + getStorageAdapter().set( + { [logStorageKey]: truncateLogToMaxSize([formattedLogItem, ...logs]) }, + () => resolve(formattedLogItem) ) ); } diff --git a/src/shared/models/blockchain.model.ts b/src/shared/models/blockchain.model.ts index b5110ad7c20..345284314d3 100644 --- a/src/shared/models/blockchain.model.ts +++ b/src/shared/models/blockchain.model.ts @@ -1 +1,3 @@ -export type Blockchains = 'bitcoin' | 'stacks'; +import { LiteralUnion } from '@shared/utils/type-utils'; + +export type Blockchains = LiteralUnion<'bitcoin' | 'stacks', string>; diff --git a/src/shared/route-urls.ts b/src/shared/route-urls.ts index 36e50fc005d..1d4fd3e4311 100644 --- a/src/shared/route-urls.ts +++ b/src/shared/route-urls.ts @@ -86,9 +86,8 @@ export enum RouteUrls { // Swap routes Swap = '/swap', 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/src/shared/utils.ts b/src/shared/utils.ts index 9df2aceaa8f..37ee9fc2ad1 100644 --- a/src/shared/utils.ts +++ b/src/shared/utils.ts @@ -48,7 +48,9 @@ export function ensureArray(value: T | T[]): T[] { export function undefinedIfLengthZero(arr: T) { return arr.length ? arr : undefined; } + type NetworkMap = Record; + export function whenNetwork(mode: NetworkModes) { return >(networkMap: T) => networkMap[mode] as T[NetworkModes]; } @@ -68,3 +70,7 @@ export function closeWindow() { // eslint-disable-next-line no-restricted-properties window.close(); } + +export function removeTrailingNullCharacters(s: string) { + return s.replace(/\0*$/g, ''); +} diff --git a/theme/recipes/button.ts b/theme/recipes/button.ts index a0469bcc9b9..710c76dc6e4 100644 --- a/theme/recipes/button.ts +++ b/theme/recipes/button.ts @@ -64,6 +64,13 @@ export const buttonRecipe = defineRecipe({ color: 'brown.1', _hover: { bg: 'brown.10' }, _active: { bg: 'brown.12' }, + _disabled: { + _hover: { + bg: 'brown.6', + }, + bg: 'brown.6', + color: 'white', + }, ...focusStyles, ...loadingStyles('brown.2'), }, @@ -84,7 +91,7 @@ export const buttonRecipe = defineRecipe({ // Ghost button ghost: { - _hover: { bg: 'brown.3' }, + _hover: { bg: 'brown.2' }, _focus: { _before: { border: '2px solid', borderColor: 'blue.500' } }, ...loadingStyles('brown.12'), }, diff --git a/theme/semantic-tokens.ts b/theme/semantic-tokens.ts index 2636852533e..498d032efca 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/theme/tokens.ts b/theme/tokens.ts index d06014c41e7..252ffee29ff 100644 --- a/theme/tokens.ts +++ b/theme/tokens.ts @@ -40,4 +40,9 @@ export const tokens = defineTokens({ 'extra-loose': { value: '32px' }, }, colors, + borders: { + default: { value: '1px solid {colors.accent.border-default}' }, + error: { value: '1px solid {colors.error}' }, + 'action-primary-default': { value: '1px solid {colors.accent.action-primary-default}' }, + }, }); diff --git a/yarn.lock b/yarn.lock index 34f11ceb63f..85eaf897be7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4793,7 +4793,7 @@ "@blockstack/stacks-transactions" "0.7.0" cross-fetch "^3.0.4" -"@stacks/stacks-blockchain-api-types@*": +"@stacks/stacks-blockchain-api-types@*", "@stacks/stacks-blockchain-api-types@^7.1.10": version "7.3.2" resolved "https://registry.yarnpkg.com/@stacks/stacks-blockchain-api-types/-/stacks-blockchain-api-types-7.3.2.tgz#33838e96312c2be1df93dce1c76e6d584b966a39" integrity sha512-1r0+eqEWOOo7UYrFq9HGbc02DVME3NVCW/45sNKPN31PkOMMaK59DHragPJ2QbxPFiutVDUCS924+48+o3+0Tw== @@ -8259,6 +8259,13 @@ 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: + version "0.1.22" + resolved "https://registry.yarnpkg.com/alex-sdk/-/alex-sdk-0.1.22.tgz#ea94f2ebbb962c402ee485e10c5de5b5b66240af" + integrity sha512-g8sQN5Cs8mbkbOb0sHFN//lYVlJq6jG452LGOtNSOeoP7I5WNWgkwn0OrW8jqxjanbQCgoouP4xutXky1JDIGQ== + dependencies: + clarity-codegen "^0.2.6" + anser@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/anser/-/anser-2.1.1.tgz#8afae28d345424c82de89cc0e4d1348eb0c5af7c" @@ -8712,7 +8719,7 @@ aws4@^1.8.0: resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.12.0.tgz#ce1c9d143389679e253b314241ea9aa5cec980d3" integrity sha512-NmWvPnx0F1SfrQbYwOi7OeaNGokp9XhzNioJ/CSBs8Qa4vxug81mhJEAVZwxXuBmYB5KDRfMq/F3RR0BIU7sWg== -axios@1.5.1: +axios@1.5.1, axios@^1.5.0: version "1.5.1" resolved "https://registry.yarnpkg.com/axios/-/axios-1.5.1.tgz#11fbaa11fc35f431193a9564109c88c1f27b585f" integrity sha512-Q28iYCWzNHjAm+yEAot5QaAMxhMghWLFVf7rRdwhUI+c2jix2DUXjAHXVi+s1ibs3mjPO/cCgbA++3BjD0vP/A== @@ -9574,6 +9581,17 @@ cipher-base@^1.0.0, cipher-base@^1.0.1, cipher-base@^1.0.3: inherits "^2.0.1" safe-buffer "^5.0.1" +clarity-codegen@^0.2.6: + version "0.2.6" + resolved "https://registry.yarnpkg.com/clarity-codegen/-/clarity-codegen-0.2.6.tgz#897bfdc0374279c3a6dceebbe83bb6b553d6184b" + integrity sha512-1ZZoPO4VcqPkOaOPaj0OxgVeAJAjpga2nbbMTVynrYBEwN77hrWIwYfnICR0K3XFoyeW+mzxnYw9CpOvEA9eWQ== + dependencies: + "@stacks/stacks-blockchain-api-types" "^7.1.10" + axios "^1.5.0" + lodash "^4.17.21" + yargs "^17.7.2" + yqueue "^1.0.1" + classnames@^2.3.2: version "2.3.2" resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.3.2.tgz#351d813bf0137fcc6a76a16b88208d2560a0d924" @@ -20812,7 +20830,7 @@ yargs@17.7.1: y18n "^5.0.5" yargs-parser "^21.1.1" -yargs@17.7.2, yargs@^17.0.0, yargs@^17.7.1: +yargs@17.7.2, yargs@^17.0.0, yargs@^17.7.1, yargs@^17.7.2: version "17.7.2" resolved "https://registry.yarnpkg.com/yargs/-/yargs-17.7.2.tgz#991df39aca675a192b816e1e0363f9d75d2aa269" integrity sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w== @@ -20860,6 +20878,11 @@ yocto-queue@^1.0.0: resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-1.0.0.tgz#7f816433fb2cbc511ec8bf7d263c3b58a1a3c251" integrity sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g== +yqueue@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/yqueue/-/yqueue-1.0.1.tgz#3b4f17344f2481350577f0fd29146556439f542b" + integrity sha512-DBxJZBRafFLA/tCc5uO8ZTGFr+sQgn1FRJkZ4cVrIQIk6bv2bInraE3mbpLAJw9z93JGrLkqDoyTLrrZaCNq5w== + yup@1.3.2: version "1.3.2" resolved "https://registry.yarnpkg.com/yup/-/yup-1.3.2.tgz#afffc458f1513ed386e6aaf4bcaa4e67a9e270dc"