From 5df7a40ffa0c7bd9676acfc1a184532a3a5b599b Mon Sep 17 00:00:00 2001 From: Petr Knetl Date: Mon, 17 Jun 2024 12:07:34 +0200 Subject: [PATCH 1/2] refactor(suite): move transaction review output utils to common --- .../actions/wallet/moveLabelsForRbfActions.ts | 9 +- .../src/actions/wallet/send/sendFormThunks.ts | 2 +- .../wallet/stake/stakeFormEthereumActions.ts | 2 +- .../suite/src/actions/wallet/stakeActions.ts | 8 +- .../TransactionReviewModalContent.tsx | 59 ++- .../TransactionReviewOutput.tsx | 2 +- .../TransactionReviewOutputList.tsx | 12 +- .../TransactionReviewTotalOutput.tsx | 8 +- .../suite/src/hooks/suite/useDisplayMode.ts | 3 +- .../wallet/__tests__/useSendForm.test.tsx | 3 +- .../suite/src/hooks/wallet/form/useCompose.ts | 6 +- .../suite/src/hooks/wallet/form/useFees.ts | 2 +- .../src/hooks/wallet/form/useStakeCompose.ts | 3 +- .../src/hooks/wallet/form/useUtxoSelection.ts | 13 +- .../suite/src/hooks/wallet/useClaimEthForm.ts | 7 +- .../wallet/useCoinmarketRecomposeAndSign.ts | 3 +- .../suite/src/hooks/wallet/useSendForm.ts | 15 +- .../src/hooks/wallet/useSendFormCompose.ts | 3 +- .../src/hooks/wallet/useSendFormFields.ts | 8 +- .../src/hooks/wallet/useSendFormImport.ts | 10 +- .../src/hooks/wallet/useSendFormOutputs.ts | 3 +- .../suite/src/hooks/wallet/useStakeEthForm.ts | 11 +- .../src/hooks/wallet/useUnstakeEthForm.ts | 14 +- .../wallet/__tests__/sendFormReducer.test.ts | 4 +- .../suite/src/types/wallet}/claimForm.ts | 3 +- .../types/wallet/coinmarketExchangeForm.ts | 3 +- .../src/types/wallet/coinmarketSellForm.ts | 3 +- packages/suite/src/types/wallet/sendForm.ts | 100 ++++++ .../suite/src/types/wallet/transaction.ts | 42 --- packages/suite/src/utils/suite/validation.ts | 3 +- suite-common/wallet-core/package.json | 1 + .../wallet-core/src/fees/feesReducer.ts | 2 +- suite-common/wallet-core/src/index.ts | 1 + .../wallet-core/src/send/sendFormActions.ts | 2 +- .../wallet-core/src/send/sendFormReducer.ts | 56 ++- .../wallet-core/src/send/sendFormThunks.ts | 2 +- .../wallet-core/src/send/sendFormTypes.ts | 31 +- .../wallet-core/src/stake/stakeReducer.ts | 15 +- .../wallet-core/src/stake/stakeSelectors.ts | 3 +- .../wallet-core/src/stake/stakeTypes.ts | 64 +++- suite-common/wallet-types/package.json | 3 +- suite-common/wallet-types/src/index.ts | 2 - suite-common/wallet-types/src/sendForm.ts | 108 +----- suite-common/wallet-types/src/stakeForm.ts | 51 +-- suite-common/wallet-types/src/transaction.ts | 40 +++ suite-common/wallet-types/src/unstakeForm.ts | 15 - suite-common/wallet-utils/package.json | 1 + suite-common/wallet-utils/src/index.ts | 1 + .../src/reviewTransactionUtils.ts | 336 ++++++++++++++++++ .../wallet-utils/src/sendFormUtils.ts | 6 +- suite-common/wallet-utils/tsconfig.json | 1 + .../src/screens/SendFormScreen.tsx | 17 +- .../module-send/src/sendFormThunks.ts | 122 +++++++ yarn.lock | 2 +- 54 files changed, 874 insertions(+), 372 deletions(-) rename {suite-common/wallet-types/src => packages/suite/src/types/wallet}/claimForm.ts (77%) create mode 100644 packages/suite/src/types/wallet/sendForm.ts delete mode 100644 packages/suite/src/types/wallet/transaction.ts delete mode 100644 suite-common/wallet-types/src/unstakeForm.ts create mode 100644 suite-common/wallet-utils/src/reviewTransactionUtils.ts create mode 100644 suite-native/module-send/src/sendFormThunks.ts diff --git a/packages/suite/src/actions/wallet/moveLabelsForRbfActions.ts b/packages/suite/src/actions/wallet/moveLabelsForRbfActions.ts index adf4643adbd..17e5aba9fad 100644 --- a/packages/suite/src/actions/wallet/moveLabelsForRbfActions.ts +++ b/packages/suite/src/actions/wallet/moveLabelsForRbfActions.ts @@ -1,13 +1,10 @@ -import { selectLabelingDataForAccount } from '../../reducers/suite/metadataReducer'; +import { selectLabelingDataForAccount } from 'src/reducers/suite/metadataReducer'; import { findChainedTransactions, findTransactions } from '@suite-common/wallet-utils'; import { Dispatch, GetState } from 'src/types/suite'; import * as metadataLabelingActions from 'src/actions/suite/metadataLabelingActions'; import { AccountLabels, AccountOutputLabels } from '@suite-common/metadata-types'; -import { - AccountKey, - RbfLabelsToBeUpdated, - WalletAccountTransaction, -} from '@suite-common/wallet-types'; +import { AccountKey, WalletAccountTransaction } from '@suite-common/wallet-types'; +import { RbfLabelsToBeUpdated } from 'src/types/wallet/sendForm'; type DeleteAllOutputLabelsParams = { labels: AccountLabels['outputLabels']['labels']; diff --git a/packages/suite/src/actions/wallet/send/sendFormThunks.ts b/packages/suite/src/actions/wallet/send/sendFormThunks.ts index 6b05d0b8904..ef6188917a2 100644 --- a/packages/suite/src/actions/wallet/send/sendFormThunks.ts +++ b/packages/suite/src/actions/wallet/send/sendFormThunks.ts @@ -7,7 +7,6 @@ import { FormState, GeneralPrecomposedTransactionFinal, PrecomposedTransactionFinal, - RbfLabelsToBeUpdated, } from '@suite-common/wallet-types'; import { enhancePrecomposedTransactionThunk, @@ -32,6 +31,7 @@ import { findLabelsToBeMovedOrDeleted, moveLabelsForRbfAction } from '../moveLab import { selectMetadata } from 'src/reducers/suite/metadataReducer'; import * as metadataLabelingActions from 'src/actions/suite/metadataLabelingActions'; import * as modalActions from 'src/actions/suite/modalActions'; +import { RbfLabelsToBeUpdated } from 'src/types/wallet/sendForm'; export const MODULE_PREFIX = '@send'; diff --git a/packages/suite/src/actions/wallet/stake/stakeFormEthereumActions.ts b/packages/suite/src/actions/wallet/stake/stakeFormEthereumActions.ts index 0009ae93d94..f7729188507 100644 --- a/packages/suite/src/actions/wallet/stake/stakeFormEthereumActions.ts +++ b/packages/suite/src/actions/wallet/stake/stakeFormEthereumActions.ts @@ -14,7 +14,6 @@ import { } from '@suite-common/wallet-utils'; import { StakeFormState, - ComposeActionContext, PrecomposedLevels, PrecomposedTransaction, PrecomposedTransactionFinal, @@ -36,6 +35,7 @@ import { import { Ethereum } from '@everstake/wallet-sdk'; import { MIN_ETH_FOR_WITHDRAWALS } from 'src/constants/suite/ethStaking'; import { NetworkSymbol } from '@suite-common/wallet-config'; +import { ComposeActionContext } from '@suite-common/wallet-core'; const calculate = ( availableBalance: string, diff --git a/packages/suite/src/actions/wallet/stakeActions.ts b/packages/suite/src/actions/wallet/stakeActions.ts index d8e22df8895..5aa0427ad3d 100644 --- a/packages/suite/src/actions/wallet/stakeActions.ts +++ b/packages/suite/src/actions/wallet/stakeActions.ts @@ -6,16 +6,12 @@ import { replaceTransactionThunk, syncAccountsWithBlockchainThunk, stakeActions, + ComposeActionContext, } from '@suite-common/wallet-core'; import { notificationsActions } from '@suite-common/toast-notifications'; import { formatNetworkAmount, tryGetAccountIdentity } from '@suite-common/wallet-utils'; -import { - StakeFormState, - PrecomposedTransactionFinal, - ComposeActionContext, - StakeType, -} from '@suite-common/wallet-types'; +import { StakeFormState, PrecomposedTransactionFinal, StakeType } from '@suite-common/wallet-types'; import * as modalActions from '../suite/modalActions'; import { Dispatch, GetState } from 'src/types/suite'; diff --git a/packages/suite/src/components/suite/modals/ReduxModal/TransactionReviewModal/TransactionReviewModalContent.tsx b/packages/suite/src/components/suite/modals/ReduxModal/TransactionReviewModal/TransactionReviewModalContent.tsx index 55c36acafd9..3f34af21dec 100644 --- a/packages/suite/src/components/suite/modals/ReduxModal/TransactionReviewModal/TransactionReviewModalContent.tsx +++ b/packages/suite/src/components/suite/modals/ReduxModal/TransactionReviewModal/TransactionReviewModalContent.tsx @@ -2,19 +2,24 @@ import { useState } from 'react'; import styled from 'styled-components'; import { ConfirmOnDevice, variables } from '@trezor/components'; import { Deferred } from '@trezor/utils'; -import { selectDevice, StakeState } from '@suite-common/wallet-core'; +import { + DeviceRootState, + selectDevice, + selectSendFormDraftByAccountKey, + selectSendFormReviewButtonRequestsCount, + selectStakePrecomposedForm, + StakeState, +} from '@suite-common/wallet-core'; import { FormState, StakeFormState } from '@suite-common/wallet-types'; -import { isCardanoTx } from '@suite-common/wallet-utils'; +import { constructTransactionReviewOutputs } from '@suite-common/wallet-utils'; import { SendState } from '@suite-common/wallet-core'; import { useSelector } from 'src/hooks/suite'; import { selectIsActionAbortable } from 'src/reducers/suite/suiteReducer'; -import { constructOutputs } from 'src/utils/wallet/reviewTransactionUtils'; import { getTransactionReviewModalActionText } from 'src/utils/suite/transactionReview'; import { Modal, Translation } from 'src/components/suite'; import { TransactionReviewSummary } from './TransactionReviewSummary'; import { TransactionReviewOutputList } from './TransactionReviewOutputList/TransactionReviewOutputList'; import { TransactionReviewEvmExplanation } from './TransactionReviewEvmExplanation'; -import { DeviceModelInternal } from '@trezor/connect'; import { spacings } from '@trezor/theme'; import { getTxStakeNameByDataHex } from '@suite-common/suite-utils'; @@ -61,27 +66,32 @@ export const TransactionReviewModalContent = ({ const { account } = selectedAccount; const { precomposedTx, serializedTx } = txInfoState; + const precomposedForm = useSelector(state => + isStakeState(txInfoState) + ? selectStakePrecomposedForm(state) + : selectSendFormDraftByAccountKey(state, account?.key), + ); + + const decreaseOutputId = precomposedTx?.useNativeRbf + ? precomposedForm?.setMaxOutputId + : undefined; + + const buttonRequestsCount = useSelector((state: DeviceRootState) => + selectSendFormReviewButtonRequestsCount(state, account?.symbol, decreaseOutputId), + ); + if (!account) { return null; } - const precomposedForm: FormState | StakeFormState | undefined = isStakeState(txInfoState) - ? txInfoState.precomposedForm - : txInfoState.drafts[account.key]; - if (selectedAccount.status !== 'loaded' || !device || !precomposedTx || !precomposedForm) { return null; } const { networkType } = account; - const isCardano = isCardanoTx(account, precomposedTx); - const isEthereum = networkType === 'ethereum'; const isRbfAction = !!precomposedTx.prevTxid; - const decreaseOutputId = precomposedTx.useNativeRbf - ? precomposedForm.setMaxOutputId - : undefined; - const outputs = constructOutputs({ + const outputs = constructTransactionReviewOutputs({ account, decreaseOutputId, device, @@ -94,25 +104,6 @@ export const TransactionReviewModalContent = ({ ? precomposedForm.ethereumStakeType : getTxStakeNameByDataHex(outputs[0]?.value); - // omit other button requests (like passphrase) - const buttonRequests = device.buttonRequests.filter( - ({ code }) => - code === 'ButtonRequest_ConfirmOutput' || - code === 'ButtonRequest_SignTx' || - (code === 'ButtonRequest_Other' && (isCardano || isEthereum)), // Cardano and Ethereum are using ButtonRequest_Other - ); - - // NOTE: T1B1 edge-case - // while confirming decrease amount 'ButtonRequest_ConfirmOutput' is called twice (confirm decrease address, confirm decrease amount) - // remove 1 additional element to keep it consistent with T2T1 where this step is swipeable with one button request - if ( - typeof decreaseOutputId === 'number' && - deviceModelInternal === DeviceModelInternal.T1B1 && - buttonRequests.filter(r => r.code === 'ButtonRequest_ConfirmOutput').length > 1 - ) { - buttonRequests.splice(-1, 1); - } - // get estimate mining time let estimateTime; const symbolFees = fees[selectedAccount.account.symbol]; @@ -124,8 +115,6 @@ export const TransactionReviewModalContent = ({ estimateTime = symbolFees.blockTime * matchedFeeLevel.blocks * 60; } - const buttonRequestsCount = isCardano ? buttonRequests.length - 1 : buttonRequests.length; - const onCancel = isActionAbortable || serializedTx ? () => { diff --git a/packages/suite/src/components/suite/modals/ReduxModal/TransactionReviewModal/TransactionReviewOutputList/TransactionReviewOutput.tsx b/packages/suite/src/components/suite/modals/ReduxModal/TransactionReviewModal/TransactionReviewOutputList/TransactionReviewOutput.tsx index cd89055297d..ae2aae3f4dd 100644 --- a/packages/suite/src/components/suite/modals/ReduxModal/TransactionReviewModal/TransactionReviewOutputList/TransactionReviewOutput.tsx +++ b/packages/suite/src/components/suite/modals/ReduxModal/TransactionReviewModal/TransactionReviewOutputList/TransactionReviewOutput.tsx @@ -4,7 +4,7 @@ import { Translation } from 'src/components/suite'; import { formatNetworkAmount, formatAmount, isTestnet } from '@suite-common/wallet-utils'; import { BTC_LOCKTIME_VALUE } from '@suite-common/wallet-constants'; import { Network, NetworkSymbol } from 'src/types/wallet'; -import { ReviewOutput } from 'src/types/wallet/transaction'; +import { ReviewOutput } from '@suite-common/wallet-types'; import { TransactionReviewStepIndicator, TransactionReviewStepIndicatorProps, diff --git a/packages/suite/src/components/suite/modals/ReduxModal/TransactionReviewModal/TransactionReviewOutputList/TransactionReviewOutputList.tsx b/packages/suite/src/components/suite/modals/ReduxModal/TransactionReviewModal/TransactionReviewOutputList/TransactionReviewOutputList.tsx index 1aff4b4e5e7..bacfc50ca7d 100644 --- a/packages/suite/src/components/suite/modals/ReduxModal/TransactionReviewModal/TransactionReviewOutputList/TransactionReviewOutputList.tsx +++ b/packages/suite/src/components/suite/modals/ReduxModal/TransactionReviewModal/TransactionReviewOutputList/TransactionReviewOutputList.tsx @@ -12,9 +12,9 @@ import { TransactionReviewDetails } from './TransactionReviewDetails'; import { TransactionReviewOutput } from './TransactionReviewOutput'; import type { Account } from 'src/types/wallet'; import type { FormState, GeneralPrecomposedTransactionFinal } from '@suite-common/wallet-types'; -import { getOutputState } from 'src/utils/wallet/reviewTransactionUtils'; +import { getTransactionReviewOutputState } from '@suite-common/wallet-utils'; import { TransactionReviewTotalOutput } from './TransactionReviewTotalOutput'; -import { ReviewOutput } from 'src/types/wallet/transaction'; +import { ReviewOutput } from '@suite-common/wallet-types'; import { spacingsPx } from '@trezor/theme'; import { StakeFormState, StakeType } from '@suite-common/wallet-types'; @@ -184,13 +184,15 @@ export const TransactionReviewOutputList = ({ const totalRef = useRef(null); useEffect(() => { - const isLastStep = getOutputState(outputs.length, buttonRequestsCount) === 'active'; + const isLastStep = + getTransactionReviewOutputState(outputs.length, buttonRequestsCount) === 'active'; if (isLastStep) { totalRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }); } else { const activeIndex = outputs.findIndex( - (_, index) => getOutputState(index, buttonRequestsCount) === 'active', + (_, index) => + getTransactionReviewOutputState(index, buttonRequestsCount) === 'active', ); outputRefs.current[activeIndex]?.scrollIntoView({ behavior: 'smooth', block: 'start' }); @@ -208,7 +210,7 @@ export const TransactionReviewOutputList = ({ {outputs.map((output, index) => { const state = signedTx ? 'success' - : getOutputState(index, buttonRequestsCount); + : getTransactionReviewOutputState(index, buttonRequestsCount); return ( ; const StepIndicator = ({ signedTx, outputs, buttonRequestsCount }: StepIndicatorProps) => { - const state = signedTx ? 'success' : getOutputState(outputs.length, buttonRequestsCount); + const state = signedTx + ? 'success' + : getTransactionReviewOutputState(outputs.length, buttonRequestsCount); return ; }; diff --git a/packages/suite/src/hooks/suite/useDisplayMode.ts b/packages/suite/src/hooks/suite/useDisplayMode.ts index dd00f33ca55..4302572ee6e 100644 --- a/packages/suite/src/hooks/suite/useDisplayMode.ts +++ b/packages/suite/src/hooks/suite/useDisplayMode.ts @@ -4,9 +4,8 @@ import { AddressDisplayOptions } from '@suite-common/wallet-types'; import { selectAddressDisplayType } from 'src/reducers/suite/suiteReducer'; import { selectSelectedAccount } from 'src/reducers/wallet/selectedAccountReducer'; import { useSelector } from './useSelector'; -import { StakeType } from '@suite-common/wallet-types'; +import { StakeType, ReviewOutput } from '@suite-common/wallet-types'; import { DisplayMode } from 'src/types/suite'; -import { ReviewOutput } from '../../types/wallet/transaction'; type UseDisplayModeProps = { type: ReviewOutput['type']; diff --git a/packages/suite/src/hooks/wallet/__tests__/useSendForm.test.tsx b/packages/suite/src/hooks/wallet/__tests__/useSendForm.test.tsx index e35637600a5..e01fd2b3b59 100644 --- a/packages/suite/src/hooks/wallet/__tests__/useSendForm.test.tsx +++ b/packages/suite/src/hooks/wallet/__tests__/useSendForm.test.tsx @@ -12,12 +12,13 @@ import { UserAction, actionSequence, } from 'src/support/tests/hooksHelper'; -import { FormState, SendContextValues } from '@suite-common/wallet-types'; +import { FormState } from '@suite-common/wallet-types'; import SendIndex from 'src/views/wallet/send'; import * as fixtures from '../__fixtures__/useSendForm'; import { useSendFormContext } from '../useSendForm'; import { act, waitFor } from '@testing-library/react'; +import { SendContextValues } from 'src/types/wallet/sendForm'; const TEST_TIMEOUT = 30000; diff --git a/packages/suite/src/hooks/wallet/form/useCompose.ts b/packages/suite/src/hooks/wallet/form/useCompose.ts index 03b75eef5d3..f1272bd4df2 100644 --- a/packages/suite/src/hooks/wallet/form/useCompose.ts +++ b/packages/suite/src/hooks/wallet/form/useCompose.ts @@ -5,13 +5,10 @@ import { FeeLevel } from '@trezor/connect'; import { useAsyncDebounce } from '@trezor/react-utils'; import { useDispatch, useSelector, useTranslation } from 'src/hooks/suite'; import { signAndPushSendFormTransactionThunk } from 'src/actions/wallet/send/sendFormThunks'; -import { composeSendFormTransactionThunk } from '@suite-common/wallet-core'; +import { ComposeActionContext, composeSendFormTransactionThunk } from '@suite-common/wallet-core'; import { findComposeErrors } from '@suite-common/wallet-utils'; import { FormState, - UseSendFormState, - ComposeActionContext, - SendContextValues, PrecomposedTransaction, PrecomposedTransactionCardano, PrecomposedLevels, @@ -19,6 +16,7 @@ import { } from '@suite-common/wallet-types'; import { COMPOSE_ERROR_TYPES } from '@suite-common/wallet-constants'; import { selectSelectedAccount } from 'src/reducers/wallet/selectedAccountReducer'; +import { SendContextValues, UseSendFormState } from '../../../types/wallet/sendForm'; const DEFAULT_FIELD = 'outputs.0.amount'; diff --git a/packages/suite/src/hooks/wallet/form/useFees.ts b/packages/suite/src/hooks/wallet/form/useFees.ts index e0433e7cac6..6d383b454d2 100644 --- a/packages/suite/src/hooks/wallet/form/useFees.ts +++ b/packages/suite/src/hooks/wallet/form/useFees.ts @@ -8,8 +8,8 @@ import { FormState, PrecomposedLevels, PrecomposedLevelsCardano, - SendContextValues, } from '@suite-common/wallet-types'; +import { SendContextValues } from '../../../types/wallet/sendForm'; interface Props extends UseFormReturn { defaultValue?: FeeLevel['label']; diff --git a/packages/suite/src/hooks/wallet/form/useStakeCompose.ts b/packages/suite/src/hooks/wallet/form/useStakeCompose.ts index eb3d6d396be..4ff21991443 100644 --- a/packages/suite/src/hooks/wallet/form/useStakeCompose.ts +++ b/packages/suite/src/hooks/wallet/form/useStakeCompose.ts @@ -7,13 +7,12 @@ import { composeTransaction } from 'src/actions/wallet/stakeActions'; import { findComposeErrors } from '@suite-common/wallet-utils'; import { StakeFormState, - StakeContextValues, - ComposeActionContext, PrecomposedTransaction, PrecomposedLevels, } from '@suite-common/wallet-types'; import { COMPOSE_ERROR_TYPES } from '@suite-common/wallet-constants'; import { FeeLevel } from '@trezor/connect'; +import { ComposeActionContext, StakeContextValues } from '@suite-common/wallet-core'; const DEFAULT_FIELD = 'outputs.0.amount'; diff --git a/packages/suite/src/hooks/wallet/form/useUtxoSelection.ts b/packages/suite/src/hooks/wallet/form/useUtxoSelection.ts index 13f14d3f208..c8e39b62485 100644 --- a/packages/suite/src/hooks/wallet/form/useUtxoSelection.ts +++ b/packages/suite/src/hooks/wallet/form/useUtxoSelection.ts @@ -1,16 +1,15 @@ import { useEffect, useMemo } from 'react'; import { UseFormReturn } from 'react-hook-form'; -import { - UseSendFormState, - ExcludedUtxos, - UtxoSelectionContext, - SendContextValues, - FormState, -} from '@suite-common/wallet-types'; +import { ExcludedUtxos, FormState } from '@suite-common/wallet-types'; import type { AccountUtxo, PROTO } from '@trezor/connect'; import { getUtxoOutpoint, isSameUtxo } from '@suite-common/wallet-utils'; import { useCoinjoinRegisteredUtxos } from './useCoinjoinRegisteredUtxos'; +import { + SendContextValues, + UseSendFormState, + UtxoSelectionContext, +} from '../../../types/wallet/sendForm'; interface UtxoSelectionContextProps extends UseFormReturn, diff --git a/packages/suite/src/hooks/wallet/useClaimEthForm.ts b/packages/suite/src/hooks/wallet/useClaimEthForm.ts index d81757d8a78..c7a42d9e4b4 100644 --- a/packages/suite/src/hooks/wallet/useClaimEthForm.ts +++ b/packages/suite/src/hooks/wallet/useClaimEthForm.ts @@ -10,15 +10,12 @@ import { useStakeCompose } from './form/useStakeCompose'; import { selectLocalCurrency } from 'src/reducers/wallet/settingsReducer'; import { signTransaction } from 'src/actions/wallet/stakeActions'; -import { - ClaimContextValues, - ClaimFormState, - PrecomposedTransactionFinal, -} from '@suite-common/wallet-types'; +import { PrecomposedTransactionFinal } from '@suite-common/wallet-types'; import { getEthNetworkForWalletSdk, getStakeFormsDefaultValues } from 'src/utils/suite/stake'; // @ts-expect-error import { Ethereum } from '@everstake/wallet-sdk'; import { useFees } from './form/useFees'; +import { ClaimContextValues, ClaimFormState } from 'src/types/wallet/claimForm'; export const ClaimEthFormContext = createContext(null); ClaimEthFormContext.displayName = 'ClaimEthFormContext'; diff --git a/packages/suite/src/hooks/wallet/useCoinmarketRecomposeAndSign.ts b/packages/suite/src/hooks/wallet/useCoinmarketRecomposeAndSign.ts index 67a3800163e..a85ace9cd4e 100644 --- a/packages/suite/src/hooks/wallet/useCoinmarketRecomposeAndSign.ts +++ b/packages/suite/src/hooks/wallet/useCoinmarketRecomposeAndSign.ts @@ -4,10 +4,11 @@ import { signAndPushSendFormTransactionThunk } from 'src/actions/wallet/send/sen import { notificationsActions } from '@suite-common/toast-notifications'; import { DEFAULT_VALUES, DEFAULT_PAYMENT } from '@suite-common/wallet-constants'; -import { FormState, UseSendFormState } from '@suite-common/wallet-types'; +import { FormState } from '@suite-common/wallet-types'; import { getFeeLevels } from '@suite-common/wallet-utils'; import type { FormOptions, SelectedAccountLoaded } from '@suite-common/wallet-types'; import { composeSendFormTransactionThunk } from '@suite-common/wallet-core'; +import { UseSendFormState } from 'src/types/wallet/sendForm'; export const useCoinmarketRecomposeAndSign = () => { const { translationString } = useTranslation(); diff --git a/packages/suite/src/hooks/wallet/useSendForm.ts b/packages/suite/src/hooks/wallet/useSendForm.ts index c0413584f00..f01055bd1f4 100644 --- a/packages/suite/src/hooks/wallet/useSendForm.ts +++ b/packages/suite/src/hooks/wallet/useSendForm.ts @@ -15,12 +15,7 @@ import { import { goto } from 'src/actions/suite/routerActions'; import { fillSendForm } from 'src/actions/suite/protocolActions'; import { AppState } from 'src/types/suite'; -import { - FormState, - SendContextValues, - TokenAddress, - UseSendFormState, -} from '@suite-common/wallet-types'; +import { FormState, TokenAddress } from '@suite-common/wallet-types'; import { getFeeLevels, getDefaultValues, @@ -39,6 +34,7 @@ import { useUtxoSelection } from './form/useUtxoSelection'; import { useExcludedUtxos } from './form/useExcludedUtxos'; import { selectCurrentFiatRates, selectFiatRatesByFiatRateKey } from '@suite-common/wallet-core'; import { FiatCurrencyCode } from '@suite-common/suite-config'; +import { SendContextValues, UseSendFormState } from 'src/types/wallet/sendForm'; export const SendContext = createContext(null); SendContext.displayName = 'SendContext'; @@ -46,7 +42,7 @@ SendContext.displayName = 'SendContext'; // Props of @wallet-views/send/index export interface SendFormProps { selectedAccount: AppState['wallet']['selectedAccount']; - localCurrency: AppState['wallet']['settings']['localCurrency']; + localCurrency: FiatCurrencyCode; fees: AppState['wallet']['fees']; online: boolean; sendRaw?: boolean; @@ -66,9 +62,10 @@ const getStateFromProps = (props: UseSendFormProps) => { const coinFees = props.fees[symbol]; const levels = getFeeLevels(networkType, coinFees); const feeInfo = { ...coinFees, levels }; + const currencyCode = props.localCurrency; const localCurrencyOption = { - value: props.localCurrency, - label: props.localCurrency.toUpperCase(), + value: currencyCode, + label: currencyCode as Uppercase, }; return { diff --git a/packages/suite/src/hooks/wallet/useSendFormCompose.ts b/packages/suite/src/hooks/wallet/useSendFormCompose.ts index 0d0a8ce34f8..08ad13831e0 100644 --- a/packages/suite/src/hooks/wallet/useSendFormCompose.ts +++ b/packages/suite/src/hooks/wallet/useSendFormCompose.ts @@ -3,8 +3,6 @@ import { FieldPath, UseFormReturn } from 'react-hook-form'; import { FormState, - UseSendFormState, - SendContextValues, ExcludedUtxos, PrecomposedTransaction, PrecomposedTransactionCardano, @@ -21,6 +19,7 @@ import { TranslationKey } from 'src/components/suite/Translation'; import { useDispatch } from 'react-redux'; import { composeSendFormTransactionThunk } from '@suite-common/wallet-core'; import { useTranslation } from '../suite'; +import { SendContextValues, UseSendFormState } from 'src/types/wallet/sendForm'; type Props = UseFormReturn & { state: UseSendFormState; diff --git a/packages/suite/src/hooks/wallet/useSendFormFields.ts b/packages/suite/src/hooks/wallet/useSendFormFields.ts index 2cc7d6a0c37..8e0f1c8a816 100644 --- a/packages/suite/src/hooks/wallet/useSendFormFields.ts +++ b/packages/suite/src/hooks/wallet/useSendFormFields.ts @@ -1,15 +1,11 @@ import { useCallback } from 'react'; import { FieldPath, UseFormReturn } from 'react-hook-form'; import { formatNetworkAmount, toFiatCurrency } from '@suite-common/wallet-utils'; -import { - FormState, - FormOptions, - UseSendFormState, - SendContextValues, -} from '@suite-common/wallet-types'; +import { FormState, FormOptions } from '@suite-common/wallet-types'; import { isFeatureFlagEnabled } from '@suite-common/suite-utils'; import { useBitcoinAmountUnit } from './useBitcoinAmountUnit'; import { Rate } from '@suite-common/wallet-types'; +import { SendContextValues, UseSendFormState } from 'src/types/wallet/sendForm'; type Props = UseFormReturn & { fiatRate?: Rate; diff --git a/packages/suite/src/hooks/wallet/useSendFormImport.ts b/packages/suite/src/hooks/wallet/useSendFormImport.ts index 616c1e80acb..169bb9b07ea 100644 --- a/packages/suite/src/hooks/wallet/useSendFormImport.ts +++ b/packages/suite/src/hooks/wallet/useSendFormImport.ts @@ -12,16 +12,10 @@ import { getFiatRateKey, toFiatCurrency, } from '@suite-common/wallet-utils'; -import { - UseSendFormState, - Output, - Timestamp, - FiatRatesResult, - Rate, - FiatRates, -} from '@suite-common/wallet-types'; +import { Output, Timestamp, FiatRatesResult, Rate, FiatRates } from '@suite-common/wallet-types'; import { updateFiatRatesThunk } from '@suite-common/wallet-core'; import { NetworkSymbol } from '@suite-common/wallet-config'; +import { UseSendFormState } from 'src/types/wallet/sendForm'; type useSendFormImportProps = { network: UseSendFormState['network']; diff --git a/packages/suite/src/hooks/wallet/useSendFormOutputs.ts b/packages/suite/src/hooks/wallet/useSendFormOutputs.ts index e3b7e15aa0f..9dd712a942a 100644 --- a/packages/suite/src/hooks/wallet/useSendFormOutputs.ts +++ b/packages/suite/src/hooks/wallet/useSendFormOutputs.ts @@ -1,7 +1,8 @@ import { useCallback, useEffect } from 'react'; import { useFieldArray, UseFormReturn } from 'react-hook-form'; -import { FormState, UseSendFormState, SendContextValues } from '@suite-common/wallet-types'; +import { FormState } from '@suite-common/wallet-types'; import { DEFAULT_PAYMENT, DEFAULT_OPRETURN } from '@suite-common/wallet-constants'; +import { SendContextValues, UseSendFormState } from 'src/types/wallet/sendForm'; type Props = UseFormReturn & { outputsFieldArray: ReturnType>; diff --git a/packages/suite/src/hooks/wallet/useStakeEthForm.ts b/packages/suite/src/hooks/wallet/useStakeEthForm.ts index 8d9996f0c21..aa0e31ca0c0 100644 --- a/packages/suite/src/hooks/wallet/useStakeEthForm.ts +++ b/packages/suite/src/hooks/wallet/useStakeEthForm.ts @@ -31,14 +31,13 @@ import { import { selectLocalCurrency } from 'src/reducers/wallet/settingsReducer'; import { signTransaction } from 'src/actions/wallet/stakeActions'; +import { PrecomposedTransactionFinal, StakeFormState } from '@suite-common/wallet-types'; +import { getEthNetworkForWalletSdk, getStakeFormsDefaultValues } from 'src/utils/suite/stake'; import { - PrecomposedTransactionFinal, - StakeFormState, - StakeContextValues, AmountLimitsString, -} from '@suite-common/wallet-types'; -import { getEthNetworkForWalletSdk, getStakeFormsDefaultValues } from 'src/utils/suite/stake'; -import { selectFiatRatesByFiatRateKey } from '@suite-common/wallet-core'; + StakeContextValues, + selectFiatRatesByFiatRateKey, +} from '@suite-common/wallet-core'; // @ts-expect-error import { Ethereum } from '@everstake/wallet-sdk'; import { useFees } from './form/useFees'; diff --git a/packages/suite/src/hooks/wallet/useUnstakeEthForm.ts b/packages/suite/src/hooks/wallet/useUnstakeEthForm.ts index 6d267760aad..853dee646ed 100644 --- a/packages/suite/src/hooks/wallet/useUnstakeEthForm.ts +++ b/packages/suite/src/hooks/wallet/useUnstakeEthForm.ts @@ -21,17 +21,17 @@ import { useStakeCompose } from './form/useStakeCompose'; import { selectLocalCurrency } from 'src/reducers/wallet/settingsReducer'; import { signTransaction } from 'src/actions/wallet/stakeActions'; -import { - AmountLimitsString, - PrecomposedTransactionFinal, - UnstakeContextValues as UnstakeContextValuesBase, - UnstakeFormState, -} from '@suite-common/wallet-types'; +import { PrecomposedTransactionFinal } from '@suite-common/wallet-types'; import { getEthNetworkForWalletSdk, getStakeFormsDefaultValues } from 'src/utils/suite/stake'; import { useFormDraft } from './useFormDraft'; import useDebounce from 'react-use/lib/useDebounce'; import { isChanged } from '@suite-common/suite-utils'; -import { selectFiatRatesByFiatRateKey } from '@suite-common/wallet-core'; +import { + AmountLimitsString, + selectFiatRatesByFiatRateKey, + UnstakeContextValues as UnstakeContextValuesBase, + UnstakeFormState, +} from '@suite-common/wallet-core'; // @ts-expect-error import { Ethereum } from '@everstake/wallet-sdk'; import { useFees } from './form/useFees'; diff --git a/packages/suite/src/reducers/wallet/__tests__/sendFormReducer.test.ts b/packages/suite/src/reducers/wallet/__tests__/sendFormReducer.test.ts index e999250d037..a499e17a1b6 100644 --- a/packages/suite/src/reducers/wallet/__tests__/sendFormReducer.test.ts +++ b/packages/suite/src/reducers/wallet/__tests__/sendFormReducer.test.ts @@ -1,10 +1,10 @@ import { STORAGE } from 'src/actions/suite/constants'; import { Action } from 'src/types/suite'; import { FormState, PrecomposedTransactionFinal } from '@suite-common/wallet-types'; -import { accountsActions } from '@suite-common/wallet-core'; +import { SerializedTx, accountsActions } from '@suite-common/wallet-core'; import { prepareSendFormReducer, initialState, sendFormActions } from '@suite-common/wallet-core'; -import { Account, SerializedTx } from '@suite-common/wallet-types'; +import { Account } from '@suite-common/wallet-types'; import { PreloadStoreAction } from 'src/support/suite/preloadStore'; import { extraDependencies } from 'src/support/extraDependencies'; diff --git a/suite-common/wallet-types/src/claimForm.ts b/packages/suite/src/types/wallet/claimForm.ts similarity index 77% rename from suite-common/wallet-types/src/claimForm.ts rename to packages/suite/src/types/wallet/claimForm.ts index 14c500541c8..8060952d196 100644 --- a/suite-common/wallet-types/src/claimForm.ts +++ b/packages/suite/src/types/wallet/claimForm.ts @@ -1,6 +1,7 @@ import { UseFormReturn } from 'react-hook-form'; -import { BaseStakeContextValues, StakeFormState } from './stakeForm'; +import { BaseStakeContextValues } from '@suite-common/wallet-core'; +import { StakeFormState } from '@suite-common/wallet-types'; export interface ClaimFormState extends Omit< diff --git a/packages/suite/src/types/wallet/coinmarketExchangeForm.ts b/packages/suite/src/types/wallet/coinmarketExchangeForm.ts index e3c13278ea9..419a7f10292 100644 --- a/packages/suite/src/types/wallet/coinmarketExchangeForm.ts +++ b/packages/suite/src/types/wallet/coinmarketExchangeForm.ts @@ -11,8 +11,9 @@ import type { } from '@suite-common/wallet-types'; import type { AmountLimits, CryptoAmountLimits, Option } from './coinmarketCommonTypes'; import type { WithSelectedAccountLoadedProps } from 'src/components/wallet'; -import { Rate, SendContextValues } from '@suite-common/wallet-types'; +import { Rate } from '@suite-common/wallet-types'; import { CryptoSymbol, CryptoSymbolInfo } from 'invity-api'; +import { SendContextValues } from './sendForm'; export const CRYPTO_INPUT = 'outputs.0.amount'; export const CRYPTO_TOKEN = 'outputs.0.token'; diff --git a/packages/suite/src/types/wallet/coinmarketSellForm.ts b/packages/suite/src/types/wallet/coinmarketSellForm.ts index 39a5dd205a3..7e48a1b4b12 100644 --- a/packages/suite/src/types/wallet/coinmarketSellForm.ts +++ b/packages/suite/src/types/wallet/coinmarketSellForm.ts @@ -12,7 +12,8 @@ import type { } from '@suite-common/wallet-types'; import type { Option, DefaultCountryOption, AmountLimits } from './coinmarketCommonTypes'; import type { WithSelectedAccountLoadedProps } from 'src/components/wallet'; -import { Rate, SendContextValues } from '@suite-common/wallet-types'; +import { Rate } from '@suite-common/wallet-types'; +import { SendContextValues } from './sendForm'; export const OUTPUT_AMOUNT = 'outputs.0.amount'; export const CRYPTO_TOKEN = 'outputs.0.token'; diff --git a/packages/suite/src/types/wallet/sendForm.ts b/packages/suite/src/types/wallet/sendForm.ts new file mode 100644 index 00000000000..faba6bb6c01 --- /dev/null +++ b/packages/suite/src/types/wallet/sendForm.ts @@ -0,0 +1,100 @@ +import { Dispatch, SetStateAction } from 'react'; +import { FieldPath, UseFormReturn } from 'react-hook-form'; + +import { Network } from '@suite-common/wallet-config'; +import { AccountUtxo, FeeLevel, PROTO } from '@trezor/connect'; + +import { + Account, + AccountKey, + ExcludedUtxos, + FeeInfo, + FormOptions, + FormState, + Output, + PrecomposedLevels, + PrecomposedLevelsCardano, + Rate, + WalletAccountTransaction, +} from '@suite-common/wallet-types'; +import { FiatCurrencyCode } from '@suite-common/suite-config'; + +export type ExportFileType = 'csv' | 'pdf' | 'json'; + +// local state of @wallet-hooks/useSendForm +export type UseSendFormState = { + account: Account; + network: Network; + coinFees: FeeInfo; + localCurrencyOption: { value: FiatCurrencyCode; label: Uppercase }; + feeInfo: FeeInfo; + composedLevels?: PrecomposedLevels | PrecomposedLevelsCardano; + online: boolean; + metadataEnabled: boolean; +}; + +export interface UtxoSelectionContext { + excludedUtxos: ExcludedUtxos; + allUtxosSelected: boolean; + composedInputs: PROTO.TxInputType[]; + dustUtxos: AccountUtxo[]; + isCoinControlEnabled: boolean; + lowAnonymityUtxos: AccountUtxo[]; + selectedUtxos: AccountUtxo[]; + spendableUtxos: AccountUtxo[]; + coinjoinRegisteredUtxos: AccountUtxo[]; + isLowAnonymityUtxoSelected: boolean; + anonymityWarningChecked: boolean; + toggleAnonymityWarning: () => void; + toggleCheckAllUtxos: () => void; + toggleCoinControl: () => void; + toggleUtxoSelection: (utxo: AccountUtxo) => void; +} + +// strongly typed UseFormMethods.getValues with fallback value +export interface GetDefaultValue { + ( + fieldName: K, + fallback?: T, + ): K extends keyof FormState ? FormState[K] : unknown; + (fieldName: K, fallback: T): K extends keyof FormState ? FormState[K] : T; +} + +export type SendContextValues = + UseFormReturn & + UseSendFormState & { + isLoading: boolean; + fiatRate?: Rate; + // additional fields + outputs: Partial[]; // useFieldArray fields + updateContext: (value: Partial) => void; + resetContext: () => void; + composeTransaction: (field?: FieldPath) => void; + loadTransaction: () => Promise; + signTransaction: () => void; + // useSendFormFields utils: + calculateFiat: (outputIndex: number, amount?: string) => void; + setAmount: (outputIndex: number, amount: string) => void; + changeFeeLevel: (currentLevel: FeeLevel['label']) => void; + resetDefaultValue: (field: FieldPath) => void; + setMax: (index: number, active: boolean) => void; + getDefaultValue: GetDefaultValue; + toggleOption: (option: FormOptions) => void; + // useSendFormOutputs utils: + addOutput: () => void; // useFieldArray append + removeOutput: (index: number) => void; // useFieldArray remove + addOpReturn: () => void; + removeOpReturn: (index: number) => void; + // useSendFormCompose + setDraftSaveRequest: Dispatch>; + // UTXO selection + utxoSelection: UtxoSelectionContext; + }; + +export type RbfLabelsToBeUpdated = Record< + AccountKey, + { + toBeMoved: WalletAccountTransaction; + toBeDeleted: WalletAccountTransaction[]; + } +>; diff --git a/packages/suite/src/types/wallet/transaction.ts b/packages/suite/src/types/wallet/transaction.ts deleted file mode 100644 index 470d2416b12..00000000000 --- a/packages/suite/src/types/wallet/transaction.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { TokenInfo } from '@trezor/connect'; - -export type ReviewOutput = - | { - type: - | 'opreturn' - | 'data' - | 'locktime' - | 'fee' - | 'destination-tag' - | 'txid' - | 'address' - | 'amount' - | 'gas' - | 'contract' - | 'regular_legacy'; - label?: string; - value: string; - value2?: string; - token?: TokenInfo; - } - | { - type: 'fee-replace'; - label?: undefined; - value: string; - value2: string; - token?: undefined; - } - | { - type: 'reduce-output'; - label: string; - value: string; - value2: string; - token?: undefined; - }; - -export type { - SignTransactionData, - ComposeTransactionData, - SignedTx, - ReviewTransactionData, -} from '@suite-common/wallet-types'; diff --git a/packages/suite/src/utils/suite/validation.ts b/packages/suite/src/utils/suite/validation.ts index c025aa195d3..1fddfeab2d5 100644 --- a/packages/suite/src/utils/suite/validation.ts +++ b/packages/suite/src/utils/suite/validation.ts @@ -1,6 +1,7 @@ import { Formatter } from '@suite-common/formatters'; import { NetworkSymbol } from '@suite-common/wallet-config'; -import { Account, AmountLimitsString } from '@suite-common/wallet-types'; +import { AmountLimitsString } from '@suite-common/wallet-core'; +import { Account } from '@suite-common/wallet-types'; import { findToken, formatNetworkAmount, diff --git a/suite-common/wallet-core/package.json b/suite-common/wallet-core/package.json index 8673cb01aed..500bd28a58d 100644 --- a/suite-common/wallet-core/package.json +++ b/suite-common/wallet-core/package.json @@ -37,6 +37,7 @@ "@trezor/utils": "workspace:*", "date-fns": "^2.30.0", "proxy-memoize": "2.0.2", + "react-hook-form": "^7.50.1", "web3-utils": "^4.2.3" } } diff --git a/suite-common/wallet-core/src/fees/feesReducer.ts b/suite-common/wallet-core/src/fees/feesReducer.ts index d95482eddb8..516e8a7dab6 100644 --- a/suite-common/wallet-core/src/fees/feesReducer.ts +++ b/suite-common/wallet-core/src/fees/feesReducer.ts @@ -1,7 +1,7 @@ import { createReducer } from '@reduxjs/toolkit'; -import { FeeInfo } from '@suite-common/wallet-types'; import { NetworkSymbol, networksCompatibility } from '@suite-common/wallet-config'; +import { FeeInfo } from '@suite-common/wallet-types'; import { blockchainActions } from '../blockchain/blockchainActions'; diff --git a/suite-common/wallet-core/src/index.ts b/suite-common/wallet-core/src/index.ts index 880447ad296..8410ff65597 100644 --- a/suite-common/wallet-core/src/index.ts +++ b/suite-common/wallet-core/src/index.ts @@ -25,6 +25,7 @@ export * from './firmware/firmwareReducer'; export * from './send/sendFormActions'; export * from './send/sendFormReducer'; export * from './send/sendFormThunks'; +export * from './send/sendFormTypes'; export * from './device/deviceActions'; export * from './device/deviceThunks'; export * from './device/deviceReducer'; diff --git a/suite-common/wallet-core/src/send/sendFormActions.ts b/suite-common/wallet-core/src/send/sendFormActions.ts index a6a11b931d9..8718ef7eec5 100644 --- a/suite-common/wallet-core/src/send/sendFormActions.ts +++ b/suite-common/wallet-core/src/send/sendFormActions.ts @@ -2,13 +2,13 @@ import { createAction } from '@reduxjs/toolkit'; import { FormState, - SerializedTx, AccountKey, GeneralPrecomposedTransactionFinal, } from '@suite-common/wallet-types'; import { BlockbookTransaction } from '@trezor/blockchain-link-types'; import { SEND_MODULE_PREFIX } from './sendFormConstants'; +import { SerializedTx } from './sendFormTypes'; const storeDraft = createAction( `${SEND_MODULE_PREFIX}/store-draft`, diff --git a/suite-common/wallet-core/src/send/sendFormReducer.ts b/suite-common/wallet-core/src/send/sendFormReducer.ts index 28e27f6160f..e669cfd7f92 100644 --- a/suite-common/wallet-core/src/send/sendFormReducer.ts +++ b/suite-common/wallet-core/src/send/sendFormReducer.ts @@ -1,15 +1,24 @@ +import { G } from '@mobily/ts-belt'; + import { AccountKey, FormState, GeneralPrecomposedTransactionFinal, } from '@suite-common/wallet-types'; import { cloneObject } from '@trezor/utils'; -import { SerializedTx } from '@suite-common/wallet-types'; import { createReducerWithExtraDeps } from '@suite-common/redux-utils'; import { BlockbookTransaction } from '@trezor/blockchain-link-types'; +import { NetworkSymbol, networks } from '@suite-common/wallet-config'; +import { DeviceModelInternal } from '@trezor/connect'; import { sendFormActions } from './sendFormActions'; import { accountsActions } from '../accounts/accountsActions'; +import { + DeviceRootState, + selectDeviceButtonRequestsCodes, + selectDeviceModel, +} from '../device/deviceReducer'; +import { SerializedTx } from './sendFormTypes'; export type SendState = { drafts: { @@ -90,5 +99,46 @@ export const selectSendPrecomposedTx = (state: SendRootState) => state.wallet.se export const selectSendSerializedTx = (state: SendRootState) => state.wallet.send.serializedTx; export const selectSendSignedTx = (state: SendRootState) => state.wallet.send.signedTx; export const selectSendFormDrafts = (state: SendRootState) => state.wallet.send.drafts; -export const selectSendFormDraftByAccountKey = (state: SendRootState, accountKey: AccountKey) => - state.wallet.send.drafts[accountKey]; + +export const selectSendFormDraftByAccountKey = ( + state: SendRootState, + accountKey?: AccountKey, +): FormState | null => { + if (G.isUndefined(accountKey)) return null; + + return state.wallet.send.drafts[accountKey] ?? null; +}; + +export const selectSendFormReviewButtonRequestsCount = ( + state: DeviceRootState, + networkSymbol?: NetworkSymbol, + decreaseOutputId?: number, +) => { + const buttonRequestCodes = selectDeviceButtonRequestsCodes(state); + const deviceModel = selectDeviceModel(state); + const { networkType } = networks[networkSymbol ?? 'btc']; + + const isCardano = networkType === 'cardano'; + const isEthereum = networkType === 'ethereum'; + + const sendFormReviewRequest = buttonRequestCodes.filter( + code => + code === 'ButtonRequest_ConfirmOutput' || + code === 'ButtonRequest_SignTx' || + isCardano || + (isEthereum && code === 'ButtonRequest_Other'), + ); + + // NOTE: T1B1 edge-case + // while confirming decrease amount 'ButtonRequest_ConfirmOutput' is called twice (confirm decrease address, confirm decrease amount) + // remove 1 additional element to keep it consistent with T2T1 where this step is swipeable with one button request + if ( + G.isNumber(decreaseOutputId) && + deviceModel === DeviceModelInternal.T1B1 && + sendFormReviewRequest.filter(code => code === 'ButtonRequest_ConfirmOutput').length > 1 + ) { + sendFormReviewRequest.splice(-1, 1); + } + + return isCardano ? sendFormReviewRequest.length - 1 : sendFormReviewRequest.length; +}; diff --git a/suite-common/wallet-core/src/send/sendFormThunks.ts b/suite-common/wallet-core/src/send/sendFormThunks.ts index 9a9938058d5..0c1cad0e379 100644 --- a/suite-common/wallet-core/src/send/sendFormThunks.ts +++ b/suite-common/wallet-core/src/send/sendFormThunks.ts @@ -5,7 +5,6 @@ import { createThunk } from '@suite-common/redux-utils'; import { Account, AccountKey, - ComposeActionContext, FormState, GeneralPrecomposedTransactionFinal, PrecomposedTransactionFinal, @@ -64,6 +63,7 @@ import { composeSolanaSendFormTransactionThunk, } from './sendFormSolanaThunks'; import { SEND_MODULE_PREFIX } from './sendFormConstants'; +import { ComposeActionContext } from './sendFormTypes'; export const convertSendFormDraftsBtcAmountUnitsThunk = createThunk( `${SEND_MODULE_PREFIX}/convertSendFormDraftsBtcAmountUnitsThunk`, diff --git a/suite-common/wallet-core/src/send/sendFormTypes.ts b/suite-common/wallet-core/src/send/sendFormTypes.ts index 1d804d1194b..19b32695dd0 100644 --- a/suite-common/wallet-core/src/send/sendFormTypes.ts +++ b/suite-common/wallet-core/src/send/sendFormTypes.ts @@ -1,9 +1,36 @@ import { Account, - ComposeActionContext, - FormState, + FeeInfo, + WalletAccountTransaction, PrecomposedTransactionFinal, + ExcludedUtxos, + FormState, } from '@suite-common/wallet-types'; +import { TokenInfo } from '@trezor/connect'; +import { Network, NetworkSymbol } from '@suite-common/wallet-config'; + +export type SerializedTx = { tx: string; coin: NetworkSymbol }; + +export interface ComposeActionContext { + account: Account; + network: Network; + feeInfo: FeeInfo; + excludedUtxos?: ExcludedUtxos; + prison?: Record; +} + +export type EthTransactionData = { + token?: TokenInfo; + chainId: number; + to: string; + amount: string; + data?: string; + gasLimit: string; + gasPrice: string; + nonce: string; +}; + +export type TransactionType = WalletAccountTransaction['type']; export type ComposeTransactionThunkArguments = { formValues: FormState; diff --git a/suite-common/wallet-core/src/stake/stakeReducer.ts b/suite-common/wallet-core/src/stake/stakeReducer.ts index a8b31bd5257..81cfac20d01 100644 --- a/suite-common/wallet-core/src/stake/stakeReducer.ts +++ b/suite-common/wallet-core/src/stake/stakeReducer.ts @@ -1,16 +1,12 @@ import { createReducerWithExtraDeps } from '@suite-common/redux-utils'; -import { - StakeFormState, - PrecomposedTransactionFinal, - Timestamp, - SerializedTx, -} from '@suite-common/wallet-types'; +import { Timestamp, StakeFormState, PrecomposedTransactionFinal } from '@suite-common/wallet-types'; import { cloneObject } from '@trezor/utils'; import { NetworkSymbol } from '@suite-common/wallet-config'; import { stakeActions } from './stakeActions'; -import { StakeRootState, ValidatorsQueue } from './stakeTypes'; +import { ValidatorsQueue } from './stakeTypes'; import { fetchEverstakeData } from './stakeThunks'; +import { SerializedTx } from '../send/sendFormTypes'; export interface StakeState { precomposedTx?: PrecomposedTransactionFinal; @@ -38,6 +34,8 @@ export interface StakeState { }; } +export type StakeRootState = { wallet: { stake: StakeState } }; + export const stakeInitialState: StakeState = { precomposedTx: undefined, serializedTx: undefined, @@ -122,3 +120,6 @@ export const prepareStakeReducer = createReducerWithExtraDeps(stakeInitialState, }); export const selectStake = (state: StakeRootState) => state.wallet.stake; + +export const selectStakePrecomposedForm = (state: StakeRootState) => + state.wallet.stake.precomposedForm; diff --git a/suite-common/wallet-core/src/stake/stakeSelectors.ts b/suite-common/wallet-core/src/stake/stakeSelectors.ts index 892815d4f5b..22cdc7fc269 100644 --- a/suite-common/wallet-core/src/stake/stakeSelectors.ts +++ b/suite-common/wallet-core/src/stake/stakeSelectors.ts @@ -1,6 +1,7 @@ import { NetworkSymbol } from '@suite-common/wallet-config'; -import { StakeRootState, BACKUP_ETH_APY } from './stakeTypes'; +import { BACKUP_ETH_APY } from './stakeTypes'; +import { StakeRootState } from './stakeReducer'; export const selectEverstakeData = ( state: StakeRootState, diff --git a/suite-common/wallet-core/src/stake/stakeTypes.ts b/suite-common/wallet-core/src/stake/stakeTypes.ts index 26c68b2fdec..ad6ae176bd8 100644 --- a/suite-common/wallet-core/src/stake/stakeTypes.ts +++ b/suite-common/wallet-core/src/stake/stakeTypes.ts @@ -1,6 +1,15 @@ -import { NetworkSymbol, getNetworkFeatures } from '@suite-common/wallet-config'; +import { UseFormReturn, FormState as ReactHookFormState } from 'react-hook-form'; -import { StakeState } from './stakeReducer'; +import { Network, NetworkSymbol, getNetworkFeatures } from '@suite-common/wallet-config'; +import { + Account, + Rate, + FeeInfo, + StakeFormState, + PrecomposedLevels, +} from '@suite-common/wallet-types'; +import { FiatCurrencyCode } from '@suite-common/suite-config'; +import { FeeLevel } from '@trezor/connect'; // Used when Everstake pool stats are not available from the API. export const BACKUP_ETH_APY = 4.13; @@ -54,4 +63,53 @@ export interface ValidatorsQueue { updatedAt?: number; } -export type StakeRootState = { wallet: { stake: StakeState } }; +export interface AmountLimitsString { + currency: string; + minCrypto?: string; + maxCrypto?: string; + minFiat?: string; + maxFiat?: string; +} + +export interface BaseStakeContextValues { + account: Account; + network: Network; + localCurrency: FiatCurrencyCode; + composedLevels?: PrecomposedLevels; + isComposing: boolean; + clearForm: () => void; + signTx: () => Promise; + selectedFee: FeeLevel['label']; + feeInfo: FeeInfo; + changeFeeLevel: (level: FeeLevel['label']) => void; +} + +export type StakeContextValues = UseFormReturn & + BaseStakeContextValues & { + formState: ReactHookFormState; + removeDraft: (key: string) => void; + isDraft: boolean; + amountLimits: AmountLimitsString; + isAmountForWithdrawalWarningShown: boolean; + isAdviceForWithdrawalWarningShown: boolean; + isConfirmModalOpen: boolean; + onCryptoAmountChange: (amount: string) => void; + onFiatAmountChange: (amount: string) => void; + setMax: () => void; + setRatioAmount: (divisor: number) => void; + closeConfirmModal: () => void; + onSubmit: () => void; + currentRate: Rate | undefined; + isLoading: boolean; + }; + +export interface UnstakeFormState extends Omit {} + +export type UnstakeContextValues = UseFormReturn & + BaseStakeContextValues & { + formState: ReactHookFormState; + onCryptoAmountChange: (amount: string) => Promise; + onFiatAmountChange: (amount: string) => void; + onOptionChange: (amount: string) => Promise; + currentRate: Rate | undefined; + }; diff --git a/suite-common/wallet-types/package.json b/suite-common/wallet-types/package.json index 32e4c43a9d0..3fb58e813d7 100644 --- a/suite-common/wallet-types/package.json +++ b/suite-common/wallet-types/package.json @@ -20,7 +20,6 @@ "@trezor/connect": "workspace:*", "@trezor/type-utils": "workspace:*", "@trezor/utils": "workspace:*", - "react": "18.2.0", - "react-hook-form": "^7.50.1" + "react": "18.2.0" } } diff --git a/suite-common/wallet-types/src/index.ts b/suite-common/wallet-types/src/index.ts index 5320fed3226..c8b9351920b 100644 --- a/suite-common/wallet-types/src/index.ts +++ b/suite-common/wallet-types/src/index.ts @@ -11,5 +11,3 @@ export * from './selectedAccount'; export * from './transaction'; export * from './stake'; export * from './stakeForm'; -export * from './unstakeForm'; -export * from './claimForm'; diff --git a/suite-common/wallet-types/src/sendForm.ts b/suite-common/wallet-types/src/sendForm.ts index e6dcfeb1f46..62aafb4bea4 100644 --- a/suite-common/wallet-types/src/sendForm.ts +++ b/suite-common/wallet-types/src/sendForm.ts @@ -1,20 +1,6 @@ -import { Dispatch, SetStateAction } from 'react'; -import { FieldPath, UseFormReturn } from 'react-hook-form'; +import { AccountUtxo, FeeLevel } from '@trezor/connect'; -import { Network, NetworkSymbol } from '@suite-common/wallet-config'; -import { AccountUtxo, FeeLevel, PROTO } from '@trezor/connect'; - -import { Account, AccountKey } from './account'; -import { - CurrencyOption, - FeeInfo, - Output, - PrecomposedLevels, - PrecomposedLevelsCardano, - RbfTransactionParams, - WalletAccountTransaction, -} from './transaction'; -import { Rate } from './fiatRates'; +import { Output, RbfTransactionParams } from './transaction'; export type FormOptions = | 'broadcast' @@ -47,93 +33,3 @@ export interface FormState { anonymityWarningChecked?: boolean; selectedUtxos: AccountUtxo[]; } - -export type SerializedTx = { tx: string; coin: NetworkSymbol }; - -export type ExcludedUtxos = Record; - -// local state of @wallet-hooks/useSendForm -export type UseSendFormState = { - account: Account; - network: Network; - coinFees: FeeInfo; - localCurrencyOption: CurrencyOption; - feeInfo: FeeInfo; - composedLevels?: PrecomposedLevels | PrecomposedLevelsCardano; - online: boolean; - metadataEnabled: boolean; -}; - -export interface ComposeActionContext { - account: Account; - network: Network; - feeInfo: FeeInfo; - excludedUtxos?: ExcludedUtxos; - prison?: Record; -} - -export interface UtxoSelectionContext { - excludedUtxos: ExcludedUtxos; - allUtxosSelected: boolean; - composedInputs: PROTO.TxInputType[]; - dustUtxos: AccountUtxo[]; - isCoinControlEnabled: boolean; - lowAnonymityUtxos: AccountUtxo[]; - selectedUtxos: AccountUtxo[]; - spendableUtxos: AccountUtxo[]; - coinjoinRegisteredUtxos: AccountUtxo[]; - isLowAnonymityUtxoSelected: boolean; - anonymityWarningChecked: boolean; - toggleAnonymityWarning: () => void; - toggleCheckAllUtxos: () => void; - toggleCoinControl: () => void; - toggleUtxoSelection: (utxo: AccountUtxo) => void; -} - -// strongly typed UseFormMethods.getValues with fallback value -export interface GetDefaultValue { - ( - fieldName: K, - fallback?: T, - ): K extends keyof FormState ? FormState[K] : unknown; - (fieldName: K, fallback: T): K extends keyof FormState ? FormState[K] : T; -} - -export type SendContextValues = - UseFormReturn & - UseSendFormState & { - isLoading: boolean; - fiatRate?: Rate; - // additional fields - outputs: Partial[]; // useFieldArray fields - updateContext: (value: Partial) => void; - resetContext: () => void; - composeTransaction: (field?: FieldPath) => void; - loadTransaction: () => Promise; - signTransaction: () => void; - // useSendFormFields utils: - calculateFiat: (outputIndex: number, amount?: string) => void; - setAmount: (outputIndex: number, amount: string) => void; - changeFeeLevel: (currentLevel: FeeLevel['label']) => void; - resetDefaultValue: (field: FieldPath) => void; - setMax: (index: number, active: boolean) => void; - getDefaultValue: GetDefaultValue; - toggleOption: (option: FormOptions) => void; - // useSendFormOutputs utils: - addOutput: () => void; // useFieldArray append - removeOutput: (index: number) => void; // useFieldArray remove - addOpReturn: () => void; - removeOpReturn: (index: number) => void; - // useSendFormCompose - setDraftSaveRequest: Dispatch>; - // UTXO selection - utxoSelection: UtxoSelectionContext; - }; - -export type RbfLabelsToBeUpdated = Record< - AccountKey, - { - toBeMoved: WalletAccountTransaction; - toBeDeleted: WalletAccountTransaction[]; - } ->; diff --git a/suite-common/wallet-types/src/stakeForm.ts b/suite-common/wallet-types/src/stakeForm.ts index 4f5fe01c958..38bdaffde4d 100644 --- a/suite-common/wallet-types/src/stakeForm.ts +++ b/suite-common/wallet-types/src/stakeForm.ts @@ -1,57 +1,8 @@ -import { UseFormReturn, FormState as ReactHookFormState } from 'react-hook-form'; - -import { FeeLevel } from '@trezor/connect'; -import { FiatCurrencyCode } from '@suite-common/suite-config'; -import { Network } from '@suite-common/wallet-config'; - -import { FeeInfo, PrecomposedLevels } from './transaction'; -import { FormState } from './sendForm'; -import { Account } from './account'; import { StakeType } from './stake'; -import { Rate } from './fiatRates'; - -export interface AmountLimitsString { - currency: string; - minCrypto?: string; - maxCrypto?: string; - minFiat?: string; - maxFiat?: string; -} +import { FormState } from './sendForm'; export interface StakeFormState extends FormState { fiatInput?: string; cryptoInput?: string; ethereumStakeType: StakeType; } - -export interface BaseStakeContextValues { - account: Account; - network: Network; - localCurrency: FiatCurrencyCode; - composedLevels?: PrecomposedLevels; - isComposing: boolean; - clearForm: () => void; - signTx: () => Promise; - selectedFee: FeeLevel['label']; - feeInfo: FeeInfo; - changeFeeLevel: (level: FeeLevel['label']) => void; -} - -export type StakeContextValues = UseFormReturn & - BaseStakeContextValues & { - formState: ReactHookFormState; - removeDraft: (key: string) => void; - isDraft: boolean; - amountLimits: AmountLimitsString; - isAmountForWithdrawalWarningShown: boolean; - isAdviceForWithdrawalWarningShown: boolean; - isConfirmModalOpen: boolean; - onCryptoAmountChange: (amount: string) => void; - onFiatAmountChange: (amount: string) => void; - setMax: () => void; - setRatioAmount: (divisor: number) => void; - closeConfirmModal: () => void; - onSubmit: () => void; - currentRate: Rate | undefined; - isLoading: boolean; - }; diff --git a/suite-common/wallet-types/src/transaction.ts b/suite-common/wallet-types/src/transaction.ts index e838bb39808..6212c59d493 100644 --- a/suite-common/wallet-types/src/transaction.ts +++ b/suite-common/wallet-types/src/transaction.ts @@ -243,3 +243,43 @@ export type TransactionFiatRateUpdatePayload = { export type TransactionType = Pick['type']; export type ExportFileType = 'csv' | 'pdf' | 'json'; + +export type ReviewOutput = + | { + type: + | 'opreturn' + | 'data' + | 'locktime' + | 'fee' + | 'destination-tag' + | 'txid' + | 'address' + | 'amount' + | 'gas' + | 'contract' + | 'regular_legacy'; + label?: string; + value: string; + value2?: string; + token?: TokenInfo; + } + | { + type: 'fee-replace'; + label?: undefined; + value: string; + value2: string; + token?: undefined; + } + | { + type: 'reduce-output'; + label: string; + value: string; + value2: string; + token?: undefined; + }; + +export type ReviewOutputType = ReviewOutput['type']; + +export type ReviewOutputState = 'active' | 'success' | undefined; + +export type ExcludedUtxos = Record; diff --git a/suite-common/wallet-types/src/unstakeForm.ts b/suite-common/wallet-types/src/unstakeForm.ts deleted file mode 100644 index 20581798d5a..00000000000 --- a/suite-common/wallet-types/src/unstakeForm.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { UseFormReturn, FormState as ReactHookFormState } from 'react-hook-form'; - -import { BaseStakeContextValues, StakeFormState } from './stakeForm'; -import { Rate } from './fiatRates'; - -export interface UnstakeFormState extends Omit {} - -export type UnstakeContextValues = UseFormReturn & - BaseStakeContextValues & { - formState: ReactHookFormState; - onCryptoAmountChange: (amount: string) => Promise; - onFiatAmountChange: (amount: string) => void; - onOptionChange: (amount: string) => Promise; - currentRate: Rate | undefined; - }; diff --git a/suite-common/wallet-utils/package.json b/suite-common/wallet-utils/package.json index ff917709746..2a18ebcf5a8 100644 --- a/suite-common/wallet-utils/package.json +++ b/suite-common/wallet-utils/package.json @@ -30,6 +30,7 @@ "@trezor/blockchain-link-types": "workspace:*", "@trezor/blockchain-link-utils": "workspace:*", "@trezor/connect": "workspace:*", + "@trezor/device-utils": "workspace:*", "@trezor/urls": "workspace:*", "@trezor/utils": "workspace:*", "date-fns": "^2.30.0", diff --git a/suite-common/wallet-utils/src/index.ts b/suite-common/wallet-utils/src/index.ts index a21907521a1..d51275a1e0a 100644 --- a/suite-common/wallet-utils/src/index.ts +++ b/suite-common/wallet-utils/src/index.ts @@ -17,5 +17,6 @@ export * from './transactionUtils'; export * from './validationUtils'; export * from './antiFraud'; export * from './stakingUtils'; +export * from './reviewTransactionUtils'; export { analyzeTransactions as analyzeTransactionsFixtures } from './__fixtures__/transactionUtils'; diff --git a/suite-common/wallet-utils/src/reviewTransactionUtils.ts b/suite-common/wallet-utils/src/reviewTransactionUtils.ts new file mode 100644 index 00000000000..3f184086ffc --- /dev/null +++ b/suite-common/wallet-utils/src/reviewTransactionUtils.ts @@ -0,0 +1,336 @@ +import { fromWei, toWei } from 'web3-utils'; + +import { CardanoOutput } from '@trezor/connect'; +import { getFirmwareVersion } from '@trezor/device-utils'; +import { versionUtils } from '@trezor/utils'; +import { + Account, + FormState, + GeneralPrecomposedTransactionFinal, + ReviewOutputState, +} from '@suite-common/wallet-types'; +import { StakeFormState, ReviewOutput } from '@suite-common/wallet-types'; +import { TrezorDevice } from '@suite-common/suite-types'; + +import { getShortFingerprint, isCardanoTx } from './cardanoUtils'; + +export const getTransactionReviewOutputState = ( + index: number, + buttonRequestsCount: number, +): ReviewOutputState => { + if (index === buttonRequestsCount - 1) return 'active'; + if (index < buttonRequestsCount - 1) return 'success'; + + return undefined; +}; + +export const getIsUpdatedSendFlow = (device: TrezorDevice) => { + const firmwareVersion = getFirmwareVersion(device); + + return versionUtils.isNewerOrEqual(firmwareVersion, '2.6.0'); +}; + +export const getIsUpdatedEthereumSendFlow = ( + device: TrezorDevice, + network: Account['networkType'], +) => { + if (network !== 'ethereum') return false; + + const firmwareVersion = getFirmwareVersion(device); + + // publicly introduced in 2.6.3, versions 2.6.1 and 2.6.2 were internal + return versionUtils.isNewer(firmwareVersion, '2.6.0'); +}; + +const getCardanoTokenBundle = (account: Account, output: CardanoOutput) => { + // Transforms cardano's tokenBundle into outputs, 1 output per one token + // since suite supports only 1 token per output it will return just one item + if (!output.tokenBundle || output.tokenBundle.length === 0 || 'addressParameters' in output) + return undefined; + + if (account.tokens) { + return output.tokenBundle + .map(policyGroup => + policyGroup.tokenAmounts.map(token => { + const accountToken = account.tokens!.find( + currentToken => + currentToken.contract === + `${policyGroup.policyId}${token.assetNameBytes}`, + ); + if (!accountToken) return; + + const fingerprint = accountToken.name + ? getShortFingerprint(accountToken.name) + : undefined; + + return { + type: 'cardano', + contract: output.address, + balance: token.amount, + symbol: token.assetNameBytes + ? Buffer.from(token.assetNameBytes, 'hex').toString('utf8') + : fingerprint, + decimals: accountToken.decimals, + }; + }), + ) + .flat(); + } +}; + +type ConstructOutputsParams = { + precomposedTx: GeneralPrecomposedTransactionFinal; + decreaseOutputId: number | undefined; + account: Account; + precomposedForm: FormState | StakeFormState; +}; + +const constructOldFlow = ({ + precomposedTx, + decreaseOutputId, + account, + precomposedForm, +}: ConstructOutputsParams) => { + const outputs: ReviewOutput[] = []; + + const isCardano = isCardanoTx(account, precomposedTx); + const { networkType } = account; + + const hasBitcoinLockTime = 'bitcoinLockTime' in precomposedForm; + const hasRippleDestinationTag = 'rippleDestinationTag' in precomposedForm; + + // used in the bumb fee flow + if (typeof precomposedTx.useNativeRbf === 'boolean' && precomposedTx.useNativeRbf) { + outputs.push( + { + type: 'txid', + value: precomposedTx.prevTxid!, + }, + { + type: 'fee-replace', + value: precomposedTx.feeDifference, + value2: precomposedTx.fee, + }, + ); + + // add decrease output confirmation step between txid and fee + if (typeof decreaseOutputId === 'number') { + outputs.splice(1, 0, { + type: 'reduce-output', + label: precomposedTx.outputs[decreaseOutputId].address!, + value: precomposedTx.feeDifference, + value2: precomposedTx.outputs[decreaseOutputId].amount.toString(), + }); + } + } else if (isCardano) { + precomposedTx.outputs.forEach(o => { + // iterate only through "external" outputs (change output has addressParameters field instead of address) + if ('address' in o) { + const tokenBundle = getCardanoTokenBundle(account, o)?.[0]; // send form supports one token per output + + // each output will include certain amount of ADA (cardano token outputs require ADA) + outputs.push({ + type: 'regular_legacy', + value: o.address, + }); + + // if the output also includes a token then we need to render another row with the token + if (tokenBundle) { + outputs.push({ + type: 'regular_legacy', + label: o.address, + value: tokenBundle.balance ?? '0', + token: tokenBundle, + }); + } + } + }); + } else { + precomposedTx.outputs.forEach(o => { + if (typeof o.address === 'string') { + outputs.push({ + type: 'regular_legacy', + value: o.address, + }); + } else if (o.script_type === 'PAYTOOPRETURN') { + outputs.push({ + type: 'opreturn', + value: o.op_return_data, + }); + } + }); + } + + if (hasBitcoinLockTime && precomposedForm.bitcoinLockTime) { + outputs.push({ type: 'locktime', value: precomposedForm.bitcoinLockTime }); + } + + if (precomposedForm.ethereumDataHex && !precomposedTx.token) { + outputs.push({ type: 'data', value: precomposedForm.ethereumDataHex }); + } + + if (networkType === 'ripple') { + // ripple displays requests on device in different order: + // 1. destination tag + // 2. fee + // 3. output + outputs.unshift({ type: 'fee', value: precomposedTx.fee }); + if (hasRippleDestinationTag && precomposedForm.rippleDestinationTag) { + outputs.unshift({ + type: 'destination-tag', + value: precomposedForm.rippleDestinationTag, + }); + } + } else if (!precomposedTx.useNativeRbf) { + outputs.push({ type: 'fee', value: precomposedTx.fee }); + } + + return outputs; +}; + +const constructNewFlow = ({ + precomposedTx, + decreaseOutputId, + account, + precomposedForm, + isUpdatedEthereumSendFlow, +}: ConstructOutputsParams & { isUpdatedEthereumSendFlow: boolean }) => { + const outputs: ReviewOutput[] = []; + + const isCardano = isCardanoTx(account, precomposedTx); + const isSolana = account.networkType === 'solana'; + const { networkType } = account; + + const hasBitcoinLockTime = 'bitcoinLockTime' in precomposedForm; + const hasRippleDestinationTag = 'rippleDestinationTag' in precomposedForm; + + if (precomposedForm.ethereumDataHex && !precomposedTx.token) { + outputs.push({ type: 'data', value: precomposedForm.ethereumDataHex }); + } + + // used in the bump fee flow + if (typeof precomposedTx.useNativeRbf === 'boolean' && precomposedTx.useNativeRbf) { + outputs.push( + { + type: 'txid', + value: precomposedTx.prevTxid!, + }, + { + type: 'fee-replace', + value: precomposedTx.feeDifference, + value2: precomposedTx.fee, + }, + ); + + // add decrease output confirmation step between txid and fee + if (typeof decreaseOutputId === 'number') { + outputs.splice(1, 0, { + type: 'reduce-output', + label: precomposedTx.outputs[decreaseOutputId].address!, + value: precomposedTx.feeDifference, + value2: precomposedTx.outputs[decreaseOutputId].amount.toString(), + }); + } + } else if (isCardano) { + precomposedTx.outputs.forEach(o => { + // iterate only through "external" outputs (change output has addressParameters field instead of address) + if ('address' in o) { + const tokenBundle = getCardanoTokenBundle(account, o)?.[0]; // send form supports one token per output + + // each output will include certain amount of ADA (cardano token outputs require ADA) + outputs.push({ + type: 'regular_legacy', + value: o.address, + }); + + // if the output also includes a token then we need to render another row with the token + if (tokenBundle) { + outputs.push({ + type: 'regular_legacy', + label: o.address, + value: tokenBundle.balance ?? '0', + token: tokenBundle, + }); + } + } + }); + } else { + precomposedTx.outputs.forEach(o => { + if (typeof o.address === 'string') { + const tokenOutput: ReviewOutput = { + type: 'contract', + value: precomposedTx.token ? precomposedTx.token.contract : '', + }; + + // this is displayed only for tokens without definitions + if (precomposedTx.token && !precomposedTx.isTokenKnown && !isSolana) { + outputs.push(tokenOutput); + } + + outputs.push({ type: 'address', value: o.address }); + if (!isSolana && !isUpdatedEthereumSendFlow) { + outputs.push({ + type: 'amount', + value: o.amount.toString(), + token: precomposedTx.token, + }); + } + + // Solana tokens are displayed *after* the address + if (precomposedTx.token && !precomposedTx.isTokenKnown && isSolana) { + outputs.push(tokenOutput); + } + } else if (o.script_type === 'PAYTOOPRETURN') { + outputs.push({ + type: 'opreturn', + value: o.op_return_data, + }); + } + }); + } + + if (networkType === 'ethereum' && !isUpdatedEthereumSendFlow) { + // device shows ether, precomposedTx.feePerByte is in gwei + const wei = toWei(precomposedTx.feePerByte, 'gwei'); // from gwei to wei + const ether = fromWei(wei, 'ether'); // from wei to ether + + outputs.push({ + type: 'gas', + value: ether, + }); + } + + if (hasBitcoinLockTime && precomposedForm.bitcoinLockTime) { + outputs.push({ type: 'locktime', value: precomposedForm.bitcoinLockTime }); + } + + if ( + networkType === 'ripple' && + hasRippleDestinationTag && + precomposedForm.rippleDestinationTag + ) { + outputs.unshift({ + type: 'destination-tag', + value: precomposedForm.rippleDestinationTag, + }); + } + + return outputs; +}; + +export const constructTransactionReviewOutputs = ({ + device, + ...params +}: ConstructOutputsParams & { device: TrezorDevice }) => { + const isUpdatedSendFlow = getIsUpdatedSendFlow(device); // >= 2.6.0 + const isUpdatedEthereumSendFlow = getIsUpdatedEthereumSendFlow( + device, + params.account.networkType, + ); // > 2.6.0 && isEthereum + + if (!isUpdatedSendFlow) { + return constructOldFlow(params); + } + + return constructNewFlow({ isUpdatedEthereumSendFlow, ...params }); +}; diff --git a/suite-common/wallet-utils/src/sendFormUtils.ts b/suite-common/wallet-utils/src/sendFormUtils.ts index 6de5ab6ced3..845786a9808 100644 --- a/suite-common/wallet-utils/src/sendFormUtils.ts +++ b/suite-common/wallet-utils/src/sendFormUtils.ts @@ -26,7 +26,6 @@ import type { EthTransactionData, ExternalOutput, Output, - UseSendFormState, RbfTransactionParams, Account, CurrencyOption, @@ -405,10 +404,7 @@ export const restoreOrigOutputsOrder = ( }); }; -export const getDefaultValues = ( - currency: Output['currency'], - network: UseSendFormState['network'], -): FormState => ({ +export const getDefaultValues = (currency: Output['currency'], network: Network): FormState => ({ ...DEFAULT_VALUES, options: isFeatureFlagEnabled('RBF') && network.features?.includes('rbf') diff --git a/suite-common/wallet-utils/tsconfig.json b/suite-common/wallet-utils/tsconfig.json index 0981b04e3d3..7acf3bc5892 100644 --- a/suite-common/wallet-utils/tsconfig.json +++ b/suite-common/wallet-utils/tsconfig.json @@ -23,6 +23,7 @@ "path": "../../packages/blockchain-link-utils" }, { "path": "../../packages/connect" }, + { "path": "../../packages/device-utils" }, { "path": "../../packages/urls" }, { "path": "../../packages/utils" } ] diff --git a/suite-native/module-send/src/screens/SendFormScreen.tsx b/suite-native/module-send/src/screens/SendFormScreen.tsx index 90c37c4798d..93f8a35545d 100644 --- a/suite-native/module-send/src/screens/SendFormScreen.tsx +++ b/suite-native/module-send/src/screens/SendFormScreen.tsx @@ -1,4 +1,5 @@ -import { useSelector } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; +import { useEffect } from 'react'; import { GoBackIcon, @@ -9,23 +10,31 @@ import { StackProps, } from '@suite-native/navigation'; import { HStack, Text, VStack } from '@suite-native/atoms'; -import { AccountsRootState, selectAccountByKey } from '@suite-common/wallet-core'; +import { + AccountsRootState, + selectAccountByKey, + updateFeeInfoThunk, +} from '@suite-common/wallet-core'; import { CryptoAmountFormatter } from '@suite-native/formatters'; -import { SendForm } from '../components/SendForm'; +import { SendOutputsForm } from '../components/SendOutputsForm'; export const SendFormScreen = ({ route: { params }, }: StackProps) => { const { accountKey } = params; + const dispatch = useDispatch(); const account = useSelector((state: AccountsRootState) => selectAccountByKey(state, accountKey), ); + useEffect(() => { + if (account?.symbol) dispatch(updateFeeInfoThunk(account.symbol)); + }, []); + if (!account) return; - // TODO: move text content to @suite-native/intl package when is copy ready return ( } />} diff --git a/suite-native/module-send/src/sendFormThunks.ts b/suite-native/module-send/src/sendFormThunks.ts new file mode 100644 index 00000000000..1cb86274cf2 --- /dev/null +++ b/suite-native/module-send/src/sendFormThunks.ts @@ -0,0 +1,122 @@ +import { G } from '@mobily/ts-belt'; +import { isRejected } from '@reduxjs/toolkit'; + +import { createThunk } from '@suite-common/redux-utils'; +import { + ComposeActionContext, + composeSendFormTransactionThunk, + deviceActions, + enhancePrecomposedTransactionThunk, + pushSendFormTransactionThunk, + selectAccountByKey, + selectDevice, + selectNetworkFeeInfo, + sendFormActions, + signTransactionThunk, +} from '@suite-common/wallet-core'; +import { Account, AccountKey, FormState } from '@suite-common/wallet-types'; +import { getNetwork } from '@suite-common/wallet-utils'; +import { requestPrioritizedDeviceAccess } from '@suite-native/device-mutex'; +import { BlockbookTransaction } from '@trezor/blockchain-link-types'; +import { SignedTransaction } from '@trezor/connect'; + +const SEND_MODULE_PREFIX = '@suite-native/send'; + +export const onDeviceTransactionReviewThunk = createThunk< + BlockbookTransaction, + { accountKey: AccountKey; formState: FormState }, + { rejectValue: string } +>( + `${SEND_MODULE_PREFIX}/onDeviceTransactionReviewThunk`, + async ( + { accountKey, formState }, + { dispatch, getState, rejectWithValue, fulfillWithValue }, + ) => { + const account = selectAccountByKey(getState(), accountKey); + const device = selectDevice(getState()); + const networkFeeInfo = selectNetworkFeeInfo(getState(), account?.symbol); + + if (!account || !networkFeeInfo || !device) + return rejectWithValue('Failed to get account, fee info or device from redux store.'); + + const network = getNetwork(account.symbol); + + if (!network) + return rejectWithValue('Failed to derive account network from account symbol.'); + const composeContext: ComposeActionContext = { + account, + network, + feeInfo: networkFeeInfo, + }; + + dispatch(deviceActions.removeButtonRequests({ device })); + + //compose transaction with specific fee levels + const precomposedFeeLevels = await dispatch( + composeSendFormTransactionThunk({ + formValues: formState, + formState: composeContext, + }), + ).unwrap(); + + // TODO: select fee level based on user selection when there is fee input added to the send form. + const selectedFeeLevel = precomposedFeeLevels?.normal; + + if (!selectedFeeLevel || selectedFeeLevel.type !== 'final') + return rejectWithValue('Requested fee level not found in composed transaction levels.'); + + // prepare transaction with select fee level + const precomposedTransaction = await dispatch( + enhancePrecomposedTransactionThunk({ + transactionFormValues: formState, + precomposedTransaction: selectedFeeLevel, + selectedAccount: account, + }), + ).unwrap(); + + if (!precomposedTransaction) + return rejectWithValue('Thunk prepareTransactionForSigningThunk failed.'); + const signTransactionResponse = await requestPrioritizedDeviceAccess(() => + dispatch( + signTransactionThunk({ + formValues: formState, + precomposedTransaction, + selectedAccount: account, + }), + ).unwrap(), + ); + + if ( + !signTransactionResponse.success || + G.isNullable(signTransactionResponse.payload?.signedTx) + ) + return rejectWithValue('Device failed to sign the transaction.'); + + return fulfillWithValue(signTransactionResponse.payload.signedTx); + }, +); + +export const sendTransactionAndCleanupSendFormThunk = createThunk( + `${SEND_MODULE_PREFIX}/sendTransactionAndCleanupSendFormThunk`, + async ( + { + account, + }: { account: Account; signedTransaction: SignedTransaction['signedTransaction'] }, + { dispatch, rejectWithValue }, + ) => { + const response = await dispatch( + pushSendFormTransactionThunk({ + selectedAccount: account, + }), + ); + + if (isRejected(response)) { + return rejectWithValue(response.error ?? 'Failed to push transaction to blockchain.'); + } + + dispatch(sendFormActions.dispose()); + dispatch(sendFormActions.removeDraft({ accountKey: account.key })); + + return response.payload; + }, +); diff --git a/yarn.lock b/yarn.lock index ef9b948b7a3..16544009974 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8882,6 +8882,7 @@ __metadata: "@trezor/utils": "workspace:*" date-fns: "npm:^2.30.0" proxy-memoize: "npm:2.0.2" + react-hook-form: "npm:^7.50.1" web3-utils: "npm:^4.2.3" languageName: unknown linkType: soft @@ -8900,7 +8901,6 @@ __metadata: "@trezor/type-utils": "workspace:*" "@trezor/utils": "workspace:*" react: "npm:18.2.0" - react-hook-form: "npm:^7.50.1" languageName: unknown linkType: soft From 77256e6cef1a4b05f2207d2a6c864e1ae21906ad Mon Sep 17 00:00:00 2001 From: Petr Knetl Date: Mon, 17 Jun 2024 15:25:24 +0200 Subject: [PATCH 2/2] feat(suite-native): send form flow - review & send --- .../{useAsyncDebounce.ts => useDebounce.ts} | 7 +- packages/react-utils/src/index.ts | 2 +- .../suite/src/hooks/wallet/form/useCompose.ts | 4 +- .../src/hooks/wallet/form/useStakeCompose.ts | 4 +- .../src/hooks/wallet/useSendFormCompose.ts | 4 +- .../utils/wallet/reviewTransactionUtils.ts | 7 +- .../wallet-config/src/networksConfig.ts | 2 + suite-native/atoms/src/AlertBox.tsx | 2 +- .../components/PortfolioContent.tsx | 23 +-- suite-native/module-send/package.json | 7 + .../src/components/ReviewOutputItem.tsx | 68 ++++++++ .../src/components/ReviewOutputItemList.tsx | 77 +++++++++ .../module-send/src/components/SendForm.tsx | 93 ----------- .../src/components/SendOutputFields.tsx | 47 ++++++ .../src/components/SendOutputsForm.tsx | 146 ++++++++++++++++++ .../src/components/SendTransactionButton.tsx | 78 ++++++++++ .../src/navigation/SendStackNavigator.tsx | 7 +- suite-native/module-send/src/redux.d.ts | 7 + .../src/screens/SendAccountsScreen.tsx | 6 +- ...ndFormScreen.tsx => SendOutputsScreen.tsx} | 8 +- .../src/screens/SendReviewScreen.tsx | 50 ++++++ suite-native/module-send/src/selectors.ts | 54 +++++++ .../module-send/src/sendFormSchema.ts | 120 -------------- .../module-send/src/sendOutputsFormSchema.ts | 128 +++++++++++++++ suite-native/module-send/src/types.ts | 3 + suite-native/module-send/tsconfig.json | 10 ++ suite-native/navigation/src/navigators.ts | 5 +- suite-native/navigation/src/routes.ts | 3 +- suite-native/state/src/reducers.ts | 3 + yarn.lock | 8 + 30 files changed, 728 insertions(+), 255 deletions(-) rename packages/react-utils/src/hooks/{useAsyncDebounce.ts => useDebounce.ts} (83%) create mode 100644 suite-native/module-send/src/components/ReviewOutputItem.tsx create mode 100644 suite-native/module-send/src/components/ReviewOutputItemList.tsx delete mode 100644 suite-native/module-send/src/components/SendForm.tsx create mode 100644 suite-native/module-send/src/components/SendOutputFields.tsx create mode 100644 suite-native/module-send/src/components/SendOutputsForm.tsx create mode 100644 suite-native/module-send/src/components/SendTransactionButton.tsx create mode 100644 suite-native/module-send/src/redux.d.ts rename suite-native/module-send/src/screens/{SendFormScreen.tsx => SendOutputsScreen.tsx} (89%) create mode 100644 suite-native/module-send/src/screens/SendReviewScreen.tsx create mode 100644 suite-native/module-send/src/selectors.ts delete mode 100644 suite-native/module-send/src/sendFormSchema.ts create mode 100644 suite-native/module-send/src/sendOutputsFormSchema.ts create mode 100644 suite-native/module-send/src/types.ts diff --git a/packages/react-utils/src/hooks/useAsyncDebounce.ts b/packages/react-utils/src/hooks/useDebounce.ts similarity index 83% rename from packages/react-utils/src/hooks/useAsyncDebounce.ts rename to packages/react-utils/src/hooks/useDebounce.ts index 0de42ca1eb2..70503de32f6 100644 --- a/packages/react-utils/src/hooks/useAsyncDebounce.ts +++ b/packages/react-utils/src/hooks/useDebounce.ts @@ -3,14 +3,17 @@ import { useCallback, useEffect, useRef } from 'react'; import { createDeferred } from '@trezor/utils'; import type { Timeout } from '@trezor/type-utils'; +type AsyncFunction = (...args: any) => Promise; +type SyncFunction = (...args: any) => any; + // composeTransaction should be debounced from both sides // `timeout` prevents from calling '@trezor/connect' method to many times (inputs mad-clicking) // TODO: maybe it should be converted to regular module, could be useful elsewhere -export const useAsyncDebounce = () => { +export const useDebounce = () => { const timeout = useRef(null); const debounce = useCallback( - async Promise>(fn: F): Promise> => { + async (fn: F): Promise> => { // clear previous timeout if (timeout.current) clearTimeout(timeout.current); // set new timeout diff --git a/packages/react-utils/src/index.ts b/packages/react-utils/src/index.ts index 57b5d088367..3b1590f3892 100644 --- a/packages/react-utils/src/index.ts +++ b/packages/react-utils/src/index.ts @@ -1,4 +1,4 @@ -export { useAsyncDebounce } from './hooks/useAsyncDebounce'; +export { useDebounce } from './hooks/useDebounce'; export { useDidUpdate } from './hooks/useDidUpdate'; export { useKeyPress } from './hooks/useKeyPress'; export { useOnce } from './hooks/useOnce'; diff --git a/packages/suite/src/hooks/wallet/form/useCompose.ts b/packages/suite/src/hooks/wallet/form/useCompose.ts index f1272bd4df2..97eef407f9f 100644 --- a/packages/suite/src/hooks/wallet/form/useCompose.ts +++ b/packages/suite/src/hooks/wallet/form/useCompose.ts @@ -2,7 +2,7 @@ import { useEffect, useRef, useCallback, useState } from 'react'; import { FieldPath, UseFormReturn } from 'react-hook-form'; import { FeeLevel } from '@trezor/connect'; -import { useAsyncDebounce } from '@trezor/react-utils'; +import { useDebounce } from '@trezor/react-utils'; import { useDispatch, useSelector, useTranslation } from 'src/hooks/suite'; import { signAndPushSendFormTransactionThunk } from 'src/actions/wallet/send/sendFormThunks'; import { ComposeActionContext, composeSendFormTransactionThunk } from '@suite-common/wallet-core'; @@ -49,7 +49,7 @@ export const useCompose = ({ const dispatch = useDispatch(); // actions - const debounce = useAsyncDebounce(); + const debounce = useDebounce(); // Type assertion allowing to make the hook reusable, see https://stackoverflow.com/a/73624072 // This allows the hook to set values and errors for fields shared among multiple forms without passing them as arguments. diff --git a/packages/suite/src/hooks/wallet/form/useStakeCompose.ts b/packages/suite/src/hooks/wallet/form/useStakeCompose.ts index 4ff21991443..7c909d5bdfa 100644 --- a/packages/suite/src/hooks/wallet/form/useStakeCompose.ts +++ b/packages/suite/src/hooks/wallet/form/useStakeCompose.ts @@ -1,7 +1,7 @@ import { useEffect, useRef, useCallback, useState } from 'react'; import { FieldPath, UseFormReturn } from 'react-hook-form'; -import { useAsyncDebounce } from '@trezor/react-utils'; +import { useDebounce } from '@trezor/react-utils'; import { useDispatch, useTranslation } from 'src/hooks/suite'; import { composeTransaction } from 'src/actions/wallet/stakeActions'; import { findComposeErrors } from '@suite-common/wallet-utils'; @@ -40,7 +40,7 @@ export const useStakeCompose = ({ const dispatch = useDispatch(); // actions - const debounce = useAsyncDebounce(); + const debounce = useDebounce(); // Type assertion allowing to make the hook reusable, see https://stackoverflow.com/a/73624072 // This allows the hook to set values and errors for fields shared among multiple forms without passing them as arguments. diff --git a/packages/suite/src/hooks/wallet/useSendFormCompose.ts b/packages/suite/src/hooks/wallet/useSendFormCompose.ts index 08ad13831e0..4e5328900f8 100644 --- a/packages/suite/src/hooks/wallet/useSendFormCompose.ts +++ b/packages/suite/src/hooks/wallet/useSendFormCompose.ts @@ -9,7 +9,7 @@ import { PrecomposedLevels, PrecomposedLevelsCardano, } from '@suite-common/wallet-types'; -import { useAsyncDebounce } from '@trezor/react-utils'; +import { useDebounce } from '@trezor/react-utils'; import { isChanged } from '@suite-common/suite-utils'; import { findComposeErrors } from '@suite-common/wallet-utils'; import { FeeLevel } from '@trezor/connect'; @@ -58,7 +58,7 @@ export const useSendFormCompose = ({ const composeRequestID = useRef(0); // compose ID, incremented with every compose request - const debounce = useAsyncDebounce(); + const debounce = useDebounce(); const composeDraft = useCallback( async (formValues: FormState) => { diff --git a/packages/suite/src/utils/wallet/reviewTransactionUtils.ts b/packages/suite/src/utils/wallet/reviewTransactionUtils.ts index 2ac4ebe1ef2..b3859fc7ceb 100644 --- a/packages/suite/src/utils/wallet/reviewTransactionUtils.ts +++ b/packages/suite/src/utils/wallet/reviewTransactionUtils.ts @@ -4,10 +4,13 @@ import { CardanoOutput } from '@trezor/connect'; import { getFirmwareVersion } from '@trezor/device-utils'; import { versionUtils } from '@trezor/utils'; import { TrezorDevice } from 'src/types/suite/index'; -import { FormState, GeneralPrecomposedTransactionFinal } from '@suite-common/wallet-types'; +import { + FormState, + GeneralPrecomposedTransactionFinal, + ReviewOutput, +} from '@suite-common/wallet-types'; import { Account } from 'src/types/wallet/index'; import { getShortFingerprint, isCardanoTx } from '@suite-common/wallet-utils'; -import { ReviewOutput } from 'src/types/wallet/transaction'; import { StakeFormState } from '@suite-common/wallet-types'; import { getTxStakeNameByDataHex } from '@suite-common/suite-utils'; diff --git a/suite-common/wallet-config/src/networksConfig.ts b/suite-common/wallet-config/src/networksConfig.ts index e4cb75fd67f..99c122c763d 100644 --- a/suite-common/wallet-config/src/networksConfig.ts +++ b/suite-common/wallet-config/src/networksConfig.ts @@ -625,6 +625,8 @@ export const getTestnets = (debug = false) => n => !n.accountType && n.testnet === true && (!n.isDebugOnly || debug), ); +export const getAllNetworkSymbols = () => networksCompatibility.map(n => n.symbol); + export const getEthereumTypeNetworkSymbols = () => networksCompatibility.filter(n => n.networkType === 'ethereum').map(n => n.symbol); diff --git a/suite-native/atoms/src/AlertBox.tsx b/suite-native/atoms/src/AlertBox.tsx index 659601c3c1f..27b32d9656c 100644 --- a/suite-native/atoms/src/AlertBox.tsx +++ b/suite-native/atoms/src/AlertBox.tsx @@ -7,7 +7,7 @@ import { Icon, IconName } from '@suite-common/icons'; import { Box } from './Box'; import { Text } from './Text'; -type AlertBoxVariant = 'info' | 'success' | 'warning' | 'error'; +export type AlertBoxVariant = 'info' | 'success' | 'warning' | 'error'; type AlertBoxStyle = { backgroundColor: Color; diff --git a/suite-native/module-home/src/screens/HomeScreen/components/PortfolioContent.tsx b/suite-native/module-home/src/screens/HomeScreen/components/PortfolioContent.tsx index fbda92f2630..34fe8b8522c 100644 --- a/suite-native/module-home/src/screens/HomeScreen/components/PortfolioContent.tsx +++ b/suite-native/module-home/src/screens/HomeScreen/components/PortfolioContent.tsx @@ -13,12 +13,7 @@ import { SendStackRoutes, StackNavigationProps, } from '@suite-native/navigation'; -import { - AccountsRootState, - DeviceRootState, - selectDeviceAccountsByNetworkSymbol, - selectIsPortfolioTrackerDevice, -} from '@suite-common/wallet-core'; +import { selectIsPortfolioTrackerDevice } from '@suite-common/wallet-core'; import { Translation } from '@suite-native/intl'; import { PortfolioGraph, PortfolioGraphRef } from './PortfolioGraph'; @@ -28,10 +23,6 @@ export const PortfolioContent = forwardRef((_props, ref) => { const isPortfolioTrackerDevice = useSelector(selectIsPortfolioTrackerDevice); - const testnetAccounts = useSelector((state: AccountsRootState & DeviceRootState) => - selectDeviceAccountsByNetworkSymbol(state, 'test'), - ); - const [isUsbDeviceConnectFeatureEnabled] = useFeatureFlag(FeatureFlag.IsDeviceConnectEnabled); const [isSendEnabled] = useFeatureFlag(FeatureFlag.IsSendEnabled); @@ -85,15 +76,9 @@ export const PortfolioContent = forwardRef((_props, ref) => { )} {/* TODO: remove this button when there is a proper design of the send flow ready */} {isSendEnabled && ( - - - + )} diff --git a/suite-native/module-send/package.json b/suite-native/module-send/package.json index e62b7c36f0e..5ed95cdc5a2 100644 --- a/suite-native/module-send/package.json +++ b/suite-native/module-send/package.json @@ -15,6 +15,7 @@ "@react-navigation/native": "6.1.17", "@react-navigation/native-stack": "6.9.26", "@reduxjs/toolkit": "1.9.5", + "@suite-common/redux-utils": "workspace:*", "@suite-common/validators": "workspace:*", "@suite-common/wallet-config": "workspace:*", "@suite-common/wallet-core": "workspace:*", @@ -23,11 +24,17 @@ "@suite-native/accounts": "workspace:*", "@suite-native/atoms": "workspace:*", "@suite-native/device-manager": "workspace:*", + "@suite-native/device-mutex": "workspace:*", "@suite-native/formatters": "workspace:*", "@suite-native/forms": "workspace:*", "@suite-native/navigation": "workspace:*", + "@suite-native/toasts": "workspace:*", + "@trezor/blockchain-link-types": "workspace:*", + "@trezor/connect": "workspace:*", + "@trezor/react-utils": "workspace:*", "@trezor/utils": "workspace:*", "react": "18.2.0", + "react-hook-form": "^7.50.1", "react-redux": "8.0.7" } } diff --git a/suite-native/module-send/src/components/ReviewOutputItem.tsx b/suite-native/module-send/src/components/ReviewOutputItem.tsx new file mode 100644 index 00000000000..369e725a5e6 --- /dev/null +++ b/suite-native/module-send/src/components/ReviewOutputItem.tsx @@ -0,0 +1,68 @@ +import { AlertBox, AlertBoxVariant, Box, Text, VStack } from '@suite-native/atoms'; +import { ReviewOutputState } from '@suite-common/wallet-types'; +import { ReviewOutputType } from '@suite-common/wallet-types'; +import { CryptoAmountFormatter } from '@suite-native/formatters'; +import { NetworkSymbol } from '@suite-common/wallet-config'; + +import { StatefulReviewOutput } from '../types'; + +type ReviewOutputItemProps = { + networkSymbol: NetworkSymbol; + reviewOutput: StatefulReviewOutput; + status: ReviewOutputState; +}; + +const alertBoxVariantMap = { + active: 'warning', + success: 'success', +} as const satisfies Record, AlertBoxVariant>; + +const ReviewOutputItemValue = ({ + outputType, + networkSymbol, + value, +}: { + outputType: ReviewOutputType; + value: string; + networkSymbol: NetworkSymbol; +}) => { + if (outputType === 'amount') { + return ( + + ); + } + + return ( + + {value} + + ); +}; + +export const ReviewOutputItem = ({ reviewOutput, networkSymbol }: ReviewOutputItemProps) => { + const alertBoxVariant = reviewOutput.state ? alertBoxVariantMap[reviewOutput.state] : 'error'; + + return ( + + + {reviewOutput.type}: + + + } + /> + + ); +}; diff --git a/suite-native/module-send/src/components/ReviewOutputItemList.tsx b/suite-native/module-send/src/components/ReviewOutputItemList.tsx new file mode 100644 index 00000000000..4684b5d1a44 --- /dev/null +++ b/suite-native/module-send/src/components/ReviewOutputItemList.tsx @@ -0,0 +1,77 @@ +import { useSelector } from 'react-redux'; + +import { ErrorMessage, Loader, Text } from '@suite-native/atoms'; +import { AccountKey } from '@suite-common/wallet-types'; +import { + AccountsRootState, + DeviceRootState, + SendRootState, + selectAccountByKey, + selectSendPrecomposedTx, +} from '@suite-common/wallet-core'; +import { CryptoAmountFormatter } from '@suite-native/formatters'; + +import { ReviewOutputItem } from './ReviewOutputItem'; +import { selectTransactionReviewOutputs } from '../selectors'; + +type ReviewOutputItemListProps = { accountKey: AccountKey }; + +export const ReviewOutputItemList = ({ accountKey }: ReviewOutputItemListProps) => { + const account = useSelector((state: AccountsRootState) => + selectAccountByKey(state, accountKey), + ); + + const reviewOutputs = useSelector( + (state: AccountsRootState & DeviceRootState & SendRootState) => + selectTransactionReviewOutputs(state, accountKey), + ); + + const precomposedTx = useSelector(selectSendPrecomposedTx); + + // TODO: wait for device access with loader or something? + if (!account) return ; + + if (!precomposedTx) return ; + + const { fee, totalSpent } = precomposedTx; + + // TODO: proper UI when is design ready + return ( + <> + {account.accountLabel} + Compare these values with those on Trezor device: + {reviewOutputs?.map(output => ( + + ))} + {totalSpent && ( + <> + Total amount: + + + )} + {fee && ( + <> + Including fee: + + + )} + + ); +}; diff --git a/suite-native/module-send/src/components/SendForm.tsx b/suite-native/module-send/src/components/SendForm.tsx deleted file mode 100644 index 026a3f08cc2..00000000000 --- a/suite-native/module-send/src/components/SendForm.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import React from 'react'; -import { useSelector } from 'react-redux'; - -import { formInputsMaxLength } from '@suite-common/validators'; -import { VStack, Button, Text } from '@suite-native/atoms'; -import { useForm, Form, TextInputField } from '@suite-native/forms'; -import { AccountKey } from '@suite-common/wallet-types'; -import { - AccountsRootState, - FeesRootState, - selectAccountByKey, - selectNetworkFeeInfo, -} from '@suite-common/wallet-core'; - -import { SendFormValues, sendFormValidationSchema } from '../sendFormSchema'; - -type SendFormProps = { - accountKey: AccountKey; -}; - -const amountTransformer = (value: string) => - value - .replace(/[^0-9\.]/g, '') // remove all non-numeric characters - .replace(/(?<=\..*)\./g, '') // keep only first appearance of the '.' symbol - .replace(/(?<=^0+)0/g, ''); // remove all leading zeros except the first one - -export const SendForm = ({ accountKey }: SendFormProps) => { - const account = useSelector((state: AccountsRootState) => - selectAccountByKey(state, accountKey), - ); - - const networkFeeInfo = useSelector((state: FeesRootState) => - selectNetworkFeeInfo(state, account?.symbol), - ); - - const form = useForm({ - validation: sendFormValidationSchema, - context: { - networkFeeInfo, - networkSymbol: account?.symbol, - availableAccountBalance: account?.availableBalance, - }, - defaultValues: { - address: '', - amount: '', - }, - }); - - const { - handleSubmit, - formState: { isValid }, - } = form; - - const handleValidateForm = handleSubmit(() => { - // TODO: start on-device inputs validation via TrezorConnect - }); - - return ( -
- - - - - - - {/* TODO: remove this message in followup PR */} - {isValid && Form is valid 🎉} - -
- ); -}; diff --git a/suite-native/module-send/src/components/SendOutputFields.tsx b/suite-native/module-send/src/components/SendOutputFields.tsx new file mode 100644 index 00000000000..b5ac9318d04 --- /dev/null +++ b/suite-native/module-send/src/components/SendOutputFields.tsx @@ -0,0 +1,47 @@ +import { useFieldArray } from 'react-hook-form'; + +import { formInputsMaxLength } from '@suite-common/validators'; +import { VStack } from '@suite-native/atoms'; +import { TextInputField, useFormContext } from '@suite-native/forms'; + +const amountTransformer = (value: string) => + value + .replace(/[^0-9\.]/g, '') // remove all non-numeric characters + .replace(/^\./g, '') // remove '.' symbol if it is not preceded by number + .replace(/(?<=\..*)\./g, '') // keep only first appearance of the '.' symbol + .replace(/(?<=^0+)0/g, ''); // remove all leading zeros except the first one + +export const SendOutputFields = () => { + const { control } = useFormContext(); + const outputs = useFieldArray({ control, name: 'outputs' }); + + return ( + <> + {outputs.fields.map((output, index) => ( + + + + + ))} + {/* + TODO: add output (outputs.append({...})) button + issue: https://github.com/trezor/trezor-suite/issues/12944 + */} + + ); +}; diff --git a/suite-native/module-send/src/components/SendOutputsForm.tsx b/suite-native/module-send/src/components/SendOutputsForm.tsx new file mode 100644 index 00000000000..16e692b8372 --- /dev/null +++ b/suite-native/module-send/src/components/SendOutputsForm.tsx @@ -0,0 +1,146 @@ +import React, { useCallback, useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { useWatch } from 'react-hook-form'; + +import { isRejected } from '@reduxjs/toolkit'; +import { useNavigation } from '@react-navigation/native'; + +import { VStack, Button } from '@suite-native/atoms'; +import { Form, useForm } from '@suite-native/forms'; +import { AccountKey, FormState } from '@suite-common/wallet-types'; +import { useDebounce } from '@trezor/react-utils'; +import { + AccountsRootState, + FeesRootState, + SendRootState, + selectAccountByKey, + selectNetworkFeeInfo, + selectSendFormDraftByAccountKey, + sendFormActions, +} from '@suite-common/wallet-core'; +import { + SendStackParamList, + SendStackRoutes, + StackNavigationProps, +} from '@suite-native/navigation'; +import { useToast } from '@suite-native/toasts'; + +import { onDeviceTransactionReviewThunk } from '../sendFormThunks'; +import { SendOutputsFormValues, sendOutputsFormValidationSchema } from '../sendOutputsFormSchema'; +import { SendOutputFields } from './SendOutputFields'; + +type SendFormProps = { + accountKey: AccountKey; +}; + +// TODO: this data structure will be revisited in a follow up PR +const constructFormDraft = ({ outputs }: SendOutputsFormValues): FormState => ({ + outputs: outputs.map(({ address, amount }) => ({ + type: 'payment', + address, + amount, + token: null, + fiat: '0', + currency: { label: 'usd', value: '1000' }, + })), + isCoinControlEnabled: false, + hasCoinControlBeenOpened: false, + selectedUtxos: [], + feeLimit: '0', + feePerUnit: '0', + options: [], +}); + +export const SendOutputsForm = ({ accountKey }: SendFormProps) => { + const dispatch = useDispatch(); + const debounce = useDebounce(); + const { showToast } = useToast(); + const navigation = + useNavigation>(); + + const account = useSelector((state: AccountsRootState) => + selectAccountByKey(state, accountKey), + ); + const networkFeeInfo = useSelector((state: FeesRootState) => + selectNetworkFeeInfo(state, account?.symbol), + ); + const sendFormDraft = useSelector((state: SendRootState) => + selectSendFormDraftByAccountKey(state, accountKey), + ); + + const form = useForm({ + validation: sendOutputsFormValidationSchema, + context: { + networkFeeInfo, + networkSymbol: account?.symbol, + availableAccountBalance: account?.availableBalance, + }, + defaultValues: { + outputs: [ + { + address: 'bcrt1q7r9yvcdgcl6wmtta58yxf29a8kc96jkyyk8fsw', + amount: sendFormDraft?.outputs[0]?.amount, + }, + ], + }, + }); + + const { + handleSubmit, + control, + getValues, + formState: { isValid, isSubmitting }, + } = form; + const watchedFormValues = useWatch({ control }); + + const storeFormDraftIfValid = useCallback(() => { + dispatch( + sendFormActions.storeDraft({ + accountKey, + formState: constructFormDraft(getValues()), + }), + ); + }, [accountKey, dispatch, getValues]); + + useEffect(() => { + if (watchedFormValues && isValid) debounce(storeFormDraftIfValid); + }, [isValid, storeFormDraftIfValid, watchedFormValues, debounce]); + + const handleNavigateToReviewScreen = handleSubmit(async values => { + // TODO: navigate to SendFeeScreen instead, when ready + // issue: https://github.com/trezor/trezor-suite/issues/10871 + navigation.navigate(SendStackRoutes.SendReview, { accountKey }); + const response = await dispatch( + onDeviceTransactionReviewThunk({ + accountKey, + formState: constructFormDraft(values), + }), + ); + + if (isRejected(response)) { + // TODO: display error message based on the error code + showToast({ variant: 'error', message: 'Something went wrong', icon: 'closeCircle' }); + + navigation.navigate(SendStackRoutes.SendAccounts); + } + }); + + return ( +
+ + + {isValid && ( + + )} + +
+ ); +}; diff --git a/suite-native/module-send/src/components/SendTransactionButton.tsx b/suite-native/module-send/src/components/SendTransactionButton.tsx new file mode 100644 index 00000000000..14674e32417 --- /dev/null +++ b/suite-native/module-send/src/components/SendTransactionButton.tsx @@ -0,0 +1,78 @@ +import { useDispatch, useSelector } from 'react-redux'; + +import { CommonActions, useNavigation } from '@react-navigation/native'; +import { isRejected } from '@reduxjs/toolkit'; + +import { + AccountsRootState, + selectAccountByKey, + selectSendSignedTx, +} from '@suite-common/wallet-core'; +import { Text } from '@suite-native/atoms'; +import { AccountKey } from '@suite-common/wallet-types'; +import { VStack, Button } from '@suite-native/atoms'; +import { RootStackRoutes, AppTabsRoutes } from '@suite-native/navigation'; +import { useToast } from '@suite-native/toasts'; + +import { sendTransactionAndCleanupSendFormThunk } from '../sendFormThunks'; + +const navigateToAccountDetail = ({ accountKey }: { accountKey: AccountKey }) => + // Reset navigation stack to the account detail screen with HomeStack as a previous step, so the user can navigate back there. + CommonActions.reset({ + index: 1, + routes: [ + { + name: RootStackRoutes.AppTabs, + params: { + screen: AppTabsRoutes.HomeStack, + }, + }, + { + name: RootStackRoutes.AccountDetail, + params: { + accountKey, + }, + }, + ], + }); + +export const SendTransactionButton = ({ accountKey }: { accountKey: AccountKey }) => { + const dispatch = useDispatch(); + const { showToast } = useToast(); + const navigation = useNavigation(); + + const account = useSelector((state: AccountsRootState) => + selectAccountByKey(state, accountKey), + ); + const signedTransaction = useSelector(selectSendSignedTx); + + if (!signedTransaction || !account) return null; + + const handleSendTransaction = async () => { + const sendResponse = await dispatch( + sendTransactionAndCleanupSendFormThunk({ account, signedTransaction }), + ).unwrap(); + + if (isRejected(sendResponse)) { + // TODO: set error state + } + + showToast({ variant: 'success', message: 'Transaction sent', icon: 'check' }); + + navigation.dispatch(navigateToAccountDetail({ accountKey })); + }; + + return ( + + Transaction was signed by the Trezor device. + + + ); +}; diff --git a/suite-native/module-send/src/navigation/SendStackNavigator.tsx b/suite-native/module-send/src/navigation/SendStackNavigator.tsx index e54a8413450..b1943790607 100644 --- a/suite-native/module-send/src/navigation/SendStackNavigator.tsx +++ b/suite-native/module-send/src/navigation/SendStackNavigator.tsx @@ -7,7 +7,8 @@ import { } from '@suite-native/navigation'; import { SendAccountsScreen } from '../screens/SendAccountsScreen'; -import { SendFormScreen } from '../screens/SendFormScreen'; +import { SendOutputsScreen } from '../screens/SendOutputsScreen'; +import { SendReviewScreen } from '../screens/SendReviewScreen'; const SendStack = createNativeStackNavigator(); @@ -17,6 +18,8 @@ export const SendStackNavigator = () => ( screenOptions={stackNavigationOptionsConfig} > - + + {/* TODO: SendFeeScreen https://github.com/trezor/trezor-suite/issues/10871 */} + ); diff --git a/suite-native/module-send/src/redux.d.ts b/suite-native/module-send/src/redux.d.ts new file mode 100644 index 00000000000..df9a0c3f969 --- /dev/null +++ b/suite-native/module-send/src/redux.d.ts @@ -0,0 +1,7 @@ +import { AsyncThunkAction } from '@reduxjs/toolkit'; + +declare module 'redux' { + export interface Dispatch { + >(thunk: TThunk): ReturnType; + } +} diff --git a/suite-native/module-send/src/screens/SendAccountsScreen.tsx b/suite-native/module-send/src/screens/SendAccountsScreen.tsx index 4822edd0907..ce07bb68609 100644 --- a/suite-native/module-send/src/screens/SendAccountsScreen.tsx +++ b/suite-native/module-send/src/screens/SendAccountsScreen.tsx @@ -10,15 +10,15 @@ import { } from '@suite-native/navigation'; import { AccountKey } from '@suite-common/wallet-types'; -// TODO: So far we do not want enable send form for any other network than Bitcoin Testnet. +// TODO: So far we do not want enable send form for any other network than Bitcoin Testnet and Regtest. // This filter will be removed in a follow up PR. -const TESTNET_FILTER = 'Bitcoin Testnet'; +const TESTNET_FILTER = 'TEST'; export const SendAccountsScreen = ({ navigation, }: StackProps) => { const navigateToSendFormScreen = (accountKey: AccountKey) => - navigation.navigate(SendStackRoutes.SendForm, { + navigation.navigate(SendStackRoutes.SendOutputs, { accountKey, }); diff --git a/suite-native/module-send/src/screens/SendFormScreen.tsx b/suite-native/module-send/src/screens/SendOutputsScreen.tsx similarity index 89% rename from suite-native/module-send/src/screens/SendFormScreen.tsx rename to suite-native/module-send/src/screens/SendOutputsScreen.tsx index 93f8a35545d..16bdd917141 100644 --- a/suite-native/module-send/src/screens/SendFormScreen.tsx +++ b/suite-native/module-send/src/screens/SendOutputsScreen.tsx @@ -19,9 +19,9 @@ import { CryptoAmountFormatter } from '@suite-native/formatters'; import { SendOutputsForm } from '../components/SendOutputsForm'; -export const SendFormScreen = ({ +export const SendOutputsScreen = ({ route: { params }, -}: StackProps) => { +}: StackProps) => { const { accountKey } = params; const dispatch = useDispatch(); @@ -31,7 +31,7 @@ export const SendFormScreen = ({ useEffect(() => { if (account?.symbol) dispatch(updateFeeInfoThunk(account.symbol)); - }, []); + }, [account?.symbol, dispatch]); if (!account) return; @@ -54,7 +54,7 @@ export const SendFormScreen = ({ /> - +
); diff --git a/suite-native/module-send/src/screens/SendReviewScreen.tsx b/suite-native/module-send/src/screens/SendReviewScreen.tsx new file mode 100644 index 00000000000..0feb80661aa --- /dev/null +++ b/suite-native/module-send/src/screens/SendReviewScreen.tsx @@ -0,0 +1,50 @@ +import { useDispatch } from 'react-redux'; + +import { + Screen, + ScreenSubHeader, + SendStackParamList, + SendStackRoutes, + StackProps, +} from '@suite-native/navigation'; +import { IconButton, VStack } from '@suite-native/atoms'; +import { cancelSignSendFormTransactionThunk } from '@suite-common/wallet-core'; + +import { ReviewOutputItemList } from '../components/ReviewOutputItemList'; +import { SendTransactionButton } from '../components/SendTransactionButton'; + +export const SendReviewScreen = ({ + route, +}: StackProps) => { + const { accountKey } = route.params; + const dispatch = useDispatch(); + + const handleCancel = () => { + dispatch(cancelSignSendFormTransactionThunk()); + }; + + return ( + + } + /> + } + > + + + + + + ); +}; diff --git a/suite-native/module-send/src/selectors.ts b/suite-native/module-send/src/selectors.ts new file mode 100644 index 00000000000..9341625dd36 --- /dev/null +++ b/suite-native/module-send/src/selectors.ts @@ -0,0 +1,54 @@ +import { + SendRootState, + AccountsRootState, + DeviceRootState, + selectAccountByKey, + selectDevice, + selectSendPrecomposedTx, + selectSendFormDraftByAccountKey, + selectSendFormReviewButtonRequestsCount, +} from '@suite-common/wallet-core'; +import { + constructTransactionReviewOutputs, + getTransactionReviewOutputState, +} from '@suite-common/wallet-utils'; + +import { StatefulReviewOutput } from './types'; + +export const selectTransactionReviewOutputs = ( + state: SendRootState & AccountsRootState & DeviceRootState, + accountKey: string, +): StatefulReviewOutput[] | null => { + const precomposedForm = selectSendFormDraftByAccountKey(state, accountKey); + const precomposedTx = selectSendPrecomposedTx(state); + + const decreaseOutputId = precomposedTx?.useNativeRbf + ? precomposedForm?.setMaxOutputId + : undefined; + + const account = selectAccountByKey(state, accountKey); + const device = selectDevice(state); + + const sendReviewButtonRequests = selectSendFormReviewButtonRequestsCount( + state, + account?.symbol, + decreaseOutputId, + ); + if (!account || !device || !precomposedForm || !precomposedTx) return null; + + const outputs = constructTransactionReviewOutputs({ + account, + decreaseOutputId, + device, + precomposedForm, + precomposedTx, + }); + + return outputs?.map( + (output, outputIndex) => + ({ + ...output, + state: getTransactionReviewOutputState(outputIndex, sendReviewButtonRequests), + }) as StatefulReviewOutput, + ); +}; diff --git a/suite-native/module-send/src/sendFormSchema.ts b/suite-native/module-send/src/sendFormSchema.ts deleted file mode 100644 index ddf241f7132..00000000000 --- a/suite-native/module-send/src/sendFormSchema.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { G } from '@mobily/ts-belt'; - -import { BigNumber } from '@trezor/utils/src/bigNumber'; -import { NetworkSymbol } from '@suite-common/wallet-config'; -import { formatNetworkAmount, isAddressValid } from '@suite-common/wallet-utils'; -import { FeeInfo } from '@suite-common/wallet-types'; -import { yup } from '@suite-common/validators'; - -export type SendFormFormContext = { - networkSymbol?: NetworkSymbol; - availableAccountBalance?: string; - networkFeeInfo?: FeeInfo; -}; - -const isAmountDust = ({ - value, - networkSymbol, - isValueInSats, - networkFeeInfo, -}: { - value: string; - networkSymbol: NetworkSymbol; - isValueInSats: boolean; - networkFeeInfo: FeeInfo; -}) => { - const valueBigNumber = new BigNumber(value); - const rawDust = networkFeeInfo.dustLimit?.toString(); - - const dustThreshold = - rawDust && (isValueInSats ? rawDust : formatNetworkAmount(rawDust, networkSymbol)); - - if (!dustThreshold) { - return false; - } - - return valueBigNumber.lt(dustThreshold); -}; - -const isAmountHigherThanBalance = ({ - value, - networkSymbol, - isValueInSats, - availableAccountBalance, -}: { - value: string; - networkSymbol: NetworkSymbol; - isValueInSats: boolean; - availableAccountBalance: string; -}) => { - const formattedAvailableBalance = isValueInSats - ? availableAccountBalance - : formatNetworkAmount(availableAccountBalance, networkSymbol); - - const valueBig = new BigNumber(value); - - return valueBig.gt(formattedAvailableBalance); -}; - -// TODO: change error messages copy when is design ready -export const sendFormValidationSchema = yup.object({ - address: yup - .string() - .required() - .test( - 'is-invalid-address', - 'Address is not valid.', - (value, { options: { context } }: yup.TestContext) => { - const networkSymbol = context?.networkSymbol; - - return ( - G.isNotNullable(value) && - G.isNotNullable(networkSymbol) && - isAddressValid(value, networkSymbol) - ); - }, - ), - amount: yup - .string() - .required() - .matches(/^\d+.*\d+$/, 'Invalid decimal value.') - .test( - 'is-dust-amount', - 'The value is lower than dust threshold.', - (value, { options: { context } }: yup.TestContext) => { - if (!context || !value) return false; - const { networkSymbol, networkFeeInfo } = context; - - if (!networkSymbol || !networkFeeInfo) return false; - - return !isAmountDust({ - value, - networkSymbol, - isValueInSats: false, - networkFeeInfo, - }); - }, - ) - .test( - 'is-higher-than-balance', - 'Amount is higher than available balance.', - (value, { options: { context } }: yup.TestContext) => { - if (!context || !value) return false; - const { networkSymbol, networkFeeInfo, availableAccountBalance } = context; - - if (!networkSymbol || !networkFeeInfo || !availableAccountBalance) return false; - - return !isAmountHigherThanBalance({ - value, - networkSymbol, - isValueInSats: false, - availableAccountBalance, - }); - }, - ), - // TODO: other validations have to be added in the following PRs - // 1) validation of token amount - // 2) check if the amount is not higher than XRP reserve -}); - -export type SendFormValues = yup.InferType; diff --git a/suite-native/module-send/src/sendOutputsFormSchema.ts b/suite-native/module-send/src/sendOutputsFormSchema.ts new file mode 100644 index 00000000000..b544f7d88de --- /dev/null +++ b/suite-native/module-send/src/sendOutputsFormSchema.ts @@ -0,0 +1,128 @@ +import { G } from '@mobily/ts-belt'; + +import { BigNumber } from '@trezor/utils/src/bigNumber'; +import { NetworkSymbol } from '@suite-common/wallet-config'; +import { formatNetworkAmount, isAddressValid } from '@suite-common/wallet-utils'; +import { FeeInfo } from '@suite-common/wallet-types'; +import { yup } from '@suite-common/validators'; + +export type SendFormFormContext = { + networkSymbol?: NetworkSymbol; + availableAccountBalance?: string; + networkFeeInfo?: FeeInfo; +}; + +const isAmountDust = ({ + value, + networkSymbol, + isValueInSats, + networkFeeInfo, +}: { + value: string; + networkSymbol: NetworkSymbol; + isValueInSats: boolean; + networkFeeInfo: FeeInfo; +}) => { + const valueBigNumber = new BigNumber(value); + const rawDust = networkFeeInfo.dustLimit?.toString(); + + const dustThreshold = + rawDust && (isValueInSats ? rawDust : formatNetworkAmount(rawDust, networkSymbol)); + + if (!dustThreshold) { + return false; + } + + return valueBigNumber.lt(dustThreshold); +}; + +const isAmountHigherThanBalance = ({ + value, + networkSymbol, + isValueInSats, + availableAccountBalance, +}: { + value: string; + networkSymbol: NetworkSymbol; + isValueInSats: boolean; + availableAccountBalance: string; +}) => { + const formattedAvailableBalance = isValueInSats + ? availableAccountBalance + : formatNetworkAmount(availableAccountBalance, networkSymbol); + + const valueBig = new BigNumber(value); + + return valueBig.gt(formattedAvailableBalance); +}; + +// TODO: change error messages copy when is design ready +export const sendOutputsFormValidationSchema = yup.object({ + outputs: yup + .array( + yup.object({ + address: yup + .string() + .required() + .test( + 'is-invalid-address', + 'Address is not valid.', + (value, { options: { context } }: yup.TestContext) => { + const networkSymbol = context?.networkSymbol; + + return ( + G.isNotNullable(value) && + G.isNotNullable(networkSymbol) && + isAddressValid(value, networkSymbol) + ); + }, + ), + amount: yup + .string() + .required() + .matches(/^\d*\.?\d+$/, 'Invalid decimal value.') + .test( + 'is-dust-amount', + 'The value is lower than dust threshold.', + (value, { options: { context } }: yup.TestContext) => { + if (!context || !value) return false; + const { networkSymbol, networkFeeInfo } = context; + + if (!networkSymbol || !networkFeeInfo) return false; + + return !isAmountDust({ + value, + networkSymbol, + isValueInSats: false, + networkFeeInfo, + }); + }, + ) + .test( + 'is-higher-than-balance', + 'Amount is higher than available balance.', + (value, { options: { context } }: yup.TestContext) => { + if (!context || !value) return false; + const { networkSymbol, networkFeeInfo, availableAccountBalance } = + context; + + if (!networkSymbol || !networkFeeInfo || !availableAccountBalance) + return false; + + return !isAmountHigherThanBalance({ + value, + networkSymbol, + isValueInSats: false, + availableAccountBalance, + }); + }, + ), + // TODO: other validations have to be added in the following PRs + // 1) validation of token amount + // 2) check if the amount is not higher than XRP reserve + }), + ) + .required(), +}); + +export type SendOutputsFormValues = yup.InferType; diff --git a/suite-native/module-send/src/types.ts b/suite-native/module-send/src/types.ts new file mode 100644 index 00000000000..b45c31213b5 --- /dev/null +++ b/suite-native/module-send/src/types.ts @@ -0,0 +1,3 @@ +import { ReviewOutput, ReviewOutputState } from '@suite-common/wallet-types'; + +export type StatefulReviewOutput = ReviewOutput & { state: ReviewOutputState }; diff --git a/suite-native/module-send/tsconfig.json b/suite-native/module-send/tsconfig.json index a98c6bf15e7..49a16ce713e 100644 --- a/suite-native/module-send/tsconfig.json +++ b/suite-native/module-send/tsconfig.json @@ -2,6 +2,9 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "outDir": "libDev" }, "references": [ + { + "path": "../../suite-common/redux-utils" + }, { "path": "../../suite-common/validators" }, @@ -20,9 +23,16 @@ { "path": "../accounts" }, { "path": "../atoms" }, { "path": "../device-manager" }, + { "path": "../device-mutex" }, { "path": "../formatters" }, { "path": "../forms" }, { "path": "../navigation" }, + { "path": "../toasts" }, + { + "path": "../../packages/blockchain-link-types" + }, + { "path": "../../packages/connect" }, + { "path": "../../packages/react-utils" }, { "path": "../../packages/utils" } ] } diff --git a/suite-native/navigation/src/navigators.ts b/suite-native/navigation/src/navigators.ts index 145ff36c543..36ff5e1fdc2 100644 --- a/suite-native/navigation/src/navigators.ts +++ b/suite-native/navigation/src/navigators.ts @@ -63,7 +63,10 @@ export type ReceiveStackParamList = { export type SendStackParamList = { [SendStackRoutes.SendAccounts]: undefined; - [SendStackRoutes.SendForm]: { + [SendStackRoutes.SendOutputs]: { + accountKey: AccountKey; + }; + [SendStackRoutes.SendReview]: { accountKey: AccountKey; }; }; diff --git a/suite-native/navigation/src/routes.ts b/suite-native/navigation/src/routes.ts index bb1ddcc72eb..ee5c391e814 100644 --- a/suite-native/navigation/src/routes.ts +++ b/suite-native/navigation/src/routes.ts @@ -72,7 +72,8 @@ export enum ReceiveStackRoutes { export enum SendStackRoutes { SendAccounts = 'SendAccounts', - SendForm = 'SendForm', + SendOutputs = 'SendOutputs', + SendReview = 'SendReview', } export enum AddCoinAccountStackRoutes { diff --git a/suite-native/state/src/reducers.ts b/suite-native/state/src/reducers.ts index 9de6e5669ce..242e024a50c 100644 --- a/suite-native/state/src/reducers.ts +++ b/suite-native/state/src/reducers.ts @@ -8,6 +8,7 @@ import { prepareDeviceReducer, prepareDiscoveryReducer, prepareFiatRatesReducer, + prepareSendFormReducer, prepareTransactionsReducer, } from '@suite-common/wallet-core'; import { appSettingsReducer, appSettingsPersistWhitelist } from '@suite-native/settings'; @@ -44,6 +45,7 @@ const messageSystemReducer = prepareMessageSystemReducer(extraDependencies); const deviceReducer = prepareDeviceReducer(extraDependencies); const discoveryReducer = prepareDiscoveryReducer(extraDependencies); const tokenDefinitionsReducer = prepareTokenDefinitionsReducer(extraDependencies); +const sendFormReducer = prepareSendFormReducer(extraDependencies); export const prepareRootReducers = async () => { const appSettingsPersistedReducer = await preparePersistReducer({ @@ -59,6 +61,7 @@ export const prepareRootReducers = async () => { fiat: fiatRatesReducer, transactions: transactionsReducer, discovery: discoveryReducer, + send: sendFormReducer, fees: feesReducer, }); diff --git a/yarn.lock b/yarn.lock index 16544009974..1a3f14509ef 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8925,6 +8925,7 @@ __metadata: "@trezor/blockchain-link-types": "workspace:*" "@trezor/blockchain-link-utils": "workspace:*" "@trezor/connect": "workspace:*" + "@trezor/device-utils": "workspace:*" "@trezor/urls": "workspace:*" "@trezor/utils": "workspace:*" date-fns: "npm:^2.30.0" @@ -9737,6 +9738,7 @@ __metadata: "@react-navigation/native": "npm:6.1.17" "@react-navigation/native-stack": "npm:6.9.26" "@reduxjs/toolkit": "npm:1.9.5" + "@suite-common/redux-utils": "workspace:*" "@suite-common/validators": "workspace:*" "@suite-common/wallet-config": "workspace:*" "@suite-common/wallet-core": "workspace:*" @@ -9745,11 +9747,17 @@ __metadata: "@suite-native/accounts": "workspace:*" "@suite-native/atoms": "workspace:*" "@suite-native/device-manager": "workspace:*" + "@suite-native/device-mutex": "workspace:*" "@suite-native/formatters": "workspace:*" "@suite-native/forms": "workspace:*" "@suite-native/navigation": "workspace:*" + "@suite-native/toasts": "workspace:*" + "@trezor/blockchain-link-types": "workspace:*" + "@trezor/connect": "workspace:*" + "@trezor/react-utils": "workspace:*" "@trezor/utils": "workspace:*" react: "npm:18.2.0" + react-hook-form: "npm:^7.50.1" react-redux: "npm:8.0.7" languageName: unknown linkType: soft