From 896757b9028dd246950f02555d424b342c33b45a Mon Sep 17 00:00:00 2001 From: tomasklim Date: Wed, 22 Nov 2023 16:16:39 +0100 Subject: [PATCH 1/8] feat(suite): fetch token definitions for each token of an account --- .../suite/src/middlewares/wallet/index.ts | 7 ++- packages/suite/src/reducers/wallet/index.ts | 3 ++ .../wallet-config/src/networksConfig.ts | 16 ++++-- suite-common/wallet-core/src/index.ts | 4 ++ .../tokenDefinitionsMiddleware.ts | 35 ++++++++++++ .../tokenDefinitionsReducer.ts | 53 +++++++++++++++++++ .../tokenDefinitionsSelectors.ts | 14 +++++ .../tokenDefinitionsThunks.ts | 33 ++++++++++++ .../tokenDefinitionsTypes.ts | 8 +++ .../src/__tests__/accountUtils.test.ts | 7 ++- 10 files changed, 173 insertions(+), 7 deletions(-) create mode 100644 suite-common/wallet-core/src/token-definitions/tokenDefinitionsMiddleware.ts create mode 100644 suite-common/wallet-core/src/token-definitions/tokenDefinitionsReducer.ts create mode 100644 suite-common/wallet-core/src/token-definitions/tokenDefinitionsSelectors.ts create mode 100644 suite-common/wallet-core/src/token-definitions/tokenDefinitionsThunks.ts create mode 100644 suite-common/wallet-core/src/token-definitions/tokenDefinitionsTypes.ts diff --git a/packages/suite/src/middlewares/wallet/index.ts b/packages/suite/src/middlewares/wallet/index.ts index 90b4b10d2c3..515e852fd21 100644 --- a/packages/suite/src/middlewares/wallet/index.ts +++ b/packages/suite/src/middlewares/wallet/index.ts @@ -1,4 +1,8 @@ -import { prepareFiatRatesMiddleware, prepareBlockchainMiddleware } from '@suite-common/wallet-core'; +import { + prepareFiatRatesMiddleware, + prepareBlockchainMiddleware, + prepareTokenDefinitionsMiddleware, +} from '@suite-common/wallet-core'; import { prepareDiscoveryMiddleware } from './discoveryMiddleware'; import storageMiddleware from './storageMiddleware'; @@ -15,6 +19,7 @@ export default [ walletMiddleware, prepareDiscoveryMiddleware(extraDependencies), prepareFiatRatesMiddleware(extraDependencies), + prepareTokenDefinitionsMiddleware(extraDependencies), storageMiddleware, graphMiddleware, coinmarketMiddleware, diff --git a/packages/suite/src/reducers/wallet/index.ts b/packages/suite/src/reducers/wallet/index.ts index 8566d7fa7f9..c4ac1a9b394 100644 --- a/packages/suite/src/reducers/wallet/index.ts +++ b/packages/suite/src/reducers/wallet/index.ts @@ -6,6 +6,7 @@ import { prepareTransactionsReducer, prepareBlockchainReducer, prepareDiscoveryReducer, + prepareTokenDefinitionsReducer, } from '@suite-common/wallet-core'; import { extraDependencies } from 'src/support/extraDependencies'; @@ -28,6 +29,7 @@ export const accountsReducer = prepareAccountsReducer(extraDependencies); export const blockchainReducer = prepareBlockchainReducer(extraDependencies); export const fiatRatesReducer = prepareFiatRatesReducer(extraDependencies); export const discoveryReducer = prepareDiscoveryReducer(extraDependencies); +export const tokenDefinitionsReducer = prepareTokenDefinitionsReducer(extraDependencies); const WalletReducers = combineReducers({ fiat: fiatRatesReducer, @@ -47,6 +49,7 @@ const WalletReducers = combineReducers({ cardanoStaking: cardanoStakingReducer, pollings: pollingReducer, coinjoin: coinjoinReducer, + tokenDefinitions: tokenDefinitionsReducer, }); export default WalletReducers; diff --git a/suite-common/wallet-config/src/networksConfig.ts b/suite-common/wallet-config/src/networksConfig.ts index 03dd0b59a43..57cf30604ca 100644 --- a/suite-common/wallet-config/src/networksConfig.ts +++ b/suite-common/wallet-config/src/networksConfig.ts @@ -72,7 +72,7 @@ export const networks = { address: 'https://eth1.trezor.io/address/', queryString: '', }, - features: ['rbf', 'sign-verify', 'tokens'], + features: ['rbf', 'sign-verify', 'tokens', 'token-definitions'], label: 'TR_NETWORK_ETHEREUM_LABEL', tooltip: 'TR_NETWORK_ETHEREUM_TOOLTIP', customBackends: ['blockbook'], @@ -338,7 +338,7 @@ export const networks = { address: 'https://sepolia1.trezor.io/address/', queryString: '', }, - features: ['rbf', 'sign-verify', 'tokens'], + features: ['rbf', 'sign-verify', 'tokens', 'token-definitions'], customBackends: ['blockbook'], accountTypes: {}, }, @@ -357,7 +357,7 @@ export const networks = { address: 'https://goerli1.trezor.io/address/', queryString: '', }, - features: ['rbf', 'sign-verify', 'tokens'], + features: ['rbf', 'sign-verify', 'tokens', 'token-definitions'], customBackends: ['blockbook'], accountTypes: {}, }, @@ -376,7 +376,7 @@ export const networks = { address: 'https://holesky1.trezor.io/address/', queryString: '', }, - features: ['rbf', 'sign-verify', 'tokens'], + features: ['rbf', 'sign-verify', 'tokens', 'token-definitions'], customBackends: ['blockbook'], accountTypes: {}, }, @@ -519,7 +519,13 @@ export type NetworkSymbol = keyof Networks; export type NetworkType = Network['networkType']; type NetworkValue = Networks[NetworkSymbol]; export type AccountType = Keys | 'imported' | 'taproot' | 'normal'; -export type NetworkFeature = 'rbf' | 'sign-verify' | 'amount-unit' | 'tokens' | 'staking'; +export type NetworkFeature = + | 'rbf' + | 'sign-verify' + | 'amount-unit' + | 'tokens' + | 'staking' + | 'token-definitions'; export type Network = Without & { symbol: NetworkSymbol; accountType?: AccountType; diff --git a/suite-common/wallet-core/src/index.ts b/suite-common/wallet-core/src/index.ts index ffa2d00965d..cbdbe8a2f47 100644 --- a/suite-common/wallet-core/src/index.ts +++ b/suite-common/wallet-core/src/index.ts @@ -23,3 +23,7 @@ export * from './device/deviceActions'; export * from './device/deviceThunks'; export * from './device/deviceReducer'; export * from './device/deviceConstants'; +export * from './token-definitions/tokenDefinitionsSelectors'; +export * from './token-definitions/tokenDefinitionsReducer'; +export * from './token-definitions/tokenDefinitionsThunks'; +export * from './token-definitions/tokenDefinitionsMiddleware'; diff --git a/suite-common/wallet-core/src/token-definitions/tokenDefinitionsMiddleware.ts b/suite-common/wallet-core/src/token-definitions/tokenDefinitionsMiddleware.ts new file mode 100644 index 00000000000..0f52d8d65d0 --- /dev/null +++ b/suite-common/wallet-core/src/token-definitions/tokenDefinitionsMiddleware.ts @@ -0,0 +1,35 @@ +import { createMiddlewareWithExtraDeps } from '@suite-common/redux-utils'; + +import { accountsActions } from '../accounts/accountsActions'; +import { getTokenDefinitionThunk } from './tokenDefinitionsThunks'; +import { selectSpecificTokenDefinition } from './tokenDefinitionsSelectors'; + +export const prepareTokenDefinitionsMiddleware = createMiddlewareWithExtraDeps( + (action, { dispatch, next, getState }) => { + next(action); + + if (accountsActions.updateSelectedAccount.match(action)) { + const { network } = action.payload; + + if ( + action.payload.status === 'loaded' && + network?.features.includes('token-definitions') + ) { + action.payload.account.tokens?.forEach(token => { + const contractAddress = token.contract; + + const tokenDefinition = selectSpecificTokenDefinition( + getState(), + network?.symbol, + contractAddress, + ); + + if (!tokenDefinition || tokenDefinition.error) { + dispatch(getTokenDefinitionThunk({ network, contractAddress })); + } + }); + } + } + return action; + }, +); diff --git a/suite-common/wallet-core/src/token-definitions/tokenDefinitionsReducer.ts b/suite-common/wallet-core/src/token-definitions/tokenDefinitionsReducer.ts new file mode 100644 index 00000000000..6ed34e21ea1 --- /dev/null +++ b/suite-common/wallet-core/src/token-definitions/tokenDefinitionsReducer.ts @@ -0,0 +1,53 @@ +import { createReducerWithExtraDeps } from '@suite-common/redux-utils'; + +import { getTokenDefinitionThunk } from './tokenDefinitionsThunks'; +import { TokenDefinitionsState } from './tokenDefinitionsTypes'; + +const initialStatePredefined: Partial = {}; + +export const prepareTokenDefinitionsReducer = createReducerWithExtraDeps( + initialStatePredefined, + builder => { + builder + .addCase(getTokenDefinitionThunk.pending, (state, action) => { + const { network, contractAddress } = action.meta.arg; + + if (!state[network.symbol]) { + state[network.symbol] = {}; + } + + const networkDefinitions = state[network.symbol]; + + if (networkDefinitions) { + networkDefinitions[contractAddress] = { + isTokenKnown: undefined, + error: false, + }; + } + }) + .addCase(getTokenDefinitionThunk.fulfilled, (state, action) => { + const { network, contractAddress } = action.meta.arg; + + const networkDefinitions = state[network.symbol]; + + if (networkDefinitions) { + networkDefinitions[contractAddress] = { + isTokenKnown: action.payload, + error: false, + }; + } + }) + .addCase(getTokenDefinitionThunk.rejected, (state, action) => { + const { network, contractAddress } = action.meta.arg; + + const networkDefinitions = state[network.symbol]; + + if (networkDefinitions) { + networkDefinitions[contractAddress] = { + isTokenKnown: undefined, + error: true, + }; + } + }); + }, +); diff --git a/suite-common/wallet-core/src/token-definitions/tokenDefinitionsSelectors.ts b/suite-common/wallet-core/src/token-definitions/tokenDefinitionsSelectors.ts new file mode 100644 index 00000000000..588a6455774 --- /dev/null +++ b/suite-common/wallet-core/src/token-definitions/tokenDefinitionsSelectors.ts @@ -0,0 +1,14 @@ +import { NetworkSymbol } from '@suite-common/wallet-config'; + +import { TokenDefinitionsRootState } from './tokenDefinitionsTypes'; + +export const selectTokenDefinitions = ( + state: TokenDefinitionsRootState, + networkSymbol: NetworkSymbol, +) => state.wallet.tokenDefinitions?.[networkSymbol] || {}; + +export const selectSpecificTokenDefinition = ( + state: TokenDefinitionsRootState, + networkSymbol: NetworkSymbol, + contractAddress: string, +) => state.wallet.tokenDefinitions?.[networkSymbol]?.[contractAddress]; diff --git a/suite-common/wallet-core/src/token-definitions/tokenDefinitionsThunks.ts b/suite-common/wallet-core/src/token-definitions/tokenDefinitionsThunks.ts new file mode 100644 index 00000000000..00b23247c21 --- /dev/null +++ b/suite-common/wallet-core/src/token-definitions/tokenDefinitionsThunks.ts @@ -0,0 +1,33 @@ +import { createThunk } from '@suite-common/redux-utils'; +import { Network } from '@suite-common/wallet-config'; + +import { selectSpecificTokenDefinition } from './tokenDefinitionsSelectors'; + +const actionsPrefix = '@common/wallet-core/token-definitions'; + +export const getTokenDefinitionThunk = createThunk( + `${actionsPrefix}/getTokenDefinition`, + async ( + params: { network: Network; contractAddress: string }, + { getState, fulfillWithValue, rejectWithValue }, + ) => { + const { network, contractAddress } = params; + const { isTokenKnown } = + selectSpecificTokenDefinition(getState(), network.symbol, contractAddress) || {}; + + if (isTokenKnown === undefined) { + try { + const fetchedTokenDefinition = await fetch( + `https://data.trezor.io/firmware/eth-definitions/chain-id/${ + network.chainId + }/token-${contractAddress.substring(2).toLowerCase()}.dat`, + { method: 'HEAD' }, + ); + + return fulfillWithValue(fetchedTokenDefinition.ok); + } catch (error) { + return rejectWithValue(error.toString()); + } + } + }, +); diff --git a/suite-common/wallet-core/src/token-definitions/tokenDefinitionsTypes.ts b/suite-common/wallet-core/src/token-definitions/tokenDefinitionsTypes.ts new file mode 100644 index 00000000000..0845986bb6d --- /dev/null +++ b/suite-common/wallet-core/src/token-definitions/tokenDefinitionsTypes.ts @@ -0,0 +1,8 @@ +import { NetworkSymbol } from '@suite-common/wallet-config'; +import { TokenDefinitions } from '@suite-common/wallet-types'; + +export type TokenDefinitionsState = { + [key in NetworkSymbol]?: TokenDefinitions; +}; + +export type TokenDefinitionsRootState = { wallet: { tokenDefinitions: TokenDefinitionsState } }; diff --git a/suite-common/wallet-utils/src/__tests__/accountUtils.test.ts b/suite-common/wallet-utils/src/__tests__/accountUtils.test.ts index 4e8f14d0f3d..33ac846e0ae 100644 --- a/suite-common/wallet-utils/src/__tests__/accountUtils.test.ts +++ b/suite-common/wallet-utils/src/__tests__/accountUtils.test.ts @@ -249,7 +249,12 @@ describe('account utils', () => { expect(getNetworkFeatures(btcAcc)).toEqual(['rbf', 'sign-verify', 'amount-unit']); expect(getNetworkFeatures(btcTaprootAcc)).toEqual(['rbf', 'amount-unit']); - expect(getNetworkFeatures(ethAcc)).toEqual(['rbf', 'sign-verify', 'tokens']); + expect(getNetworkFeatures(ethAcc)).toEqual([ + 'rbf', + 'sign-verify', + 'tokens', + 'token-definitions', + ]); expect(getNetworkFeatures(coinjoinAcc)).toEqual(['rbf', 'amount-unit']); }); From a1ab69baf8e5d483150849890979263e438341fb Mon Sep 17 00:00:00 2001 From: tomasklim Date: Wed, 22 Nov 2023 16:17:04 +0100 Subject: [PATCH 2/8] feat(suite): use token definitions from store for send form --- .../src/actions/wallet/sendFormActions.ts | 24 +++++++++---------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/packages/suite/src/actions/wallet/sendFormActions.ts b/packages/suite/src/actions/wallet/sendFormActions.ts index 457460e79ab..14caee25eb7 100644 --- a/packages/suite/src/actions/wallet/sendFormActions.ts +++ b/packages/suite/src/actions/wallet/sendFormActions.ts @@ -8,6 +8,7 @@ import { replaceTransactionThunk, syncAccountsWithBlockchainThunk, selectDevice, + selectSpecificTokenDefinition, } from '@suite-common/wallet-core'; import { notificationsActions } from '@suite-common/toast-notifications'; import { @@ -386,7 +387,7 @@ export const signTransaction = ) => async (dispatch: Dispatch, getState: GetState) => { const device = selectDevice(getState()); - const { account, network } = getState().wallet.selectedAccount; + const { account } = getState().wallet.selectedAccount; if (!device || !account) return; @@ -424,20 +425,17 @@ export const signTransaction = } if ( - account.networkType === 'ethereum' && !isCardanoTx(account, enhancedTxInfo) && - enhancedTxInfo.token?.contract && - network?.chainId + account.networkType === 'ethereum' && + enhancedTxInfo.token?.contract ) { - const isTokenKnown = await fetch( - `https://data.trezor.io/firmware/eth-definitions/chain-id/${ - network.chainId - }/token-${enhancedTxInfo.token.contract.substring(2).toLowerCase()}.dat`, - ) - .then(response => response.ok) - .catch(() => false); - - enhancedTxInfo.isTokenKnown = isTokenKnown; + const tokenDefinition = selectSpecificTokenDefinition( + getState(), + account.symbol, + enhancedTxInfo.token.contract, + ); + + enhancedTxInfo.isTokenKnown = !!tokenDefinition?.isTokenKnown; } // store formValues and transactionInfo in send reducer to be used by TransactionReviewModal From 575c27abf8abe81ea5516b6914712f87c68f7a48 Mon Sep 17 00:00:00 2001 From: tomasklim Date: Mon, 29 Jan 2024 11:04:48 +0100 Subject: [PATCH 3/8] feat(suite): fetch fiat rates only for known tokens --- .../wallet-config/src/networksConfig.ts | 4 ++++ .../src/fiat-rates/fiatRatesThunks.ts | 21 +++++++++++++++++-- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/suite-common/wallet-config/src/networksConfig.ts b/suite-common/wallet-config/src/networksConfig.ts index 57cf30604ca..2004a2f8be1 100644 --- a/suite-common/wallet-config/src/networksConfig.ts +++ b/suite-common/wallet-config/src/networksConfig.ts @@ -569,3 +569,7 @@ export const getEthereumTypeNetworkSymbols = () => export const getTestnetSymbols = () => getTestnets().map(n => n.symbol); export const getNetworkType = (symbol: NetworkSymbol) => networks[symbol]?.networkType; + +// takes into account just network features, not features for specific accountTypes +export const getNetworkFeatures = (symbol: NetworkSymbol) => + networks[symbol]?.features as unknown as NetworkFeature; diff --git a/suite-common/wallet-core/src/fiat-rates/fiatRatesThunks.ts b/suite-common/wallet-core/src/fiat-rates/fiatRatesThunks.ts index 89c90401d28..b8e3fb2c512 100644 --- a/suite-common/wallet-core/src/fiat-rates/fiatRatesThunks.ts +++ b/suite-common/wallet-core/src/fiat-rates/fiatRatesThunks.ts @@ -8,11 +8,12 @@ import { FiatCurrencyCode } from '@suite-common/suite-config'; import { Account, TickerId, RateType, Timestamp } from '@suite-common/wallet-types'; import { isTestnet } from '@suite-common/wallet-utils'; import { AccountTransaction } from '@trezor/connect'; -import { networks } from '@suite-common/wallet-config'; +import { getNetworkFeatures, networks } from '@suite-common/wallet-config'; import { fiatRatesActionsPrefix, REFETCH_INTERVAL } from './fiatRatesConstants'; import { selectTickersToBeUpdated, selectTransactionsWithMissingRates } from './fiatRatesSelectors'; import { transactionsActions } from '../transactions/transactionsActions'; +import { selectSpecificTokenDefinition } from '../token-definitions/tokenDefinitionsSelectors'; type UpdateTxsFiatRatesThunkPayload = { account: Account; @@ -86,7 +87,23 @@ type UpdateCurrentFiatRatesThunkPayload = { export const updateFiatRatesThunk = createThunk( `${fiatRatesActionsPrefix}/updateFiatRates`, - async ({ ticker, localCurrency, rateType }: UpdateCurrentFiatRatesThunkPayload) => { + async ( + { ticker, localCurrency, rateType }: UpdateCurrentFiatRatesThunkPayload, + { getState }, + ) => { + const networkFeatures = getNetworkFeatures(ticker.symbol); + if (ticker.tokenAddress && networkFeatures.includes('token-definitions')) { + const tokenDefinition = selectSpecificTokenDefinition( + getState(), + ticker.symbol, + ticker.tokenAddress, + ); + + if (!tokenDefinition?.isTokenKnown) { + return; + } + } + const rate = await fetchFn[rateType](ticker, localCurrency); if (!rate) { From 9f9b0a4d2c4c4147846a33489c34ef8ab99dd5a8 Mon Sep 17 00:00:00 2001 From: tomasklim Date: Mon, 29 Jan 2024 13:01:30 +0100 Subject: [PATCH 4/8] chore(suite): refactor zero value phishing --- .../components/wallet/TransactionItem/TransactionHeading.tsx | 5 ++--- .../components/wallet/TransactionItem/TransactionItem.tsx | 2 ++ .../TransactionItem/TransactionTarget/TransactionTarget.tsx | 4 ++-- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/suite/src/components/wallet/TransactionItem/TransactionHeading.tsx b/packages/suite/src/components/wallet/TransactionItem/TransactionHeading.tsx index e0af1dfbb59..55f4be1da6b 100644 --- a/packages/suite/src/components/wallet/TransactionItem/TransactionHeading.tsx +++ b/packages/suite/src/components/wallet/TransactionItem/TransactionHeading.tsx @@ -2,7 +2,6 @@ import { useState } from 'react'; import styled, { useTheme } from 'styled-components'; import { variables, Icon } from '@trezor/components'; import { HELP_CENTER_ZERO_VALUE_ATTACKS } from '@trezor/urls'; -import { getIsZeroValuePhishing } from '@suite-common/suite-utils'; import { FormattedCryptoAmount, TooltipSymbol, @@ -76,6 +75,7 @@ interface TransactionHeadingProps { txItemIsHovered: boolean; nestedItemIsHovered: boolean; onClick: () => void; + isZeroValuePhishing: boolean; dataTestBase: string; } @@ -86,6 +86,7 @@ export const TransactionHeading = ({ txItemIsHovered, nestedItemIsHovered, onClick, + isZeroValuePhishing, dataTestBase, }: TransactionHeadingProps) => { const [headingIsHovered, setHeadingIsHovered] = useState(false); @@ -101,8 +102,6 @@ export const TransactionHeading = ({ const targetSymbol = transaction.type === 'self' ? transaction.symbol : symbol; let amount = null; - const isZeroValuePhishing = getIsZeroValuePhishing(transaction); - if (useSingleRowLayout) { // For single token transaction instead of showing coin amount we rather show the token amount // In case of sent-to-self transaction we rely on getTargetAmount returning transaction.amount which will be equal to a fee diff --git a/packages/suite/src/components/wallet/TransactionItem/TransactionItem.tsx b/packages/suite/src/components/wallet/TransactionItem/TransactionItem.tsx index 2ef2d914474..e59c81a9920 100644 --- a/packages/suite/src/components/wallet/TransactionItem/TransactionItem.tsx +++ b/packages/suite/src/components/wallet/TransactionItem/TransactionItem.tsx @@ -183,6 +183,7 @@ export const TransactionItem = memo( txItemIsHovered={txItemIsHovered} nestedItemIsHovered={nestedItemIsHovered} onClick={() => openTxDetailsModal()} + isZeroValuePhishing={isZeroValuePhishing} dataTestBase={dataTestBase} /> @@ -214,6 +215,7 @@ export const TransactionItem = memo( accountMetadata={accountMetadata} accountKey={accountKey} isActionDisabled={isActionDisabled} + isZeroValuePhishing={isZeroValuePhishing} /> )} {t.type === 'token' && ( diff --git a/packages/suite/src/components/wallet/TransactionItem/TransactionTarget/TransactionTarget.tsx b/packages/suite/src/components/wallet/TransactionItem/TransactionTarget/TransactionTarget.tsx index a504f9c7b65..55686bd0534 100644 --- a/packages/suite/src/components/wallet/TransactionItem/TransactionTarget/TransactionTarget.tsx +++ b/packages/suite/src/components/wallet/TransactionItem/TransactionTarget/TransactionTarget.tsx @@ -1,4 +1,3 @@ -import { getIsZeroValuePhishing } from '@suite-common/suite-utils'; import { notificationsActions, ToastPayload } from '@suite-common/toast-notifications'; import { getTxOperation, @@ -38,7 +37,6 @@ export const TokenTransfer = ({ }: TokenTransferProps) => { const operation = getTxOperation(transfer.type); const isNft = isNftTokenTransfer(transfer); - const isZeroValuePhishing = getIsZeroValuePhishing(transaction); return ( { const dispatch = useDispatch(); From 6c7e384be8881e38db0981cc53e0339db16ea1a4 Mon Sep 17 00:00:00 2001 From: tomasklim Date: Mon, 29 Jan 2024 15:43:56 +0100 Subject: [PATCH 5/8] feat(suite): mark phishing txs --- .../TxDetailModal/TxDetailModal.tsx | 9 +- .../TransactionItem/TransactionHeading.tsx | 16 +- .../TransactionItem/TransactionItem.tsx | 29 ++- .../TokenTransferAddressLabel.tsx | 12 +- .../TransactionTarget/TransactionTarget.tsx | 8 +- .../src/__tests__/antiFraud.test.ts | 38 --- suite-common/suite-utils/src/antiFraud.ts | 8 - suite-common/suite-utils/src/index.ts | 1 - .../src/transactions/transactionsReducer.ts | 22 +- .../src/transactions/transactionsThunks.ts | 19 +- suite-common/wallet-types/src/index.ts | 1 + .../wallet-types/src/tokenDefinitions.ts | 8 + suite-common/wallet-utils/package.json | 2 + .../src/__fixtures__/antiFraud.ts | 238 ++++++++++++++++++ .../src/__tests__/antiFraud.test.ts | 38 +++ suite-common/wallet-utils/src/antiFraud.ts | 28 +++ .../src/exportTransactionsUtils.ts | 26 +- suite-common/wallet-utils/src/index.ts | 1 + suite-common/wallet-utils/tsconfig.json | 3 + yarn.lock | 2 + 20 files changed, 415 insertions(+), 94 deletions(-) delete mode 100644 suite-common/suite-utils/src/__tests__/antiFraud.test.ts delete mode 100644 suite-common/suite-utils/src/antiFraud.ts create mode 100644 suite-common/wallet-types/src/tokenDefinitions.ts create mode 100644 suite-common/wallet-utils/src/__fixtures__/antiFraud.ts create mode 100644 suite-common/wallet-utils/src/__tests__/antiFraud.test.ts create mode 100644 suite-common/wallet-utils/src/antiFraud.ts diff --git a/packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/TxDetailModal/TxDetailModal.tsx b/packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/TxDetailModal/TxDetailModal.tsx index 6d902df2814..2402771299b 100644 --- a/packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/TxDetailModal/TxDetailModal.tsx +++ b/packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/TxDetailModal/TxDetailModal.tsx @@ -2,7 +2,6 @@ import { useState, useMemo } from 'react'; import styled from 'styled-components'; import { variables, Button } from '@trezor/components'; import { HELP_CENTER_ZERO_VALUE_ATTACKS } from '@trezor/urls'; -import { getIsZeroValuePhishing } from '@suite-common/suite-utils'; import { isPending, findChainedTransactions, @@ -14,6 +13,7 @@ import { selectAccountByKey, selectTransactionConfirmations, selectAllPendingTransactions, + selectIsPhishingTransaction, } from '@suite-common/wallet-core'; import { Translation, Modal, TrezorLink } from 'src/components/suite'; import { WalletAccountTransaction } from 'src/types/wallet'; @@ -32,7 +32,7 @@ const StyledModal = styled(Modal)` const PhishingBanner = styled.div` margin-bottom: 6px; - padding: 6px 0; + padding: 6px 10px; border-radius: ${borders.radii.xs}; background: ${({ theme }) => theme.BG_RED}; color: ${({ theme }) => theme.TYPE_WHITE}; @@ -42,6 +42,7 @@ const PhishingBanner = styled.div` const HelpLink = styled(TrezorLink)` color: ${({ theme }) => theme.TYPE_WHITE}; + font-size: ${variables.FONT_SIZE.SMALL}; `; const SectionActions = styled.div` @@ -103,7 +104,7 @@ export const TxDetailModal = ({ tx, rbfForm, onCancel }: TxDetailModalProps) => ); const account = useSelector(state => selectAccountByKey(state, accountKey)); const network = account && getAccountNetwork(account); - const isZeroValuePhishing = getIsZeroValuePhishing(tx); + const isPhishingTransaction = useSelector(state => selectIsPhishingTransaction(state, tx)); return ( onCancel={onCancel} heading={} > - {isZeroValuePhishing && ( + {isPhishingTransaction && ( ` } `; -const StyledCryptoAmount = styled(FormattedCryptoAmount)<{ isZeroValuePhishing: boolean }>` - color: ${({ theme, isZeroValuePhishing }) => - isZeroValuePhishing ? theme.TYPE_LIGHT_GREY : theme.TYPE_DARK_GREY}; +const StyledCryptoAmount = styled(FormattedCryptoAmount)<{ isPhishingTransaction: boolean }>` + color: ${({ theme, isPhishingTransaction }) => + isPhishingTransaction ? theme.TYPE_LIGHT_GREY : theme.TYPE_DARK_GREY}; font-size: ${variables.FONT_SIZE.NORMAL}; font-weight: ${variables.FONT_WEIGHT.MEDIUM}; white-space: nowrap; @@ -75,7 +75,7 @@ interface TransactionHeadingProps { txItemIsHovered: boolean; nestedItemIsHovered: boolean; onClick: () => void; - isZeroValuePhishing: boolean; + isPhishingTransaction: boolean; dataTestBase: string; } @@ -86,7 +86,7 @@ export const TransactionHeading = ({ txItemIsHovered, nestedItemIsHovered, onClick, - isZeroValuePhishing, + isPhishingTransaction, dataTestBase, }: TransactionHeadingProps) => { const [headingIsHovered, setHeadingIsHovered] = useState(false); @@ -118,7 +118,7 @@ export const TransactionHeading = ({ value={targetAmount} symbol={targetSymbol} signValue={operation} - isZeroValuePhishing={isZeroValuePhishing} + isPhishingTransaction={isPhishingTransaction} /> ); } @@ -132,7 +132,7 @@ export const TransactionHeading = ({ value={formatNetworkAmount(abs, transaction.symbol)} symbol={transaction.symbol} signValue={transactionAmount} - isZeroValuePhishing={isZeroValuePhishing} + isPhishingTransaction={isPhishingTransaction} /> ); } @@ -145,7 +145,7 @@ export const TransactionHeading = ({ onClick={onClick} > - {isZeroValuePhishing && ( + {isPhishingTransaction && ( ` - opacity: ${({ isZeroValuePhishing }) => isZeroValuePhishing && 0.6}; + opacity: ${({ isPhishingTransaction }) => isPhishingTransaction && 0.6}; ${({ isPending }) => isPending && @@ -145,8 +145,10 @@ export const TransactionItem = memo( }), ); }; + const isPhishingTransaction = useSelector(state => + selectIsPhishingTransaction(state, transaction), + ); - const isZeroValuePhishing = getIsZeroValuePhishing(transaction); const dataTestBase = `@transaction-item/${index}${ transaction.deadline ? '/prepending' : '' }`; @@ -162,7 +164,7 @@ export const TransactionItem = memo( isPending={isPending} ref={anchorRef} shouldHighlight={shouldHighlight} - isZeroValuePhishing={isZeroValuePhishing} + isPhishingTransaction={isPhishingTransaction} className={className} > @@ -183,7 +185,7 @@ export const TransactionItem = memo( txItemIsHovered={txItemIsHovered} nestedItemIsHovered={nestedItemIsHovered} onClick={() => openTxDetailsModal()} - isZeroValuePhishing={isZeroValuePhishing} + isPhishingTransaction={isPhishingTransaction} dataTestBase={dataTestBase} /> @@ -215,7 +217,9 @@ export const TransactionItem = memo( accountMetadata={accountMetadata} accountKey={accountKey} isActionDisabled={isActionDisabled} - isZeroValuePhishing={isZeroValuePhishing} + isPhishingTransaction={ + isPhishingTransaction + } /> )} {t.type === 'token' && ( @@ -229,6 +233,9 @@ export const TransactionItem = memo( ? false : i === previewTargets.length - 1 } + isPhishingTransaction={ + isPhishingTransaction + } /> )} {t.type === 'internal' && ( @@ -272,6 +279,9 @@ export const TransactionItem = memo( accountMetadata } accountKey={accountKey} + isPhishingTransaction={ + isPhishingTransaction + } /> )} {t.type === 'token' && ( @@ -285,6 +295,9 @@ export const TransactionItem = memo( DEFAULT_LIMIT - 1 } + isPhishingTransaction={ + isPhishingTransaction + } /> )} {t.type === 'internal' && ( diff --git a/packages/suite/src/components/wallet/TransactionItem/TransactionTarget/TokenTransferAddressLabel.tsx b/packages/suite/src/components/wallet/TransactionItem/TransactionTarget/TokenTransferAddressLabel.tsx index 980bd91d712..8d08849de92 100644 --- a/packages/suite/src/components/wallet/TransactionItem/TransactionTarget/TokenTransferAddressLabel.tsx +++ b/packages/suite/src/components/wallet/TransactionItem/TransactionTarget/TokenTransferAddressLabel.tsx @@ -10,20 +10,24 @@ const BlurWrapper = styled.span<{ isBlurred: boolean }>` interface TokenTransferAddressLabelProps { transfer: ArrayElement; type: WalletAccountTransaction['type']; - isZeroValuePhishing: boolean; + isPhishingTransaction: boolean; } export const TokenTransferAddressLabel = ({ transfer, type, - isZeroValuePhishing, + isPhishingTransaction, }: TokenTransferAddressLabelProps) => { if (type === 'self') { return ; } if (type === 'sent') { - return ; + return ( + + + + ); } - return {transfer.to}; + return {transfer.to}; }; diff --git a/packages/suite/src/components/wallet/TransactionItem/TransactionTarget/TransactionTarget.tsx b/packages/suite/src/components/wallet/TransactionItem/TransactionTarget/TransactionTarget.tsx index 55686bd0534..aa2316cde59 100644 --- a/packages/suite/src/components/wallet/TransactionItem/TransactionTarget/TransactionTarget.tsx +++ b/packages/suite/src/components/wallet/TransactionItem/TransactionTarget/TransactionTarget.tsx @@ -28,11 +28,13 @@ interface BaseTransfer { interface TokenTransferProps extends BaseTransfer { transfer: ArrayElement; transaction: WalletAccountTransaction; + isPhishingTransaction: boolean; } export const TokenTransfer = ({ transfer, transaction, + isPhishingTransaction, ...baseLayoutProps }: TokenTransferProps) => { const operation = getTxOperation(transfer.type); @@ -43,7 +45,7 @@ export const TokenTransfer = ({ {...baseLayoutProps} addressLabel={ @@ -109,7 +111,7 @@ interface TransactionTargetProps extends BaseTransfer { accountKey: string; accountMetadata?: AccountLabels; isActionDisabled?: boolean; - isZeroValuePhishing: boolean; + isPhishingTransaction: boolean; } export const TransactionTarget = ({ @@ -118,7 +120,7 @@ export const TransactionTarget = ({ accountMetadata, accountKey, isActionDisabled, - isZeroValuePhishing, + isPhishingTransaction, ...baseLayoutProps }: TransactionTargetProps) => { const dispatch = useDispatch(); diff --git a/suite-common/suite-utils/src/__tests__/antiFraud.test.ts b/suite-common/suite-utils/src/__tests__/antiFraud.test.ts deleted file mode 100644 index 457c789a087..00000000000 --- a/suite-common/suite-utils/src/__tests__/antiFraud.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { TokenTransfer } from '@trezor/blockchain-link'; -import type { WalletAccountTransaction } from '@suite-common/wallet-types'; - -import { getIsZeroValuePhishing } from '../antiFraud'; - -describe('antifraud utils', () => { - test('detects potential zero-value phishing transactions', () => { - expect( - getIsZeroValuePhishing({ - amount: '0', - tokens: [ - { - amount: '0', - } as TokenTransfer, - { - amount: '0', - } as TokenTransfer, - { - amount: '0', - } as TokenTransfer, - ], - } as WalletAccountTransaction), - ).toBe(true); - expect( - getIsZeroValuePhishing({ - amount: '0', - tokens: [ - { - amount: '0', - } as TokenTransfer, - { - amount: '0.00132342', - } as TokenTransfer, - ], - } as WalletAccountTransaction), - ).toBe(false); - }); -}); diff --git a/suite-common/suite-utils/src/antiFraud.ts b/suite-common/suite-utils/src/antiFraud.ts deleted file mode 100644 index c8d477215fc..00000000000 --- a/suite-common/suite-utils/src/antiFraud.ts +++ /dev/null @@ -1,8 +0,0 @@ -import BigNumber from 'bignumber.js'; - -import type { WalletAccountTransaction } from '@suite-common/wallet-types'; - -export const getIsZeroValuePhishing = (transaction: WalletAccountTransaction) => - new BigNumber(transaction.amount).isEqualTo(0) && - !!transaction.tokens.length && - transaction.tokens.every(token => new BigNumber(token.amount).isEqualTo(0)); diff --git a/suite-common/suite-utils/src/index.ts b/suite-common/suite-utils/src/index.ts index 7f3c39c2cdc..2a937bb3b27 100644 --- a/suite-common/suite-utils/src/index.ts +++ b/suite-common/suite-utils/src/index.ts @@ -2,7 +2,6 @@ export * from './date'; export * from './device'; export * from './features'; export * from './build'; -export * from './antiFraud'; export * from './resolveStaticPath'; export * from './comparison'; export * from './txsPerPage'; diff --git a/suite-common/wallet-core/src/transactions/transactionsReducer.ts b/suite-common/wallet-core/src/transactions/transactionsReducer.ts index 1d7e5940eda..acbc47d65f2 100644 --- a/suite-common/wallet-core/src/transactions/transactionsReducer.ts +++ b/suite-common/wallet-core/src/transactions/transactionsReducer.ts @@ -1,8 +1,13 @@ import { memoizeWithArgs } from 'proxy-memoize'; import { Account, WalletAccountTransaction, AccountKey } from '@suite-common/wallet-types'; -import { findTransaction, getConfirmations, isPending } from '@suite-common/wallet-utils'; -import { getIsZeroValuePhishing } from '@suite-common/suite-utils'; +import { + findTransaction, + getConfirmations, + isPending, + getIsZeroValuePhishing, + getIsPhishingTransaction, +} from '@suite-common/wallet-utils'; import { createReducerWithExtraDeps } from '@suite-common/redux-utils'; import { accountsActions } from '../accounts/accountsActions'; @@ -11,6 +16,8 @@ import { selectBlockchainHeightBySymbol, BlockchainRootState, } from '../blockchain/blockchainReducer'; +import { selectTokenDefinitions } from '../token-definitions/tokenDefinitionsSelectors'; +import { TokenDefinitionsRootState } from '../token-definitions/tokenDefinitionsTypes'; export interface TransactionsState { isLoading: boolean; @@ -281,3 +288,14 @@ export const selectIsTransactionZeroValuePhishing = ( return getIsZeroValuePhishing(transaction); }; + +export const selectIsPhishingTransaction = ( + state: TokenDefinitionsRootState, + transaction: WalletAccountTransaction, +) => { + const tokenDefinitions = selectTokenDefinitions(state, transaction.symbol); + + if (!tokenDefinitions) return false; + + return getIsPhishingTransaction(transaction, tokenDefinitions); +}; diff --git a/suite-common/wallet-core/src/transactions/transactionsThunks.ts b/suite-common/wallet-core/src/transactions/transactionsThunks.ts index 5d82fb86032..bbfd91a1d26 100644 --- a/suite-common/wallet-core/src/transactions/transactionsThunks.ts +++ b/suite-common/wallet-core/src/transactions/transactionsThunks.ts @@ -30,6 +30,7 @@ import { selectTransactions } from './transactionsReducer'; import { transactionsActionsPrefix, transactionsActions } from './transactionsActions'; import { selectAccountByKey, selectAccounts } from '../accounts/accountsReducer'; import { selectBlockchainHeightBySymbol } from '../blockchain/blockchainReducer'; +import { selectTokenDefinitions } from '../token-definitions/tokenDefinitionsSelectors'; /** * Replace existing transaction in the reducer (RBF) @@ -240,6 +241,7 @@ export const exportTransactionsThunk = createThunk( // Get state of transactions const allTransactions = selectTransactions(getState()); const localCurrency = selectors.selectLocalCurrency(getState()); + const tokenDefinitions = selectTokenDefinitions(getState(), account.symbol); // TODO: this is not nice (copy-paste) // metadata reducer is still not part of trezor-common and I can not import it @@ -275,13 +277,16 @@ export const exportTransactionsThunk = createThunk( : transactions; // Prepare data in right format - const data = await formatData({ - coin: account.symbol, - accountName, - type, - transactions: filteredTransaction, - localCurrency, - }); + const data = await formatData( + { + coin: account.symbol, + accountName, + type, + transactions: filteredTransaction, + localCurrency, + }, + tokenDefinitions, + ); // Save file const fileName = getExportedFileName(accountName, type); diff --git a/suite-common/wallet-types/src/index.ts b/suite-common/wallet-types/src/index.ts index e0b64cfb9df..7cdcb535136 100644 --- a/suite-common/wallet-types/src/index.ts +++ b/suite-common/wallet-types/src/index.ts @@ -9,3 +9,4 @@ export * from './sendForm'; export * from './settings'; export * from './selectedAccount'; export * from './transaction'; +export * from './tokenDefinitions'; diff --git a/suite-common/wallet-types/src/tokenDefinitions.ts b/suite-common/wallet-types/src/tokenDefinitions.ts new file mode 100644 index 00000000000..ccf035ff5ee --- /dev/null +++ b/suite-common/wallet-types/src/tokenDefinitions.ts @@ -0,0 +1,8 @@ +export type TokenDefinitions = { + [contractAddress: string]: TokenDefinition; +}; + +export type TokenDefinition = { + isTokenKnown: boolean | undefined; + error: boolean; +}; diff --git a/suite-common/wallet-utils/package.json b/suite-common/wallet-utils/package.json index 3af7fc7d506..3308360eabc 100644 --- a/suite-common/wallet-utils/package.json +++ b/suite-common/wallet-utils/package.json @@ -12,6 +12,7 @@ "test-unit:watch": "jest -c ../../jest.config.base.js -o --watch" }, "dependencies": { + "@mobily/ts-belt": "^3.13.1", "@suite-common/fiat-services": "workspace:*", "@suite-common/metadata-types": "workspace:*", "@suite-common/suite-config": "workspace:*", @@ -22,6 +23,7 @@ "@suite-common/wallet-config": "workspace:*", "@suite-common/wallet-constants": "workspace:*", "@suite-common/wallet-types": "workspace:*", + "@trezor/blockchain-link": "workspace:*", "@trezor/connect": "workspace:*", "@trezor/urls": "workspace:*", "@trezor/utils": "workspace:*", diff --git a/suite-common/wallet-utils/src/__fixtures__/antiFraud.ts b/suite-common/wallet-utils/src/__fixtures__/antiFraud.ts new file mode 100644 index 00000000000..f8caadea5e6 --- /dev/null +++ b/suite-common/wallet-utils/src/__fixtures__/antiFraud.ts @@ -0,0 +1,238 @@ +import type { TokenTransfer } from '@trezor/blockchain-link'; +import type { TokenDefinitions, WalletAccountTransaction } from '@suite-common/wallet-types'; + +export const getIsZeroValuePhishingFixtures = [ + { + testName: 'detects potential zero-value phishing transactions', + transaction: { + amount: '0', + tokens: [ + { amount: '0' } as TokenTransfer, + { amount: '0' } as TokenTransfer, + { amount: '0' } as TokenTransfer, + ], + } as WalletAccountTransaction, + result: true, + }, + { + testName: 'detects non-zero value transaction', + transaction: { + amount: '0', + tokens: [{ amount: '0' } as TokenTransfer, { amount: '0.00132342' } as TokenTransfer], + } as WalletAccountTransaction, + result: false, + }, + { + testName: 'transaction with zero ETH and mixed token values', + transaction: { + amount: '0', + tokens: [{ amount: '0' } as TokenTransfer, { amount: '1.23' } as TokenTransfer], + } as WalletAccountTransaction, + result: false, + }, + { + testName: 'transaction with non-zero ETH and zero-value tokens', + transaction: { + amount: '1', + tokens: [{ amount: '0' } as TokenTransfer], + } as WalletAccountTransaction, + result: false, + }, + { + testName: 'transaction with zero ETH and no tokens', + transaction: { + amount: '0', + tokens: [], + } as unknown as WalletAccountTransaction, + result: false, + }, + { + testName: 'transaction with non-zero ETH and no tokens', + transaction: { + amount: '1', + tokens: [], + } as unknown as WalletAccountTransaction, + result: false, + }, +]; + +export const getIsFakeTokenPhishingFixtures = [ + { + testName: 'non-zero tx', + transaction: { + amount: '1.23', + tokens: [{ standard: 'ERC20', contract: '0xA' }], + } as WalletAccountTransaction, + tokenDefinitions: { + '0xA': { isTokenKnown: false, isLoading: false, error: false }, + } as TokenDefinitions, + result: false, + }, + { + testName: 'only fake tokens tx', + transaction: { + amount: '0', + tokens: [ + { standard: 'ERC20', contract: '0xA' }, + { standard: 'ERC20', contract: '0xB' }, + ], + } as WalletAccountTransaction, + tokenDefinitions: { + '0xA': { isTokenKnown: false, isLoading: false, error: false }, + '0xB': { isTokenKnown: false, isLoading: false, error: false }, + } as TokenDefinitions, + result: true, + }, + { + testName: 'one fake, one legit token tx', + transaction: { + amount: '0', + tokens: [ + { standard: 'ERC20', contract: '0xA' }, + { standard: 'ERC20', contract: '0xB' }, + ], + } as WalletAccountTransaction, + tokenDefinitions: { + '0xA': { isTokenKnown: true, isLoading: false, error: false }, + '0xB': { isTokenKnown: false, isLoading: false, error: false }, + } as TokenDefinitions, + result: false, + }, + { + testName: 'only legit tokens tx', + transaction: { + amount: '0', + tokens: [ + { standard: 'ERC20', contract: '0xA' }, + { standard: 'ERC20', contract: '0xB' }, + ], + } as WalletAccountTransaction, + tokenDefinitions: { + '0xA': { isTokenKnown: true, isLoading: false, error: false }, + '0xB': { isTokenKnown: true, isLoading: false, error: false }, + } as TokenDefinitions, + result: false, + }, + { + testName: 'no tokens tx', + transaction: { + amount: '0', + tokens: [], + } as unknown as WalletAccountTransaction, + tokenDefinitions: {} as TokenDefinitions, + result: false, + }, + { + testName: 'NFT token with fake token tx', + transaction: { + amount: '0', + tokens: [ + { standard: 'ERC1155', contract: '0xN' }, + { standard: 'ERC20', contract: '0xA' }, + ], + } as WalletAccountTransaction, + tokenDefinitions: { + '0xA': { isTokenKnown: false, isLoading: false, error: false }, + } as TokenDefinitions, + result: false, + }, + { + testName: 'just NFT token tx', + transaction: { + amount: '0', + tokens: [{ standard: 'ERC721', contract: '0xN' }], + } as WalletAccountTransaction, + tokenDefinitions: {} as TokenDefinitions, + result: false, + }, +]; + +export const getIsPhishingTransactionFixtures = [ + { + testName: 'legit tx with known token', + transaction: { + amount: '1', + tokens: [{ amount: '1', standard: 'ERC20', contract: '0xA' }], + } as WalletAccountTransaction, + tokenDefinitions: { + '0xA': { isTokenKnown: true, isLoading: false, error: false }, + } as TokenDefinitions, + result: false, + }, + { + testName: 'zero value phishing tx', + transaction: { + amount: '0', + tokens: [{ amount: '0', standard: 'ERC20', contract: '0xA' }], + } as WalletAccountTransaction, + tokenDefinitions: { + '0xA': { isTokenKnown: true, isLoading: false, error: false }, + } as TokenDefinitions, + result: true, + }, + { + testName: 'fake token tx', + transaction: { + amount: '1', + tokens: [{ amount: '5', standard: 'ERC20', contract: '0xA' }], + } as WalletAccountTransaction, + tokenDefinitions: { + '0xA': { isTokenKnown: false, isLoading: false, error: false }, + } as TokenDefinitions, + result: false, + }, + { + testName: 'NFT token tx', + transaction: { + amount: '1', + tokens: [{ amount: '0', standard: 'ERC1155', contract: '0xA' }], + } as WalletAccountTransaction, + tokenDefinitions: { + '0xB': { isTokenKnown: true, isLoading: false, error: false }, + } as TokenDefinitions, + result: false, + }, + { + testName: 'fake tx with fake token', + transaction: { + amount: '0', + tokens: [{ amount: '0', standard: 'ERC20', contract: '0xA' }], + } as WalletAccountTransaction, + tokenDefinitions: { + '0xA': { isTokenKnown: false, isLoading: false, error: false }, + } as TokenDefinitions, + result: true, + }, + { + testName: 'solana fake tx', + transaction: { + amount: '0', + tokens: [{ amount: '1', standard: 'SPL', contract: 'AAA' }], + } as WalletAccountTransaction, + tokenDefinitions: { + AAA: { isTokenKnown: false, isLoading: false, error: false }, + } as TokenDefinitions, + result: true, + }, + { + testName: 'solana legit tx', + transaction: { + amount: '0', + tokens: [{ amount: '1', standard: 'SPL', contract: 'AAA' }], + } as WalletAccountTransaction, + tokenDefinitions: { + AAA: { isTokenKnown: true, isLoading: false, error: false }, + } as TokenDefinitions, + result: false, + }, + { + testName: 'no token definitions available for this network', + transaction: { + amount: '1', + tokens: [{ amount: '1', standard: 'ERC20', contract: '0xA' }], + } as WalletAccountTransaction, + tokenDefinitions: {} as TokenDefinitions, + result: false, + }, + // Additional test cases here, if necessary +]; diff --git a/suite-common/wallet-utils/src/__tests__/antiFraud.test.ts b/suite-common/wallet-utils/src/__tests__/antiFraud.test.ts new file mode 100644 index 00000000000..6bf90537707 --- /dev/null +++ b/suite-common/wallet-utils/src/__tests__/antiFraud.test.ts @@ -0,0 +1,38 @@ +import { + getIsFakeTokenPhishingFixtures, + getIsZeroValuePhishingFixtures, + getIsPhishingTransactionFixtures, +} from '../__fixtures__/antiFraud'; +import { + getIsFakeTokenPhishing, + getIsPhishingTransaction, + getIsZeroValuePhishing, +} from '../antiFraud'; + +describe('getIsZeroValuePhishing', () => { + getIsZeroValuePhishingFixtures.forEach(({ testName, transaction, result }) => { + test(testName, () => { + expect(getIsZeroValuePhishing(transaction)).toBe(result); + }); + }); +}); + +describe('getIsFakeTokenPhishing', () => { + getIsFakeTokenPhishingFixtures.forEach( + ({ testName, transaction, tokenDefinitions, result }) => { + test(testName, () => { + expect(getIsFakeTokenPhishing(transaction, tokenDefinitions)).toBe(result); + }); + }, + ); +}); + +describe('getIsPhishingTransaction', () => { + getIsPhishingTransactionFixtures.forEach( + ({ testName, transaction, tokenDefinitions, result }) => { + test(testName, () => { + expect(getIsPhishingTransaction(transaction, tokenDefinitions)).toBe(result); + }); + }, + ); +}); diff --git a/suite-common/wallet-utils/src/antiFraud.ts b/suite-common/wallet-utils/src/antiFraud.ts new file mode 100644 index 00000000000..819ea4b4e34 --- /dev/null +++ b/suite-common/wallet-utils/src/antiFraud.ts @@ -0,0 +1,28 @@ +import BigNumber from 'bignumber.js'; +import { D } from '@mobily/ts-belt'; + +import type { TokenDefinitions, WalletAccountTransaction } from '@suite-common/wallet-types'; + +import { isNftTokenTransfer } from './transactionUtils'; + +export const getIsZeroValuePhishing = (transaction: WalletAccountTransaction) => + new BigNumber(transaction.amount).isEqualTo(0) && + D.isNotEmpty(transaction.tokens) && + transaction.tokens.every(token => new BigNumber(token.amount).isEqualTo(0)); + +export const getIsFakeTokenPhishing = ( + transaction: WalletAccountTransaction, + tokenDefinitions: TokenDefinitions, +) => + new BigNumber(transaction.amount).isEqualTo(0) && // native currency is zero + D.isNotEmpty(transaction.tokens) && // there are tokens in tx + transaction.tokens.every(tokenTx => !isNftTokenTransfer(tokenTx)) && // non-nfts + !transaction.tokens.some(tokenTx => tokenDefinitions[tokenTx.contract]?.isTokenKnown); // all tokens are unknown + +export const getIsPhishingTransaction = ( + transaction: WalletAccountTransaction, + tokenDefinitions: TokenDefinitions, +) => + getIsZeroValuePhishing(transaction) || + (D.isNotEmpty(tokenDefinitions) && // at least one token definition is available + getIsFakeTokenPhishing(transaction, tokenDefinitions)); diff --git a/suite-common/wallet-utils/src/exportTransactionsUtils.ts b/suite-common/wallet-utils/src/exportTransactionsUtils.ts index b8b1b4e856d..7d3b2d035a7 100644 --- a/suite-common/wallet-utils/src/exportTransactionsUtils.ts +++ b/suite-common/wallet-utils/src/exportTransactionsUtils.ts @@ -6,12 +6,16 @@ import BigNumber from 'bignumber.js'; import { trezorLogo } from '@suite-common/suite-constants'; import { TransactionTarget } from '@trezor/connect'; import { Network } from '@suite-common/wallet-config'; -import { ExportFileType, WalletAccountTransaction } from '@suite-common/wallet-types'; -import { getIsZeroValuePhishing } from '@suite-common/suite-utils'; +import { + ExportFileType, + TokenDefinitions, + WalletAccountTransaction, +} from '@suite-common/wallet-types'; import { formatNetworkAmount, formatAmount } from './accountUtils'; import { getNftTokenId, isNftTokenTransfer } from './transactionUtils'; import { localizeNumber } from './localizeNumberUtils'; +import { getIsPhishingTransaction } from './antiFraud'; type AccountTransactionForExports = Omit & { targets: (TransactionTarget & { metadataLabel?: string })[]; @@ -131,12 +135,12 @@ const makePdf = ( }); }); -const prepareContent = (data: Data): Fields[] => { +const prepareContent = (data: Data, tokenDefinitions: TokenDefinitions): Fields[] => { const { transactions, coin } = data; return transactions .map(formatAmounts(coin)) .flatMap(t => { - if (getIsZeroValuePhishing(t)) { + if (getIsPhishingTransaction(t, tokenDefinitions)) { return null; } @@ -253,7 +257,7 @@ const sanitizeCsvValue = (value: string) => { return value; }; -const prepareCsv = (data: Data) => { +const prepareCsv = (data: Data, tokenDefinitions: TokenDefinitions) => { const csvFields: Fields = { timestamp: 'Timestamp', date: 'Date', @@ -270,7 +274,7 @@ const prepareCsv = (data: Data) => { other: 'Other', }; - const content = prepareContent(data); + const content = prepareContent(data, tokenDefinitions); const lines: string[] = []; @@ -299,7 +303,7 @@ const prepareCsv = (data: Data) => { return lines.join(CSV_NEWLINE); }; -const preparePdf = (data: Data): TDocumentDefinitions => { +const preparePdf = (data: Data, tokenDefinitions: TokenDefinitions): TDocumentDefinitions => { const pdfFields = { dateTime: 'Date & Time', type: 'Type', @@ -312,7 +316,7 @@ const preparePdf = (data: Data): TDocumentDefinitions => { const fieldKeys = Object.keys(pdfFields); const fieldValues = Object.values(pdfFields); - const content = prepareContent(data); + const content = prepareContent(data, tokenDefinitions); const lines: any[] = []; content.forEach(item => { @@ -390,16 +394,16 @@ const preparePdf = (data: Data): TDocumentDefinitions => { }; }; -export const formatData = async (data: Data) => { +export const formatData = async (data: Data, tokenDefinitions: TokenDefinitions) => { const { coin, type, transactions } = data; switch (type) { case 'csv': { - const csv = prepareCsv(data); + const csv = prepareCsv(data, tokenDefinitions); return new Blob([csv], { type: 'text/csv;charset=utf-8' }); } case 'pdf': { - const pdfLayout = preparePdf(data); + const pdfLayout = preparePdf(data, tokenDefinitions); const pdfMake = await loadPdfMake(); const pdf = await makePdf(pdfLayout, pdfMake); return pdf; diff --git a/suite-common/wallet-utils/src/index.ts b/suite-common/wallet-utils/src/index.ts index 090fa10d60f..84c6c69d77d 100644 --- a/suite-common/wallet-utils/src/index.ts +++ b/suite-common/wallet-utils/src/index.ts @@ -16,5 +16,6 @@ export * from './settingsUtils'; export * from './tokenUtils'; export * from './transactionUtils'; export * from './validationUtils'; +export * from './antiFraud'; export { analyzeTransactions as analyzeTransactionsFixtures } from './__fixtures__/transactionUtils'; diff --git a/suite-common/wallet-utils/tsconfig.json b/suite-common/wallet-utils/tsconfig.json index fec53eb5527..4888d9d790a 100644 --- a/suite-common/wallet-utils/tsconfig.json +++ b/suite-common/wallet-utils/tsconfig.json @@ -13,6 +13,9 @@ { "path": "../wallet-config" }, { "path": "../wallet-constants" }, { "path": "../wallet-types" }, + { + "path": "../../packages/blockchain-link" + }, { "path": "../../packages/connect" }, { "path": "../../packages/urls" }, { "path": "../../packages/utils" } diff --git a/yarn.lock b/yarn.lock index 9d628ab1940..1cdf7a89d0d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7177,6 +7177,7 @@ __metadata: version: 0.0.0-use.local resolution: "@suite-common/wallet-utils@workspace:suite-common/wallet-utils" dependencies: + "@mobily/ts-belt": "npm:^3.13.1" "@suite-common/fiat-services": "workspace:*" "@suite-common/metadata-types": "workspace:*" "@suite-common/suite-config": "workspace:*" @@ -7187,6 +7188,7 @@ __metadata: "@suite-common/wallet-config": "workspace:*" "@suite-common/wallet-constants": "workspace:*" "@suite-common/wallet-types": "workspace:*" + "@trezor/blockchain-link": "workspace:*" "@trezor/connect": "workspace:*" "@trezor/urls": "workspace:*" "@trezor/utils": "workspace:*" From a37b60093393ee3b8e52d5bfb3127df84b1f0dbd Mon Sep 17 00:00:00 2001 From: tomasklim Date: Wed, 31 Jan 2024 15:38:46 +0100 Subject: [PATCH 6/8] feat(suite): disable copying address for phishing txs --- .../AdvancedTxDetails/AdvancedTxDetails.tsx | 4 +- .../AdvancedTxDetails/IODetails/IODetails.tsx | 72 ++++++++++++++++--- .../TxDetailModal/IOAddress.tsx | 47 ++++++++---- .../TxDetailModal/TxDetailModal.tsx | 1 + .../TokenTransferAddressLabel.tsx | 10 ++- 5 files changed, 107 insertions(+), 27 deletions(-) diff --git a/packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/TxDetailModal/AdvancedTxDetails/AdvancedTxDetails.tsx b/packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/TxDetailModal/AdvancedTxDetails/AdvancedTxDetails.tsx index efd46658a83..365aad131d8 100644 --- a/packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/TxDetailModal/AdvancedTxDetails/AdvancedTxDetails.tsx +++ b/packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/TxDetailModal/AdvancedTxDetails/AdvancedTxDetails.tsx @@ -55,6 +55,7 @@ interface AdvancedTxDetailsProps { tx: WalletAccountTransaction; chainedTxs?: ChainedTransactions; explorerUrl: string; + isPhishingTransaction: boolean; } export const AdvancedTxDetails = ({ @@ -63,6 +64,7 @@ export const AdvancedTxDetails = ({ tx, chainedTxs, explorerUrl, + isPhishingTransaction, }: AdvancedTxDetailsProps) => { const [selectedTab, setSelectedTab] = useState(defaultTab ?? 'amount'); @@ -71,7 +73,7 @@ export const AdvancedTxDetails = ({ if (selectedTab === 'amount') { content = ; } else if (selectedTab === 'io' && network.networkType !== 'ripple') { - content = ; + content = ; } else if (selectedTab === 'chained' && chainedTxs) { content = ; } diff --git a/packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/TxDetailModal/AdvancedTxDetails/IODetails/IODetails.tsx b/packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/TxDetailModal/AdvancedTxDetails/IODetails/IODetails.tsx index 8e4433c5e25..6a183f6ca4a 100644 --- a/packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/TxDetailModal/AdvancedTxDetails/IODetails/IODetails.tsx +++ b/packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/TxDetailModal/AdvancedTxDetails/IODetails/IODetails.tsx @@ -150,12 +150,14 @@ interface IOGridRow { anonymitySet?: AnonymitySet; tx: WalletAccountTransaction; vinvout: WalletAccountTransaction['details']['vin'][number]; + isPhishingTransaction?: boolean; } const IOGridRow = ({ anonymitySet, tx: { symbol }, vinvout: { isAccountOwned, addresses, value }, + isPhishingTransaction, }: IOGridRow) => { const anonymity = addresses?.length && anonymitySet?.[addresses[0]]; @@ -167,6 +169,7 @@ const IOGridRow = ({ txAddress={addresses?.length ? addresses[0] : ''} explorerUrl={explorerTxUrl} explorerUrlQueryString={explorerUrlQueryString} + shouldAllowCopy={!isPhishingTransaction} />
@@ -220,9 +223,16 @@ interface GridRowGroupComponentProps { to?: string; symbol: string; amount?: string | ReactNode; + isPhishingTransaction?: boolean; } -const GridRowGroupComponent = ({ from, to, symbol, amount }: GridRowGroupComponentProps) => { +const GridRowGroupComponent = ({ + from, + to, + symbol, + amount, + isPhishingTransaction, +}: GridRowGroupComponentProps) => { const theme = useTheme(); const { explorerTxUrl, explorerUrlQueryString } = useExplorerTxUrl(); @@ -233,6 +243,7 @@ const GridRowGroupComponent = ({ from, to, symbol, amount }: GridRowGroupCompone txAddress={from} explorerUrl={explorerTxUrl} explorerUrlQueryString={explorerUrlQueryString} + shouldAllowCopy={!isPhishingTransaction} />
{typeof amount === 'string' ? ( @@ -249,6 +260,7 @@ const GridRowGroupComponent = ({ from, to, symbol, amount }: GridRowGroupCompone txAddress={to} explorerUrl={explorerTxUrl} explorerUrlQueryString={explorerUrlQueryString} + shouldAllowCopy={!isPhishingTransaction} /> @@ -261,9 +273,13 @@ interface TokensByStandard { interface EthereumSpecificBalanceDetailsRowProps { tx: WalletAccountTransaction; + isPhishingTransaction?: boolean; } -const EthereumSpecificBalanceDetailsRow = ({ tx }: EthereumSpecificBalanceDetailsRowProps) => { +const EthereumSpecificBalanceDetailsRow = ({ + tx, + isPhishingTransaction, +}: EthereumSpecificBalanceDetailsRowProps) => { const tokensByStandard: TokensByStandard = tx.tokens.reduce( (acc: TokensByStandard, value: TokenTransfer) => { const { standard } = value; @@ -294,6 +310,7 @@ const EthereumSpecificBalanceDetailsRow = ({ tx }: EthereumSpecificBalanceDetail to={transfer.to} amount={formatNetworkAmount(transfer.amount, tx.symbol)} symbol={tx.symbol} + isPhishingTransaction={isPhishingTransaction} /> ))} @@ -324,6 +341,7 @@ const EthereumSpecificBalanceDetailsRow = ({ tx }: EthereumSpecificBalanceDetail ) } symbol={transfer.symbol} + isPhishingTransaction={isPhishingTransaction} /> ))} @@ -333,7 +351,15 @@ const EthereumSpecificBalanceDetailsRow = ({ tx }: EthereumSpecificBalanceDetail ); }; -const SolanaSpecificBalanceDetailsRow = ({ tx }: { tx: WalletAccountTransaction }) => { +type SolanaSpecificBalanceDetailsRowProps = { + tx: WalletAccountTransaction; + isPhishingTransaction?: boolean; +}; + +const SolanaSpecificBalanceDetailsRow = ({ + tx, + isPhishingTransaction, +}: SolanaSpecificBalanceDetailsRowProps) => { const { tokens } = tx; return ( <> @@ -345,6 +371,7 @@ const SolanaSpecificBalanceDetailsRow = ({ tx }: { tx: WalletAccountTransaction to={transfer.to} amount={formatAmount(transfer.amount, transfer.decimals)} symbol={transfer.symbol} + isPhishingTransaction={isPhishingTransaction} /> ))} @@ -353,9 +380,10 @@ const SolanaSpecificBalanceDetailsRow = ({ tx }: { tx: WalletAccountTransaction interface BalanceDetailsRowProps { tx: WalletAccountTransaction; + isPhishingTransaction?: boolean; } -const BalanceDetailsRow = ({ tx }: BalanceDetailsRowProps) => { +const BalanceDetailsRow = ({ tx, isPhishingTransaction }: BalanceDetailsRowProps) => { const vout = tx?.details?.vout[0]; const vin = tx?.details?.vin[0]; const value = formatNetworkAmount(vin.value || vout.value || '', tx.symbol); @@ -372,6 +400,7 @@ const BalanceDetailsRow = ({ tx }: BalanceDetailsRowProps) => { to={vout.addresses[0]} amount={value} symbol={tx.symbol} + isPhishingTransaction={isPhishingTransaction} /> @@ -382,9 +411,10 @@ type IOSectionColumnProps = { tx: WalletAccountTransaction; inputs: WalletAccountTransaction['details']['vin'][number][]; outputs: WalletAccountTransaction['details']['vin'][number][]; + isPhishingTransaction?: boolean; }; -const IOSectionColumn = ({ tx, inputs, outputs }: IOSectionColumnProps) => { +const IOSectionColumn = ({ tx, inputs, outputs, isPhishingTransaction }: IOSectionColumnProps) => { const theme = useTheme(); const { selectedAccount } = useSelector(state => state.wallet); @@ -404,6 +434,7 @@ const IOSectionColumn = ({ tx, inputs, outputs }: IOSectionColumnProps) => { anonymitySet={anonymitySet} tx={tx} vinvout={input} + isPhishingTransaction={isPhishingTransaction} /> ))} @@ -419,6 +450,7 @@ const IOSectionColumn = ({ tx, inputs, outputs }: IOSectionColumnProps) => { anonymitySet={anonymitySet} tx={tx} vinvout={output} + isPhishingTransaction={isPhishingTransaction} /> ))} @@ -439,6 +471,7 @@ const CollapsibleIOSection = ({ outputs, heading, opened, + isPhishingTransaction, }: CollapsibleIOSectionProps) => { const { elevation } = useElevation(); return inputs?.length || outputs?.length ? ( @@ -448,17 +481,23 @@ const CollapsibleIOSection = ({ isOpen={opened} variant="large" > - + ) : null; }; interface IODetailsProps { tx: WalletAccountTransaction; + isPhishingTransaction: boolean; } // Not ready for Cardano tokens, they will not be visible, probably -export const IODetails = ({ tx }: IODetailsProps) => { +export const IODetails = ({ tx, isPhishingTransaction }: IODetailsProps) => { const { selectedAccount } = useSelector(state => state.wallet); const { network } = selectedAccount; @@ -466,8 +505,11 @@ export const IODetails = ({ tx }: IODetailsProps) => { return ( - - + + ); } @@ -476,8 +518,16 @@ export const IODetails = ({ tx }: IODetailsProps) => { return ( - - + + ); } diff --git a/packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/TxDetailModal/IOAddress.tsx b/packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/TxDetailModal/IOAddress.tsx index 938069e2a6e..ff9760120c7 100644 --- a/packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/TxDetailModal/IOAddress.tsx +++ b/packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/TxDetailModal/IOAddress.tsx @@ -28,23 +28,27 @@ const onHoverTextOverflowContainerHover = css` } `; -const TextOverflowContainer = styled.div` +const TextOverflowContainer = styled.div<{ shouldAllowCopy?: boolean }>` position: relative; display: inline-flex; max-width: 100%; overflow: hidden; color: ${({ theme }) => theme.TYPE_DARK_GREY}; - cursor: pointer; + cursor: ${({ shouldAllowCopy }) => (shouldAllowCopy ? 'pointer' : 'cursor')}; user-select: none; - @media (hover: none) { - ${onHoverTextOverflowContainerHover} - } + ${({ shouldAllowCopy }) => + shouldAllowCopy && + css` + @media (hover: none) { + ${onHoverTextOverflowContainerHover} + } - :hover, - :focus { - ${onHoverTextOverflowContainerHover} - } + :hover, + :focus { + ${onHoverTextOverflowContainerHover} + } + `} `; const SpanTextStart = styled.span` @@ -62,13 +66,23 @@ interface IOAddressProps { explorerUrl?: string; txAddress?: string; explorerUrlQueryString?: string; + shouldAllowCopy?: boolean; } -export const IOAddress = ({ txAddress, explorerUrl, explorerUrlQueryString }: IOAddressProps) => { +export const IOAddress = ({ + txAddress, + explorerUrl, + explorerUrlQueryString, + shouldAllowCopy = true, +}: IOAddressProps) => { const [isClicked, setIsClicked] = useState(false); const theme = useTheme(); const copy = () => { + if (!shouldAllowCopy) { + return; + } + copyToClipboard(txAddress || ''); setIsClicked(true); @@ -84,6 +98,7 @@ export const IOAddress = ({ txAddress, explorerUrl, explorerUrlQueryString }: IO onMouseLeave={() => setIsClicked(false)} data-test="@tx-detail/txid-value" id={txAddress} + shouldAllowCopy={shouldAllowCopy} > {txAddress.length <= 5 ? ( {txAddress} @@ -93,9 +108,15 @@ export const IOAddress = ({ txAddress, explorerUrl, explorerUrlQueryString }: IO {txAddress.slice(-4)} )} - - - + {shouldAllowCopy ? ( + + + + ) : null} {explorerUrl ? ( network={network!} tx={tx} chainedTxs={chainedTxs} + isPhishingTransaction={isPhishingTransaction} /> )}
diff --git a/packages/suite/src/components/wallet/TransactionItem/TransactionTarget/TokenTransferAddressLabel.tsx b/packages/suite/src/components/wallet/TransactionItem/TransactionTarget/TokenTransferAddressLabel.tsx index 8d08849de92..5a0ecbb0e4c 100644 --- a/packages/suite/src/components/wallet/TransactionItem/TransactionTarget/TokenTransferAddressLabel.tsx +++ b/packages/suite/src/components/wallet/TransactionItem/TransactionTarget/TokenTransferAddressLabel.tsx @@ -1,10 +1,16 @@ -import styled from 'styled-components'; +import styled, { css } from 'styled-components'; import { ArrayElement } from '@trezor/type-utils'; import { Translation, AddressLabeling } from 'src/components/suite'; import { WalletAccountTransaction } from 'src/types/wallet'; const BlurWrapper = styled.span<{ isBlurred: boolean }>` - filter: ${({ isBlurred }) => isBlurred && 'blur(2px)'}; + ${({ isBlurred }) => + isBlurred && + css` + filter: blur(2px); + pointer-events: none; + user-select: none; + `}; `; interface TokenTransferAddressLabelProps { From af5bccc6f309f0601c46b3d727fbb255ada3925b Mon Sep 17 00:00:00 2001 From: tomasklim Date: Wed, 31 Jan 2024 16:38:20 +0100 Subject: [PATCH 7/8] feat(suite): send form tokens - 0 balance hidden, cat unrecognized --- packages/suite/src/support/messages.ts | 8 ++ .../Amount/components/TokenSelect.tsx | 79 +++++++++++++++---- 2 files changed, 71 insertions(+), 16 deletions(-) diff --git a/packages/suite/src/support/messages.ts b/packages/suite/src/support/messages.ts index a5b8ab0d0b4..aedf50de91a 100644 --- a/packages/suite/src/support/messages.ts +++ b/packages/suite/src/support/messages.ts @@ -2805,6 +2805,14 @@ export default defineMessages({ defaultMessage: 'Community', id: 'TR_COMMUNITY_LANGUAGES', }, + TR_TOKEN_UNRECOGNIZED_BY_TREZOR: { + defaultMessage: 'Unrecognized tokens', + id: 'TR_TOKEN_UNRECOGNIZED_BY_TREZOR', + }, + TR_TOKEN_UNRECOGNIZED_BY_TREZOR_TOOLTIP: { + defaultMessage: 'Unrecognized tokens pose potential risks. Use caution.', + id: 'TR_TOKEN_UNRECOGNIZED_BY_TREZOR_TOOLTIP', + }, TR_LEARN: { defaultMessage: 'Learn', description: 'Link to Suite Guide.', diff --git a/packages/suite/src/views/wallet/send/components/Outputs/components/Amount/components/TokenSelect.tsx b/packages/suite/src/views/wallet/send/components/Outputs/components/Amount/components/TokenSelect.tsx index fd00065520c..e030d6615ba 100644 --- a/packages/suite/src/views/wallet/send/components/Outputs/components/Amount/components/TokenSelect.tsx +++ b/packages/suite/src/views/wallet/send/components/Outputs/components/Amount/components/TokenSelect.tsx @@ -12,33 +12,77 @@ import { sortTokensWithRates, } from '@suite-common/wallet-utils'; import { useSelector } from 'src/hooks/suite'; -import { selectCoinsLegacy } from '@suite-common/wallet-core'; +import { selectCoinsLegacy, selectTokenDefinitions } from '@suite-common/wallet-core'; +import BigNumber from 'bignumber.js'; +import { TokenDefinitions } from '@suite-common/wallet-types'; +import { TooltipSymbol, Translation } from 'src/components/suite'; +import { getNetworkFeatures } from '@suite-common/wallet-config'; + +const UnrecognizedTokensHeading = styled.div` + display: flex; + align-items: center; +`; interface Option { - label: string; - value: string | null; - fingerprint: string | undefined; + options: { + label: string; + value: string | null; + fingerprint?: string; + }[]; + label?: React.ReactNode; } -export const buildTokenOptions = (tokens: Account['tokens'], symbol: Account['symbol']) => { +export const buildTokenOptions = ( + tokens: Account['tokens'], + symbol: Account['symbol'], + tokenDefinitions: TokenDefinitions, +) => { // ETH option const result: Option[] = [ { - value: null, - fingerprint: undefined, - label: symbol.toUpperCase(), + options: [{ value: null, fingerprint: undefined, label: symbol.toUpperCase() }], }, ]; if (tokens) { + const unknownTokens: Option['options'] = []; + const hasNetworkFeatures = getNetworkFeatures(symbol).includes('token-definitions'); + tokens.forEach(token => { + if (new BigNumber(token?.balance || '').eq('0')) { + return; + } + const tokenName = token.symbol || 'N/A'; + + if (tokenDefinitions[token.contract]?.isTokenKnown || !hasNetworkFeatures) { + result[0].options.push({ + value: token.contract, + label: tokenName.toUpperCase(), + fingerprint: token.name, + }); + } else { + unknownTokens.push({ + value: token.contract, + label: `${tokenName.toUpperCase().slice(0, 7)}…`, + fingerprint: token.name, + }); + } + }); + + if (unknownTokens.length) { result.push({ - value: token.contract, - label: tokenName.toUpperCase(), - fingerprint: token.name, + label: ( + + + } + /> + + ), + options: unknownTokens, }); - }); + } } return result; @@ -124,6 +168,7 @@ export const TokenSelect = ({ output, outputId }: TokenSelectProps) => { watch, } = useSendFormContext(); const coins = useSelector(selectCoinsLegacy); + const tokenDefinitions = useSelector(state => selectTokenDefinitions(state, account.symbol)); const sortedTokens = useMemo(() => { const tokensWithRates = enhanceTokensWithRates(account.tokens, coins); @@ -136,7 +181,7 @@ export const TokenSelect = ({ output, outputId }: TokenSelectProps) => { const tokenValue = getDefaultValue(tokenInputName, output.token); const isSetMaxActive = getDefaultValue('setMaxOutputId') === outputId; const dataEnabled = getDefaultValue('options', []).includes('ethereumData'); - const options = buildTokenOptions(sortedTokens, account.symbol); + const options = buildTokenOptions(sortedTokens, account.symbol, tokenDefinitions); // Amount needs to be re-validated again AFTER token change propagation (decimal places, available balance) // watch token change and use "useSendFormFields.setAmount" util for validation (if amount is set) @@ -169,12 +214,14 @@ export const TokenSelect = ({ output, outputId }: TokenSelectProps) => { options={options} minValueWidth="58px" isSearchable - isDisabled={options.length === 1} // disable when account has no tokens to choose from - value={options.find(o => o.value === tokenValue)} + isDisabled={options.length === 1 && options[0].options.length === 1} // disable when account has no tokens to choose from + value={options + .flatMap(group => group.options) + .find(option => option.value === tokenValue)} isClearable={false} components={customComponents} isClean - onChange={(selected: Option) => { + onChange={(selected: Option['options'][0]) => { // change selected value onChange(selected.value); // clear errors in Amount input From ff9e38444954df45edee22af32fdbe97e0af06c6 Mon Sep 17 00:00:00 2001 From: tomasklim Date: Mon, 5 Feb 2024 18:04:35 +0100 Subject: [PATCH 8/8] feat(suite): unrecognized token section in tokens --- .../wallet/tokens/components/TokenList.tsx | 177 ++++++++++++------ .../suite/src/views/wallet/tokens/index.tsx | 1 + 2 files changed, 117 insertions(+), 61 deletions(-) diff --git a/packages/suite/src/views/wallet/tokens/components/TokenList.tsx b/packages/suite/src/views/wallet/tokens/components/TokenList.tsx index 1e85055d064..f0a393a4a5b 100644 --- a/packages/suite/src/views/wallet/tokens/components/TokenList.tsx +++ b/packages/suite/src/views/wallet/tokens/components/TokenList.tsx @@ -1,12 +1,20 @@ import { useMemo, Fragment } from 'react'; import styled, { css, useTheme } from 'styled-components'; import { variables, Icon, Card } from '@trezor/components'; -import { FiatValue, FormattedCryptoAmount, TrezorLink } from 'src/components/suite'; +import { + FiatValue, + FormattedCryptoAmount, + QuestionTooltip, + TrezorLink, +} from 'src/components/suite'; import { Account } from 'src/types/wallet'; import { useSelector } from 'src/hooks/suite'; import { enhanceTokensWithRates, sortTokensWithRates } from '@suite-common/wallet-utils'; -import { selectCoinsLegacy } from '@suite-common/wallet-core'; +import { selectCoinsLegacy, selectTokenDefinitions } from '@suite-common/wallet-core'; import { NoRatesTooltip } from 'src/components/suite/Ticker/NoRatesTooltip'; +import { FiatRates, TokenInfo } from '@trezor/blockchain-link-types'; +import { spacingsPx } from '@trezor/theme'; +import { NetworkSymbol, getNetworkFeatures } from '@suite-common/wallet-config'; const Wrapper = styled(Card)<{ isTestnet?: boolean }>` display: grid; @@ -68,25 +76,39 @@ const StyledNoRatesTooltip = styled(NoRatesTooltip)` justify-content: flex-end; `; +const StyledQuestionTooltip = styled(QuestionTooltip)<{ addMarginTop: boolean }>` + ${({ addMarginTop }) => + addMarginTop && + css` + margin-top: ${spacingsPx.xxl}; + `} + margin-bottom: ${spacingsPx.sm}; +`; + interface TokenListProps { tokens: Account['tokens']; networkType: Account['networkType']; explorerUrl: string; explorerUrlQueryString: string; isTestnet?: boolean; + networkSymbol: NetworkSymbol; } +type EnhancedTokenInfo = TokenInfo & { rates?: FiatRates }; + export const TokenList = ({ tokens, explorerUrl, explorerUrlQueryString, isTestnet, networkType, + networkSymbol, }: TokenListProps) => { const theme = useTheme(); const coins = useSelector(selectCoinsLegacy); + const tokenDefinitions = useSelector(state => selectTokenDefinitions(state, networkSymbol)); - const sortedTokens = useMemo(() => { + const sortedTokens: EnhancedTokenInfo[] = useMemo(() => { const tokensWithRates = enhanceTokensWithRates(tokens, coins); return tokensWithRates.sort(sortTokensWithRates); @@ -94,66 +116,99 @@ export const TokenList = ({ if (!tokens || tokens.length === 0) return null; + const hasNetworkFeatures = getNetworkFeatures(networkSymbol).includes('token-definitions'); + const { knownTokens, unknownTokens } = sortedTokens.reduce<{ + knownTokens: EnhancedTokenInfo[]; + unknownTokens: EnhancedTokenInfo[]; + }>( + (acc, token) => { + if (tokenDefinitions[token.contract]?.isTokenKnown || !hasNetworkFeatures) { + acc.knownTokens.push(token); + } else { + acc.unknownTokens.push(token); + } + return acc; + }, + { knownTokens: [], unknownTokens: [] }, + ); + return ( - - {sortedTokens.map(t => { - // In Cardano token name is optional and in there is no symbol. - // However, if Cardano token doesn't have a name on blockchain, its TokenInfo has both name - // and symbol props set to a token fingerprint (done in blockchain-link) and we - // don't want to render it twice. - // In ethereum we are fine with rendering symbol - name even if they are the same. - const symbolMatchesName = - networkType === 'cardano' && t.symbol?.toLowerCase() === t.name?.toLowerCase(); - const noSymbol = !t.symbol || symbolMatchesName; - - const isTokenWithRate = Boolean(t.rates && Object.keys(t.rates).length); - - return ( - - - {!noSymbol && {t.symbol}} - - {!noSymbol && ` - `} - {t.name} - - - - {t.balance && ( - - )} - - {!isTestnet && ( - - {t.balance && t.symbol && isTokenWithRate ? ( - - - - ) : ( - - )} - + <> + {[knownTokens, unknownTokens].map((tokens, groupIndex) => + tokens.length ? ( + + {groupIndex === 1 && ( + )} - - - - - + + {tokens.map(t => { + const symbolMatchesName = + networkType === 'cardano' && + t.symbol?.toLowerCase() === t.name?.toLowerCase(); + const noSymbol = !t.symbol || symbolMatchesName; + + const isTokenWithRate = Boolean( + t.rates && Object.keys(t.rates).length, + ); + + return ( + + + {!noSymbol && {t.symbol}} + + {!noSymbol && ` - `} + {t.name} + + + + {t.balance && ( + + )} + + {!isTestnet && ( + + {t.balance && t.symbol && isTokenWithRate ? ( + + + + ) : ( + + )} + + )} + + + + + + + ); + })} + - ); - })} - + ) : null, + )} + ); }; diff --git a/packages/suite/src/views/wallet/tokens/index.tsx b/packages/suite/src/views/wallet/tokens/index.tsx index 0b2fb211be4..905a918ebab 100644 --- a/packages/suite/src/views/wallet/tokens/index.tsx +++ b/packages/suite/src/views/wallet/tokens/index.tsx @@ -25,6 +25,7 @@ export const Tokens = () => { explorerUrlQueryString={explorerUrlQueryString} tokens={account.tokens} networkType={account.networkType} + networkSymbol={account.symbol} /> {!account.tokens?.length && }