diff --git a/shared/constants/bridge.ts b/shared/constants/bridge.ts index 06c54dbf3a1d..7b670d6afa59 100644 --- a/shared/constants/bridge.ts +++ b/shared/constants/bridge.ts @@ -33,7 +33,7 @@ export const ETH_USDT_ADDRESS = '0xdac17f958d2ee523a2206206994597c13d831ec7'; export const METABRIDGE_ETHEREUM_ADDRESS = '0x0439e60F02a8900a951603950d8D4527f400C3f1'; export const BRIDGE_QUOTE_MAX_ETA_SECONDS = 60 * 60; // 1 hour -export const BRIDGE_QUOTE_MAX_RETURN_DIFFERENCE_PERCENTAGE = 0.5; // if a quote returns in x times less return than the best quote, ignore it +export const BRIDGE_QUOTE_MAX_RETURN_DIFFERENCE_PERCENTAGE = 0.35; // if a quote returns in x times less return than the best quote, ignore it export const BRIDGE_PREFERRED_GAS_ESTIMATE = 'high'; export const BRIDGE_DEFAULT_SLIPPAGE = 0.5; diff --git a/shared/constants/swaps.ts b/shared/constants/swaps.ts index 545c47f06f1b..14a32da18ba6 100644 --- a/shared/constants/swaps.ts +++ b/shared/constants/swaps.ts @@ -24,6 +24,7 @@ export const SLIPPAGE_LOW_ERROR = 'slippage-low'; export const SLIPPAGE_NEGATIVE_ERROR = 'slippage-negative'; export const MAX_ALLOWED_SLIPPAGE = 15; +export const SWAPS_QUOTE_MAX_RETURN_DIFFERENCE_PERCENTAGE = 0.35; // An address that the metaswap-api recognizes as the default token for the current network, // in place of the token address that ERC-20 tokens have diff --git a/ui/components/app/assets/nfts/nft-details/nft-details.tsx b/ui/components/app/assets/nfts/nft-details/nft-details.tsx index 8a857e5f0ce1..a0de608a0e56 100644 --- a/ui/components/app/assets/nfts/nft-details/nft-details.tsx +++ b/ui/components/app/assets/nfts/nft-details/nft-details.tsx @@ -349,7 +349,7 @@ export default function NftDetails({ nft }: { nft: Nft }) { ; - DefaultStory.storyName = 'Default'; + +export const NoImageStory = (args) => ; +NoImageStory.storyName = 'No Image'; +NoImageStory.args = { + ...DefaultStory.args, + src: '', +}; diff --git a/ui/ducks/bridge/selectors.test.ts b/ui/ducks/bridge/selectors.test.ts index 3cfc8ae4a554..48ffaa58bc00 100644 --- a/ui/ducks/bridge/selectors.test.ts +++ b/ui/ducks/bridge/selectors.test.ts @@ -1183,7 +1183,7 @@ describe('Bridge selectors', () => { ).toStrictEqual(false); }); - it('should return isEstimatedReturnLow=true return value is 50% less than sent funds', () => { + it('should return isEstimatedReturnLow=true return value is less than 65% of sent funds', () => { const state = createBridgeMockStore({ featureFlagOverrides: { extensionConfig: { @@ -1239,7 +1239,7 @@ describe('Bridge selectors', () => { expect(result.isEstimatedReturnLow).toStrictEqual(true); }); - it('should return isEstimatedReturnLow=false when return value is more than 50% of sent funds', () => { + it('should return isEstimatedReturnLow=false when return value is more than 65% of sent funds', () => { const state = createBridgeMockStore({ featureFlagOverrides: { extensionConfig: { @@ -1254,7 +1254,7 @@ describe('Bridge selectors', () => { fromToken: { address: zeroAddress(), symbol: 'ETH' }, toToken: { address: zeroAddress(), symbol: 'TEST' }, fromTokenExchangeRate: 2524.25, - toTokenExchangeRate: 0.63, + toTokenExchangeRate: 0.95, fromTokenInputValue: 1, }, bridgeStateOverrides: { @@ -1292,7 +1292,7 @@ describe('Bridge selectors', () => { expect( getBridgeQuotes(state as never).activeQuote?.adjustedReturn .valueInCurrency, - ).toStrictEqual(new BigNumber('12.87194306627291988')); + ).toStrictEqual(new BigNumber('20.69239170627291988')); expect(result.isEstimatedReturnLow).toStrictEqual(false); }); diff --git a/ui/ducks/bridge/selectors.ts b/ui/ducks/bridge/selectors.ts index b37cbb1948e9..eeeba29c8a94 100644 --- a/ui/ducks/bridge/selectors.ts +++ b/ui/ducks/bridge/selectors.ts @@ -547,7 +547,7 @@ export const getValidationErrors = createDeepEqualSelector( fromTokenInputValue ? activeQuote.adjustedReturn.valueInCurrency.lt( new BigNumber( - BRIDGE_QUOTE_MAX_RETURN_DIFFERENCE_PERCENTAGE, + 1 - BRIDGE_QUOTE_MAX_RETURN_DIFFERENCE_PERCENTAGE, ).times(activeQuote.sentAmount.valueInCurrency), ) : false, diff --git a/ui/ducks/swaps/swaps.js b/ui/ducks/swaps/swaps.js index d21cabef4f8f..e79a6f54df47 100644 --- a/ui/ducks/swaps/swaps.js +++ b/ui/ducks/swaps/swaps.js @@ -86,6 +86,7 @@ import { SWAPS_FETCH_ORDER_CONFLICT, ALLOWED_SMART_TRANSACTIONS_CHAIN_IDS, Slippage, + SWAPS_QUOTE_MAX_RETURN_DIFFERENCE_PERCENTAGE, } from '../../../shared/constants/swaps'; import { IN_PROGRESS_TRANSACTION_STATUSES, @@ -100,6 +101,7 @@ import { import { EtherDenomination } from '../../../shared/constants/common'; import { Numeric } from '../../../shared/modules/Numeric'; import { calculateMaxGasLimit } from '../../../shared/lib/swaps-utils'; +import { useTokenFiatAmount } from '../../hooks/useTokenFiatAmount'; const debugLog = createProjectLogger('swaps'); @@ -1435,3 +1437,55 @@ export function cancelSwapsSmartTransaction(uuid) { } }; } + +export const getIsEstimatedReturnLow = ({ usedQuote, rawNetworkFees }) => { + const sourceTokenAmount = calcTokenAmount( + usedQuote?.sourceAmount, + usedQuote?.sourceTokenInfo?.decimals, + ); + // Disabled because it's not a hook + // eslint-disable-next-line react-hooks/rules-of-hooks + const sourceTokenFiatAmount = useTokenFiatAmount( + usedQuote?.sourceTokenInfo?.address, + sourceTokenAmount || 0, + usedQuote?.sourceTokenInfo?.symbol, + { + showFiat: true, + }, + true, + null, + false, + ); + const destinationTokenAmount = calcTokenAmount( + usedQuote?.destinationAmount, + usedQuote?.destinationTokenInfo?.decimals, + ); + // Disabled because it's not a hook + // eslint-disable-next-line react-hooks/rules-of-hooks + const destinationTokenFiatAmount = useTokenFiatAmount( + usedQuote?.destinationTokenInfo?.address, + destinationTokenAmount || 0, + usedQuote?.destinationTokenInfo?.symbol, + { + showFiat: true, + }, + true, + null, + false, + ); + const adjustedReturnValue = + destinationTokenFiatAmount && rawNetworkFees + ? new BigNumber(destinationTokenFiatAmount).minus( + new BigNumber(rawNetworkFees), + ) + : null; + const isEstimatedReturnLow = + sourceTokenFiatAmount && adjustedReturnValue + ? adjustedReturnValue.lt( + new BigNumber(sourceTokenFiatAmount).times( + 1 - SWAPS_QUOTE_MAX_RETURN_DIFFERENCE_PERCENTAGE, + ), + ) + : false; + return isEstimatedReturnLow; +}; diff --git a/ui/helpers/utils/nfts.js b/ui/helpers/utils/nfts.js index b1d9303135d8..6c728cbc4687 100644 --- a/ui/helpers/utils/nfts.js +++ b/ui/helpers/utils/nfts.js @@ -1,3 +1,30 @@ +const NFT_ALT_TEXT_MAX_LENGTH = 100; + +export const nftTruncateAltText = (text, maxLength) => { + // if the text is shorter than or equal to maxLength, return it + if (text.length <= maxLength) { + return text; + } + + const truncated = text.substring(0, maxLength); + const lastSpaceIndex = truncated.lastIndexOf(' '); + + // If there's a space within the truncated text, cut at the last space + if (lastSpaceIndex > 0) { + return `${truncated.substring(0, lastSpaceIndex)}...`; + } + + // If no space is found, return the truncated text with ellipsis + return `${truncated}...`; +}; + export const getNftImageAlt = ({ name, tokenId, description }) => { - return description ?? `${name} ${tokenId}`; + // If there is no name, tokenId, or description, return an empty string + if (!name && !tokenId && !description) { + return ''; + } + + // if name or tokenId is undefined, don't include them in the alt text + const altText = description ?? `${name ?? ''} ${tokenId ?? ''}`.trim(); + return nftTruncateAltText(altText, NFT_ALT_TEXT_MAX_LENGTH); }; diff --git a/ui/helpers/utils/nfts.test.js b/ui/helpers/utils/nfts.test.js index 7b7a74971534..8e4fcc566d47 100644 --- a/ui/helpers/utils/nfts.test.js +++ b/ui/helpers/utils/nfts.test.js @@ -1,4 +1,4 @@ -import { getNftImageAlt } from './nfts'; +import { getNftImageAlt, nftTruncateAltText } from './nfts'; describe('NFTs Utils', () => { describe('getNftImageAlt', () => { @@ -27,5 +27,40 @@ describe('NFTs Utils', () => { }), ).toBe('Cool NFT 555'); }); + + it('returns an empty string when no name, tokenId, or description is provided', () => { + expect( + getNftImageAlt({ + name: null, + tokenId: null, + description: null, + }), + ).toBe(''); + expect(getNftImageAlt({})).toBe(''); + }); + }); + + describe('nftTruncateAltText', () => { + it('returns the full text if it is shorter than or equal to maxLength', () => { + expect(nftTruncateAltText('Short text', 20)).toBe('Short text'); + }); + + it('truncates the text and adds ellipsis if it is longer than maxLength', () => { + expect( + nftTruncateAltText('This is a long text that needs truncation', 20), + ).toBe('This is a long text...'); + }); + + it('truncates at the last space within maxLength if possible', () => { + expect( + nftTruncateAltText('This is a long text that needs truncation', 25), + ).toBe('This is a long text that...'); + }); + + it('truncates without cutting words if possible', () => { + expect( + nftTruncateAltText('This is a long text that needs truncation', 10), + ).toBe('This is a...'); + }); }); }); diff --git a/ui/helpers/utils/token-util.js b/ui/helpers/utils/token-util.js index 92efd04bf555..31708e9aa5e0 100644 --- a/ui/helpers/utils/token-util.js +++ b/ui/helpers/utils/token-util.js @@ -254,7 +254,7 @@ export function getTokenFiatAmount( currentTokenInFiat = currentTokenInFiat.round(2).toString(); let result; - if (hideCurrencySymbol) { + if (hideCurrencySymbol && formatted) { result = formatCurrency(currentTokenInFiat, currentCurrency); } else if (formatted) { result = `${formatCurrency( diff --git a/ui/hooks/useTokenFiatAmount.js b/ui/hooks/useTokenFiatAmount.js index 6f48715b7fae..8f2bf891a515 100644 --- a/ui/hooks/useTokenFiatAmount.js +++ b/ui/hooks/useTokenFiatAmount.js @@ -28,6 +28,7 @@ import { isEqualCaseInsensitive } from '../../shared/modules/string-utils'; * @param {boolean} hideCurrencySymbol - Indicates whether the returned formatted amount should include the trailing currency symbol * @returns {string} The formatted token amount in the user's chosen fiat currency * @param {string} [chainId] - The chain id + * @param {boolean} formatted - Whether the return value should be formatted or not */ export function useTokenFiatAmount( tokenAddress, @@ -36,6 +37,7 @@ export function useTokenFiatAmount( overrides = {}, hideCurrencySymbol, chainId = null, + formatted = true, ) { const allMarketData = useSelector(getMarketData); @@ -91,7 +93,7 @@ export function useTokenFiatAmount( currentCurrency, tokenAmount, tokenSymbol, - true, + formatted, hideCurrencySymbol, ), [ diff --git a/ui/pages/confirmations/components/confirm/info/shared/nft-send-heading/nft-send-heading.tsx b/ui/pages/confirmations/components/confirm/info/shared/nft-send-heading/nft-send-heading.tsx index 09c5ffa922c6..0645ec71b2ab 100644 --- a/ui/pages/confirmations/components/confirm/info/shared/nft-send-heading/nft-send-heading.tsx +++ b/ui/pages/confirmations/components/confirm/info/shared/nft-send-heading/nft-send-heading.tsx @@ -47,7 +47,7 @@ const NFTSendHeading = () => { ); const imageOriginal = (nft as Nft | undefined)?.imageOriginal; const image = (nft as Nft | undefined)?.image; - const nftImageAlt = nft && getNftImageAlt(nft); + const nftImageAlt = nft ? getNftImageAlt(nft) : ''; const nftSrcUrl = imageOriginal ?? (image || ''); const isIpfsURL = nftSrcUrl?.startsWith('ipfs:'); const currentChain = networkConfigurations[chainId]; @@ -56,7 +56,7 @@ const NFTSendHeading = () => { = ({ ); const selectedNetworksList = selectedTestNetwork - ? [...nonTestNetworks, selectedTestNetwork] - : nonTestNetworks; + ? [...nonTestNetworks, selectedTestNetwork].map(({ chainId }) => chainId) + : nonTestNetworks.map(({ chainId }) => chainId); + + const supportedRequestedChainIds = requestedChainIds.filter((chainId) => + selectedNetworksList.includes(chainId), + ); + const defaultSelectedChainIds = - requestedChainIds.length > 0 - ? requestedChainIds - : selectedNetworksList.map(({ chainId }) => chainId); + supportedRequestedChainIds.length > 0 + ? supportedRequestedChainIds + : selectedNetworksList; + const [selectedChainIds, setSelectedChainIds] = useState( defaultSelectedChainIds, ); @@ -111,12 +118,18 @@ export const ConnectPage: React.FC = ({ ); }, [accounts]); + const supportedRequestedAccounts = requestedAccounts.filter((account) => + evmAccounts.find(({ address }) => isEqualCaseInsensitive(address, account)), + ); + const currentAccount = useSelector(getSelectedInternalAccount); const currentAccountAddress = isEvmAccountType(currentAccount.type) ? [currentAccount.address] : []; // We do not support non-EVM accounts connections const defaultAccountsAddresses = - requestedAccounts.length > 0 ? requestedAccounts : currentAccountAddress; + supportedRequestedAccounts.length > 0 + ? supportedRequestedAccounts + : currentAccountAddress; const [selectedAccountAddresses, setSelectedAccountAddresses] = useState( defaultAccountsAddresses, ); diff --git a/ui/pages/swaps/prepare-swap-page/prepare-swap-page.js b/ui/pages/swaps/prepare-swap-page/prepare-swap-page.js index 847f225568f4..9272991cf2cc 100644 --- a/ui/pages/swaps/prepare-swap-page/prepare-swap-page.js +++ b/ui/pages/swaps/prepare-swap-page/prepare-swap-page.js @@ -29,6 +29,7 @@ import { TextVariant, BLOCK_SIZES, FontWeight, + TextAlign, } from '../../../helpers/constants/design-system'; import { fetchQuotesAndSetQuoteState, @@ -95,6 +96,7 @@ import { QUOTES_NOT_AVAILABLE_ERROR, QUOTES_EXPIRED_ERROR, MAX_ALLOWED_SLIPPAGE, + SWAPS_QUOTE_MAX_RETURN_DIFFERENCE_PERCENTAGE, } from '../../../../shared/constants/swaps'; import { CHAINID_DEFAULT_BLOCK_EXPLORER_URL_MAP, @@ -132,6 +134,7 @@ import { Text, TextField, TextFieldSize, + BannerAlertSeverity, } from '../../../components/component-library'; import { ModalContent } from '../../../components/component-library/modal-content/deprecated'; import { ModalHeader } from '../../../components/component-library/modal-header/deprecated'; @@ -178,6 +181,8 @@ export default function PrepareSwapPage({ const [quoteCount, updateQuoteCount] = useState(0); const [prefetchingQuotes, setPrefetchingQuotes] = useState(false); const [rotateSwitchTokens, setRotateSwitchTokens] = useState(false); + const [isLowReturnBannerOpen, setIsLowReturnBannerOpen] = useState(true); + const [isEstimatedReturnLow, setIsEstimatedReturnLow] = useState(false); const isBridgeSupported = useSelector(getIsBridgeEnabled); const isFeatureFlagLoaded = useSelector(getIsFeatureFlagLoaded); @@ -358,6 +363,8 @@ export default function PrepareSwapPage({ ) { dispatch(setSwapsErrorKey(QUOTES_NOT_AVAILABLE_ERROR)); } + // Resets the banner visibility when the estimated return is low + setIsLowReturnBannerOpen(true); }; // The below logic simulates a sequential loading of the aggregator quotes, even though we are fetching them all with a single call. @@ -396,6 +403,11 @@ export default function PrepareSwapPage({ prefetchingQuotes, ]); + useEffect(() => { + // Reopens the low return banner if a new quote is selected + setIsLowReturnBannerOpen(true); + }, [usedQuote]); + const onFromSelect = (token) => { if ( token?.address && @@ -1165,6 +1177,18 @@ export default function PrepareSwapPage({ )} + {isEstimatedReturnLow && isLowReturnBannerOpen && ( + setIsLowReturnBannerOpen(false)} + /> + )} {swapsErrorKey && ( )} {showReviewQuote && ( - + )} {!areQuotesPresent && ( diff --git a/ui/pages/swaps/prepare-swap-page/review-quote.js b/ui/pages/swaps/prepare-swap-page/review-quote.js index 6575878d9992..120ace0823b0 100644 --- a/ui/pages/swaps/prepare-swap-page/review-quote.js +++ b/ui/pages/swaps/prepare-swap-page/review-quote.js @@ -44,6 +44,7 @@ import { fetchSwapsSmartTransactionFees, getSmartTransactionFees, getCurrentSmartTransactionsEnabled, + getIsEstimatedReturnLow, } from '../../../ducks/swaps/swaps'; import { getCurrentChainId } from '../../../../shared/modules/selectors/networks'; import { @@ -181,7 +182,10 @@ ViewAllQuotesLink.propTypes = { t: PropTypes.func.isRequired, }; -export default function ReviewQuote({ setReceiveToAmount }) { +export default function ReviewQuote({ + setReceiveToAmount, + setIsEstimatedReturnLow, +}) { const history = useHistory(); const dispatch = useDispatch(); const t = useContext(I18nContext); @@ -421,7 +425,7 @@ export default function ReviewQuote({ setReceiveToAmount }) { sourceTokenValue, } = renderableDataForUsedQuote; - let { feeInFiat, feeInEth, rawEthFee, feeInUsd } = + let { feeInFiat, feeInEth, rawEthFee, feeInUsd, rawNetworkFees } = getRenderableNetworkFeesForQuote({ tradeGas: usedGasLimit, approveGas, @@ -472,14 +476,15 @@ export default function ReviewQuote({ setReceiveToAmount }) { smartTransactionFees?.tradeTxFees.maxFeeEstimate + (smartTransactionFees?.approvalTxFees?.maxFeeEstimate || 0); - ({ feeInFiat, feeInEth, rawEthFee, feeInUsd } = getFeeForSmartTransaction({ - chainId, - currentCurrency, - conversionRate, - USDConversionRate, - nativeCurrencySymbol, - feeInWeiDec: stxEstimatedFeeInWeiDec, - })); + ({ feeInFiat, feeInEth, rawEthFee, feeInUsd, rawNetworkFees } = + getFeeForSmartTransaction({ + chainId, + currentCurrency, + conversionRate, + USDConversionRate, + nativeCurrencySymbol, + feeInWeiDec: stxEstimatedFeeInWeiDec, + })); additionalTrackingParams.stx_fee_in_usd = Number(feeInUsd); additionalTrackingParams.stx_fee_in_eth = Number(rawEthFee); additionalTrackingParams.estimated_gas = @@ -1122,6 +1127,12 @@ export default function ReviewQuote({ setReceiveToAmount }) { currentCurrency, ]); + const isEstimatedReturnLow = getIsEstimatedReturnLow({ + usedQuote, + rawNetworkFees, + }); + setIsEstimatedReturnLow(isEstimatedReturnLow); + return (
@@ -1489,4 +1500,5 @@ export default function ReviewQuote({ setReceiveToAmount }) { ReviewQuote.propTypes = { setReceiveToAmount: PropTypes.func.isRequired, + setIsEstimatedReturnLow: PropTypes.func.isRequired, }; diff --git a/ui/pages/swaps/prepare-swap-page/review-quote.test.js b/ui/pages/swaps/prepare-swap-page/review-quote.test.js index 1e4ab9199226..d13adbe85b87 100644 --- a/ui/pages/swaps/prepare-swap-page/review-quote.test.js +++ b/ui/pages/swaps/prepare-swap-page/review-quote.test.js @@ -34,6 +34,7 @@ const middleware = [thunk]; const createProps = (customProps = {}) => { return { setReceiveToAmount: jest.fn(), + setIsEstimatedReturnLow: jest.fn(), ...customProps, }; }; @@ -143,6 +144,62 @@ describe('ReviewQuote', () => { expect(getByText('Swap')).toBeInTheDocument(); }); + it('should call setIsEstimatedReturnLow(true) when return value is less than 65% of sent funds', async () => { + const setReceiveToAmountMock = jest.fn(); + const setIsEstimatedReturnLowMock = jest.fn(); + const props = { + setReceiveToAmount: setReceiveToAmountMock, + setIsEstimatedReturnLow: setIsEstimatedReturnLowMock, + }; + + const state = createSwapsMockStore(); + + // Set up market data for price calculations + state.metamask.marketData = { + [CHAIN_IDS.MAINNET]: { + '0x6B175474E89094C44Da98b954EedeAC495271d0F': { + // DAI + price: 100, + decimal: 18, + }, + '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48': { + // USDC + price: 60, + decimal: 6, + }, + }, + }; + + // Set up the quotes with amounts that will result in less than 65% return + state.metamask.swapsState.quotes = { + TEST_AGG_2: { + sourceAmount: '1000000000000000000', // 1 DAI (18 decimals) + destinationAmount: '1000000', // 1 USDC (6 decimals) + trade: { + value: '0x0', + }, + sourceTokenInfo: { + symbol: 'DAI', + decimals: 18, + address: '0x6B175474E89094C44Da98b954EedeAC495271d0F', + }, + destinationTokenInfo: { + symbol: 'USDC', + decimals: 6, + address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + }, + }, + }; + + const store = configureMockStore(middleware)(state); + + await act(async () => { + renderWithProvider(, store); + }); + + expect(setIsEstimatedReturnLowMock).toHaveBeenCalledWith(true); + }); + describe('uses gas fee estimates from transaction controller if 1559 and smart disabled', () => { let smartDisabled1559State; diff --git a/ui/pages/swaps/swaps.util.ts b/ui/pages/swaps/swaps.util.ts index 7065c7ae90dc..7beb613bce58 100644 --- a/ui/pages/swaps/swaps.util.ts +++ b/ui/pages/swaps/swaps.util.ts @@ -352,6 +352,7 @@ export const getFeeForSmartTransaction = ({ const chainCurrencySymbolToUse = nativeCurrencySymbol || SWAPS_CHAINID_DEFAULT_TOKEN_MAP[chainId]?.symbol; return { + rawNetworkFees, feeInUsd, feeInFiat: formattedNetworkFee, feeInEth: `${ethFee} ${chainCurrencySymbolToUse}`,