diff --git a/app/_locales/de/messages.json b/app/_locales/de/messages.json index 6d0878b57d5c..27d17aa94179 100644 --- a/app/_locales/de/messages.json +++ b/app/_locales/de/messages.json @@ -861,15 +861,9 @@ "bridgeSelectTokenAndAmount": { "message": "Token und Betrag auswählen" }, - "bridgeTimingTooltipText": { - "message": "Dies ist die voraussichtliche Dauer, bis das Bridging abgeschlossen ist." - }, "bridgeTo": { "message": "Bridge nach" }, - "bridgeTotalFeesTooltipText": { - "message": "Dazu gehören Gas-Gebühren (die an Krypto-Miner gezahlt werden) und Relayer-Gebühren (die für die Bereitstellung komplexer Dienste wie Bridging entrichtet werden).\nDie Gebühren richten sich nach dem Netzwerk-Traffic und der Komplexität der Transaktionen. MetaMask profitiert von keiner der Gebühren." - }, "browserNotSupported": { "message": "Ihr Browser wird nicht unterstützt …" }, @@ -1989,9 +1983,6 @@ "estimatedFeeTooltip": { "message": "Betrag, der für die Bearbeitung der Transaktion im Netzwerk gezahlt wurde." }, - "estimatedTime": { - "message": "Geschätzte Dauer" - }, "ethGasPriceFetchWarning": { "message": "Der Gas-Preis, der sich aus der Gas-Hauptschätzungsdienst ergibt, ist derzeit nicht verfügbar." }, @@ -6118,9 +6109,6 @@ "total": { "message": "Gesamt" }, - "totalFees": { - "message": "Gesamtgebühren" - }, "totalVolume": { "message": "Gesamtvolumen" }, diff --git a/app/_locales/el/messages.json b/app/_locales/el/messages.json index 58045c5b0578..ba948b6aabb9 100644 --- a/app/_locales/el/messages.json +++ b/app/_locales/el/messages.json @@ -861,15 +861,9 @@ "bridgeSelectTokenAndAmount": { "message": "Επιλέξτε token και ποσό" }, - "bridgeTimingTooltipText": { - "message": "Αυτός είναι ο εκτιμώμενος χρόνος που θα χρειαστεί για να ολοκληρωθεί η διασύνδεση." - }, "bridgeTo": { "message": "Γέφυρα σε" }, - "bridgeTotalFeesTooltipText": { - "message": "Αυτό περιλαμβάνει τα τέλη συναλλαγών (που καταβάλλονται στους αναλυτές κρυπτονομισμάτων) και τέλη αποδεκτών (που καταβάλλονται για την παροχή σύνθετων υπηρεσιών όπως η διασύνδεση).\nΤα τέλη βασίζονται στην κίνηση του δικτύου και την πολυπλοκότητα των συναλλαγών. Το MetaMask δεν επωφελείται από κανένα από τα δύο τέλη." - }, "browserNotSupported": { "message": "Το Πρόγραμμα Περιήγησής σας δεν υποστηρίζεται..." }, @@ -1989,9 +1983,6 @@ "estimatedFeeTooltip": { "message": "Ποσό που καταβλήθηκε για τη διεκπεραίωση της συναλλαγής στο δίκτυο." }, - "estimatedTime": { - "message": "Εκτιμώμενος χρόνος" - }, "ethGasPriceFetchWarning": { "message": "Η εφεδρική τιμή του τέλους συναλλαγής παρέχεται καθώς η κύρια υπηρεσία εκτίμησης τελών συναλλαγής, δεν είναι διαθέσιμη αυτή τη στιγμή." }, @@ -6118,9 +6109,6 @@ "total": { "message": "Σύνολο" }, - "totalFees": { - "message": "Συνολικά τέλη" - }, "totalVolume": { "message": "Συνολικός όγκος" }, diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json index f42a539277d1..7fc208a80b63 100644 --- a/app/_locales/en/messages.json +++ b/app/_locales/en/messages.json @@ -746,6 +746,9 @@ "beCareful": { "message": "Be careful" }, + "bestPrice": { + "message": "Best price" + }, "beta": { "message": "Beta" }, @@ -862,6 +865,12 @@ "message": "Approve $1 for bridge", "description": "Used in the transaction display list to describe a transaction that is an approve call on a token that is to be bridged. $1 is the symbol of a token that has been approved." }, + "bridgeApprovalWarning": { + "message": "You are allowing access to the specified amount, $1 $2. The contract will not access any additional funds." + }, + "bridgeApprovalWarningForHardware": { + "message": "You will need to allow access to $1 $2 for bridging, and then approve bridging to USDC. This will require two separate confirmations." + }, "bridgeCalculatingAmount": { "message": "Calculating..." }, @@ -872,7 +881,7 @@ "message": "Bridge, don't send" }, "bridgeEnterAmount": { - "message": "Enter amount" + "message": "Select amount" }, "bridgeExplorerLinkViewOn": { "message": "View on $1" @@ -911,22 +920,19 @@ "message": "Swapping $1 for $2", "description": "$1 is the amount of the source asset, $2 is the amount of the destination asset" }, + "bridgeTerms": { + "message": "Terms" + }, "bridgeTimingMinutes": { "message": "$1 min", "description": "$1 is the ticker symbol of a an asset the user is being prompted to purchase" }, - "bridgeTimingTooltipText": { - "message": "This is the estimated time it will take for the bridging to be complete." - }, "bridgeTo": { "message": "Bridge to" }, "bridgeToChain": { "message": "Bridge to $1" }, - "bridgeTotalFeesTooltipText": { - "message": "This includes gas fees (paid to crypto miners) and relayer fees (paid to power complex services like bridging).\nFees are based on network traffic and transaction complexity. MetaMask does not profit from either fee." - }, "bridgeTxDetailsBaseFee": { "message": "Base fee (GWEI)" }, @@ -979,6 +985,15 @@ "bridgeTypeDirectionTo": { "message": "To" }, + "bridgeValidationInsufficientGasMessage": { + "message": "You don't have enough $1 to pay the gas fee for this bridge. Enter a smaller amount or buy more $1." + }, + "bridgeValidationInsufficientGasTitle": { + "message": "More $1 needed for gas" + }, + "bridging": { + "message": "Bridging" + }, "browserNotSupported": { "message": "Your browser is not supported..." }, @@ -988,6 +1003,9 @@ "builtAroundTheWorld": { "message": "MetaMask is designed and built around the world." }, + "bulletpoint": { + "message": "·" + }, "busy": { "message": "Busy" }, @@ -1559,6 +1577,9 @@ "message": "Use $1 to customize the gas price. This can be confusing if you aren’t familiar. Interact at your own risk.", "description": "$1 is key 'advanced' (text: 'Advanced') separated here so that it can be passed in with bold font-weight" }, + "customSlippage": { + "message": "Custom" + }, "customSpendLimit": { "message": "Custom spend limit" }, @@ -2118,9 +2139,6 @@ "estimatedFeeTooltip": { "message": "Amount paid to process the transaction on network." }, - "estimatedTime": { - "message": "Estimated time" - }, "ethGasPriceFetchWarning": { "message": "Backup gas price is provided as the main gas estimation service is unavailable right now." }, @@ -2370,6 +2388,7 @@ "gotIt": { "message": "Got it" }, + "grantExactAccess": { "message": "Grant exact access" }, "grantedToWithColon": { "message": "Granted to:" }, @@ -2498,6 +2517,12 @@ "holdToRevealUnlockedLabel": { "message": "hold to reveal circle unlocked" }, + "howQuotesWork": { + "message": "How quotes work" + }, + "howQuotesWorkExplanation": { + "message": "This quote has the best return of the quotes we searched. This is based on the swap rate, which includes bridging fees and a $1% MetaMask fee, minus gas fees. Gas fees depend on how busy the network is and how complex the transaction is." + }, "id": { "message": "ID" }, @@ -2963,6 +2988,12 @@ "low": { "message": "Low" }, + "lowEstimatedReturnTooltipMessage": { + "message": "Either your rate or your fees are less favorable than usual. It looks like you'll get back less than $1% of the amount you’re bridging." + }, + "lowEstimatedReturnTooltipTitle": { + "message": "Low estimated return" + }, "lowGasSettingToolTipMessage": { "message": "Use $1 to wait for a cheaper price. Time estimates are much less accurate as prices are somewhat unpredictable.", "description": "$1 is key 'low' separated here so that it can be passed in with bold font-weight" @@ -3119,6 +3150,9 @@ "message": "+ $1 more networks", "description": "$1 is the number of networks" }, + "moreQuotes": { + "message": "More quotes" + }, "multichainAddEthereumChainConfirmationDescription": { "message": "You're adding this network to MetaMask and giving this site permission to use it." }, @@ -3246,6 +3280,9 @@ "networkFee": { "message": "Network fee" }, + "networkFees": { + "message": "Network fees" + }, "networkIsBusy": { "message": "Network is busy. Gas prices are high and estimates are less accurate." }, @@ -3480,6 +3517,9 @@ "noNetworksFound": { "message": "No networks found for the given search query" }, + "noOptionsAvailableMessage": { + "message": "This trade route isn't available right now. Try changing the amount, network, or token and we'll find the best option." + }, "noSnaps": { "message": "You don't have any snaps installed." }, @@ -4481,18 +4521,16 @@ "quoteRate": { "message": "Quote rate" }, - "quotedNetworkFee": { - "message": "$1 network fee" - }, "quotedReceiveAmount": { "message": "$1 receive amount" }, - "quotedReceivingAmount": { - "message": "$1 receiving" - }, + "quotedTotalCost": { "message": "$1 total cost" }, "rank": { "message": "Rank" }, + "rateIncludesMMFee": { + "message": "Rate includes $1% fee" + }, "reAddAccounts": { "message": "re-add any other accounts" }, @@ -6346,9 +6384,6 @@ "total": { "message": "Total" }, - "totalFees": { - "message": "Total fees" - }, "totalVolume": { "message": "Total volume" }, @@ -6820,6 +6855,12 @@ "whatsThis": { "message": "What's this?" }, + "willApproveAmountForBridging": { + "message": "This will approve $1 for bridging." + }, + "willApproveAmountForBridgingHardware": { + "message": "You’ll need to confirm two transactions on your hardware wallet." + }, "withdrawing": { "message": "Withdrawing" }, @@ -6853,6 +6894,9 @@ "yourNFTmayBeAtRisk": { "message": "Your NFT may be at risk" }, + "yourNetworks": { + "message": "Your networks" + }, "yourPrivateSeedPhrase": { "message": "Your Secret Recovery Phrase" }, diff --git a/app/_locales/es/messages.json b/app/_locales/es/messages.json index f5bec514637e..dd3fe6d66d48 100644 --- a/app/_locales/es/messages.json +++ b/app/_locales/es/messages.json @@ -861,15 +861,9 @@ "bridgeSelectTokenAndAmount": { "message": "Seleccione token y monto" }, - "bridgeTimingTooltipText": { - "message": "Este es el tiempo estimado que tardará en completarse el puenteo." - }, "bridgeTo": { "message": "Puentear hacia" }, - "bridgeTotalFeesTooltipText": { - "message": "Esto incluye las tarifas de gas (pagadas a los mineros de criptomonedas) y las tarifas de repetidores (pagadas para alimentar servicios complejos como el puenteo).\nLas tarifas se basan en el tráfico de la red y la complejidad de las transacciones. MetaMask no lucra con ninguna de las dos tarifas." - }, "browserNotSupported": { "message": "Su explorador no es compatible..." }, @@ -1989,9 +1983,6 @@ "estimatedFeeTooltip": { "message": "Monto pagado para procesar la transacción en la red." }, - "estimatedTime": { - "message": "Tiempo estimado" - }, "ethGasPriceFetchWarning": { "message": "Se muestra el precio del gas de respaldo, ya que el servicio para calcular el precio del gas principal no se encuentra disponible en este momento." }, @@ -6118,9 +6109,6 @@ "total": { "message": "Total" }, - "totalFees": { - "message": "Tarifas totales" - }, "totalVolume": { "message": "Volúmen total" }, diff --git a/app/_locales/fr/messages.json b/app/_locales/fr/messages.json index 4d5e59b25c52..de32754a3492 100644 --- a/app/_locales/fr/messages.json +++ b/app/_locales/fr/messages.json @@ -861,15 +861,9 @@ "bridgeSelectTokenAndAmount": { "message": "Sélectionnez le jeton et le montant" }, - "bridgeTimingTooltipText": { - "message": "Il s’agit d’une estimation du temps nécessaire pour que la passerelle soit établie." - }, "bridgeTo": { "message": "Passerelle vers" }, - "bridgeTotalFeesTooltipText": { - "message": "Cela comprend les frais de gaz (payés aux mineurs de crypto-monnaies) et les frais pour relayeurs (payés pour assurer des services complexes tels que l’établissement de passerelles).\nLes frais sont basés sur le trafic réseau et la complexité des transactions. MetaMask ne tire aucun profit de ces frais." - }, "browserNotSupported": { "message": "Votre navigateur internet n’est pas compatible..." }, @@ -1989,9 +1983,6 @@ "estimatedFeeTooltip": { "message": "Montant payé pour traiter la transaction sur le réseau." }, - "estimatedTime": { - "message": "Temps estimé" - }, "ethGasPriceFetchWarning": { "message": "Le prix de carburant de sauvegarde est fourni, car le service principal d’estimation du carburant est momentanément indisponible." }, @@ -6118,9 +6109,6 @@ "total": { "message": "Total" }, - "totalFees": { - "message": "Frais totaux" - }, "totalVolume": { "message": "Volume total" }, diff --git a/app/_locales/hi/messages.json b/app/_locales/hi/messages.json index 2d4bcc52b891..9b9ad396c53a 100644 --- a/app/_locales/hi/messages.json +++ b/app/_locales/hi/messages.json @@ -861,15 +861,9 @@ "bridgeSelectTokenAndAmount": { "message": "टोकन और रकम का चयन करें" }, - "bridgeTimingTooltipText": { - "message": "ब्रिजिंग का काम पूरा होने में यह अनुमानित समय लगेगा।" - }, "bridgeTo": { "message": "इसपर ब्रिज करें" }, - "bridgeTotalFeesTooltipText": { - "message": "इसमें गैस शुल्क (क्रिप्टो माइनरों (miners) को भुगतान) और रीलेयर (relayer) शुल्क (ब्रिजिंग जैसी जटिल सेवाओं को मजबूत करने के लिए भुगतान) शामिल हैं।\nशुल्क नेटवर्क ट्रैफ़िक और ट्रांसेक्शन जटिलता पर आधारित हैं। MetaMask को किसी भी शुल्क से लाभ नहीं होता है।" - }, "browserNotSupported": { "message": "आपका ब्राउज़र सपोर्टेड नहीं है..." }, @@ -1989,9 +1983,6 @@ "estimatedFeeTooltip": { "message": "नेटवर्क पर ट्रांसेक्शन को प्रोसेस करने के लिए भुगतान की गई राशि।" }, - "estimatedTime": { - "message": "अनुमानित समय" - }, "ethGasPriceFetchWarning": { "message": "बैकअप गैस प्राइस दिया गया है क्योंकि मेन गैस एस्टीमेशन सर्विस अभी उपलब्ध नहीं है।" }, @@ -6118,9 +6109,6 @@ "total": { "message": "कुलयोग" }, - "totalFees": { - "message": "कुल शुल्क" - }, "totalVolume": { "message": "टोटल वॉल्यूम" }, diff --git a/app/_locales/id/messages.json b/app/_locales/id/messages.json index 9135a2e56bcf..8f386f0cc95e 100644 --- a/app/_locales/id/messages.json +++ b/app/_locales/id/messages.json @@ -861,15 +861,9 @@ "bridgeSelectTokenAndAmount": { "message": "Pilih token dan jumlah" }, - "bridgeTimingTooltipText": { - "message": "Ini merupakan estimasi waktu yang diperlukan untuk menyelesaikan bridge." - }, "bridgeTo": { "message": "Bridge ke" }, - "bridgeTotalFeesTooltipText": { - "message": "Ini termasuk biaya gas (dibayarkan kepada penambang kripto) dan biaya relayer (dibayarkan untuk menjalankan layanan kompleks seperti bridge).\nBiaya didasarkan pada lalu lintas jaringan dan kompleksitas transaksi. MetaMask tidak mendapat keuntungan dari kedua biaya tersebut." - }, "browserNotSupported": { "message": "Browser Anda tidak didukung..." }, @@ -1989,9 +1983,6 @@ "estimatedFeeTooltip": { "message": "Jumlah yang dibayarkan untuk memproses transaksi di jaringan." }, - "estimatedTime": { - "message": "Estimasi waktu" - }, "ethGasPriceFetchWarning": { "message": "Biaya gas cadangan diberikan karena layanan estimasi gas utama saat ini tidak tersedia." }, @@ -6118,9 +6109,6 @@ "total": { "message": "Total" }, - "totalFees": { - "message": "Total biaya" - }, "totalVolume": { "message": "Volume total" }, diff --git a/app/_locales/ja/messages.json b/app/_locales/ja/messages.json index 8404c4eb3af4..bca8d08db97f 100644 --- a/app/_locales/ja/messages.json +++ b/app/_locales/ja/messages.json @@ -861,15 +861,10 @@ "bridgeSelectTokenAndAmount": { "message": "トークンと金額を選択" }, - "bridgeTimingTooltipText": { - "message": "これは、ブリッジが完了するまでの推定時間です。" - }, "bridgeTo": { "message": "ブリッジ先:" }, - "bridgeTotalFeesTooltipText": { - "message": "これには、(仮想通貨マイナーに支払われる) ガス代と (ブリッジなどの複雑なサービスを供給するために支払われる) リレイヤー手数料が含まれます。\n手数料はネットワークトラフィックとトランザクションの複雑性に基づいています。MetaMaskはどちらの手数料からも利益を得ることはありません。" - }, + "browserNotSupported": { "message": "ご使用のブラウザはサポートされていません..." }, @@ -1989,9 +1984,6 @@ "estimatedFeeTooltip": { "message": "ネットワーク上のトランザクションの処理に支払われる金額" }, - "estimatedTime": { - "message": "推定所要時間" - }, "ethGasPriceFetchWarning": { "message": "現在メインのガスの見積もりサービスが利用できないため、バックアップのガス価格が提供されています。" }, @@ -6118,9 +6110,6 @@ "total": { "message": "合計" }, - "totalFees": { - "message": "合計手数料" - }, "totalVolume": { "message": "合計量" }, diff --git a/app/_locales/ko/messages.json b/app/_locales/ko/messages.json index cbd48592c7d0..8c89ab592576 100644 --- a/app/_locales/ko/messages.json +++ b/app/_locales/ko/messages.json @@ -861,15 +861,10 @@ "bridgeSelectTokenAndAmount": { "message": "토큰 및 금액 선택" }, - "bridgeTimingTooltipText": { - "message": "브릿지가 완료되는 데 걸리는 예상 시간입니다." - }, "bridgeTo": { "message": "브릿지 대상" }, - "bridgeTotalFeesTooltipText": { - "message": "가스비(암호화폐 채굴자에게 지급)와 릴레이어 수수료(브릿지와 같은 복잡한 서비스 제공에 지급)가 포함됩니다.\n수수료는 네트워크 트래픽과 트랜잭션 복잡성에 따라 달라집니다. MetaMask는 어떤 수수료에서도 수익을 얻지 않습니다." - }, + "browserNotSupported": { "message": "지원되지 않는 브라우저입니다..." }, @@ -1989,9 +1984,6 @@ "estimatedFeeTooltip": { "message": "네트워크에서 트랜잭션을 처리하기 위해 지불한 금액입니다." }, - "estimatedTime": { - "message": "예상 시간" - }, "ethGasPriceFetchWarning": { "message": "현재 주요 가스 견적 서비스를 사용할 수 없으므로 백업 가스 가격을 제공합니다." }, @@ -6118,9 +6110,6 @@ "total": { "message": "합계" }, - "totalFees": { - "message": "총 수수료" - }, "totalVolume": { "message": "총 거래량" }, diff --git a/app/_locales/pt/messages.json b/app/_locales/pt/messages.json index 3a01b378c686..5a5137fe8c08 100644 --- a/app/_locales/pt/messages.json +++ b/app/_locales/pt/messages.json @@ -861,15 +861,10 @@ "bridgeSelectTokenAndAmount": { "message": "Selecionar token e valor" }, - "bridgeTimingTooltipText": { - "message": "Este é o tempo estimado para a conclusão da ponte." - }, "bridgeTo": { "message": "Ponte para" }, - "bridgeTotalFeesTooltipText": { - "message": "Isso inclui as taxas de gás (pagas aos mineradores de criptmoedas) e taxas de retransmissão (pagas para promover serviços complexos como pontes).\nAs taxas são baseadas no tráfego da rede e na complexidade da transação. A MetaMask não lucra com nenhuma dessas taxas." - }, + "browserNotSupported": { "message": "Seu navegador não é compatível..." }, @@ -1989,9 +1984,6 @@ "estimatedFeeTooltip": { "message": "Valor pago para processar a transação na rede." }, - "estimatedTime": { - "message": "Tempo estimado" - }, "ethGasPriceFetchWarning": { "message": "O preço de backup do gás é fornecido porque a estimativa de gás principal está indisponível no momento." }, @@ -6118,9 +6110,6 @@ "total": { "message": "Total" }, - "totalFees": { - "message": "Taxas totais" - }, "totalVolume": { "message": "Volume total" }, diff --git a/app/_locales/ru/messages.json b/app/_locales/ru/messages.json index 385670721c1f..bee509f85e99 100644 --- a/app/_locales/ru/messages.json +++ b/app/_locales/ru/messages.json @@ -861,15 +861,9 @@ "bridgeSelectTokenAndAmount": { "message": "Выберите токен и сумму" }, - "bridgeTimingTooltipText": { - "message": "Это примерное время, которое потребуется для создания моста." - }, "bridgeTo": { "message": "Мост в" }, - "bridgeTotalFeesTooltipText": { - "message": "Сюда входят плата за газ (выплачивается майнерам криптовалюты) и плата ретранслятору (выплачивается за услуги энергетического комплекса, такие как мостовое соединение).\nРазмер комиссии зависит от сетевого трафика и сложности транзакции. MetaMask не получает прибыли ни от одной комиссии." - }, "browserNotSupported": { "message": "Ваш браузер не поддерживается..." }, @@ -1989,9 +1983,6 @@ "estimatedFeeTooltip": { "message": "Сумма, уплаченная за обработку транзакции в сети." }, - "estimatedTime": { - "message": "Примерное время" - }, "ethGasPriceFetchWarning": { "message": "Указана резервная цена газа, поскольку основной сервис определения цены газа сейчас недоступен." }, @@ -6118,9 +6109,6 @@ "total": { "message": "Итого" }, - "totalFees": { - "message": "Итого комиссий" - }, "totalVolume": { "message": "Общий объем" }, diff --git a/app/_locales/tl/messages.json b/app/_locales/tl/messages.json index a13c9c6d3006..91d6762de059 100644 --- a/app/_locales/tl/messages.json +++ b/app/_locales/tl/messages.json @@ -861,15 +861,10 @@ "bridgeSelectTokenAndAmount": { "message": "Piliin ang token at halaga" }, - "bridgeTimingTooltipText": { - "message": "Ito ang tinatayang tagal para makumpleto ang pag-bridge." - }, "bridgeTo": { "message": "I-bridge papunta sa" }, - "bridgeTotalFeesTooltipText": { - "message": "Kasama rito ang mga bayad sa gas (binabayaran sa mga crypto miner) at mga bayad sa tagapaghatid (binabayaran sa mga serbisyo ng power complex gaya ng pag-bridge).\nNakabatay ang mga bayad sa trapiko sa network at kung paano kakumplikado ang transaksyon. Hindi kumikita ang MetaMask sa anumang bayad." - }, + "browserNotSupported": { "message": "Hindi sinusuportahan ang iyong browser..." }, @@ -1989,9 +1984,6 @@ "estimatedFeeTooltip": { "message": "Halaga na binayaran para iproseso ang transaksyon sa network." }, - "estimatedTime": { - "message": "Tinatayang oras" - }, "ethGasPriceFetchWarning": { "message": "Ang backup na presyo ng gas ay ibinigay dahil ang pangunahing serbisyo ng pagtantya ng gas ay hindi available sa ngayon." }, @@ -6118,9 +6110,6 @@ "total": { "message": "Kabuuan" }, - "totalFees": { - "message": "Kabuuang bayad" - }, "totalVolume": { "message": "Kabuuang volume" }, diff --git a/app/_locales/tr/messages.json b/app/_locales/tr/messages.json index 8f761e554caa..2f0dc5a89ac2 100644 --- a/app/_locales/tr/messages.json +++ b/app/_locales/tr/messages.json @@ -861,15 +861,10 @@ "bridgeSelectTokenAndAmount": { "message": "Token ve miktar seçin" }, - "bridgeTimingTooltipText": { - "message": "Bu, köprü işleminin tamamlanacağı tahmini süredir." - }, "bridgeTo": { "message": "Şuraya köprü:" }, - "bridgeTotalFeesTooltipText": { - "message": "Buna, gaz ücretleri (kripto madencilerine ödenen) ve düzenleyici ücretleri (köprü gibi güç kompleksi hizmetlerine ödenen) dahildir. Ücretler ağ trafiğine ve işlemin karmaşıklığına dayanır. MetaMask iki ücretten de kazanç sağlamaz." - }, + "browserNotSupported": { "message": "Tarayıcınız desteklenmiyor..." }, @@ -1989,9 +1984,6 @@ "estimatedFeeTooltip": { "message": "Ağda işlemi gerçekleştirmek için ödenen tutar." }, - "estimatedTime": { - "message": "Tahmini süre" - }, "ethGasPriceFetchWarning": { "message": "Ana gaz tahmini hizmeti olarak sunulan yedek gaz fiyatı şu anda kullanılamıyor." }, @@ -6118,9 +6110,6 @@ "total": { "message": "Toplam" }, - "totalFees": { - "message": "Toplam ücretler" - }, "totalVolume": { "message": "Toplam hacim" }, diff --git a/app/_locales/vi/messages.json b/app/_locales/vi/messages.json index 883f08f49a7e..b5779f736262 100644 --- a/app/_locales/vi/messages.json +++ b/app/_locales/vi/messages.json @@ -861,15 +861,10 @@ "bridgeSelectTokenAndAmount": { "message": "Chọn token và số tiền" }, - "bridgeTimingTooltipText": { - "message": "Đây là thời gian ước tính để hoàn thành cầu nối." - }, "bridgeTo": { "message": "Cầu nối đến" }, - "bridgeTotalFeesTooltipText": { - "message": "Bao gồm phí gas (trả cho các thợ đào tiền mã hóa) và phí sàn chuyển tiếp (trả để cung cấp các dịch vụ phức tạp như cầu nối).\nPhí được tính dựa trên lưu lượng mạng và độ phức tạp của giao dịch. MetaMask không thu lợi từ bất kỳ khoản phí nào." - }, + "browserNotSupported": { "message": "Trình duyệt của bạn không được hỗ trợ..." }, @@ -1989,9 +1984,6 @@ "estimatedFeeTooltip": { "message": "Số tiền được chi trả để xử lý giao dịch trên mạng." }, - "estimatedTime": { - "message": "Thời gian ước tính" - }, "ethGasPriceFetchWarning": { "message": "Giá gas dự phòng được cung cấp vì dịch vụ ước tính giá gas chính hiện không hoạt động." }, @@ -6118,9 +6110,6 @@ "total": { "message": "Tổng" }, - "totalFees": { - "message": "Tổng phí" - }, "totalVolume": { "message": "Tổng khối lượng giao dịch" }, diff --git a/app/_locales/zh_CN/messages.json b/app/_locales/zh_CN/messages.json index fb7ed008d6f6..14eb163d7bb3 100644 --- a/app/_locales/zh_CN/messages.json +++ b/app/_locales/zh_CN/messages.json @@ -861,15 +861,10 @@ "bridgeSelectTokenAndAmount": { "message": "选择代币和金额" }, - "bridgeTimingTooltipText": { - "message": "这是完成桥接所需的预估时间。" - }, "bridgeTo": { "message": "桥接至" }, - "bridgeTotalFeesTooltipText": { - "message": "这包括燃料费(支付给加密货币矿工)和中继器费用(用于为桥接等复杂服务提供动力)。\n费用根据网络流量和交易复杂性而定。MetaMask 不会从这两项费用中获利。" - }, + "browserNotSupported": { "message": "您的浏览器不受支持……" }, @@ -1989,9 +1984,6 @@ "estimatedFeeTooltip": { "message": "为在网络上处理交易而支付的金额。" }, - "estimatedTime": { - "message": "预估时间" - }, "ethGasPriceFetchWarning": { "message": "由于目前主要的燃料估算服务不可用,因此提供了备用燃料价格。" }, @@ -6118,9 +6110,6 @@ "total": { "message": "共计" }, - "totalFees": { - "message": "总费用" - }, "totalVolume": { "message": "总交易额" }, diff --git a/app/scripts/constants/sentry-state.ts b/app/scripts/constants/sentry-state.ts index c0b075c2b28b..68926c02dc89 100644 --- a/app/scripts/constants/sentry-state.ts +++ b/app/scripts/constants/sentry-state.ts @@ -104,8 +104,10 @@ export const SENTRY_BACKGROUND_STATE = { }, destTokens: {}, destTopAssets: [], + destTokensLoadingStatus: false, srcTokens: {}, srcTopAssets: [], + srcTokensLoadingStatus: false, quoteRequest: { walletAddress: false, srcTokenAddress: true, diff --git a/app/scripts/controllers/bridge/bridge-controller.test.ts b/app/scripts/controllers/bridge/bridge-controller.test.ts index 9ffb95832350..3b0d095fa0c3 100644 --- a/app/scripts/controllers/bridge/bridge-controller.test.ts +++ b/app/scripts/controllers/bridge/bridge-controller.test.ts @@ -18,7 +18,7 @@ import { QuoteResponse } from '../../../../ui/pages/bridge/types'; import { decimalToHex } from '../../../../shared/modules/conversion.utils'; import BridgeController from './bridge-controller'; import { BridgeControllerMessenger } from './types'; -import { DEFAULT_BRIDGE_CONTROLLER_STATE } from './constants'; +import { DEFAULT_BRIDGE_CONTROLLER_STATE, RequestStatus } from './constants'; const EMPTY_INIT_STATE = { bridgeState: DEFAULT_BRIDGE_CONTROLLER_STATE, @@ -106,6 +106,7 @@ describe('BridgeController', function () { address: '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984', symbol: 'ABC', decimals: 16, + aggregators: ['lifl', 'socket'], }, { address: '0x1291478912', @@ -171,6 +172,12 @@ describe('BridgeController', function () { it('selectDestNetwork should set the bridge dest tokens and top assets', async function () { await bridgeController.selectDestNetwork('0xa'); expect(bridgeController.state.bridgeState.destTokens).toStrictEqual({ + '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984': { + address: '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984', + symbol: 'ABC', + decimals: 16, + aggregators: ['lifl', 'socket'], + }, '0x0000000000000000000000000000000000000000': { address: '0x0000000000000000000000000000000000000000', decimals: 18, @@ -178,12 +185,10 @@ describe('BridgeController', function () { name: 'Ether', symbol: 'ETH', }, - '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984': { - address: '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984', - symbol: 'ABC', - decimals: 16, - }, }); + expect( + bridgeController.state.bridgeState.destTokensLoadingStatus, + ).toStrictEqual(RequestStatus.FETCHED); expect(bridgeController.state.bridgeState.destTopAssets).toStrictEqual([ { address: '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984', symbol: 'ABC' }, ]); @@ -208,8 +213,12 @@ describe('BridgeController', function () { address: '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984', symbol: 'ABC', decimals: 16, + aggregators: ['lifl', 'socket'], }, }); + expect( + bridgeController.state.bridgeState.srcTokensLoadingStatus, + ).toStrictEqual(RequestStatus.FETCHED); expect(bridgeController.state.bridgeState.srcTopAssets).toStrictEqual([ { address: '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984', diff --git a/app/scripts/controllers/bridge/bridge-controller.ts b/app/scripts/controllers/bridge/bridge-controller.ts index 031725530f52..4770c342587c 100644 --- a/app/scripts/controllers/bridge/bridge-controller.ts +++ b/app/scripts/controllers/bridge/bridge-controller.ts @@ -224,13 +224,35 @@ export default class BridgeController extends StaticIntervalPollingController
{ - await this.#setTopAssets(chainId, 'srcTopAssets'); - await this.#setTokens(chainId, 'srcTokens'); + this.update((state) => { + state.bridgeState.srcTokensLoadingStatus = RequestStatus.LOADING; + return state; + }); + try { + await this.#setTopAssets(chainId, 'srcTopAssets'); + await this.#setTokens(chainId, 'srcTokens'); + } finally { + this.update((state) => { + state.bridgeState.srcTokensLoadingStatus = RequestStatus.FETCHED; + return state; + }); + } }; selectDestNetwork = async (chainId: Hex) => { - await this.#setTopAssets(chainId, 'destTopAssets'); - await this.#setTokens(chainId, 'destTokens'); + this.update((state) => { + state.bridgeState.destTokensLoadingStatus = RequestStatus.LOADING; + return state; + }); + try { + await this.#setTopAssets(chainId, 'destTopAssets'); + await this.#setTokens(chainId, 'destTokens'); + } finally { + this.update((state) => { + state.bridgeState.destTokensLoadingStatus = RequestStatus.FETCHED; + return state; + }); + } }; #fetchBridgeQuotes = async ({ diff --git a/app/scripts/controllers/bridge/constants.ts b/app/scripts/controllers/bridge/constants.ts index 2d507418b5d9..4903a9ee2858 100644 --- a/app/scripts/controllers/bridge/constants.ts +++ b/app/scripts/controllers/bridge/constants.ts @@ -27,6 +27,8 @@ export const DEFAULT_BRIDGE_CONTROLLER_STATE: BridgeControllerState = { }, }, srcTokens: {}, + srcTokensLoadingStatus: undefined, + destTokensLoadingStatus: undefined, srcTopAssets: [], destTokens: {}, destTopAssets: [], diff --git a/app/scripts/controllers/bridge/types.ts b/app/scripts/controllers/bridge/types.ts index 6a28eb9d6ffd..7cdfa43cabd0 100644 --- a/app/scripts/controllers/bridge/types.ts +++ b/app/scripts/controllers/bridge/types.ts @@ -37,6 +37,8 @@ export type BridgeControllerState = { bridgeFeatureFlags: BridgeFeatureFlags; srcTokens: Record; srcTopAssets: { address: string }[]; + srcTokensLoadingStatus?: RequestStatus; + destTokensLoadingStatus?: RequestStatus; destTokens: Record; destTopAssets: { address: string }[]; quoteRequest: Partial; diff --git a/shared/constants/bridge.ts b/shared/constants/bridge.ts index 06e0d55b8195..ef7cb7f8a785 100644 --- a/shared/constants/bridge.ts +++ b/shared/constants/bridge.ts @@ -29,7 +29,7 @@ export const METABRIDGE_ETHEREUM_ADDRESS = export const BRIDGE_QUOTE_MAX_ETA_SECONDS = 60 * 60; // 1 hour export const BRIDGE_QUOTE_MAX_RETURN_DIFFERENCE_PERCENTAGE = 0.8; // if a quote returns in x times less return than the best quote, ignore it -export const BRIDGE_PREFERRED_GAS_ESTIMATE = 'medium'; +export const BRIDGE_PREFERRED_GAS_ESTIMATE = 'high'; export const BRIDGE_DEFAULT_SLIPPAGE = 0.5; export const NETWORK_TO_SHORT_NETWORK_NAME_MAP: Record< @@ -46,3 +46,4 @@ export const NETWORK_TO_SHORT_NETWORK_NAME_MAP: Record< [CHAIN_IDS.ZKSYNC_ERA]: 'ZkSync Era', [CHAIN_IDS.BASE]: 'Base', }; +export const BRIDGE_MM_FEE_RATE = 0.875; diff --git a/test/data/bridge/mock-token-data.ts b/test/data/bridge/mock-token-data.ts new file mode 100644 index 000000000000..0eafa4802ea5 --- /dev/null +++ b/test/data/bridge/mock-token-data.ts @@ -0,0 +1,102 @@ +import { CHAIN_IDS } from '@metamask/transaction-controller'; + +export const mockTokenData = { + allTokens: { + [CHAIN_IDS.MAINNET]: { + '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc': [ + { + address: '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984', + balance: 'a', + decimals: 6, + }, + { + address: '0x514910771af9ca656af840dff83e8264ecf986ca', + balance: 'e', + }, + ], + '0xec1adf982415d2ef5ec55899b9bfb8bc0f29251b': [ + { + address: '0x6b3595068778dd592e39a122f4f5a5cf09c90fe2', + balance: 'e', + }, + ], + }, + [CHAIN_IDS.LINEA_MAINNET]: { + '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc': [ + { + address: '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984', + balance: 'e', + }, + ], + '0xec1adf982415d2ef5ec55899b9bfb8bc0f29251b': [ + { + address: '0x6b3595068778dd592e39a122f4f5a5cf09c90fe2', + balance: 'e', + }, + ], + }, + }, + accountsByChainId: { + [CHAIN_IDS.MAINNET]: { + '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc': { + address: '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc', + balance: '0xa', + }, + '0xec1adf982415d2ef5ec55899b9bfb8bc0f29251b': { + address: '0xec1adf982415d2ef5ec55899b9bfb8bc0f29251b', + balance: '0xe', + }, + }, + [CHAIN_IDS.LINEA_MAINNET]: { + '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc': { + address: '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc', + balance: '0xe', + }, + '0xec1adf982415d2ef5ec55899b9bfb8bc0f29251b': { + address: '0xec1adf982415d2ef5ec55899b9bfb8bc0f29251b', + balance: '0xe', + }, + }, + }, + tokensChainsCache: { + [CHAIN_IDS.MAINNET]: { + timestamp: 111111, + data: [ + { + address: '0x514910771af9ca656af840dff83e8264ecf986ca', + symbol: 'LINK', + decimals: 18, + }, + { + address: '0xc00e94cb662c3520282e6f5717214004a7f26888', + symbol: 'COMP', + decimals: 18, + }, + ], + }, + [CHAIN_IDS.LINEA_MAINNET]: { + timestamp: 111111, + data: { + '0x514910771af9ca656af840dff83e8264ecf986ca': { + address: '0x514910771af9ca656af840dff83e8264ecf986ca', + symbol: 'LINK', + decimals: 18, + }, + '0xc00e94cb662c3520282e6f5717214004a7f26888': { + address: '0xc00e94cb662c3520282e6f5717214004a7f26888', + symbol: 'COMP', + decimals: 18, + }, + }, + }, + }, + tokenBalances: { + '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc': { + '0x5': {}, + '0x1': { + '0x514910771af9ca656af840dff83e8264ecf986ca': '0x1', + '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984': '0x738', + }, + }, + }, +}; diff --git a/test/e2e/tests/metrics/errors.spec.js b/test/e2e/tests/metrics/errors.spec.js index 7ddbe6c1117a..12a44e26fbf1 100644 --- a/test/e2e/tests/metrics/errors.spec.js +++ b/test/e2e/tests/metrics/errors.spec.js @@ -874,6 +874,8 @@ describe('Sentry errors', function () { srcTokenAmount: true, walletAddress: false, }, + destTokensLoadingStatus: false, + srcTokensLoadingStatus: false, quotesLastFetched: true, quotesLoadingStatus: true, quotesRefreshCount: true, diff --git a/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-background-state.json b/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-background-state.json index cae1a6ae8951..b00f37993b78 100644 --- a/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-background-state.json +++ b/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-background-state.json @@ -76,6 +76,8 @@ "srcTokens": {}, "srcTopAssets": {}, "destTokens": {}, + "destTokensLoadingStatus": "undefined", + "srcTokensLoadingStatus": "undefined", "destTopAssets": {}, "quoteRequest": { "srcTokenAddress": "0x0000000000000000000000000000000000000000", diff --git a/test/jest/mock-store.js b/test/jest/mock-store.js index 84d18e1a3803..8281eae43390 100644 --- a/test/jest/mock-store.js +++ b/test/jest/mock-store.js @@ -6,6 +6,7 @@ import { mockNetworkState } from '../stub/networks'; import { DEFAULT_BRIDGE_CONTROLLER_STATE } from '../../app/scripts/controllers/bridge/constants'; import { DEFAULT_BRIDGE_STATUS_CONTROLLER_STATE } from '../../app/scripts/controllers/bridge-status/constants'; import { BRIDGE_PREFERRED_GAS_ESTIMATE } from '../../shared/constants/bridge'; +import { mockTokenData } from '../data/bridge/mock-token-data'; export const createGetSmartTransactionFeesApiResponse = () => { return { @@ -720,6 +721,11 @@ export const createBridgeMockStore = ( const swapsStore = createSwapsMockStore(); return { ...swapsStore, + // For initial state of dest asset picker + swaps: { + ...swapsStore.swaps, + topAssets: [], + }, bridge: { toChainId: null, sortOrder: 'cost_ascending', @@ -750,107 +756,10 @@ export const createBridgeMockStore = ( }, }, }, - allTokens: { - [CHAIN_IDS.MAINNET]: { - '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc': [ - { - address: '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984', - balance: 'a', - decimals: 6, - }, - { - address: '0x514910771af9ca656af840dff83e8264ecf986ca', - balance: 'e', - }, - ], - '0xec1adf982415d2ef5ec55899b9bfb8bc0f29251b': [ - { - address: '0x6b3595068778dd592e39a122f4f5a5cf09c90fe2', - balance: 'e', - }, - ], - }, - [CHAIN_IDS.LINEA_MAINNET]: { - '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc': [ - { - address: '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984', - balance: 'e', - }, - ], - '0xec1adf982415d2ef5ec55899b9bfb8bc0f29251b': [ - { - address: '0x6b3595068778dd592e39a122f4f5a5cf09c90fe2', - balance: 'e', - }, - ], - }, - }, - accountsByChainId: { - [CHAIN_IDS.MAINNET]: { - '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc': { - address: '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc', - balance: '0xa', - }, - '0xec1adf982415d2ef5ec55899b9bfb8bc0f29251b': { - address: '0xec1adf982415d2ef5ec55899b9bfb8bc0f29251b', - balance: '0xe', - }, - }, - [CHAIN_IDS.LINEA_MAINNET]: { - '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc': { - address: '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc', - balance: '0xe', - }, - '0xec1adf982415d2ef5ec55899b9bfb8bc0f29251b': { - address: '0xec1adf982415d2ef5ec55899b9bfb8bc0f29251b', - balance: '0xe', - }, - }, - }, - tokensChainsCache: { - [CHAIN_IDS.MAINNET]: { - timestamp: 111111, - data: [ - { - address: '0x514910771af9ca656af840dff83e8264ecf986ca', - symbol: 'LINK', - decimals: 18, - }, - { - address: '0xc00e94cb662c3520282e6f5717214004a7f26888', - symbol: 'COMP', - decimals: 18, - }, - ], - }, - [CHAIN_IDS.LINEA_MAINNET]: { - timestamp: 111111, - data: { - '0x514910771af9ca656af840dff83e8264ecf986ca': { - address: '0x514910771af9ca656af840dff83e8264ecf986ca', - symbol: 'LINK', - decimals: 18, - }, - '0xc00e94cb662c3520282e6f5717214004a7f26888': { - address: '0xc00e94cb662c3520282e6f5717214004a7f26888', - symbol: 'COMP', - decimals: 18, - }, - }, - }, - }, - tokenBalances: { - '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc': { - '0x5': {}, - '0x1': { - '0x514910771af9ca656af840dff83e8264ecf986ca': '0x1', - '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984': '0x738', - }, - }, - }, + ...mockTokenData, ...metamaskStateOverrides, bridgeState: { - ...(swapsStore.metamask.bridgeState ?? {}), + ...DEFAULT_BRIDGE_CONTROLLER_STATE, bridgeFeatureFlags: { ...featureFlagOverrides, extensionConfig: { @@ -859,8 +768,6 @@ export const createBridgeMockStore = ( ...featureFlagOverrides.extensionConfig, }, }, - quotes: DEFAULT_BRIDGE_CONTROLLER_STATE.quotes, - quoteRequest: DEFAULT_BRIDGE_CONTROLLER_STATE.quoteRequest, ...bridgeStateOverrides, }, bridgeStatusState: { diff --git a/ui/components/app/wallet-overview/coin-buttons.tsx b/ui/components/app/wallet-overview/coin-buttons.tsx index 6509b4f415c0..2819e24cad0b 100644 --- a/ui/components/app/wallet-overview/coin-buttons.tsx +++ b/ui/components/app/wallet-overview/coin-buttons.tsx @@ -463,10 +463,11 @@ const CoinButtons = ({ }); }, [chainId, defaultSwapsToken]); - const handleBridgeOnClick = useCallback(() => { + const handleBridgeOnClick = useCallback(async () => { if (!defaultSwapsToken) { return; } + await setCorrectChain(); openBridgeExperience( 'Home', defaultSwapsToken, diff --git a/ui/components/app/wallet-overview/eth-overview.test.js b/ui/components/app/wallet-overview/eth-overview.test.js index 104764251cf8..254e1eb9d1d4 100644 --- a/ui/components/app/wallet-overview/eth-overview.test.js +++ b/ui/components/app/wallet-overview/eth-overview.test.js @@ -253,7 +253,25 @@ describe('EthOverview', () => { }); it('should open the Bridge URI when clicking on Bridge button on supported network', async () => { - const { queryByTestId } = renderWithProvider(, store); + const mockedStore = configureMockStore([thunk])({ + ...store, + metamask: { + ...mockStore.metamask, + ...mockNetworkState({ chainId: '0xa86a' }), + useExternalServices: true, + bridgeState: { + bridgeFeatureFlags: { + extensionConfig: { + support: false, + }, + }, + }, + }, + }); + const { queryByTestId } = renderWithProvider( + , + mockedStore, + ); const bridgeButton = queryByTestId(ETH_OVERVIEW_BRIDGE); @@ -261,15 +279,15 @@ describe('EthOverview', () => { expect(bridgeButton).not.toBeDisabled(); fireEvent.click(bridgeButton); - expect(openTabSpy).toHaveBeenCalledTimes(1); - await waitFor(() => + await waitFor(() => { + expect(openTabSpy).toHaveBeenCalledTimes(1); expect(openTabSpy).toHaveBeenCalledWith({ url: expect.stringContaining( '/bridge?metamaskEntry=ext_bridge_button', ), - }), - ); + }); + }); }); it('should open the MMI PD Swaps URI when clicking on Swap button with a Custody account', async () => { diff --git a/ui/ducks/bridge/actions.ts b/ui/ducks/bridge/actions.ts index 766689cb8cda..0bcd3701853a 100644 --- a/ui/ducks/bridge/actions.ts +++ b/ui/ducks/bridge/actions.ts @@ -14,6 +14,7 @@ import { MetaMaskReduxDispatch } from '../../store/store'; import { bridgeSlice, setDestTokenExchangeRates, + setDestTokenUsdExchangeRates, setSrcTokenExchangeRates, } from './bridge'; @@ -25,6 +26,7 @@ const { resetInputFields, setSortOrder, setSelectedQuote, + setSlippage, } = bridgeSlice.actions; export { @@ -34,9 +36,11 @@ export { setFromToken, setFromTokenInputValue, setDestTokenExchangeRates, + setDestTokenUsdExchangeRates, setSrcTokenExchangeRates, setSortOrder, setSelectedQuote, + setSlippage, }; const callBridgeControllerMethod = ( diff --git a/ui/ducks/bridge/bridge.test.ts b/ui/ducks/bridge/bridge.test.ts index 7b00c95d09a4..1e1151df6734 100644 --- a/ui/ducks/bridge/bridge.test.ts +++ b/ui/ducks/bridge/bridge.test.ts @@ -11,6 +11,7 @@ import { // eslint-disable-next-line import/no-restricted-paths } from '../../../app/scripts/controllers/bridge/types'; import * as util from '../../helpers/utils/util'; +import { BRIDGE_DEFAULT_SLIPPAGE } from '../../../shared/constants/bridge'; import bridgeReducer from './bridge'; import { setBridgeFeatureFlags, @@ -24,6 +25,7 @@ import { updateQuoteRequestParams, resetBridgeState, setDestTokenExchangeRates, + setSlippage, } from './actions'; const middleware = [thunk]; @@ -36,6 +38,21 @@ describe('Ducks - Bridge', () => { store.clearActions(); }); + describe('setSlippage', () => { + it('calls the "bridge/setSlippage" action', () => { + const state = store.getState().bridge; + const actionPayload = 0.1; + + store.dispatch(setSlippage(actionPayload as never) as never); + + // Check redux state + const actions = store.getActions(); + expect(actions[0].type).toStrictEqual('bridge/setSlippage'); + const newState = bridgeReducer(state, actions[0]); + expect(newState.slippage).toStrictEqual(actionPayload); + }); + }); + describe('setToChainId', () => { it('calls the "bridge/setToChainId" action', () => { const state = store.getState().bridge; @@ -149,10 +166,12 @@ describe('Ducks - Bridge', () => { toChainId: null, fromToken: null, toToken: null, + slippage: BRIDGE_DEFAULT_SLIPPAGE, fromTokenInputValue: null, sortOrder: 'cost_ascending', toTokenExchangeRate: null, fromTokenExchangeRate: null, + toTokenUsdExchangeRate: null, }); }); }); @@ -213,13 +232,16 @@ describe('Ducks - Bridge', () => { fromTokenExchangeRate: null, fromTokenInputValue: null, selectedQuote: null, + slippage: BRIDGE_DEFAULT_SLIPPAGE, sortOrder: 'cost_ascending', toChainId: null, toToken: null, toTokenExchangeRate: null, + toTokenUsdExchangeRate: null, }); }); }); + describe('setDestTokenExchangeRates', () => { it('fetches token prices and updates dest exchange rates in state, native dest token', async () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/ui/ducks/bridge/bridge.ts b/ui/ducks/bridge/bridge.ts index 7abdb8c751e8..9835f484630a 100644 --- a/ui/ducks/bridge/bridge.ts +++ b/ui/ducks/bridge/bridge.ts @@ -1,24 +1,26 @@ import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'; import { Hex } from '@metamask/utils'; import { swapsSlice } from '../swaps/swaps'; -import { SwapsTokenObject } from '../../../shared/constants/swaps'; -import { SwapsEthToken } from '../../selectors'; import { + BridgeToken, QuoteMetadata, QuoteResponse, SortOrder, } from '../../pages/bridge/types'; +import { BRIDGE_DEFAULT_SLIPPAGE } from '../../../shared/constants/bridge'; import { getTokenExchangeRate } from './utils'; export type BridgeState = { toChainId: Hex | null; - fromToken: SwapsTokenObject | SwapsEthToken | null; - toToken: SwapsTokenObject | SwapsEthToken | null; + fromToken: BridgeToken; + toToken: BridgeToken; fromTokenInputValue: string | null; fromTokenExchangeRate: number | null; // Exchange rate from selected token to the default currency (can be fiat or crypto) toTokenExchangeRate: number | null; // Exchange rate from the selected token to the default currency (can be fiat or crypto) + toTokenUsdExchangeRate: number | null; // Exchange rate from the selected token to the USD. This is needed for metrics sortOrder: SortOrder; selectedQuote: (QuoteResponse & QuoteMetadata) | null; // Alternate quote selected by user. When quotes refresh, the best match will be activated. + slippage: number; }; const initialState: BridgeState = { @@ -28,8 +30,10 @@ const initialState: BridgeState = { fromTokenInputValue: null, fromTokenExchangeRate: null, toTokenExchangeRate: null, + toTokenUsdExchangeRate: null, sortOrder: SortOrder.COST_ASC, selectedQuote: null, + slippage: BRIDGE_DEFAULT_SLIPPAGE, }; export const setSrcTokenExchangeRates = createAsyncThunk( @@ -42,6 +46,11 @@ export const setDestTokenExchangeRates = createAsyncThunk( getTokenExchangeRate, ); +export const setDestTokenUsdExchangeRates = createAsyncThunk( + 'bridge/setDestTokenUsdExchangeRates', + getTokenExchangeRate, +); + const bridgeSlice = createSlice({ name: 'bridge', initialState: { ...initialState }, @@ -68,17 +77,26 @@ const bridgeSlice = createSlice({ setSelectedQuote: (state, action) => { state.selectedQuote = action.payload; }, + setSlippage: (state, action) => { + state.slippage = action.payload; + }, }, extraReducers: (builder) => { builder.addCase(setDestTokenExchangeRates.pending, (state) => { state.toTokenExchangeRate = null; }); + builder.addCase(setDestTokenUsdExchangeRates.pending, (state) => { + state.toTokenUsdExchangeRate = null; + }); builder.addCase(setSrcTokenExchangeRates.pending, (state) => { state.fromTokenExchangeRate = null; }); builder.addCase(setDestTokenExchangeRates.fulfilled, (state, action) => { state.toTokenExchangeRate = action.payload ?? null; }); + builder.addCase(setDestTokenUsdExchangeRates.fulfilled, (state, action) => { + state.toTokenUsdExchangeRate = action.payload ?? null; + }); builder.addCase(setSrcTokenExchangeRates.fulfilled, (state, action) => { state.fromTokenExchangeRate = action.payload ?? null; }); diff --git a/ui/ducks/bridge/selectors.test.ts b/ui/ducks/bridge/selectors.test.ts index 344fab115311..47abbeb1853c 100644 --- a/ui/ducks/bridge/selectors.test.ts +++ b/ui/ducks/bridge/selectors.test.ts @@ -6,10 +6,7 @@ import { CHAIN_IDS, FEATURED_RPCS, } from '../../../shared/constants/network'; -import { - ALLOWED_BRIDGE_CHAIN_IDS, - BRIDGE_QUOTE_MAX_ETA_SECONDS, -} from '../../../shared/constants/bridge'; +import { ALLOWED_BRIDGE_CHAIN_IDS } from '../../../shared/constants/bridge'; import { mockNetworkState } from '../../../test/stub/networks'; import mockErc20Erc20Quotes from '../../../test/data/bridge/mock-quotes-erc20-erc20.json'; import mockBridgeQuotesNativeErc20 from '../../../test/data/bridge/mock-quotes-native-erc20.json'; @@ -22,13 +19,11 @@ import { getFromChains, getFromToken, getFromTokens, - getFromTopAssets, getIsBridgeTx, getToChain, getToChains, getToToken, getToTokens, - getToTopAssets, getValidationErrors, } from './selectors'; @@ -204,7 +199,7 @@ describe('Bridge selectors', () => { }); describe('getToChains', () => { - it('excludes selected providerConfig and disabled chains from options', () => { + it('includes selected providerConfig and disabled chains from options', () => { const state = createBridgeMockStore({ featureFlagOverrides: { extensionConfig: { @@ -216,6 +211,7 @@ describe('Bridge selectors', () => { }, [CHAIN_IDS.OPTIMISM]: { isActiveSrc: false, isActiveDest: true }, [CHAIN_IDS.POLYGON]: { isActiveSrc: false, isActiveDest: true }, + [CHAIN_IDS.BSC]: { isActiveSrc: false, isActiveDest: true }, }, }, }, @@ -225,14 +221,20 @@ describe('Bridge selectors', () => { }); const result = getToChains(state as never); - expect(result).toHaveLength(3); + expect(result).toHaveLength(5); expect(result[0]).toStrictEqual( - expect.objectContaining({ chainId: CHAIN_IDS.ARBITRUM }), + expect.objectContaining({ chainId: CHAIN_IDS.LINEA_MAINNET }), ); expect(result[1]).toStrictEqual( - expect.objectContaining({ chainId: CHAIN_IDS.OPTIMISM }), + expect.objectContaining({ chainId: CHAIN_IDS.ARBITRUM }), ); expect(result[2]).toStrictEqual( + expect.objectContaining({ chainId: CHAIN_IDS.BSC }), + ); + expect(result[3]).toStrictEqual( + expect.objectContaining({ chainId: CHAIN_IDS.OPTIMISM }), + ); + expect(result[4]).toStrictEqual( expect.objectContaining({ chainId: CHAIN_IDS.POLYGON }), ); }); @@ -383,12 +385,15 @@ describe('Bridge selectors', () => { expect(result).toStrictEqual({ address: '0x0000000000000000000000000000000000000000', - balance: '0', + chainId: '0x1', decimals: 18, iconUrl: './images/eth_logo.svg', + image: './images/eth_logo.svg', name: 'Ether', - string: '0', symbol: 'ETH', + type: 'NATIVE', + balance: '0', + string: '0', }); }); @@ -400,12 +405,15 @@ describe('Bridge selectors', () => { expect(result).toStrictEqual({ address: '0x0000000000000000000000000000000000000000', - balance: '0', + chainId: '0x1', decimals: 18, iconUrl: './images/eth_logo.svg', + image: './images/eth_logo.svg', name: 'Ether', - string: '0', symbol: 'ETH', + type: 'NATIVE', + balance: '0', + string: '0', }); }); }); @@ -463,23 +471,14 @@ describe('Bridge selectors', () => { const result = getToTokens(state as never); expect(result).toStrictEqual({ - '0x00': { address: '0x00', symbol: 'TEST' }, - }); - }); - - it('returns empty dest tokens from controller state when toChainId is undefined', () => { - const state = createBridgeMockStore({ - bridgeStateOverrides: { - destTokens: { '0x00': { address: '0x00', symbol: 'TEST' } }, + isLoading: false, + toTokens: { + '0x00': { address: '0x00', symbol: 'TEST' }, }, + toTopAssets: [], }); - const result = getToTokens(state as never); - - expect(result).toStrictEqual({}); }); - }); - describe('getToTopAssets', () => { it('returns dest top assets from controller state when toChainId is defined', () => { const state = createBridgeMockStore({ bridgeSliceOverrides: { toChainId: '0x1' }, @@ -488,21 +487,11 @@ describe('Bridge selectors', () => { destTopAssets: [{ address: '0x00', symbol: 'TEST' }], }, }); - const result = getToTopAssets(state as never); - - expect(result).toStrictEqual([{ address: '0x00', symbol: 'TEST' }]); - }); - - it('returns empty dest top assets from controller state when toChainId is undefined', () => { - const state = createBridgeMockStore({ - bridgeStateOverrides: { - destTokens: { '0x00': { address: '0x00', symbol: 'TEST' } }, - destTopAssets: [{ address: '0x00', symbol: 'TEST' }], - }, - }); - const result = getToTopAssets(state as never); + const result = getToTokens(state as never); - expect(result).toStrictEqual([]); + expect(result.toTopAssets).toStrictEqual([ + { address: '0x00', symbol: 'TEST' }, + ]); }); }); @@ -512,17 +501,20 @@ describe('Bridge selectors', () => { bridgeSliceOverrides: { toChainId: '0x1' }, bridgeStateOverrides: { srcTokens: { '0x00': { address: '0x00', symbol: 'TEST' } }, + srcTopAssets: [{ address: '0x01', symbol: 'SYMB' }], }, }); const result = getFromTokens(state as never); expect(result).toStrictEqual({ - '0x00': { address: '0x00', symbol: 'TEST' }, + fromTokens: { + '0x00': { address: '0x00', symbol: 'TEST' }, + }, + fromTopAssets: [{ address: '0x01', symbol: 'SYMB' }], + isLoading: false, }); }); - }); - describe('getFromTopAssets', () => { it('returns src top assets from controller state', () => { const state = createBridgeMockStore({ bridgeSliceOverrides: { toChainId: '0x1' }, @@ -531,9 +523,11 @@ describe('Bridge selectors', () => { srcTopAssets: [{ address: '0x00', symbol: 'TEST' }], }, }); - const result = getFromTopAssets(state as never); + const result = getFromTokens(state as never); - expect(result).toStrictEqual([{ address: '0x00', symbol: 'TEST' }]); + expect(result.fromTopAssets).toStrictEqual([ + { address: '0x00', symbol: 'TEST' }, + ]); }); }); @@ -844,7 +838,7 @@ describe('Bridge selectors', () => { isLoading: false, isQuoteGoingToRefresh: false, quotesLastFetchedMs: undefined, - quotesRefreshCount: undefined, + quotesRefreshCount: 0, recommendedQuote: undefined, quotesInitialLoadTimeMs: undefined, sortedQuotes: [], @@ -923,136 +917,6 @@ describe('Bridge selectors', () => { mockBridgeQuotesNativeErc20[0]?.quote.requestId, ); }); - - it('should recommend 2nd cheapest quote if ETA exceeds 1 hour', () => { - const state = createBridgeMockStore({ - bridgeSliceOverrides: { sortOrder: SortOrder.COST_ASC }, - bridgeStateOverrides: { - quotes: [ - mockBridgeQuotesNativeErc20[1], - { - ...mockBridgeQuotesNativeErc20[0], - estimatedProcessingTimeInSeconds: - BRIDGE_QUOTE_MAX_ETA_SECONDS + 1, - quote: { - ...mockBridgeQuotesNativeErc20[0].quote, - requestId: 'cheapestQuoteWithLongETA', - }, - }, - ], - }, - }); - - const { activeQuote, recommendedQuote, sortedQuotes } = getBridgeQuotes( - state as never, - ); - - expect(activeQuote?.quote.requestId).toStrictEqual( - '4277a368-40d7-4e82-aa67-74f29dc5f98a', - ); - expect(recommendedQuote?.quote.requestId).toStrictEqual( - '4277a368-40d7-4e82-aa67-74f29dc5f98a', - ); - expect(sortedQuotes).toHaveLength(2); - expect(sortedQuotes[0]?.quote.requestId).toStrictEqual( - '4277a368-40d7-4e82-aa67-74f29dc5f98a', - ); - expect(sortedQuotes[1]?.quote.requestId).toStrictEqual( - 'cheapestQuoteWithLongETA', - ); - }); - - it('should recommend 2nd fastest quote if adjustedReturn is less than 80% of cheapest quote', () => { - const state = createBridgeMockStore({ - featureFlagOverrides: { - extensionConfig: { - chains: { - '0xa': { isActiveSrc: true, isActiveDest: false }, - '0x89': { isActiveSrc: false, isActiveDest: true }, - }, - }, - }, - bridgeSliceOverrides: { - toChainId: '0x89', - fromToken: { address: zeroAddress(), symbol: 'ETH' }, - toToken: { address: zeroAddress(), symbol: 'TEST' }, - fromTokenExchangeRate: 2524.25, - sortOrder: SortOrder.ETA_ASC, - toTokenExchangeRate: 0.998781, - }, - bridgeStateOverrides: { - quotes: [ - ...mockBridgeQuotesNativeErc20, - { - ...mockBridgeQuotesNativeErc20[0], - estimatedProcessingTimeInSeconds: 1, - quote: { - ...mockBridgeQuotesNativeErc20[0].quote, - requestId: 'fastestQuote', - destTokenAmount: '1', - }, - }, - ], - }, - metamaskStateOverrides: { - currencyRates: { - ETH: { - conversionRate: 2524.25, - }, - POL: { - conversionRate: 0.354073, - usdConversionRate: 1, - }, - }, - marketData: {}, - ...mockNetworkState( - { chainId: CHAIN_IDS.MAINNET }, - { chainId: CHAIN_IDS.LINEA_MAINNET }, - { chainId: CHAIN_IDS.POLYGON }, - { chainId: CHAIN_IDS.OPTIMISM }, - ), - }, - }); - - const { activeQuote, recommendedQuote, sortedQuotes } = getBridgeQuotes( - state as never, - ); - const { - sentAmount, - totalNetworkFee, - toTokenAmount, - adjustedReturn, - cost, - } = activeQuote ?? {}; - - expect(activeQuote?.quote.requestId).toStrictEqual( - '4277a368-40d7-4e82-aa67-74f29dc5f98a', - ); - expect(recommendedQuote?.quote.requestId).toStrictEqual( - '4277a368-40d7-4e82-aa67-74f29dc5f98a', - ); - expect(sentAmount?.valueInCurrency?.toString()).toStrictEqual('25.2425'); - expect(totalNetworkFee?.valueInCurrency?.toString()).toStrictEqual( - '2.52459306428938562', - ); - expect(toTokenAmount?.valueInCurrency?.toString()).toStrictEqual( - '24.226654664163', - ); - expect(adjustedReturn?.valueInCurrency?.toString()).toStrictEqual( - '21.70206159987361438', - ); - expect(cost?.valueInCurrency?.toString()).toStrictEqual( - '3.54043840012638562', - ); - expect(sortedQuotes).toHaveLength(3); - expect(sortedQuotes[0]?.quote.requestId).toStrictEqual('fastestQuote'); - expect(sortedQuotes[1]?.quote.requestId).toStrictEqual( - '4277a368-40d7-4e82-aa67-74f29dc5f98a', - ); - expect(sortedQuotes[2]?.quote.requestId).toStrictEqual( - '381c23bc-e3e4-48fe-bc53-257471e388ad', - ); - }); }); describe('getValidationErrors', () => { @@ -1089,12 +953,14 @@ describe('Bridge selectors', () => { const state = createBridgeMockStore({ bridgeSliceOverrides: { toChainId: '0x1', - fromTokenInputValue: '0.001', + fromToken: { decimals: 6, address: zeroAddress() }, + fromChain: { chainId: CHAIN_IDS.MAINNET }, }, bridgeStateOverrides: { srcTokens: { '0x00': { address: '0x00', symbol: 'TEST' } }, srcTopAssets: [{ address: '0x00', symbol: 'TEST' }], quotesLastFetched: Date.now(), + quoteRequest: { srcTokenAmount: '1000' }, }, }); const result = getValidationErrors(state as never); @@ -1138,12 +1004,14 @@ describe('Bridge selectors', () => { const state = createBridgeMockStore({ bridgeSliceOverrides: { toChainId: '0x1', - fromTokenInputValue: '0.001', + fromToken: { decimals: 6, address: zeroAddress() }, + fromChain: { chainId: CHAIN_IDS.MAINNET }, }, bridgeStateOverrides: { srcTokens: { '0x00': { address: '0x00', symbol: 'TEST' } }, srcTopAssets: [{ address: '0x00', symbol: 'TEST' }], quotesLastFetched: Date.now(), + quoteRequest: { srcTokenAmount: '1000' }, }, }); const result = getValidationErrors(state as never); @@ -1325,6 +1193,7 @@ describe('Bridge selectors', () => { toChainId: '0x89', fromToken: { address: zeroAddress(), symbol: 'ETH' }, toToken: { address: zeroAddress(), symbol: 'TEST' }, + fromTokenInputValue: '1', fromTokenExchangeRate: 2524.25, toTokenExchangeRate: 0.798781, }, diff --git a/ui/ducks/bridge/selectors.ts b/ui/ducks/bridge/selectors.ts index 2fd3d9586deb..c948639a6756 100644 --- a/ui/ducks/bridge/selectors.ts +++ b/ui/ducks/bridge/selectors.ts @@ -1,4 +1,5 @@ import { + AddNetworkFields, NetworkConfiguration, NetworkState, } from '@metamask/network-controller'; @@ -6,19 +7,17 @@ import { orderBy, uniqBy } from 'lodash'; import { createSelector } from 'reselect'; import { GasFeeEstimates } from '@metamask/gas-fee-controller'; import { BigNumber } from 'bignumber.js'; +import { calcTokenAmount } from '@metamask/notification-services-controller/push-services'; import { getIsBridgeEnabled, getMarketData, - getSwapsDefaultToken, getUSDConversionRate, getUSDConversionRateByChainId, selectConversionRateByChainId, - SwapsEthToken, } from '../../selectors/selectors'; import { ALLOWED_BRIDGE_CHAIN_IDS, BRIDGE_PREFERRED_GAS_ESTIMATE, - BRIDGE_QUOTE_MAX_ETA_SECONDS, BRIDGE_QUOTE_MAX_RETURN_DIFFERENCE_PERCENTAGE, } from '../../../shared/constants/bridge'; import { @@ -28,17 +27,18 @@ import { // eslint-disable-next-line import/no-restricted-paths } from '../../../app/scripts/controllers/bridge/types'; import { createDeepEqualSelector } from '../../../shared/modules/selectors/util'; +import { SWAPS_CHAINID_DEFAULT_TOKEN_MAP } from '../../../shared/constants/swaps'; import { getProviderConfig, getNetworkConfigurationsByChainId, } from '../../../shared/modules/selectors/networks'; -import { SwapsTokenObject } from '../../../shared/constants/swaps'; import { getConversionRate, getGasFeeEstimates } from '../metamask/metamask'; // TODO: Remove restricted import // eslint-disable-next-line import/no-restricted-paths import { RequestStatus } from '../../../app/scripts/controllers/bridge/constants'; import { L1GasFees, + BridgeToken, QuoteMetadata, QuoteResponse, SortOrder, @@ -53,8 +53,12 @@ import { calcTotalGasFee, isNativeAddress, } from '../../pages/bridge/utils/quote'; +import { AssetType } from '../../../shared/constants/transaction'; import { decGWEIToHexWEI } from '../../../shared/modules/conversion.utils'; -import { calcTokenAmount } from '../../../shared/lib/transactions-controller-utils'; +import { + CHAIN_ID_TOKEN_IMAGE_MAP, + FEATURED_RPCS, +} from '../../../shared/constants/network'; import { exchangeRatesFromNativeAndCurrencyRates, exchangeRateFromMarketData, @@ -115,18 +119,14 @@ export const getFromChain = createDeepEqualSelector( ); export const getToChains = createDeepEqualSelector( - getFromChain, getAllBridgeableNetworks, (state: BridgeAppState) => state.metamask.bridgeState?.bridgeFeatureFlags, ( - fromChain, allBridgeableNetworks, bridgeFeatureFlags, - ): NetworkConfiguration[] => - allBridgeableNetworks.filter( + ): (AddNetworkFields | NetworkConfiguration)[] => + uniqBy([...allBridgeableNetworks, ...FEATURED_RPCS], 'chainId').filter( ({ chainId }) => - fromChain?.chainId && - chainId !== fromChain.chainId && bridgeFeatureFlags[BridgeFeatureFlagsKey.EXTENSION_CONFIG].chains[ chainId ]?.isActiveDest, @@ -136,43 +136,74 @@ export const getToChains = createDeepEqualSelector( export const getToChain = createDeepEqualSelector( getToChains, (state: BridgeAppState) => state.bridge.toChainId, - (toChains, toChainId): NetworkConfiguration | undefined => + (toChains, toChainId): NetworkConfiguration | AddNetworkFields | undefined => toChains.find(({ chainId }) => chainId === toChainId), ); -export const getFromTokens = (state: BridgeAppState) => { - return state.metamask.bridgeState.srcTokens ?? {}; -}; - -export const getFromTopAssets = (state: BridgeAppState) => { - return state.metamask.bridgeState.srcTopAssets ?? []; -}; - -export const getToTopAssets = (state: BridgeAppState) => { - return state.bridge.toChainId ? state.metamask.bridgeState.destTopAssets : []; -}; +export const getFromTokens = createDeepEqualSelector( + (state: BridgeAppState) => state.metamask.bridgeState.srcTokens, + (state: BridgeAppState) => state.metamask.bridgeState.srcTopAssets, + (state: BridgeAppState) => + state.metamask.bridgeState.srcTokensLoadingStatus === RequestStatus.LOADING, + (fromTokens, fromTopAssets, isLoading) => { + return { + isLoading, + fromTokens: fromTokens ?? {}, + fromTopAssets: fromTopAssets ?? [], + }; + }, +); -export const getToTokens = (state: BridgeAppState) => { - return state.bridge.toChainId ? state.metamask.bridgeState.destTokens : {}; -}; +export const getToTokens = createDeepEqualSelector( + (state: BridgeAppState) => state.metamask.bridgeState.destTokens, + (state: BridgeAppState) => state.metamask.bridgeState.destTopAssets, + (state: BridgeAppState) => + state.metamask.bridgeState.destTokensLoadingStatus === + RequestStatus.LOADING, + (toTokens, toTopAssets, isLoading) => { + return { + isLoading, + toTokens: toTokens ?? {}, + toTopAssets: toTopAssets ?? [], + }; + }, +); -export const getFromToken = ( - state: BridgeAppState, -): SwapsTokenObject | SwapsEthToken | null => { - return state.bridge.fromToken?.address - ? state.bridge.fromToken - : getSwapsDefaultToken(state); -}; +export const getFromToken = createSelector( + (state: BridgeAppState) => state.bridge.fromToken, + getFromChain, + (fromToken, fromChain): BridgeToken => { + if (!fromChain?.chainId) { + return null; + } + if (fromToken?.address) { + return fromToken; + } + return { + ...SWAPS_CHAINID_DEFAULT_TOKEN_MAP[ + fromChain.chainId as keyof typeof SWAPS_CHAINID_DEFAULT_TOKEN_MAP + ], + chainId: fromChain.chainId, + image: + CHAIN_ID_TOKEN_IMAGE_MAP[ + fromChain.chainId as keyof typeof CHAIN_ID_TOKEN_IMAGE_MAP + ], + balance: '0', + string: '0', + type: AssetType.native, + }; + }, +); -export const getToToken = ( - state: BridgeAppState, -): SwapsTokenObject | SwapsEthToken | null => { +export const getToToken = (state: BridgeAppState): BridgeToken => { return state.bridge.toToken; }; export const getFromAmount = (state: BridgeAppState): string | null => state.bridge.fromTokenInputValue; +export const getSlippage = (state: BridgeAppState) => state.bridge.slippage; + export const getQuoteRequest = (state: BridgeAppState) => { const { quoteRequest } = state.metamask.bridgeState; return quoteRequest; @@ -243,11 +274,31 @@ export const getToTokenConversionRate = createDeepEqualSelector( getToChain, getMarketData, getToToken, + getNetworkConfigurationsByChainId, (state) => ({ state, toTokenExchangeRate: state.bridge.toTokenExchangeRate, + toTokenUsdExchangeRate: state.bridge.toTokenUsdExchangeRate, }), - (toChain, marketData, toToken, { state, toTokenExchangeRate }) => { + ( + toChain, + marketData, + toToken, + allNetworksByChainId, + { state, toTokenExchangeRate, toTokenUsdExchangeRate }, + ) => { + // When the toChain is not imported, the exchange rate to native asset is not available + // The rate in the bridge state is used instead + if ( + toChain?.chainId && + !allNetworksByChainId[toChain.chainId] && + toTokenExchangeRate + ) { + return { + valueInCurrency: toTokenExchangeRate, + usd: toTokenUsdExchangeRate, + }; + } if (toChain?.chainId && toToken && marketData) { const { chainId } = toChain; @@ -269,8 +320,8 @@ export const getToTokenConversionRate = createDeepEqualSelector( }, ); -const _getQuotesWithMetadata = createDeepEqualSelector( - (state) => state.metamask.bridgeState.quotes, +const _getQuotesWithMetadata = createSelector( + (state: BridgeAppState) => state.metamask.bridgeState.quotes, getToTokenConversionRate, getFromTokenConversionRate, getConversionRate, @@ -328,7 +379,7 @@ const _getQuotesWithMetadata = createDeepEqualSelector( }, ); -const _getSortedQuotesWithMetadata = createDeepEqualSelector( +const _getSortedQuotesWithMetadata = createSelector( _getQuotesWithMetadata, getBridgeSortOrder, (quotesWithMetadata, sortOrder) => { @@ -339,56 +390,16 @@ const _getSortedQuotesWithMetadata = createDeepEqualSelector( (quote) => quote.estimatedProcessingTimeInSeconds, 'asc', ); - case SortOrder.COST_ASC: default: return orderBy( quotesWithMetadata, - ({ cost }) => cost.valueInCurrency, + ({ cost }) => cost.valueInCurrency?.toNumber(), 'asc', ); } }, ); -const _getRecommendedQuote = createDeepEqualSelector( - _getSortedQuotesWithMetadata, - getBridgeSortOrder, - (sortedQuotesWithMetadata, sortOrder) => { - if (!sortedQuotesWithMetadata.length) { - return undefined; - } - - const bestReturnValue = BigNumber.max( - sortedQuotesWithMetadata.map( - ({ adjustedReturn }) => adjustedReturn.valueInCurrency ?? 0, - ), - ); - - const isFastestQuoteValueReasonable = ( - adjustedReturnInCurrency: BigNumber | null, - ) => - adjustedReturnInCurrency - ? adjustedReturnInCurrency - .div(bestReturnValue) - .gte(BRIDGE_QUOTE_MAX_RETURN_DIFFERENCE_PERCENTAGE) - : true; - - const isBestPricedQuoteETAReasonable = ( - estimatedProcessingTimeInSeconds: number, - ) => estimatedProcessingTimeInSeconds < BRIDGE_QUOTE_MAX_ETA_SECONDS; - - return ( - sortedQuotesWithMetadata.find((quote) => { - return sortOrder === SortOrder.ETA_ASC - ? isFastestQuoteValueReasonable(quote.adjustedReturn.valueInCurrency) - : isBestPricedQuoteETAReasonable( - quote.estimatedProcessingTimeInSeconds, - ); - }) ?? sortedQuotesWithMetadata[0] - ); - }, -); - // Generates a pseudo-unique string that identifies each quote // by aggregator, bridge, steps and value const _getQuoteIdentifier = ({ quote }: QuoteResponse & L1GasFees) => @@ -411,7 +422,6 @@ const _getSelectedQuote = createSelector( export const getBridgeQuotes = createSelector( _getSortedQuotesWithMetadata, - _getRecommendedQuote, _getSelectedQuote, (state) => state.metamask.bridgeState.quotesLastFetched, (state) => @@ -423,7 +433,6 @@ export const getBridgeQuotes = createSelector( getQuoteRequest, ( sortedQuotesWithMetadata, - recommendedQuote, selectedQuote, quotesLastFetchedMs, isLoading, @@ -434,8 +443,8 @@ export const getBridgeQuotes = createSelector( { insufficientBal }, ) => ({ sortedQuotes: sortedQuotesWithMetadata, - recommendedQuote, - activeQuote: selectedQuote ?? recommendedQuote, + recommendedQuote: sortedQuotesWithMetadata[0], + activeQuote: selectedQuote ?? sortedQuotesWithMetadata[0], quotesLastFetchedMs, isLoading, quoteFetchError, @@ -491,14 +500,14 @@ export const getFromAmountInCurrency = createSelector( export const getValidationErrors = createDeepEqualSelector( getBridgeQuotes, - getFromAmount, _getValidatedSrcAmount, getFromToken, + getFromAmount, ( { activeQuote, quotesLastFetchedMs, isLoading }, - fromAmount, validatedSrcAmount, fromToken, + fromTokenInputValue, ) => { return { isNoQuotesAvailable: Boolean( @@ -515,7 +524,7 @@ export const getValidationErrors = createDeepEqualSelector( }, // Shown after fetching quotes isInsufficientGasForQuote: (balance?: BigNumber) => { - if (balance && activeQuote && fromToken) { + if (balance && activeQuote && fromToken && fromTokenInputValue) { return isNativeAddress(fromToken.address) ? balance .sub(activeQuote.totalNetworkFee.amount) @@ -526,10 +535,13 @@ export const getValidationErrors = createDeepEqualSelector( return false; }, isInsufficientBalance: (balance?: BigNumber) => - fromAmount && balance !== undefined ? balance.lt(fromAmount) : false, + validatedSrcAmount && balance !== undefined + ? balance.lt(validatedSrcAmount) + : false, isEstimatedReturnLow: activeQuote?.sentAmount?.valueInCurrency && - activeQuote?.adjustedReturn?.valueInCurrency + activeQuote?.adjustedReturn?.valueInCurrency && + fromTokenInputValue ? activeQuote.adjustedReturn.valueInCurrency.lt( new BigNumber( BRIDGE_QUOTE_MAX_RETURN_DIFFERENCE_PERCENTAGE, diff --git a/ui/helpers/constants/design-system.ts b/ui/helpers/constants/design-system.ts index 8374e812b017..b82b379536c6 100644 --- a/ui/helpers/constants/design-system.ts +++ b/ui/helpers/constants/design-system.ts @@ -54,6 +54,7 @@ export enum Color { export enum BackgroundColor { backgroundDefault = 'background-default', backgroundAlternative = 'background-alternative', + backgroundAlternativeSoft = 'background-alternative-soft', backgroundHover = 'background-hover', backgroundPressed = 'background-pressed', overlayDefault = 'overlay-default', @@ -109,6 +110,7 @@ export enum BorderColor { export enum TextColor { textDefault = 'text-default', textAlternative = 'text-alternative', + textAlternativeSoft = 'text-alternative-soft', textMuted = 'text-muted', overlayInverse = 'overlay-inverse', primaryDefault = 'primary-default', @@ -139,6 +141,7 @@ export enum TextColor { export enum IconColor { iconDefault = 'icon-default', iconAlternative = 'icon-alternative', + iconAlternativeSoft = 'icon-alternative-soft', iconMuted = 'icon-muted', overlayInverse = 'overlay-inverse', primaryDefault = 'primary-default', diff --git a/ui/hooks/bridge/__snapshots__/useTokensWithFiltering.test.ts.snap b/ui/hooks/bridge/__snapshots__/useTokensWithFiltering.test.ts.snap new file mode 100644 index 000000000000..8fceeffe9730 --- /dev/null +++ b/ui/hooks/bridge/__snapshots__/useTokensWithFiltering.test.ts.snap @@ -0,0 +1,192 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`useTokensWithFiltering should not return tokens that are not in the allowlist 1`] = ` +[ + { + "address": "0x0000000000000000000000000000000000000000", + "balance": "0.000000000000000014", + "chainId": "0xe708", + "decimals": 18, + "image": "./images/eth_logo.svg", + "string": "0.000000000000000014", + "symbol": "ETH", + "tokenFiatAmount": 3.53395e-14, + "type": "NATIVE", + }, + { + "address": "0x0000000000000000000000000000000000000000", + "balance": "0.00000000000000001", + "chainId": "0x1", + "decimals": 18, + "image": "./images/eth_logo.svg", + "string": "0.00000000000000001", + "symbol": "ETH", + "tokenFiatAmount": 2.5242500000000003e-14, + "type": "NATIVE", + }, + { + "address": "0x6b3595068778dd592e39a122f4f5a5cf09c90fe2", + "aggregators": [], + "balance": "", + "chainId": "0x1", + "decimals": 18, + "erc20": true, + "erc721": false, + "iconUrl": "images/contract/sushi.svg", + "image": "images/contract/sushi.svg", + "name": "SushiSwap", + "string": undefined, + "symbol": "SUSHI", + "type": "TOKEN", + }, + { + "address": "0x0000000000000000000000000000000000000000", + "balance": "0x0", + "chainId": "0x1", + "decimals": 18, + "iconUrl": "./images/eth_logo.svg", + "image": "./images/eth_logo.svg", + "name": "Ether", + "string": "0x0", + "symbol": "ETH", + "type": "NATIVE", + }, + { + "address": "0x1f9840a85d5af5bf1d1762f925bdaddc4201f984", + "aggregators": [], + "balance": "", + "chainId": "0x1", + "decimals": 18, + "erc20": true, + "iconUrl": "images/contract/uni.svg", + "image": "images/contract/uni.svg", + "name": "Uniswap", + "string": undefined, + "symbol": "UNI", + "type": "TOKEN", + }, +] +`; + +exports[`useTokensWithFiltering should return all tokens when chainId === activeChainId, sorted by balance 1`] = ` +[ + { + "address": "0x1f9840a85d5af5bf1d1762f925bdaddc4201f984", + "balance": "0.00184", + "chainId": "0x1", + "decimals": 6, + "image": undefined, + "isNative": false, + "string": "0.00184", + "tokenFiatAmount": 0.004232, + "type": "TOKEN", + }, + { + "address": "0x0000000000000000000000000000000000000000", + "balance": "0.000000000000000014", + "chainId": "0xe708", + "decimals": 18, + "image": "./images/eth_logo.svg", + "string": "0.000000000000000014", + "symbol": "ETH", + "tokenFiatAmount": 3.53395e-14, + "type": "NATIVE", + }, + { + "address": "0x0000000000000000000000000000000000000000", + "balance": "0.00000000000000001", + "chainId": "0x1", + "decimals": 18, + "image": "./images/eth_logo.svg", + "string": "0.00000000000000001", + "symbol": "ETH", + "tokenFiatAmount": 2.5242500000000003e-14, + "type": "NATIVE", + }, + { + "address": "0x514910771af9ca656af840dff83e8264ecf986ca", + "balance": "1", + "chainId": "0x1", + "image": undefined, + "isNative": false, + "string": "1", + "tokenFiatAmount": null, + "type": "TOKEN", + }, + { + "address": "0x1f9840a85d5af5bf1d1762f925bdaddc4201f984", + "balance": "", + "chainId": "0x1", + "decimals": 6, + "string": undefined, + "type": "TOKEN", + }, + { + "address": "0x6b3595068778dd592e39a122f4f5a5cf09c90fe2", + "aggregators": [], + "balance": "", + "chainId": "0x1", + "decimals": 18, + "erc20": true, + "erc721": false, + "iconUrl": "images/contract/sushi.svg", + "image": "images/contract/sushi.svg", + "name": "SushiSwap", + "string": undefined, + "symbol": "SUSHI", + "type": "TOKEN", + }, + { + "address": "0x0000000000000000000000000000000000000000", + "balance": "0x0", + "chainId": "0x1", + "decimals": 18, + "iconUrl": "./images/eth_logo.svg", + "image": "./images/eth_logo.svg", + "name": "Ether", + "string": "0x0", + "symbol": "ETH", + "type": "NATIVE", + }, + { + "address": "0x1f9840a85d5af5bf1d1762f925bdaddc4201f984", + "aggregators": [], + "balance": "", + "chainId": "0x1", + "decimals": 18, + "erc20": true, + "iconUrl": "images/contract/uni.svg", + "image": "images/contract/uni.svg", + "name": "Uniswap", + "string": undefined, + "symbol": "UNI", + "type": "TOKEN", + }, + { + "address": "0xdac17f958d2ee523a2206206994597c13d831ec7", + "aggregators": [], + "balance": "", + "chainId": "0x1", + "decimals": 6, + "erc20": true, + "iconUrl": "images/contract/usdt.svg", + "image": "images/contract/usdt.svg", + "name": "Tether USD", + "string": undefined, + "symbol": "USDT", + "type": "TOKEN", + }, + { + "address": "0x0000000000000000000000000000000000000000", + "balance": "0x0", + "chainId": "0x1", + "decimals": 18, + "iconUrl": "./images/eth_logo.svg", + "image": "./images/eth_logo.svg", + "name": "Ether", + "string": "0x0", + "symbol": "ETH", + "type": "NATIVE", + }, +] +`; diff --git a/ui/hooks/bridge/useBridgeExchangeRates.ts b/ui/hooks/bridge/useBridgeExchangeRates.ts index ef3c6669a2c8..20f70b17dfd6 100644 --- a/ui/hooks/bridge/useBridgeExchangeRates.ts +++ b/ui/hooks/bridge/useBridgeExchangeRates.ts @@ -5,11 +5,16 @@ import { getQuoteRequest, getToChain, } from '../../ducks/bridge/selectors'; -import { getCurrentCurrency, getMarketData } from '../../selectors'; +import { + getCurrentCurrency, + getMarketData, + getParticipateInMetaMetrics, +} from '../../selectors'; import { decimalToPrefixedHex } from '../../../shared/modules/conversion.utils'; import { getCurrentChainId } from '../../../shared/modules/selectors/networks'; import { setDestTokenExchangeRates, + setDestTokenUsdExchangeRates, setSrcTokenExchangeRates, } from '../../ducks/bridge/bridge'; import { exchangeRateFromMarketData } from '../../ducks/bridge/utils'; @@ -19,6 +24,7 @@ export const useBridgeExchangeRates = () => { const { activeQuote } = useSelector(getBridgeQuotes); const chainId = useSelector(getCurrentChainId); const toChain = useSelector(getToChain); + const isMetaMetricsEnabled = useSelector(getParticipateInMetaMetrics); const dispatch = useDispatch(); @@ -78,6 +84,16 @@ export const useBridgeExchangeRates = () => { currency, }), ); + // If the selected currency is not USD, fetch the USD exchange rate for metrics + if (isMetaMetricsEnabled && currency !== 'usd') { + dispatch( + setDestTokenUsdExchangeRates({ + chainId: toChainId, + tokenAddress: toTokenAddress, + currency: 'usd', + }), + ); + } } } }, [toChainId, toTokenAddress]); diff --git a/ui/hooks/bridge/useBridgeTokens.ts b/ui/hooks/bridge/useBridgeTokens.ts new file mode 100644 index 000000000000..acddb7ec2fb0 --- /dev/null +++ b/ui/hooks/bridge/useBridgeTokens.ts @@ -0,0 +1,39 @@ +import { useEffect, useState } from 'react'; +import { useSelector } from 'react-redux'; +import { getAllBridgeableNetworks } from '../../ducks/bridge/selectors'; +import { fetchBridgeTokens } from '../../pages/bridge/bridge.util'; + +// This hook is used to fetch the bridge tokens for all bridgeable networks +export const useBridgeTokens = () => { + const allBridgeChains = useSelector(getAllBridgeableNetworks); + + const [tokenAllowlistByChainId, setTokenAllowlistByChainId] = useState< + Record> + >({}); + + useEffect(() => { + const tokenAllowlistPromises = Promise.allSettled( + allBridgeChains.map( + async ({ chainId }) => + await fetchBridgeTokens(chainId).then((tokens) => ({ + [chainId]: new Set(Object.keys(tokens)), + })), + ), + ); + + (async () => { + const results = await tokenAllowlistPromises; + const tokenAllowlistResults = Object.fromEntries( + results.map((result) => { + if (result.status === 'fulfilled') { + return Object.entries(result.value)[0]; + } + return []; + }), + ); + setTokenAllowlistByChainId(tokenAllowlistResults); + })(); + }, [allBridgeChains.length]); + + return tokenAllowlistByChainId; +}; diff --git a/ui/hooks/bridge/useBridging.ts b/ui/hooks/bridge/useBridging.ts index 2b7ffb0083c9..a464c2e1a37a 100644 --- a/ui/hooks/bridge/useBridging.ts +++ b/ui/hooks/bridge/useBridging.ts @@ -32,6 +32,9 @@ import { isHardwareKeyring } from '../../helpers/utils/hardware'; import { getPortfolioUrl } from '../../helpers/utils/portfolio'; import { SwapsTokenObject } from '../../../shared/constants/swaps'; import { getProviderConfig } from '../../../shared/modules/selectors/networks'; +// eslint-disable-next-line import/no-restricted-paths +import { getEnvironmentType } from '../../../app/scripts/lib/util'; +import { ENVIRONMENT_TYPE_FULLSCREEN } from '../../../shared/constants/app'; import { useCrossChainSwapsEventTracker } from './useCrossChainSwapsEventTracker'; ///: END:ONLY_INCLUDE_IF @@ -93,16 +96,18 @@ const useBridging = () => { chain_id: providerConfig.chainId, }, }); - if (usingHardwareWallet && global.platform.openExtensionInBrowser) { - global.platform.openExtensionInBrowser( - PREPARE_SWAP_ROUTE, - null, - false, - ); + const environmentType = getEnvironmentType(); + const environmentTypeIsFullScreen = + environmentType === ENVIRONMENT_TYPE_FULLSCREEN; + const bridgeRoute = `${CROSS_CHAIN_SWAP_ROUTE}${PREPARE_SWAP_ROUTE}?token=${token.address.toLowerCase()}`; + if ( + usingHardwareWallet && + global.platform.openExtensionInBrowser && + !environmentTypeIsFullScreen + ) { + global.platform.openExtensionInBrowser(bridgeRoute); } else { - history.push( - `${CROSS_CHAIN_SWAP_ROUTE}${PREPARE_SWAP_ROUTE}?token=${token.address.toLowerCase()}`, - ); + history.push(bridgeRoute); } } else { const portfolioUrl = getPortfolioUrl( diff --git a/ui/hooks/bridge/useCountdownTimer.test.ts b/ui/hooks/bridge/useCountdownTimer.test.ts index 0adc18f68c15..55360c729a44 100644 --- a/ui/hooks/bridge/useCountdownTimer.test.ts +++ b/ui/hooks/bridge/useCountdownTimer.test.ts @@ -1,6 +1,7 @@ import { renderHookWithProvider } from '../../../test/lib/render-helpers'; import { createBridgeMockStore } from '../../../test/jest/mock-store'; import { flushPromises } from '../../../test/lib/timer-helpers'; +import { SECOND } from '../../../shared/constants/time'; import { useCountdownTimer } from './useCountdownTimer'; jest.useFakeTimers(); @@ -30,13 +31,11 @@ describe('useCountdownTimer', () => { let i = 0; while (i <= 40) { const secondsLeft = Math.min(41, 40 - i + 2); - expect(result.current).toStrictEqual( - `0:${secondsLeft < 10 ? '0' : ''}${secondsLeft}`, - ); + expect(result.current).toStrictEqual(secondsLeft * SECOND); i += 10; jest.advanceTimersByTime(10000); await flushPromises(); } - expect(result.current).toStrictEqual('0:00'); + expect(result.current).toStrictEqual(0); }); }); diff --git a/ui/hooks/bridge/useCountdownTimer.ts b/ui/hooks/bridge/useCountdownTimer.ts index 39e7ac9d2eca..ad022f7d4274 100644 --- a/ui/hooks/bridge/useCountdownTimer.ts +++ b/ui/hooks/bridge/useCountdownTimer.ts @@ -1,18 +1,17 @@ -import { Duration } from 'luxon'; import { useEffect, useState } from 'react'; import { useSelector } from 'react-redux'; import { getBridgeQuotes, getBridgeQuotesConfig, } from '../../ducks/bridge/selectors'; -import { SECOND } from '../../../shared/constants/time'; +const STEP = 1000; /** * Custom hook that provides a countdown timer based on the last fetched quotes timestamp. * * This hook calculates the remaining time until the next refresh interval and updates every second. * - * @returns The formatted remaining time in 'm:ss' format. + * @returns The remaining time in milliseconds. */ export const useCountdownTimer = () => { const { quotesLastFetchedMs } = useSelector(getBridgeQuotes); @@ -22,18 +21,16 @@ export const useCountdownTimer = () => { useEffect(() => { if (quotesLastFetchedMs) { - setTimeRemaining( - refreshRate - (Date.now() - quotesLastFetchedMs) + SECOND, - ); + setTimeRemaining(refreshRate - (Date.now() - quotesLastFetchedMs) + STEP); } }, [quotesLastFetchedMs]); useEffect(() => { const interval = setInterval(() => { - setTimeRemaining(Math.max(0, timeRemaining - SECOND)); - }, SECOND); + setTimeRemaining(Math.max(0, timeRemaining - STEP)); + }, STEP); return () => clearInterval(interval); }, [timeRemaining]); - return Duration.fromMillis(timeRemaining).toFormat('m:ss'); + return timeRemaining; }; diff --git a/ui/hooks/bridge/useCrossChainSwapsEventTracker.ts b/ui/hooks/bridge/useCrossChainSwapsEventTracker.ts index f28244cba995..ad4b3698fe84 100644 --- a/ui/hooks/bridge/useCrossChainSwapsEventTracker.ts +++ b/ui/hooks/bridge/useCrossChainSwapsEventTracker.ts @@ -104,6 +104,7 @@ export const useCrossChainSwapsEventTracker = () => { action_type: ActionType.CROSSCHAIN_V1, ...properties, }, + value: 'value' in properties ? (properties.value as never) : undefined, }); }, [trackEvent], diff --git a/ui/hooks/bridge/useLatestBalance.test.ts b/ui/hooks/bridge/useLatestBalance.test.ts index 6d79672e4550..094f70f2233f 100644 --- a/ui/hooks/bridge/useLatestBalance.test.ts +++ b/ui/hooks/bridge/useLatestBalance.test.ts @@ -1,8 +1,8 @@ -import { BigNumber } from 'ethers'; +import { BigNumber } from 'bignumber.js'; import { renderHookWithProvider } from '../../../test/lib/render-helpers'; import { CHAIN_IDS } from '../../../shared/constants/network'; import { createBridgeMockStore } from '../../../test/jest/mock-store'; -import { zeroAddress } from '../../__mocks__/ethereumjs-util'; +import { zeroAddress } from 'ethereumjs-util'; import { createTestProviderTools } from '../../../test/stub/provider'; import * as tokenutil from '../../../shared/lib/token-util'; import useLatestBalance from './useLatestBalance'; @@ -47,8 +47,8 @@ describe('useLatestBalance', () => { global.ethereumProvider = provider as any; }); - it('returns formattedBalance for native asset in current chain', async () => { - mockGetBalance.mockResolvedValue(BigNumber.from('1000000000000000000')); + it('returns balanceAmount for native asset in current chain', async () => { + mockGetBalance.mockResolvedValue(new BigNumber('1000000000000000000')); const { result, waitForNextUpdate } = renderUseLatestBalance( { address: zeroAddress(), decimals: 18 }, @@ -57,7 +57,7 @@ describe('useLatestBalance', () => { ); await waitForNextUpdate(); - expect(result.current.formattedBalance).toStrictEqual('1'); + expect(result.current.balanceAmount).toStrictEqual(new BigNumber('1')); expect(mockGetBalance).toHaveBeenCalledTimes(1); expect(mockGetBalance).toHaveBeenCalledWith( @@ -66,8 +66,8 @@ describe('useLatestBalance', () => { expect(mockFetchTokenBalance).toHaveBeenCalledTimes(0); }); - it('returns formattedBalance for ERC20 asset in current chain', async () => { - mockFetchTokenBalance.mockResolvedValueOnce(BigNumber.from('15390000')); + it('returns balanceAmount for ERC20 asset in current chain', async () => { + mockFetchTokenBalance.mockResolvedValueOnce(new BigNumber('15390000')); const { result, waitForNextUpdate } = renderUseLatestBalance( { address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', decimals: '6' }, @@ -76,7 +76,7 @@ describe('useLatestBalance', () => { ); await waitForNextUpdate(); - expect(result.current.formattedBalance).toStrictEqual('15.39'); + expect(result.current.balanceAmount).toStrictEqual(new BigNumber('15.39')); expect(mockFetchTokenBalance).toHaveBeenCalledTimes(1); expect(mockFetchTokenBalance).toHaveBeenCalledWith( diff --git a/ui/hooks/bridge/useLatestBalance.ts b/ui/hooks/bridge/useLatestBalance.ts index 6aaf7da68c0c..98ee8dfd4f4c 100644 --- a/ui/hooks/bridge/useLatestBalance.ts +++ b/ui/hooks/bridge/useLatestBalance.ts @@ -1,10 +1,8 @@ import { useSelector } from 'react-redux'; import { Hex } from '@metamask/utils'; import { Numeric } from '../../../shared/modules/Numeric'; -import { DEFAULT_PRECISION } from '../useCurrencyDisplay'; import { getCurrentChainId } from '../../../shared/modules/selectors/networks'; -import { getSelectedInternalAccount, SwapsEthToken } from '../../selectors'; -import { SwapsTokenObject } from '../../../shared/constants/swaps'; +import { getSelectedInternalAccount } from '../../selectors'; import { calcLatestSrcBalance } from '../../../shared/modules/bridge-utils/balance'; import { useAsyncResult } from '../useAsyncResult'; import { calcTokenAmount } from '../../../shared/lib/transactions-controller-utils'; @@ -14,10 +12,14 @@ import { calcTokenAmount } from '../../../shared/lib/transactions-controller-uti * * @param token - The token object for which the balance is to be fetched. Can be null. * @param chainId - The chain ID to be used for fetching the balance. Optional. - * @returns An object containing the formatted balance as a string. + * @returns An object containing the balanceAmount as a string. */ const useLatestBalance = ( - token: SwapsTokenObject | SwapsEthToken | null, + token: { + address: string; + decimals: number; + symbol: string; + } | null, chainId?: Hex, ) => { const { address: selectedAddress } = useSelector(getSelectedInternalAccount); @@ -52,13 +54,6 @@ const useLatestBalance = ( const tokenDecimals = token?.decimals ? Number(token.decimals) : 1; return { - formattedBalance: - token && latestBalance - ? latestBalance - .shiftedBy(tokenDecimals) - .round(DEFAULT_PRECISION) - .toString() - : undefined, balanceAmount: token && latestBalance ? calcTokenAmount(latestBalance.toString(), tokenDecimals) diff --git a/ui/hooks/bridge/useTokensWithFiltering.test.ts b/ui/hooks/bridge/useTokensWithFiltering.test.ts new file mode 100644 index 000000000000..e6903756bcfd --- /dev/null +++ b/ui/hooks/bridge/useTokensWithFiltering.test.ts @@ -0,0 +1,108 @@ +import { renderHookWithProvider } from '../../../test/lib/render-helpers'; +import { createBridgeMockStore } from '../../../test/jest/mock-store'; +import { STATIC_MAINNET_TOKEN_LIST } from '../../../shared/constants/tokens'; +import { SWAPS_CHAINID_DEFAULT_TOKEN_MAP } from '../../../shared/constants/swaps'; +import { CHAIN_IDS } from '../../../shared/constants/network'; +import { useTokensWithFiltering } from './useTokensWithFiltering'; + +const mockUseTokenTracker = jest + .fn() + .mockReturnValue({ tokensWithBalances: [] }); +jest.mock('../useTokenTracker', () => ({ + useTokenTracker: () => mockUseTokenTracker(), +})); + +const NATIVE_TOKEN = SWAPS_CHAINID_DEFAULT_TOKEN_MAP[CHAIN_IDS.MAINNET]; + +describe('useTokensWithFiltering', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should return all tokens when chainId === activeChainId, sorted by balance', () => { + const mockStore = createBridgeMockStore({ + metamaskStateOverrides: { + completedOnboarding: true, + allDetectedTokens: { + '0x1': { + '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc': [ + { + address: '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984', + decimals: 6, + }, // USDC + ], + }, + }, + }, + }); + const { result } = renderHookWithProvider( + () => + useTokensWithFiltering( + { + [NATIVE_TOKEN.address]: NATIVE_TOKEN, + ...STATIC_MAINNET_TOKEN_LIST, + }, + [ + { address: '0x6b3595068778dd592e39a122f4f5a5cf09c90fe2' }, // UNI + { address: NATIVE_TOKEN.address }, + { address: '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984' }, // USDC + { address: '0xdac17f958d2ee523a2206206994597c13d831ec7' }, // USDT + ], + { + [CHAIN_IDS.MAINNET]: new Set( + Object.keys(STATIC_MAINNET_TOKEN_LIST), + ), + }, + CHAIN_IDS.MAINNET, + ), + mockStore, + ); + // The first 10 tokens returned + const first10Tokens = [...result.current(() => true)].slice(0, 10); + expect(first10Tokens).toMatchSnapshot(); + }); + + it('should not return tokens that are not in the allowlist', () => { + const mockStore = createBridgeMockStore({ + metamaskStateOverrides: { + completedOnboarding: true, + allDetectedTokens: { + '0x1': { + '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc': [ + { + address: '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984', + decimals: 6, + }, // USDC + ], + }, + }, + }, + }); + const { result } = renderHookWithProvider( + () => + useTokensWithFiltering( + { + [NATIVE_TOKEN.address]: NATIVE_TOKEN, + ...STATIC_MAINNET_TOKEN_LIST, + }, + [ + { address: '0x6b3595068778dd592e39a122f4f5a5cf09c90fe2' }, // UNI + { address: NATIVE_TOKEN.address }, + { address: '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984' }, // USDC + { address: '0xdac17f958d2ee523a2206206994597c13d831ec7' }, // USDT + ], + // Only 1 token in allowlist + { + [CHAIN_IDS.MAINNET]: new Set([ + '0x6b3595068778dd592e39a122f4f5a5cf09c90fe2', + ]), + }, + CHAIN_IDS.MAINNET, + ), + mockStore, + ); + // The first 5 tokens returned + const first5Tokens = [...result.current(() => true)].slice(0, 5); + expect(first5Tokens).toMatchSnapshot(); + }); +}); diff --git a/ui/hooks/bridge/useTokensWithFiltering.ts b/ui/hooks/bridge/useTokensWithFiltering.ts new file mode 100644 index 000000000000..56b16ddc4b68 --- /dev/null +++ b/ui/hooks/bridge/useTokensWithFiltering.ts @@ -0,0 +1,218 @@ +import { useCallback } from 'react'; +import { useSelector } from 'react-redux'; +import { isEqual } from 'lodash'; +import { ChainId } from '@metamask/controller-utils'; +import { Hex } from '@metamask/utils'; +import { useParams } from 'react-router-dom'; +import { zeroAddress } from 'ethereumjs-util'; +import { + getAllDetectedTokensForSelectedAddress, + getCurrentCurrency, + getSelectedInternalAccountWithBalance, + getTokenExchangeRates, +} from '../../selectors'; +import { getConversionRate } from '../../ducks/metamask/metamask'; +import { SwapsTokenObject } from '../../../shared/constants/swaps'; +import { + AssetWithDisplayData, + ERC20Asset, + NativeAsset, +} from '../../components/multichain/asset-picker-amount/asset-picker-modal/types'; +import { AssetType } from '../../../shared/constants/transaction'; +import { isNativeAddress } from '../../pages/bridge/utils/quote'; +import { CHAIN_ID_TOKEN_IMAGE_MAP } from '../../../shared/constants/network'; +import { getCurrentChainId } from '../../../shared/modules/selectors/networks'; +import { Token } from '../../components/app/assets/token-list/token-list'; +import { useMultichainBalances } from '../useMultichainBalances'; + +type FilterPredicate = ( + symbol: string, + address?: string, + tokenChainId?: string, +) => boolean; + +/** + * Returns a token list generator that filters and sorts tokens in this order + * - matches URL token parameter + * - matches search query + * - highest balance in selected currency + * - detected tokens (with balance) + * - popularity + * - all other tokens + * + * @param tokenList - a mapping of token addresses in the selected chainId to token metadata from the bridge-api + * @param topTokens - a list of top tokens from the swap-api + * @param tokenAddressAllowlistByChainId - a mapping of all supported chainIds to a Set of allowed token addresses + * @param chainId - the selected src/dest chainId + */ +export const useTokensWithFiltering = ( + tokenList: Record, + topTokens: { address: string }[], + tokenAddressAllowlistByChainId: Record>, + chainId?: ChainId | Hex, +) => { + const { token: tokenAddressFromUrl } = useParams(); + const allDetectedTokens: Record = useSelector( + getAllDetectedTokensForSelectedAddress, + ); + + const { balance } = useSelector(getSelectedInternalAccountWithBalance); + + const tokenConversionRates = useSelector(getTokenExchangeRates, isEqual); + const conversionRate = useSelector(getConversionRate); + const currentCurrency = useSelector(getCurrentCurrency); + const currentChainId = useSelector(getCurrentChainId); + + const { assetsWithBalance: multichainTokensWithBalance } = + useMultichainBalances(); + + // This transforms the token object from the bridge-api into the format expected by the AssetPicker + const buildTokenData = ( + token?: SwapsTokenObject, + ): AssetWithDisplayData | undefined => { + if (!chainId || !token) { + return undefined; + } + // Only tokens on the active chain are processed here here + const sharedFields = { ...token, chainId }; + + if (isNativeAddress(token.address)) { + return { + ...sharedFields, + type: AssetType.native, + address: zeroAddress(), + image: + CHAIN_ID_TOKEN_IMAGE_MAP[ + chainId as keyof typeof CHAIN_ID_TOKEN_IMAGE_MAP + ], + balance: currentChainId === chainId ? balance : '', + string: currentChainId === chainId ? balance : '', + }; + } + + return { + ...sharedFields, + type: AssetType.token, + image: token.iconUrl, + // Only tokens with 0 balance are processed here so hardcode empty string + balance: '', + string: undefined, + address: token.address || zeroAddress(), + }; + }; + + // This returns whether the token is blocked by any of the supported chainIds + const isTokenBlocked = (tokenAddress: string, tokenChainId: string) => + !tokenAddressAllowlistByChainId[tokenChainId]?.has( + tokenAddress.toLowerCase(), + ); + + // shouldAddToken is a filter condition passed in from the AssetPicker that determines whether a token should be included + const filteredTokenListGenerator = useCallback( + (shouldAddToken: FilterPredicate) => + (function* (): Generator< + AssetWithDisplayData | AssetWithDisplayData + > { + // If a token address is in the URL (e.g. from a deep link), yield that token first + if (tokenAddressFromUrl) { + const token = + tokenList?.[tokenAddressFromUrl] ?? + tokenList?.[tokenAddressFromUrl.toLowerCase()]; + if ( + shouldAddToken(token.symbol, token.address ?? undefined, chainId) + ) { + const tokenWithData = buildTokenData(token); + if (tokenWithData) { + yield tokenWithData; + } + } + } + + // Yield multichain tokens with balances and are not blocked + for (const token of multichainTokensWithBalance) { + if ( + shouldAddToken( + token.symbol, + token.address ?? undefined, + token.chainId, + ) && + (token.address + ? !isTokenBlocked(token.address, token.chainId) + : true) + ) { + // If there's no address, set it to the native address in swaps/bridge + yield { ...token, address: token.address || zeroAddress() }; + } + } + + // Yield all detected tokens for all supported chains + for (const token of Object.values(allDetectedTokens).flat()) { + if ( + shouldAddToken( + token.symbol, + token.address ?? undefined, + token.chainId, + ) && + (token.address + ? !isTokenBlocked(token.address, token.chainId) + : true) + ) { + yield { + ...token, + type: AssetType.token, + // Balance is not 0 but is not in the data so hardcode 0 + // If a detected token is selected useLatestBalance grabs the on-chain balance + balance: '', + string: undefined, + }; + } + } + + // Yield topTokens from selected chain + for (const token_ of topTokens) { + const matchedToken = + tokenList?.[token_.address] ?? + tokenList?.[token_.address.toLowerCase()]; + if ( + matchedToken && + shouldAddToken( + matchedToken.symbol, + matchedToken.address ?? undefined, + chainId, + ) + ) { + const token = buildTokenData(matchedToken); + if (token) { + yield token; + } + } + } + + // Yield other tokens from selected chain + for (const token_ of Object.values(tokenList)) { + if ( + token_ && + shouldAddToken(token_.symbol, token_.address ?? undefined, chainId) + ) { + const token = buildTokenData(token_); + if (token) { + yield token; + } + } + } + })(), + [ + multichainTokensWithBalance, + topTokens, + tokenConversionRates, + conversionRate, + currentCurrency, + chainId, + tokenList, + tokenAddressFromUrl, + allDetectedTokens, + ], + ); + + return filteredTokenListGenerator; +}; diff --git a/ui/hooks/useTokensWithFiltering.test.ts b/ui/hooks/useTokensWithFiltering.test.ts deleted file mode 100644 index 0a523b69bd74..000000000000 --- a/ui/hooks/useTokensWithFiltering.test.ts +++ /dev/null @@ -1,157 +0,0 @@ -import { renderHookWithProvider } from '../../test/lib/render-helpers'; -import { createBridgeMockStore } from '../../test/jest/mock-store'; -import { STATIC_MAINNET_TOKEN_LIST } from '../../shared/constants/tokens'; -import { - SWAPS_CHAINID_DEFAULT_TOKEN_MAP, - SwapsTokenObject, - TokenBucketPriority, -} from '../../shared/constants/swaps'; -import { useTokensWithFiltering } from './useTokensWithFiltering'; - -const mockUseTokenTracker = jest - .fn() - .mockReturnValue({ tokensWithBalances: [] }); -jest.mock('./useTokenTracker', () => ({ - useTokenTracker: () => mockUseTokenTracker(), -})); - -const TEST_CHAIN_ID = '0x1'; -const NATIVE_TOKEN = SWAPS_CHAINID_DEFAULT_TOKEN_MAP[TEST_CHAIN_ID]; - -const MOCK_TOP_ASSETS = [ - { address: '0x6b3595068778dd592e39a122f4f5a5cf09c90fe2' }, // UNI - { address: NATIVE_TOKEN.address }, - { address: '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984' }, // USDC - { address: '0xdac17f958d2ee523a2206206994597c13d831ec7' }, // USDT -]; - -const MOCK_TOKEN_LIST_BY_ADDRESS: Record = { - [NATIVE_TOKEN.address]: NATIVE_TOKEN, - ...STATIC_MAINNET_TOKEN_LIST, -}; - -describe('useTokensWithFiltering should return token list generator', () => { - afterEach(() => { - jest.clearAllMocks(); - }); - - it('when chainId === activeChainId and sorted by topAssets', () => { - const mockStore = createBridgeMockStore(); - const { result } = renderHookWithProvider( - () => - useTokensWithFiltering( - MOCK_TOKEN_LIST_BY_ADDRESS, - MOCK_TOP_ASSETS, - TokenBucketPriority.top, - TEST_CHAIN_ID, - ), - mockStore, - ); - - expect(result.current).toHaveLength(1); - expect(typeof result.current).toStrictEqual('function'); - const tokenGenerator = result.current(() => true); - expect(tokenGenerator.next().value).toStrictEqual({ - address: '0x0000000000000000000000000000000000000000', - balance: undefined, - decimals: 18, - iconUrl: './images/eth_logo.svg', - identiconAddress: null, - image: './images/eth_logo.svg', - name: 'Ether', - primaryLabel: 'ETH', - rawFiat: '', - chainId: '0x1', - rightPrimaryLabel: undefined, - rightSecondaryLabel: '', - secondaryLabel: 'Ether', - symbol: 'ETH', - type: 'NATIVE', - }); - expect(tokenGenerator.next().value).toStrictEqual({ - address: '0x6b3595068778dd592e39a122f4f5a5cf09c90fe2', - aggregators: [], - balance: undefined, - decimals: 18, - erc20: true, - erc721: false, - chainId: '0x1', - iconUrl: - 'https://static.cx.metamask.io/api/v1/tokenIcons/1/0x6b3595068778dd592e39a122f4f5a5cf09c90fe2.png', - identiconAddress: null, - image: 'images/contract/sushi.svg', - name: 'SushiSwap', - primaryLabel: 'SUSHI', - rawFiat: '', - rightPrimaryLabel: undefined, - rightSecondaryLabel: '', - secondaryLabel: 'SushiSwap', - symbol: 'SUSHI', - type: 'TOKEN', - }); - }); - - it('when chainId === activeChainId and sorted by balance', () => { - const mockStore = createBridgeMockStore(); - mockUseTokenTracker.mockReturnValue({ - tokensWithBalances: [ - { - address: '0xdac17f958d2ee523a2206206994597c13d831ec7', - balance: '0xa', - }, - ], - }); - const { result } = renderHookWithProvider( - () => - useTokensWithFiltering( - MOCK_TOKEN_LIST_BY_ADDRESS, - MOCK_TOP_ASSETS, - TokenBucketPriority.owned, - TEST_CHAIN_ID, - ), - mockStore, - ); - - expect(result.current).toHaveLength(1); - expect(typeof result.current).toStrictEqual('function'); - const tokenGenerator = result.current(() => true); - expect(tokenGenerator.next().value).toStrictEqual({ - address: '0x0000000000000000000000000000000000000000', - balance: '0x0', - decimals: 18, - iconUrl: './images/eth_logo.svg', - identiconAddress: null, - image: './images/eth_logo.svg', - name: 'Ether', - chainId: '0x1', - primaryLabel: 'ETH', - rawFiat: '0', - rightPrimaryLabel: '0 ETH', - rightSecondaryLabel: '$0.00 USD', - secondaryLabel: 'Ether', - string: '0', - symbol: 'ETH', - type: 'NATIVE', - }); - expect(tokenGenerator.next().value).toStrictEqual({ - address: '0xdac17f958d2ee523a2206206994597c13d831ec7', - aggregators: [], - balance: '0xa', - decimals: 6, - erc20: true, - iconUrl: - 'https://static.cx.metamask.io/api/v1/tokenIcons/1/0xdac17f958d2ee523a2206206994597c13d831ec7.png', - identiconAddress: null, - image: 'images/contract/usdt.svg', - name: 'Tether USD', - chainId: '0x1', - primaryLabel: 'USDT', - rawFiat: '', - rightPrimaryLabel: undefined, - rightSecondaryLabel: '', - secondaryLabel: 'Tether USD', - symbol: 'USDT', - type: 'TOKEN', - }); - }); -}); diff --git a/ui/hooks/useTokensWithFiltering.ts b/ui/hooks/useTokensWithFiltering.ts deleted file mode 100644 index ef155eb9ca1c..000000000000 --- a/ui/hooks/useTokensWithFiltering.ts +++ /dev/null @@ -1,202 +0,0 @@ -import { useCallback, useMemo } from 'react'; -import { useSelector } from 'react-redux'; -import { isEqual } from 'lodash'; -import { ChainId, hexToBN } from '@metamask/controller-utils'; -import { Hex } from '@metamask/utils'; -import { useParams } from 'react-router-dom'; -import { - getAllTokens, - getCurrentCurrency, - getSelectedInternalAccountWithBalance, - getShouldHideZeroBalanceTokens, - getTokenExchangeRates, -} from '../selectors'; -import { getConversionRate } from '../ducks/metamask/metamask'; -import { - SWAPS_CHAINID_DEFAULT_TOKEN_MAP, - SwapsTokenObject, - TokenBucketPriority, -} from '../../shared/constants/swaps'; -import { getValueFromWeiHex } from '../../shared/modules/conversion.utils'; -import { EtherDenomination } from '../../shared/constants/common'; -import { - AssetWithDisplayData, - ERC20Asset, - NativeAsset, - TokenWithBalance, -} from '../components/multichain/asset-picker-amount/asset-picker-modal/types'; -import { AssetType } from '../../shared/constants/transaction'; -import { isSwapsDefaultTokenSymbol } from '../../shared/modules/swaps.utils'; -import { useTokenTracker } from './useTokenTracker'; -import { getRenderableTokenData } from './useTokensToSearch'; - -/* - * Returns a token list generator that filters and sorts tokens based on - * query match, balance/popularity, all other tokens - */ -export const useTokensWithFiltering = ( - tokenList: Record, - topTokens: { address: string }[], - sortOrder: TokenBucketPriority = TokenBucketPriority.owned, - chainId?: ChainId | Hex, -) => { - const { token: tokenAddressFromUrl } = useParams(); - - // Only includes non-native tokens - const allDetectedTokens = useSelector(getAllTokens); - const { address: selectedAddress, balance: balanceOnActiveChain } = - useSelector(getSelectedInternalAccountWithBalance); - - const allDetectedTokensForChainAndAddress = chainId - ? allDetectedTokens?.[chainId]?.[selectedAddress] ?? [] - : []; - - const shouldHideZeroBalanceTokens = useSelector( - getShouldHideZeroBalanceTokens, - ); - const { - tokensWithBalances: erc20TokensWithBalances, - }: { tokensWithBalances: TokenWithBalance[] } = useTokenTracker({ - tokens: allDetectedTokensForChainAndAddress, - address: selectedAddress, - hideZeroBalanceTokens: Boolean(shouldHideZeroBalanceTokens), - }); - - const tokenConversionRates = useSelector(getTokenExchangeRates, isEqual); - const conversionRate = useSelector(getConversionRate); - const currentCurrency = useSelector(getCurrentCurrency); - - const sortedErc20TokensWithBalances = useMemo( - () => - erc20TokensWithBalances.toSorted( - (a, b) => Number(b.string) - Number(a.string), - ), - [erc20TokensWithBalances], - ); - - const filteredTokenListGenerator = useCallback( - ( - shouldAddToken: ( - symbol: string, - address?: string, - tokenChainId?: string, - ) => boolean, - ) => { - const buildTokenData = ( - token: SwapsTokenObject, - ): - | AssetWithDisplayData - | AssetWithDisplayData - | undefined => { - if (chainId && shouldAddToken(token.symbol, token.address, chainId)) { - return getRenderableTokenData( - { - ...token, - type: isSwapsDefaultTokenSymbol(token.symbol, chainId) - ? AssetType.native - : AssetType.token, - image: token.iconUrl, - chainId, - }, - tokenConversionRates, - conversionRate, - currentCurrency, - chainId, - tokenList, - ); - } - return undefined; - }; - - return (function* (): Generator< - AssetWithDisplayData | AssetWithDisplayData - > { - const balance = hexToBN(balanceOnActiveChain); - const srcBalanceFields = - sortOrder === TokenBucketPriority.owned - ? { - balance: balanceOnActiveChain, - string: getValueFromWeiHex({ - value: balance, - numberOfDecimals: 4, - toDenomination: EtherDenomination.ETH, - }), - chainId, - } - : {}; - const nativeToken = buildTokenData({ - ...SWAPS_CHAINID_DEFAULT_TOKEN_MAP[ - chainId as keyof typeof SWAPS_CHAINID_DEFAULT_TOKEN_MAP - ], - ...srcBalanceFields, - }); - if (nativeToken) { - yield nativeToken; - } - - if (tokenAddressFromUrl) { - const tokenListItem = - tokenList?.[tokenAddressFromUrl] ?? - tokenList?.[tokenAddressFromUrl.toLowerCase()]; - if (tokenListItem) { - const tokenWithTokenListData = buildTokenData(tokenListItem); - if (tokenWithTokenListData) { - yield tokenWithTokenListData; - } - } - } - - if (sortOrder === TokenBucketPriority.owned) { - for (const tokenWithBalance of sortedErc20TokensWithBalances) { - const cachedTokenData = - tokenWithBalance.address && - tokenList && - (tokenList[tokenWithBalance.address] ?? - tokenList[tokenWithBalance.address.toLowerCase()]); - if (cachedTokenData) { - const combinedTokenData = buildTokenData({ - ...tokenWithBalance, - ...(cachedTokenData ?? {}), - }); - if (combinedTokenData) { - yield combinedTokenData; - } - } - } - } - - for (const topToken of topTokens) { - const tokenListItem = - tokenList?.[topToken.address] ?? - tokenList?.[topToken.address.toLowerCase()]; - if (tokenListItem) { - const tokenWithTokenListData = buildTokenData(tokenListItem); - if (tokenWithTokenListData) { - yield tokenWithTokenListData; - } - } - } - - for (const token of Object.values(tokenList)) { - const tokenWithTokenListData = buildTokenData(token); - if (tokenWithTokenListData) { - yield tokenWithTokenListData; - } - } - })(); - }, - [ - balanceOnActiveChain, - sortedErc20TokensWithBalances, - topTokens, - tokenConversionRates, - conversionRate, - currentCurrency, - chainId, - tokenList, - tokenAddressFromUrl, - ], - ); - - return filteredTokenListGenerator; -}; diff --git a/ui/pages/asset/components/asset-page.test.tsx b/ui/pages/asset/components/asset-page.test.tsx index 28e232a0ba0b..71ca5483b50c 100644 --- a/ui/pages/asset/components/asset-page.test.tsx +++ b/ui/pages/asset/components/asset-page.test.tsx @@ -291,13 +291,13 @@ describe('AssetPage', () => { expect(bridgeButton).not.toBeDisabled(); fireEvent.click(bridgeButton as HTMLElement); - expect(openTabSpy).toHaveBeenCalledTimes(1); - await waitFor(() => + await waitFor(() => { + expect(openTabSpy).toHaveBeenCalledTimes(1); expect(openTabSpy).toHaveBeenCalledWith({ url: `https://portfolio.test/bridge?metamaskEntry=ext_bridge_button&metametricsId=&metricsEnabled=false&marketingEnabled=false&token=${token.address}`, - }), - ); + }); + }); }); it('should not show the Bridge button if chain id is not supported', async () => { diff --git a/ui/pages/asset/components/token-buttons.tsx b/ui/pages/asset/components/token-buttons.tsx index 7a1a71132fbb..c61cb5eeedb3 100644 --- a/ui/pages/asset/components/token-buttons.tsx +++ b/ui/pages/asset/components/token-buttons.tsx @@ -335,7 +335,8 @@ const TokenButtons = ({ /> } label={t('bridge')} - onClick={() => { + onClick={async () => { + await setCorrectChain(); openBridgeExperience(MetaMetricsSwapsEventSource.TokenView, { ...token, iconUrl: token.image, diff --git a/ui/pages/bridge/__snapshots__/index.test.tsx.snap b/ui/pages/bridge/__snapshots__/index.test.tsx.snap index b9a6a4c83797..1138c4c5dbba 100644 --- a/ui/pages/bridge/__snapshots__/index.test.tsx.snap +++ b/ui/pages/bridge/__snapshots__/index.test.tsx.snap @@ -3,13 +3,13 @@ exports[`Bridge renders the component with initial props 1`] = `
-

Bridge -

+
-
diff --git a/ui/pages/bridge/bridge.util.test.ts b/ui/pages/bridge/bridge.util.test.ts index a22cc39876b4..beb6c96ba770 100644 --- a/ui/pages/bridge/bridge.util.test.ts +++ b/ui/pages/bridge/bridge.util.test.ts @@ -1,8 +1,8 @@ +import { zeroAddress } from 'ethereumjs-util'; import fetchWithCache from '../../../shared/lib/fetch-with-cache'; import { CHAIN_IDS } from '../../../shared/constants/network'; import mockBridgeQuotesErc20Erc20 from '../../../test/data/bridge/mock-quotes-erc20-erc20.json'; import mockBridgeQuotesNativeErc20 from '../../../test/data/bridge/mock-quotes-native-erc20.json'; -import { zeroAddress } from '../../__mocks__/ethereumjs-util'; import { fetchBridgeFeatureFlags, fetchBridgeQuotes, @@ -167,6 +167,12 @@ describe('Bridge utils', () => { }, { address: '0x1f9840a85d5af5bf1d1762f925bdaddc4201f986', + decimals: 16, + symbol: 'DEF', + aggregators: ['lifi'], + }, + { + address: '0x1f9840a85d5af5bf1d1762f925bdaddc4201f987', symbol: 'DEF', }, { @@ -198,10 +204,11 @@ describe('Bridge utils', () => { name: 'Ether', symbol: 'ETH', }, - '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984': { - address: '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984', + '0x1f9840a85d5af5bf1d1762f925bdaddc4201f986': { + address: '0x1f9840a85d5af5bf1d1762f925bdaddc4201f986', decimals: 16, - symbol: 'ABC', + symbol: 'DEF', + aggregators: ['lifi'], }, }); }); diff --git a/ui/pages/bridge/bridge.util.ts b/ui/pages/bridge/bridge.util.ts index 13db68ba2ca4..b108bcbe88bd 100644 --- a/ui/pages/bridge/bridge.util.ts +++ b/ui/pages/bridge/bridge.util.ts @@ -50,6 +50,7 @@ import { validateResponse, QUOTE_RESPONSE_VALIDATORS, FEE_DATA_VALIDATORS, + TOKEN_AGGREGATOR_VALIDATORS, } from './utils/validators'; const CLIENT_ID_HEADER = { 'X-Client-Id': BRIDGE_CLIENT_ID }; @@ -124,7 +125,12 @@ export async function fetchBridgeTokens( tokens.forEach((token: unknown) => { if ( - validateResponse(TOKEN_VALIDATORS, token, url) && + validateResponse( + TOKEN_VALIDATORS.concat(TOKEN_AGGREGATOR_VALIDATORS), + token, + url, + false, // Don't log errors for tokens + ) && !( isSwapsDefaultTokenSymbol(token.symbol, chainId) || isSwapsDefaultTokenAddress(token.address, chainId) diff --git a/ui/pages/bridge/index.scss b/ui/pages/bridge/index.scss index e9d6009676f9..011b386e34a2 100644 --- a/ui/pages/bridge/index.scss +++ b/ui/pages/bridge/index.scss @@ -5,28 +5,40 @@ @import 'transaction-details/index'; -.bridge { - max-height: 100vh; - width: 360px; - position: relative; - - &__container { - width: 100%; - - .multichain-page-footer { - position: absolute; - width: 100%; - height: 80px; - bottom: 0; - padding: 16px; - display: flex; - - button { - flex: 1; - height: 100%; - font-size: 14px; - font-weight: 500; - } - } - } + +// TODO add to design-tokens package +.mm-avatar-base--size-xxs { + --size: 12px; +} + +[data-theme='light'], +.light { + --color-background-alternative-soft: #f9fafb; + --color-text-alternative-soft: #6a737d; + --color-icon-alternative-soft: #6a737d; +} + +[data-theme='dark'], +.dark { + --color-background-alternative-soft: #1f2124; + --color-text-alternative-soft: #848c96; + --color-icon-alternative-soft: #848c96; +} + +.mm-box--color-text-alternative-soft { + color: var(--color-text-alternative-soft); +} + +.mm-box--color-icon-alternative-soft { + color: var(--color-icon-alternative-soft); +} + +.mm-box--background-color-background-alternative-soft { + background-color: var(--color-background-alternative-soft); +} + +.bridge__container { + height: 100%; + min-width: 360px; + max-width: 480px; } diff --git a/ui/pages/bridge/index.tsx b/ui/pages/bridge/index.tsx index 9eddeffee798..10e9984ad1b9 100644 --- a/ui/pages/bridge/index.tsx +++ b/ui/pages/bridge/index.tsx @@ -1,4 +1,4 @@ -import React, { useContext, useEffect } from 'react'; +import React, { useContext, useEffect, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { Switch, useHistory } from 'react-router-dom'; import { I18nContext } from '../../contexts/i18n'; @@ -16,25 +16,21 @@ import { ButtonIconSize, IconName, } from '../../components/component-library'; -import { getProviderConfig } from '../../../shared/modules/selectors/networks'; import { - getCurrentCurrency, - getIsBridgeChain, - getIsBridgeEnabled, -} from '../../selectors'; + getCurrentChainId, + getSelectedNetworkClientId, +} from '../../../shared/modules/selectors/networks'; +import { getIsBridgeChain, getIsBridgeEnabled } from '../../selectors'; import useBridging from '../../hooks/bridge/useBridging'; -import { - Content, - Footer, - Header, -} from '../../components/multichain/pages/page'; +import { Content, Header, Page } from '../../components/multichain/pages/page'; import { useSwapsFeatureFlags } from '../swaps/hooks/useSwapsFeatureFlags'; import { resetBridgeState, setFromChain } from '../../ducks/bridge/actions'; import { useGasFeeEstimates } from '../../hooks/useGasFeeEstimates'; import { useBridgeExchangeRates } from '../../hooks/bridge/useBridgeExchangeRates'; import { useQuoteFetchEvents } from '../../hooks/bridge/useQuoteFetchEvents'; +import { TextVariant } from '../../helpers/constants/design-system'; import PrepareBridgePage from './prepare/prepare-bridge-page'; -import { BridgeCTAButton } from './prepare/bridge-cta-button'; +import { BridgeTransactionSettingsModal } from './prepare/bridge-transaction-settings-modal'; const CrossChainSwap = () => { const t = useContext(I18nContext); @@ -47,15 +43,15 @@ const CrossChainSwap = () => { const dispatch = useDispatch(); const isBridgeEnabled = useSelector(getIsBridgeEnabled); - const providerConfig = useSelector(getProviderConfig); const isBridgeChain = useSelector(getIsBridgeChain); - const currency = useSelector(getCurrentCurrency); + const selectedNetworkClientId = useSelector(getSelectedNetworkClientId); + const chainId = useSelector(getCurrentChainId); useEffect(() => { - if (isBridgeChain && isBridgeEnabled && providerConfig) { - dispatch(setFromChain(providerConfig.chainId)); + if (isBridgeChain && isBridgeEnabled && chainId) { + dispatch(setFromChain(chainId)); } - }, [isBridgeChain, isBridgeEnabled, providerConfig, currency]); + }, [isBridgeChain, isBridgeEnabled, chainId]); const resetControllerAndInputStates = async () => { await dispatch(resetBridgeState()); @@ -74,7 +70,7 @@ const CrossChainSwap = () => { }, []); // Needed for refreshing gas estimates - useGasFeeEstimates(providerConfig?.id); + useGasFeeEstimates(selectedNetworkClientId); // Needed for fetching exchange rates for tokens that have not been imported useBridgeExchangeRates(); // Emits events related to quote-fetching @@ -90,46 +86,56 @@ const CrossChainSwap = () => { await resetControllerAndInputStates(); }; + const [isSettingsModalOpen, setIsSettingsModalOpen] = useState(false); + return ( -
-
-
- } - endAccessory={ - - } - > - {t('bridge')} -
- - - { - return ; - }} - /> - - -
- -
-
-
+ +
+ } + endAccessory={ + { + setIsSettingsModalOpen(true); + }} + /> + } + > + {t('bridge')} +
+ + + { + return ( + <> + { + setIsSettingsModalOpen(false); + }} + /> + + + ); + }} + /> + + +
); }; diff --git a/ui/pages/bridge/layout/tooltip.tsx b/ui/pages/bridge/layout/tooltip.tsx index b6781c9bf480..00f52bf0b617 100644 --- a/ui/pages/bridge/layout/tooltip.tsx +++ b/ui/pages/bridge/layout/tooltip.tsx @@ -1,6 +1,10 @@ import React, { useState } from 'react'; import { Box, + Icon, + IconName, + IconSize, + PolymorphicRef, Popover, PopoverHeader, PopoverPosition, @@ -8,38 +12,64 @@ import { Text, } from '../../../components/component-library'; import { + Display, + IconColor, JustifyContent, TextAlign, TextColor, } from '../../../helpers/constants/design-system'; +import Column from './column'; const Tooltip = React.forwardRef( - ({ - children, - title, - triggerElement, - disabled = false, - ...props - }: PopoverProps<'div'> & { - triggerElement: React.ReactElement; - disabled?: boolean; - }) => { + ( + { + children, + title, + triggerElement, + disabled = false, + onClose, + iconName, + style, + ...props + }: PopoverProps<'div'> & { + triggerElement?: React.ReactElement; + disabled?: boolean; + onClose?: () => void; + iconName?: IconName; + }, + ref?: PolymorphicRef<'div'>, + ) => { const [isOpen, setIsOpen] = useState(false); const [referenceElement, setReferenceElement] = useState(null); const handleMouseEnter = () => setIsOpen(true); const handleMouseLeave = () => setIsOpen(false); - const setBoxRef = (ref: HTMLSpanElement | null) => setReferenceElement(ref); + const setBoxRef = (newRef: HTMLSpanElement | null) => + setReferenceElement(newRef); return ( - <> + - {triggerElement} + {triggerElement ?? + (iconName && ( + + )) ?? ( + + )} {!disabled && ( - - {title} - - - {children} - + + {title && ( + + {title} + + )} + + {children} + + )} - + ); }, ); diff --git a/ui/pages/bridge/prepare/__snapshots__/bridge-cta-button.test.tsx.snap b/ui/pages/bridge/prepare/__snapshots__/bridge-cta-button.test.tsx.snap index f225adec3b6d..0b46d2764523 100644 --- a/ui/pages/bridge/prepare/__snapshots__/bridge-cta-button.test.tsx.snap +++ b/ui/pages/bridge/prepare/__snapshots__/bridge-cta-button.test.tsx.snap @@ -1,14 +1,42 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`BridgeCTAButton should render the component's initial state 1`] = ` +exports[`BridgeCTAButton should disable the component when quotes are loading and there are no existing quotes 1`] = ` +
+

+

+`; + +exports[`BridgeCTAButton should enable the component when quotes are loading and there are existing quotes 1`] = `
`; + +exports[`BridgeCTAButton should render the component when amount and dest token is missing 1`] = ` +
+

+ Select token and amount +

+
+`; + +exports[`BridgeCTAButton should render the component's initial state 1`] = ` +
+

+ Select token +

+
+`; diff --git a/ui/pages/bridge/prepare/__snapshots__/prepare-bridge-page.test.tsx.snap b/ui/pages/bridge/prepare/__snapshots__/prepare-bridge-page.test.tsx.snap index b4873c7e1c89..d5b980d13fd6 100644 --- a/ui/pages/bridge/prepare/__snapshots__/prepare-bridge-page.test.tsx.snap +++ b/ui/pages/bridge/prepare/__snapshots__/prepare-bridge-page.test.tsx.snap @@ -3,125 +3,109 @@ exports[`PrepareBridgePage should render the component, with initial state 1`] = `
-
+ -
-
-
-
+ + + +
+

- + $0.00

-
- - $0.00 - -
+

+
+
- -
-
-
- -
-
+
-

- -

+

+

+ +
+
+
+
+
- - $0.00 - + Select token and amount +

@@ -239,131 +188,109 @@ exports[`PrepareBridgePage should render the component, with initial state 1`] = exports[`PrepareBridgePage should render the component, with inputs set 1`] = `
-
+ -
+ ETH logo +
-
-
+ + + +
+

- + $0.00

-
- - $5,805.77 - -
+

+
+
+
+
+
- - $0.00 - + Select token and amount +

diff --git a/ui/pages/bridge/prepare/bridge-cta-button.test.tsx b/ui/pages/bridge/prepare/bridge-cta-button.test.tsx index c4ff26f6c743..d4e0fd0855c5 100644 --- a/ui/pages/bridge/prepare/bridge-cta-button.test.tsx +++ b/ui/pages/bridge/prepare/bridge-cta-button.test.tsx @@ -22,7 +22,7 @@ describe('BridgeCTAButton', () => { }, bridgeSliceOverrides: { fromTokenInputValue: 1 }, }); - const { container, getByText, getByRole } = renderWithProvider( + const { container, getByText } = renderWithProvider( , configureStore(mockStore), ); @@ -30,7 +30,6 @@ describe('BridgeCTAButton', () => { expect(container).toMatchSnapshot(); expect(getByText('Select token')).toBeInTheDocument(); - expect(getByRole('button')).toBeDisabled(); }); it('should render the component when amount is missing', () => { @@ -54,13 +53,12 @@ describe('BridgeCTAButton', () => { toChainId: CHAIN_IDS.LINEA_MAINNET, }, }); - const { getByText, getByRole } = renderWithProvider( + const { getByText } = renderWithProvider( , configureStore(mockStore), ); - expect(getByText('Enter amount')).toBeInTheDocument(); - expect(getByRole('button')).toBeDisabled(); + expect(getByText('Select amount')).toBeInTheDocument(); }); it('should render the component when amount and dest token is missing', () => { @@ -84,13 +82,13 @@ describe('BridgeCTAButton', () => { toChainId: CHAIN_IDS.LINEA_MAINNET, }, }); - const { getByText, getByRole } = renderWithProvider( + const { getByText, container } = renderWithProvider( , configureStore(mockStore), ); expect(getByText('Select token and amount')).toBeInTheDocument(); - expect(getByRole('button')).toBeDisabled(); + expect(container).toMatchSnapshot(); }); it('should render the component when tx is submittable', () => { @@ -124,7 +122,7 @@ describe('BridgeCTAButton', () => { configureStore(mockStore), ); - expect(getByText('Confirm')).toBeInTheDocument(); + expect(getByText('Submit')).toBeInTheDocument(); expect(getByRole('button')).not.toBeDisabled(); }); @@ -160,13 +158,12 @@ describe('BridgeCTAButton', () => { quotesLoadingStatus: RequestStatus.LOADING, }, }); - const { getByText, getByRole } = renderWithProvider( + const { container } = renderWithProvider( , configureStore(mockStore), ); - expect(getByText('Fetching quotes...')).toBeInTheDocument(); - expect(getByRole('button')).toBeDisabled(); + expect(container).toMatchSnapshot(); }); it('should enable the component when quotes are loading and there are existing quotes', () => { @@ -201,12 +198,13 @@ describe('BridgeCTAButton', () => { quotesLoadingStatus: RequestStatus.LOADING, }, }); - const { getByText, getByRole } = renderWithProvider( + const { getByText, getByRole, container } = renderWithProvider( , configureStore(mockStore), ); - expect(getByText('Confirm')).toBeInTheDocument(); + expect(getByText('Submit')).toBeInTheDocument(); expect(getByRole('button')).not.toBeDisabled(); + expect(container).toMatchSnapshot(); }); }); diff --git a/ui/pages/bridge/prepare/bridge-cta-button.tsx b/ui/pages/bridge/prepare/bridge-cta-button.tsx index c8738a6551de..ea0892abaf8c 100644 --- a/ui/pages/bridge/prepare/bridge-cta-button.tsx +++ b/ui/pages/bridge/prepare/bridge-cta-button.tsx @@ -1,6 +1,10 @@ import React, { useEffect, useMemo, useState } from 'react'; import { useSelector } from 'react-redux'; -import { Button } from '../../../components/component-library'; +import { + ButtonPrimary, + ButtonPrimarySize, + Text, +} from '../../../components/component-library'; import { getFromAmount, getFromChain, @@ -12,6 +16,12 @@ import { } from '../../../ducks/bridge/selectors'; import { useI18nContext } from '../../../hooks/useI18nContext'; import useSubmitBridgeTransaction from '../hooks/useSubmitBridgeTransaction'; +import { + BlockSize, + TextAlign, + TextColor, + TextVariant, +} from '../../../helpers/constants/design-system'; import useLatestBalance from '../../../hooks/bridge/useLatestBalance'; import { useIsTxSubmittable } from '../../../hooks/bridge/useIsTxSubmittable'; import { useCrossChainSwapsEventTracker } from '../../../hooks/bridge/useCrossChainSwapsEventTracker'; @@ -19,6 +29,8 @@ import { useRequestProperties } from '../../../hooks/bridge/events/useRequestPro import { useRequestMetadataProperties } from '../../../hooks/bridge/events/useRequestMetadataProperties'; import { useTradeProperties } from '../../../hooks/bridge/events/useTradeProperties'; import { MetaMetricsEventName } from '../../../../shared/constants/metametrics'; +import { SWAPS_CHAINID_DEFAULT_TOKEN_MAP } from '../../../../shared/constants/swaps'; +import { getNativeCurrency } from '../../../ducks/metamask/metamask'; export const BridgeCTAButton = () => { const t = useI18nContext(); @@ -36,10 +48,22 @@ export const BridgeCTAButton = () => { const { submitBridgeTransaction } = useSubmitBridgeTransaction(); - const { isNoQuotesAvailable, isInsufficientBalance } = - useSelector(getValidationErrors); + const { + isNoQuotesAvailable, + isInsufficientBalance: isInsufficientBalance_, + isInsufficientGasBalance: isInsufficientGasBalance_, + isInsufficientGasForQuote: isInsufficientGasForQuote_, + } = useSelector(getValidationErrors); const { balanceAmount } = useLatestBalance(fromToken, fromChain?.chainId); + const { balanceAmount: nativeAssetBalance } = useLatestBalance( + fromChain?.chainId + ? SWAPS_CHAINID_DEFAULT_TOKEN_MAP[ + fromChain.chainId as keyof typeof SWAPS_CHAINID_DEFAULT_TOKEN_MAP + ] + : null, + fromChain?.chainId, + ); const isTxSubmittable = useIsTxSubmittable(); const trackCrossChainSwapsEvent = useCrossChainSwapsEventTracker(); @@ -47,7 +71,17 @@ export const BridgeCTAButton = () => { const requestMetadataProperties = useRequestMetadataProperties(); const tradeProperties = useTradeProperties(); + const ticker = useSelector(getNativeCurrency); const [isQuoteExpired, setIsQuoteExpired] = useState(false); + + const isInsufficientBalance = isInsufficientBalance_(balanceAmount); + const [isSubmitting, setIsSubmitting] = useState(false); + + const isInsufficientGasBalance = + isInsufficientGasBalance_(nativeAssetBalance); + const isInsufficientGasForQuote = + isInsufficientGasForQuote_(nativeAssetBalance); + useEffect(() => { let timeout: NodeJS.Timeout; // Reset the isQuoteExpired if quote fethching restarts @@ -65,19 +99,19 @@ export const BridgeCTAButton = () => { }, [isQuoteGoingToRefresh, quotesRefreshCount]); const label = useMemo(() => { - if (isQuoteExpired) { + if (isQuoteExpired && !isNoQuotesAvailable) { return t('bridgeQuoteExpired'); } - if (isLoading && !isTxSubmittable) { - return t('swapFetchingQuotes'); + if (isLoading && !isTxSubmittable && !activeQuote) { + return ''; } - if (isNoQuotesAvailable) { - return t('swapQuotesNotAvailableErrorTitle'); + if (isInsufficientGasBalance || isNoQuotesAvailable) { + return ''; } - if (isInsufficientBalance(balanceAmount)) { + if (isInsufficientBalance || isInsufficientGasForQuote) { return t('alertReasonInsufficientBalance'); } @@ -89,7 +123,7 @@ export const BridgeCTAButton = () => { } if (isTxSubmittable) { - return t('confirm'); + return t('submit'); } return t('swapSelectToken'); @@ -97,17 +131,26 @@ export const BridgeCTAButton = () => { isLoading, fromAmount, toToken, + ticker, isTxSubmittable, balanceAmount, isInsufficientBalance, isQuoteExpired, + isInsufficientGasBalance, + isInsufficientGasForQuote, + isQuoteExpired, ]); - return ( - + ); }; diff --git a/ui/pages/bridge/prepare/bridge-input-group.tsx b/ui/pages/bridge/prepare/bridge-input-group.tsx index 2f8ea8fda1c9..c7dac296025a 100644 --- a/ui/pages/bridge/prepare/bridge-input-group.tsx +++ b/ui/pages/bridge/prepare/bridge-input-group.tsx @@ -1,185 +1,271 @@ -import React from 'react'; -import { Hex } from '@metamask/utils'; +import React, { useEffect, useRef, useState } from 'react'; import { useSelector } from 'react-redux'; -import { SwapsTokenObject } from '../../../../shared/constants/swaps'; +import { BigNumber } from 'bignumber.js'; +import { getAddress } from 'ethers/lib/utils'; import { - Box, Text, TextField, TextFieldType, + ButtonLink, + PopoverPosition, + Button, + ButtonSize, } from '../../../components/component-library'; import { AssetPicker } from '../../../components/multichain/asset-picker-amount/asset-picker'; import { TabName } from '../../../components/multichain/asset-picker-amount/asset-picker-modal/asset-picker-modal-tabs'; -import CurrencyDisplay from '../../../components/ui/currency-display'; import { useI18nContext } from '../../../hooks/useI18nContext'; -import { useTokenFiatAmount } from '../../../hooks/useTokenFiatAmount'; -import { useEthFiatAmount } from '../../../hooks/useEthFiatAmount'; -import { isSwapsDefaultTokenSymbol } from '../../../../shared/modules/swaps.utils'; -import Tooltip from '../../../components/ui/tooltip'; -import { SwapsEthToken } from '../../../selectors'; +import { getCurrentCurrency, getLocale } from '../../../selectors'; +import { formatCurrencyAmount, formatTokenAmount } from '../utils/quote'; +import { Column, Row, Tooltip } from '../layout'; import { - ERC20Asset, - NativeAsset, -} from '../../../components/multichain/asset-picker-amount/asset-picker-modal/types'; -import { zeroAddress } from '../../../__mocks__/ethereumjs-util'; + Display, + FontWeight, + TextAlign, + JustifyContent, + TextVariant, + TextColor, +} from '../../../helpers/constants/design-system'; import { AssetType } from '../../../../shared/constants/transaction'; -import { - CHAIN_ID_TO_CURRENCY_SYMBOL_MAP, - CHAIN_ID_TOKEN_IMAGE_MAP, -} from '../../../../shared/constants/network'; +import { BRIDGE_QUOTE_MAX_RETURN_DIFFERENCE_PERCENTAGE } from '../../../../shared/constants/bridge'; import useLatestBalance from '../../../hooks/bridge/useLatestBalance'; import { getBridgeQuotes, getValidationErrors, } from '../../../ducks/bridge/selectors'; -import { TextColor } from '../../../helpers/constants/design-system'; - -const generateAssetFromToken = ( - chainId: Hex, - tokenDetails: SwapsTokenObject | SwapsEthToken, -): ERC20Asset | NativeAsset => { - if ('iconUrl' in tokenDetails && tokenDetails.address !== zeroAddress()) { - return { - type: AssetType.token, - image: tokenDetails.iconUrl, - symbol: tokenDetails.symbol, - address: tokenDetails.address, - chainId, - }; - } - - return { - type: AssetType.native, - image: - CHAIN_ID_TOKEN_IMAGE_MAP[ - chainId as keyof typeof CHAIN_ID_TOKEN_IMAGE_MAP - ], - symbol: - CHAIN_ID_TO_CURRENCY_SYMBOL_MAP[ - chainId as keyof typeof CHAIN_ID_TO_CURRENCY_SYMBOL_MAP - ], - chainId, - }; -}; +import { shortenString } from '../../../helpers/utils/util'; +import { BridgeToken } from '../types'; +import { useCopyToClipboard } from '../../../hooks/useCopyToClipboard'; +import { MINUTE } from '../../../../shared/constants/time'; +import { BridgeAssetPickerButton } from './components/bridge-asset-picker-button'; export const BridgeInputGroup = ({ - className, header, token, onAssetChange, onAmountChange, networkProps, + isTokenListLoading, customTokenListGenerator, + amountFieldProps, + amountInFiat, + onMaxButtonClick, isMultiselectEnabled, - amountFieldProps = {}, }: { - className: string; + amountInFiat?: BigNumber; onAmountChange?: (value: string) => void; - token: SwapsTokenObject | SwapsEthToken | null; - amountFieldProps?: Pick< + token: BridgeToken | null; + amountFieldProps: Pick< React.ComponentProps, 'testId' | 'autoFocus' | 'value' | 'readOnly' | 'disabled' | 'className' >; + onMaxButtonClick?: (value: string) => void; } & Pick< React.ComponentProps, | 'networkProps' | 'header' | 'customTokenListGenerator' | 'onAssetChange' + | 'isTokenListLoading' | 'isMultiselectEnabled' >) => { const t = useI18nContext(); - const { isLoading, activeQuote } = useSelector(getBridgeQuotes); - const { isInsufficientBalance } = useSelector(getValidationErrors); + const { isLoading } = useSelector(getBridgeQuotes); + const { isInsufficientBalance, isEstimatedReturnLow } = + useSelector(getValidationErrors); + const currency = useSelector(getCurrentCurrency); + const locale = useSelector(getLocale); - const tokenFiatValue = useTokenFiatAmount( - token?.address || undefined, - amountFieldProps?.value?.toString() || '0x0', - token?.symbol, - { - showFiat: true, - }, - true, - ); - const ethFiatValue = useEthFiatAmount( - amountFieldProps?.value?.toString() || '0x0', - { showFiat: true }, - true, - ); + const selectedChainId = networkProps?.network?.chainId; + const { balanceAmount } = useLatestBalance(token, selectedChainId); - const { formattedBalance, balanceAmount } = useLatestBalance( - token, - networkProps?.network?.chainId, - ); + const [, handleCopy] = useCopyToClipboard(MINUTE) as [ + boolean, + (text: string) => void, + ]; + + const inputRef = useRef(null); + + const [isLowReturnTooltipOpen, setIsLowReturnTooltipOpen] = useState(true); + + useEffect(() => { + if (inputRef.current) { + inputRef.current.value = amountFieldProps?.value?.toString() ?? ''; + inputRef.current.focus(); + } + }, [amountFieldProps]); const isAmountReadOnly = amountFieldProps?.readOnly || amountFieldProps?.disabled; return ( - - + + + ) => { + // Only allow numbers and at most one decimal point + if ( + e && + !/^[0-9]*\.{0,1}[0-9]*$/u.test( + `${amountFieldProps.value ?? ''}${e.key}`, + ) + ) { + e.preventDefault(); + } + }} + onChange={(e) => { + // Remove characters that are not numbers or decimal points if rendering a controlled or pasted value + const cleanedValue = e.target.value.replace(/[^0-9.]+/gu, ''); + onAmountChange?.(cleanedValue); + }} + {...amountFieldProps} + /> - - + isAmountReadOnly && !token ? ( + + ) : ( + + ) + } + + + + + + {isAmountReadOnly && + isEstimatedReturnLow && + isLowReturnTooltipOpen && ( + setIsLowReturnTooltipOpen(false)} + triggerElement={} + flip={false} + offset={[0, 80]} + > + {t('lowEstimatedReturnTooltipMessage', [ + BRIDGE_QUOTE_MAX_RETURN_DIFFERENCE_PERCENTAGE * 100, + ])} + + )} + { - onAmountChange?.(e.target.value); - }} - {...amountFieldProps} - /> - - - + textAlign={TextAlign.End} + ellipsis + > + {isAmountReadOnly && isLoading && amountFieldProps.value === '0' + ? t('bridgeCalculatingAmount') + : undefined} + {amountInFiat && formatCurrencyAmount(amountInFiat, currency, 2)} + + { + if (isAmountReadOnly && token && selectedChainId) { + handleCopy(getAddress(token.address)); + } + }} + as={isAmountReadOnly ? 'a' : 'p'} > - {formattedBalance ? `${t('balance')}: ${formattedBalance}` : ' '} + {isAmountReadOnly && + token && + selectedChainId && + token.type === AssetType.token + ? shortenString(token.address, { + truncatedCharLimit: 11, + truncatedStartChars: 4, + truncatedEndChars: 4, + skipCharacterInEnd: false, + }) + : undefined} + {!isAmountReadOnly && balanceAmount + ? formatTokenAmount(locale, balanceAmount, token?.symbol) + : undefined} + {onMaxButtonClick && + token && + token.type !== AssetType.native && + balanceAmount && ( + onMaxButtonClick(balanceAmount?.toFixed())} + > + {t('max')} + + )} - - - + + ); }; diff --git a/ui/pages/bridge/prepare/bridge-transaction-settings-modal.stories.tsx b/ui/pages/bridge/prepare/bridge-transaction-settings-modal.stories.tsx new file mode 100644 index 000000000000..9802ecce9e6f --- /dev/null +++ b/ui/pages/bridge/prepare/bridge-transaction-settings-modal.stories.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import { Provider } from 'react-redux'; +import configureStore from '../../../store/store'; +import { createBridgeMockStore } from '../../../../test/jest/mock-store'; +import { BridgeTransactionSettingsModal } from './bridge-transaction-settings-modal'; + +const storybook = { + title: 'Pages/Bridge/TransactionSettingsModal', + component: BridgeTransactionSettingsModal, +}; + +export const DefaultStory = () => { + return {}} />; +}; + +DefaultStory.storyName = 'Default'; +DefaultStory.decorators = [ + (Story) => ( + + + + ), +]; + +export default storybook; diff --git a/ui/pages/bridge/prepare/bridge-transaction-settings-modal.tsx b/ui/pages/bridge/prepare/bridge-transaction-settings-modal.tsx new file mode 100644 index 000000000000..8e03827103de --- /dev/null +++ b/ui/pages/bridge/prepare/bridge-transaction-settings-modal.tsx @@ -0,0 +1,217 @@ +import React, { useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { + Button, + ButtonPrimary, + ButtonPrimarySize, + ButtonSize, + ButtonVariant, + IconName, + Modal, + ModalContent, + ModalFooter, + ModalHeader, + ModalOverlay, + PopoverPosition, + Text, + TextField, + TextFieldType, +} from '../../../components/component-library'; +import { useI18nContext } from '../../../hooks/useI18nContext'; +import { + BackgroundColor, + BlockSize, + BorderColor, + BorderRadius, + JustifyContent, + TextColor, + TextVariant, +} from '../../../helpers/constants/design-system'; +import { getSlippage } from '../../../ducks/bridge/selectors'; +import { setSlippage } from '../../../ducks/bridge/actions'; +import { useCrossChainSwapsEventTracker } from '../../../hooks/bridge/useCrossChainSwapsEventTracker'; +import { MetaMetricsEventName } from '../../../../shared/constants/metametrics'; +import { BRIDGE_DEFAULT_SLIPPAGE } from '../../../../shared/constants/bridge'; +import { Column, Row, Tooltip } from '../layout'; + +const HARDCODED_SLIPPAGE_OPTIONS = [BRIDGE_DEFAULT_SLIPPAGE, 3]; + +export const BridgeTransactionSettingsModal = ({ + onClose, + isOpen, +}: Omit, 'children'>) => { + const t = useI18nContext(); + const trackCrossChainSwapsEvent = useCrossChainSwapsEventTracker(); + + const dispatch = useDispatch(); + + const slippage = useSelector(getSlippage); + + const [localSlippage, setLocalSlippage] = useState( + slippage, + ); + const [customSlippage, setCustomSlippage] = useState( + slippage && HARDCODED_SLIPPAGE_OPTIONS.includes(slippage) + ? undefined + : slippage, + ); + const [showCustomButton, setShowCustomButton] = useState(true); + + return ( + + + + {t('transactionSettings')} + + + {t('swapsMaxSlippage')} + + {t('swapSlippageTooltip')} + + + + {HARDCODED_SLIPPAGE_OPTIONS.map((hardcodedSlippage) => { + return ( + + ); + })} + {showCustomButton && ( + + )} + {!showCustomButton && ( + { + // Remove characters that are not numbers or decimal points if rendering a controlled or pasted value + const cleanedValue = e.target.value.replace(/[^0-9.]+/gu, ''); + setLocalSlippage(undefined); + setCustomSlippage( + cleanedValue.length > 0 ? Number(cleanedValue) : undefined, + ); + }} + autoFocus={true} + onBlur={() => { + console.log('====blur'); + setShowCustomButton(true); + }} + onFocus={() => { + console.log('====focus'); + setShowCustomButton(false); + }} + onKeyPress={(e?: React.KeyboardEvent) => { + // Only allow numbers and at most one decimal point + if ( + e && + !/^[0-9]*\.{0,1}[0-9]*$/u.test( + `${customSlippage ?? ''}${e.key}`, + ) + ) { + e.preventDefault(); + } + }} + endAccessory={%} + /> + )} + + + + { + const newSlippage = localSlippage || customSlippage; + newSlippage && + trackCrossChainSwapsEvent({ + event: MetaMetricsEventName.InputChanged, + properties: { + input: 'slippage', + value: newSlippage.toString(), + }, + }); + dispatch(setSlippage(newSlippage)); + onClose(); + }} + > + {t('submit')} + + + + + ); +}; diff --git a/ui/pages/bridge/prepare/components/bridge-asset-picker-button.tsx b/ui/pages/bridge/prepare/components/bridge-asset-picker-button.tsx new file mode 100644 index 000000000000..b04da2b981fa --- /dev/null +++ b/ui/pages/bridge/prepare/components/bridge-asset-picker-button.tsx @@ -0,0 +1,93 @@ +import React from 'react'; +import { + SelectButtonProps, + SelectButtonSize, +} from '../../../../components/component-library/select-button/select-button.types'; +import { + AvatarNetwork, + AvatarNetworkSize, + AvatarToken, + BadgeWrapper, + IconName, + SelectButton, + Text, +} from '../../../../components/component-library'; +import { + AlignItems, + BackgroundColor, + BorderColor, + BorderRadius, + Display, + OverflowWrap, + TextVariant, +} from '../../../../helpers/constants/design-system'; +import { useI18nContext } from '../../../../hooks/useI18nContext'; +import { AssetPicker } from '../../../../components/multichain/asset-picker-amount/asset-picker'; + +export const BridgeAssetPickerButton = ({ + asset, + networkProps, + networkImageSrc, + ...props +}: { + networkImageSrc?: string; +} & SelectButtonProps<'div'> & + Pick, 'asset' | 'networkProps'>) => { + const t = useI18nContext(); + + return ( + + {asset?.symbol ?? t('bridgeTo')} + + } + startAccessory={ + asset ? ( + + ) : undefined + } + > + {asset ? ( + + ) : undefined} + + ) : undefined + } + {...props} + /> + ); +}; diff --git a/ui/pages/bridge/prepare/index.scss b/ui/pages/bridge/prepare/index.scss index 079c057c59de..cfefc86e52e0 100644 --- a/ui/pages/bridge/prepare/index.scss +++ b/ui/pages/bridge/prepare/index.scss @@ -1,144 +1,38 @@ @use "design-system"; .prepare-bridge-page { - display: flex; - flex-flow: column; flex: 1; - width: 100%; - gap: 24px; - &__content { - display: flex; - flex-direction: column; - padding: 16px 0 16px 0; - border-radius: 8px; - border: 1px solid var(--color-border-muted); - } - - .bridge-box { - display: flex; - flex-direction: column; - gap: 4px; - justify-content: center; - padding: 8px 16px 8px 16px; - } - - &__input-row { - display: flex; - flex-direction: row; - justify-content: space-between; - width: 298px; - gap: 16px; - - input[type="number"]::-webkit-inner-spin-button { - -webkit-appearance: none; - -moz-appearance: none; - display: none; - } - - input[type="number"]:hover::-webkit-inner-spin-button { - -webkit-appearance: none; - -moz-appearance: none; - display: none; - } - - .mm-text-field { - background-color: inherit; - - &--focused { - outline: none; - } - } - - .defined { - opacity: 1; - - & > .mm-input--disabled { - opacity: 1; - } - } - } - - .amount-input { - border: none; - - input { - text-align: right; - padding-right: 0; - font-size: 24px; - font-weight: 700; - - &:focus, - &:focus-visible { - outline: none; - } - } + .mm-text-field { + background-color: inherit; - .mm-text-field--focused { + &--focused { outline: none; } } - &__amounts-row { - display: flex; - flex-direction: row; - justify-content: space-between; - width: 298px; - height: 22px; - - p, - span { - color: var(--color-text-alternative); - font-size: 12px; - } - } - - .asset-picker { - border: 1px solid var(--color-border-muted); - height: 40px; - min-width: fit-content; - max-width: fit-content; - background-color: inherit; - + .defined { + & > .mm-input--disabled, p { - font-size: 14px; - font-weight: 500; + opacity: 1; } + } - .mm-avatar-token { - height: 24px; - width: 24px; - border: 1px solid var(--color-border-muted); - } + .mm-select-button__content { + max-height: 100%; + overflow: hidden; + } - .mm-badge-wrapper__badge-container .mm-avatar-base { - height: 10px; - width: 10px; - border: none; - } + .amount-input { + border: none; + padding: 0; + width: 100%; + gap: 4px; + height: fit-content; } &__switch-tokens { - display: flex; - justify-content: center; - align-items: center; - - &::before, - &::after { - content: ''; - border-top: 1px solid var(--color-border-muted); - flex-grow: 1; - } - button { - border-radius: 50%; - padding: 10px; - border: 1px solid var(--color-border-muted); - transition: all 0.3s ease-in-out; - cursor: pointer; - width: 40px; - height: 40px; - &:hover:enabled { background: var(--color-background-default-hover); @@ -161,12 +55,54 @@ } .mm-icon { - color: var(--color-icon-alternative); transition: all 0.3s ease-in-out; } + } + + .mascot-background-animation__animation { + margin-top: 24px; + margin-bottom: 16px; + } + + .highlight { + padding: 16px; + background: var(--color-background-default); + border: none; + border-radius: 8px; + + [data-theme='light'], + .light { + box-shadow: 0 0 2px 0 #e2e4e9, 0 0 16px 0 rgba(226, 228, 233, 0.16); + } + + [data-theme='dark'], + .dark { + box-shadow: 0 0 2px 0 #18191b, 0 0 16px 0 #18191b; + } + } +} + +.bridge-settings-modal { + .mm-button-secondary { + &:hover { + background-color: var(--color-background-default-hover); + } + } + + .mm-text-field { + height: 32px; + width: 94px; + + &--focused, + &:focus-visible { + outline: none; + } - &:disabled { - cursor: not-allowed; + input { + font-size: var(--font-size-2); + padding-top: 1px; + width: 100%; + height: 32px; } } } diff --git a/ui/pages/bridge/prepare/prepare-bridge-page.stories.tsx b/ui/pages/bridge/prepare/prepare-bridge-page.stories.tsx new file mode 100644 index 000000000000..3e955d3e34ef --- /dev/null +++ b/ui/pages/bridge/prepare/prepare-bridge-page.stories.tsx @@ -0,0 +1,248 @@ +import React from 'react'; +import { Provider } from 'react-redux'; +import configureStore from '../../../store/store'; +import { createBridgeMockStore } from '../../../../test/jest/mock-store'; +import { CHAIN_IDS } from '../../../../shared/constants/network'; +import { RequestStatus } from '../../../../app/scripts/controllers/bridge/constants'; +import CrossChainSwap from '../index'; +import { MemoryRouter } from 'react-router-dom'; +import { + CROSS_CHAIN_SWAP_ROUTE, + PREPARE_SWAP_ROUTE, +} from '../../../helpers/constants/routes'; +import mockBridgeQuotesErc20Erc20 from '../../../../test/data/bridge/mock-quotes-erc20-erc20.json'; + +const storybook = { + title: 'Pages/Bridge/CrossChainSwapPage', + component: CrossChainSwap, +}; + +const Wrapper = ({ children }) => ( +
+ + {children} + +
+); + +const mockFeatureFlags = { + srcNetworkAllowlist: [CHAIN_IDS.MAINNET, CHAIN_IDS.LINEA_MAINNET], + destNetworkAllowlist: [CHAIN_IDS.MAINNET, CHAIN_IDS.LINEA_MAINNET], + extensionSupport: true, + extensionConfig: { + refreshRate: 30000, + maxRefreshCount: 5, + }, +}; +const mockBridgeSlice = { + toChainId: CHAIN_IDS.LINEA_MAINNET, + toNativeExchangeRate: 1, + toTokenExchangeRate: 0.99, + fromTokenInputValue: '1', +}; +export const DefaultStory = () => { + return ; +}; +DefaultStory.storyName = 'Default'; +DefaultStory.decorators = [ + (Story) => ( + + + + + + ), +]; + +export const LoadingStory = () => { + return ; +}; +LoadingStory.storyName = 'Loading Quotes'; +LoadingStory.decorators = [ + (Story) => ( + + + + + + ), +]; + +export const NoQuotesStory = () => { + return ; +}; +NoQuotesStory.storyName = 'No Quotes'; +NoQuotesStory.decorators = [ + (Story) => ( + + + + + + ), +]; + +export const QuotesFetchedStory = () => { + return ; +}; +QuotesFetchedStory.storyName = 'Quotes Available'; +QuotesFetchedStory.decorators = [ + (Story) => ( + + + + + + ), +]; + +export default storybook; diff --git a/ui/pages/bridge/prepare/prepare-bridge-page.test.tsx b/ui/pages/bridge/prepare/prepare-bridge-page.test.tsx index 6803deb301aa..de8fdfc25b36 100644 --- a/ui/pages/bridge/prepare/prepare-bridge-page.test.tsx +++ b/ui/pages/bridge/prepare/prepare-bridge-page.test.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { act } from '@testing-library/react'; import * as reactRouterUtils from 'react-router-dom-v5-compat'; +import { zeroAddress } from 'ethereumjs-util'; import { fireEvent, renderWithProvider } from '../../../../test/jest'; import configureStore from '../../../store/store'; import { createBridgeMockStore } from '../../../../test/jest/mock-store'; @@ -42,6 +43,34 @@ describe('PrepareBridgePage', () => { }, }, }, + metamaskStateOverrides: { + completedOnboarding: true, + allDetectedTokens: { + '0x1': { + '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc': [ + { + address: '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984', + decimals: 6, + }, // USDC + ], + }, + }, + }, + bridgeStateOverrides: { + srcTokens: { + '0x6b3595068778dd592e39a122f4f5a5cf09c90fe2': { + address: '0x6b3595068778dd592e39a122f4f5a5cf09c90fe2', + }, // UNI, + [zeroAddress()]: { address: zeroAddress() }, + '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984': { + address: '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984', + decimals: 6, + }, // USDC + }, + srcTopAssets: [ + { address: '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984' }, + ], + }, }); const { container, getByRole, getByTestId } = renderWithProvider( , @@ -57,7 +86,7 @@ describe('PrepareBridgePage', () => { await act(() => { fireEvent.change(getByTestId('from-amount'), { target: { value: '2' } }); }); - expect(getByTestId('from-amount').closest('input')).toHaveValue(2); + expect(getByTestId('from-amount').closest('input')).toHaveValue('2'); expect(getByTestId('to-amount')).toBeInTheDocument(); expect(getByTestId('to-amount').closest('input')).toBeDisabled(); @@ -126,21 +155,25 @@ describe('PrepareBridgePage', () => { expect(container).toMatchSnapshot(); expect(getByRole('button', { name: /ETH/u })).toBeInTheDocument(); - expect(getByRole('button', { name: /UNI/u })).toBeInTheDocument(); + expect(getByRole('button', { name: /Bridge to/u })).toBeInTheDocument(); expect(getByTestId('from-amount')).toBeInTheDocument(); expect(getByTestId('from-amount').closest('input')).not.toBeDisabled(); - expect(getByTestId('from-amount').closest('input')).toHaveValue(1); + + await act(() => { + fireEvent.change(getByTestId('from-amount'), { target: { value: '1' } }); + }); + expect(getByTestId('from-amount').closest('input')).toHaveValue('1'); await act(() => { fireEvent.change(getByTestId('from-amount'), { target: { value: '2' } }); }); - expect(getByTestId('from-amount').closest('input')).toHaveValue(2); + expect(getByTestId('from-amount').closest('input')).toHaveValue('2'); expect(getByTestId('to-amount')).toBeInTheDocument(); expect(getByTestId('to-amount').closest('input')).toBeDisabled(); - expect(getByTestId('switch-tokens').closest('button')).not.toBeDisabled(); + expect(getByTestId('switch-tokens').closest('button')).toBeDisabled(); }); it('should throw an error if token decimals are not defined', async () => { diff --git a/ui/pages/bridge/prepare/prepare-bridge-page.tsx b/ui/pages/bridge/prepare/prepare-bridge-page.tsx index 1533fc1a9c20..1433c627af21 100644 --- a/ui/pages/bridge/prepare/prepare-bridge-page.tsx +++ b/ui/pages/bridge/prepare/prepare-bridge-page.tsx @@ -1,8 +1,15 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; import { useSelector, useDispatch } from 'react-redux'; import classnames from 'classnames'; import { debounce } from 'lodash'; import { useHistory, useLocation } from 'react-router-dom'; +import { BigNumber } from 'bignumber.js'; import { setFromChain, setFromToken, @@ -12,6 +19,7 @@ import { setToChainId, setToToken, updateQuoteRequestParams, + resetBridgeState, } from '../../../ducks/bridge/actions'; import { getBridgeQuotes, @@ -20,29 +28,45 @@ import { getFromChains, getFromToken, getFromTokens, - getFromTopAssets, getQuoteRequest, + getSlippage, getToChain, getToChains, getToToken, getToTokens, - getToTopAssets, + getFromAmountInCurrency, + getValidationErrors, + getBridgeQuotesConfig, } from '../../../ducks/bridge/selectors'; import { + BannerAlert, + BannerAlertSeverity, Box, ButtonIcon, IconName, + PopoverPosition, + Text, } from '../../../components/component-library'; -import { BlockSize } from '../../../helpers/constants/design-system'; +import { + BackgroundColor, + BlockSize, + Display, + FlexDirection, + IconColor, + JustifyContent, + TextAlign, + TextColor, + TextVariant, +} from '../../../helpers/constants/design-system'; import { useI18nContext } from '../../../hooks/useI18nContext'; -import { TokenBucketPriority } from '../../../../shared/constants/swaps'; -import { useTokensWithFiltering } from '../../../hooks/useTokensWithFiltering'; +import { SWAPS_CHAINID_DEFAULT_TOKEN_MAP } from '../../../../shared/constants/swaps'; +import { useTokensWithFiltering } from '../../../hooks/bridge/useTokensWithFiltering'; import { setActiveNetwork } from '../../../store/actions'; import { hexToDecimal } from '../../../../shared/modules/conversion.utils'; import { QuoteRequest } from '../types'; import { calcTokenValue } from '../../../../shared/lib/swaps-utils'; import { BridgeQuoteCard } from '../quotes/bridge-quote-card'; -import { isValidQuoteRequest } from '../utils/quote'; +import { formatTokenAmount, isValidQuoteRequest } from '../utils/quote'; import { getProviderConfig } from '../../../../shared/modules/selectors/networks'; import { CrossChainSwapsEventProperties, @@ -51,7 +75,19 @@ import { import { useRequestProperties } from '../../../hooks/bridge/events/useRequestProperties'; import { MetaMetricsEventName } from '../../../../shared/constants/metametrics'; import { isNetworkAdded } from '../../../ducks/bridge/utils'; +import { Footer } from '../../../components/multichain/pages/page'; +import MascotBackgroundAnimation from '../../swaps/mascot-background-animation/mascot-background-animation'; +import { Column, Row, Tooltip } from '../layout'; +import useRamps from '../../../hooks/ramps/useRamps/useRamps'; +import { getNativeCurrency } from '../../../ducks/metamask/metamask'; +import useLatestBalance from '../../../hooks/bridge/useLatestBalance'; +import { useCountdownTimer } from '../../../hooks/bridge/useCountdownTimer'; +import { useBridgeTokens } from '../../../hooks/bridge/useBridgeTokens'; +import { getCurrentKeyring, getLocale } from '../../../selectors'; +import { isHardwareKeyring } from '../../../helpers/utils/hardware'; +import { SECOND } from '../../../../shared/constants/time'; import { BridgeInputGroup } from './bridge-input-group'; +import { BridgeCTAButton } from './bridge-cta-button'; const PrepareBridgePage = () => { const dispatch = useDispatch(); @@ -59,12 +95,18 @@ const PrepareBridgePage = () => { const t = useI18nContext(); const fromToken = useSelector(getFromToken); - const fromTokens = useSelector(getFromTokens); - const fromTopAssets = useSelector(getFromTopAssets); + const { + fromTokens, + fromTopAssets, + isLoading: isFromTokensLoading, + } = useSelector(getFromTokens); const toToken = useSelector(getToToken); - const toTokens = useSelector(getToTokens); - const toTopAssets = useSelector(getToTopAssets); + const { + toTokens, + toTopAssets, + isLoading: isToTokensLoading, + } = useSelector(getToTokens); const fromChains = useSelector(getFromChains); const toChains = useSelector(getToChains); @@ -72,37 +114,106 @@ const PrepareBridgePage = () => { const toChain = useSelector(getToChain); const fromAmount = useSelector(getFromAmount); + const fromAmountInFiat = useSelector(getFromAmountInCurrency); const providerConfig = useSelector(getProviderConfig); + const slippage = useSelector(getSlippage); const quoteRequest = useSelector(getQuoteRequest); - const { activeQuote } = useSelector(getBridgeQuotes); + const { isLoading, activeQuote, isQuoteGoingToRefresh } = + useSelector(getBridgeQuotes); + + const { refreshRate } = useSelector(getBridgeQuotesConfig); + + const keyring = useSelector(getCurrentKeyring); + // @ts-expect-error keyring type is wrong maybe? + const isUsingHardwareWallet = isHardwareKeyring(keyring.type); + const locale = useSelector(getLocale); + + const ticker = useSelector(getNativeCurrency); + const { + isNoQuotesAvailable, + isInsufficientGasForQuote, + isInsufficientBalance, + } = useSelector(getValidationErrors); + const { openBuyCryptoInPdapp } = useRamps(); + const { balanceAmount: nativeAssetBalance } = useLatestBalance( + SWAPS_CHAINID_DEFAULT_TOKEN_MAP[ + fromChain?.chainId as keyof typeof SWAPS_CHAINID_DEFAULT_TOKEN_MAP + ], + fromChain?.chainId, + ); + + const { balanceAmount: srcTokenBalance } = useLatestBalance( + fromToken, + fromChain?.chainId, + ); + + const tokenAddressAllowlistByChainId = useBridgeTokens(); const fromTokenListGenerator = useTokensWithFiltering( fromTokens, fromTopAssets, - TokenBucketPriority.owned, + tokenAddressAllowlistByChainId, fromChain?.chainId, ); const toTokenListGenerator = useTokensWithFiltering( toTokens, toTopAssets, - TokenBucketPriority.top, + tokenAddressAllowlistByChainId, toChain?.chainId, ); const { flippedRequestProperties } = useRequestProperties(); const trackCrossChainSwapsEvent = useCrossChainSwapsEventTracker(); + const millisecondsUntilNextRefresh = useCountdownTimer(); + const [rotateSwitchTokens, setRotateSwitchTokens] = useState(false); + // Background updates are debounced when the switch button is clicked + // To prevent putting the frontend in an unexpected state, prevent the user + // from switching tokens within the debounce period + const [isSwitchingTemporarilyDisabled, setIsSwitchingTemporarilyDisabled] = + useState(false); + useEffect(() => { + setIsSwitchingTemporarilyDisabled(true); + const switchButtonTimer = setTimeout(() => { + setIsSwitchingTemporarilyDisabled(false); + }, SECOND); + + return () => { + clearTimeout(switchButtonTimer); + }; + }, [rotateSwitchTokens]); + + useEffect(() => { + // Reset controller and inputs on load + dispatch(resetBridgeState()); + }, []); + + const scrollRef = useRef(null); + + useEffect(() => { + if (isInsufficientGasForQuote(nativeAssetBalance)) { + scrollRef.current?.scrollIntoView({ + behavior: 'smooth', + block: 'start', + }); + } + }, [isInsufficientGasForQuote(nativeAssetBalance)]); + const quoteParams = useMemo( () => ({ srcTokenAddress: fromToken?.address, destTokenAddress: toToken?.address || undefined, srcTokenAmount: - fromAmount && fromAmount !== '' && fromToken?.decimals - ? calcTokenValue(fromAmount, fromToken.decimals).toString() + fromAmount && fromToken?.decimals + ? calcTokenValue( + // Treat empty or incomplete amount as 0 to reject NaN + ['', '.'].includes(fromAmount) ? '0' : fromAmount, + fromToken.decimals, + ).toFixed() : undefined, srcChainId: fromChain?.chainId ? Number(hexToDecimal(fromChain.chainId)) @@ -114,6 +225,7 @@ const PrepareBridgePage = () => { // Otherwise quotes get filtered out by the bridge-api when the wallet's real // balance is less than the tenderly balance insufficientBal: Boolean(providerConfig?.rpcUrl?.includes('tenderly')), + slippage, }), [ fromToken, @@ -122,6 +234,7 @@ const PrepareBridgePage = () => { toChain?.chainId, fromAmount, providerConfig, + slippage, ], ); @@ -179,7 +292,9 @@ const PrepareBridgePage = () => { case fromTokens[tokenAddressFromUrl]?.address?.toLowerCase(): { // If there is a matching fromToken, set it as the fromToken const matchedToken = fromTokens[tokenAddressFromUrl]; - dispatch(setFromToken(matchedToken)); + dispatch( + setFromToken({ ...matchedToken, image: matchedToken.iconUrl }), + ); removeTokenFromUrl(); break; } @@ -191,74 +306,117 @@ const PrepareBridgePage = () => { }, [fromChain, fromToken, fromTokens, search]); return ( -
- - { - dispatch(setFromTokenInputValue(e)); - }} - onAssetChange={(token) => { - token?.address && - trackInputEvent({ - input: 'token_source', - value: token.address, - }); - dispatch(setFromToken(token)); - dispatch(setFromTokenInputValue(null)); - }} - networkProps={{ - network: fromChain, - networks: fromChains, - onNetworkChange: (networkConfig) => { + + { + dispatch(setFromTokenInputValue(e)); + }} + onAssetChange={(token) => { + dispatch(setFromToken(token)); + dispatch(setFromTokenInputValue(null)); + token?.address && + trackInputEvent({ + input: 'token_source', + value: token.address, + }); + dispatch(setFromToken(token)); + dispatch(setFromTokenInputValue(null)); + }} + networkProps={{ + network: fromChain, + networks: fromChains, + onNetworkChange: (networkConfig) => { + networkConfig.chainId !== fromChain?.chainId && trackInputEvent({ input: 'chain_source', value: networkConfig.chainId, }); - if (networkConfig.chainId === toChain?.chainId) { - dispatch(setToChainId(null)); - } - if (isNetworkAdded(networkConfig)) { - dispatch( - setActiveNetwork( - networkConfig.rpcEndpoints[ - networkConfig.defaultRpcEndpointIndex - ].networkClientId, - ), - ); - } - dispatch(setFromChain(networkConfig.chainId)); - dispatch(setFromToken(null)); - dispatch(setFromTokenInputValue(null)); - }, - header: t('bridgeFrom'), - }} - customTokenListGenerator={ - fromTokens && fromTopAssets ? fromTokenListGenerator : undefined - } - amountFieldProps={{ - testId: 'from-amount', - autoFocus: true, - value: fromAmount || undefined, - }} - isMultiselectEnabled={true} - /> + if (networkConfig.chainId === toChain?.chainId) { + dispatch(setToChainId(null)); + dispatch(setToToken(null)); + } + if (isNetworkAdded(networkConfig)) { + dispatch( + setActiveNetwork( + networkConfig.rpcEndpoints[ + networkConfig.defaultRpcEndpointIndex + ].networkClientId, + ), + ); + } + dispatch(setFromChain(networkConfig.chainId)); + dispatch(setFromToken(null)); + dispatch(setFromTokenInputValue(null)); + }, + header: t('yourNetworks'), + }} + isMultiselectEnabled + customTokenListGenerator={ + fromTokens && fromTopAssets ? fromTokenListGenerator : undefined + } + onMaxButtonClick={(value: string) => { + dispatch(setFromTokenInputValue(value)); + }} + amountInFiat={fromAmountInFiat} + amountFieldProps={{ + testId: 'from-amount', + autoFocus: true, + value: fromAmount || undefined, + }} + isTokenListLoading={isFromTokensLoading} + /> - + + { + if (!isNetworkAdded(toChain)) { + return; + } setRotateSwitchTokens(!rotateSwitchTokens); flippedRequestProperties && trackCrossChainSwapsEvent({ @@ -283,7 +441,6 @@ const PrepareBridgePage = () => { { @@ -298,33 +455,153 @@ const PrepareBridgePage = () => { network: toChain, networks: toChains, onNetworkChange: (networkConfig) => { - trackInputEvent({ - input: 'chain_destination', - value: networkConfig.chainId, - }); + networkConfig.chainId !== toChain?.chainId && + trackInputEvent({ + input: 'chain_destination', + value: networkConfig.chainId, + }); dispatch(setToChainId(networkConfig.chainId)); dispatch(setToChain(networkConfig.chainId)); + dispatch(setToToken(null)); }, header: t('bridgeTo'), + shouldDisableNetwork: ({ chainId }) => + chainId === fromChain?.chainId, }} customTokenListGenerator={ toChain && toTokens && toTopAssets ? toTokenListGenerator - : fromTokenListGenerator + : undefined + } + amountInFiat={ + activeQuote?.toTokenAmount?.valueInCurrency || undefined } amountFieldProps={{ testId: 'to-amount', readOnly: true, disabled: true, - value: activeQuote?.toTokenAmount?.amount.toFixed() ?? '0', - className: activeQuote?.toTokenAmount.amount + value: activeQuote?.toTokenAmount?.amount + ? formatTokenAmount(locale, activeQuote.toTokenAmount.amount) + : '0', + autoFocus: false, + className: activeQuote?.toTokenAmount?.amount ? 'amount-input defined' : 'amount-input', }} + isTokenListLoading={isToTokensLoading} /> - - -
+ + {isLoading && !activeQuote ? ( + <> + + {t('swapFetchingQuotes')} + + + + ) : null} + + + + + {activeQuote && isQuoteGoingToRefresh && ( + + )} + +
+ + {activeQuote?.approval && fromAmount && fromToken ? ( + + + {isUsingHardwareWallet + ? t('willApproveAmountForBridgingHardware') + : t('willApproveAmountForBridging', [ + formatTokenAmount( + locale, + new BigNumber(fromAmount), + fromToken.symbol, + ), + ])} + + {fromAmount && ( + + {isUsingHardwareWallet + ? t('bridgeApprovalWarningForHardware', [ + fromAmount, + fromToken.symbol, + ]) + : t('bridgeApprovalWarning', [ + fromAmount, + fromToken.symbol, + ])} + + )} + + ) : null} +
+
+
+ {isNoQuotesAvailable && ( + + )} + {!isLoading && + activeQuote && + !isInsufficientBalance(srcTokenBalance) && + isInsufficientGasForQuote(nativeAssetBalance) && ( + openBuyCryptoInPdapp()} + /> + )} + + ); }; diff --git a/ui/pages/bridge/quotes/__snapshots__/bridge-quote-card.test.tsx.snap b/ui/pages/bridge/quotes/__snapshots__/bridge-quote-card.test.tsx.snap index 6b69b8ec9a6c..4b0b41df569d 100644 --- a/ui/pages/bridge/quotes/__snapshots__/bridge-quote-card.test.tsx.snap +++ b/ui/pages/bridge/quotes/__snapshots__/bridge-quote-card.test.tsx.snap @@ -7,177 +7,158 @@ exports[`BridgeQuoteCard should not render when there is no quote 1`] = `
exports[`BridgeQuoteCard should render the recommended quote 1`] = `
-

- New quotes in 0:30 -

-
-
+
+
+

+ Bridging +

+
+ Ethereum Mainnet logo +

- Quote rate + OP Mainnet

-
-
+
-

- 1 USDC = 1.00 USDC + Polygon

+

+ Network fees +

- Total fees + $2.52

-
-
- -
-
-
-
-
-

- 0.001000 ETH -

-
+ · +

- $2.52 + 0.001 ETH

-
-
@@ -186,171 +167,158 @@ Fees are based on network traffic and transaction complexity. MetaMask does not exports[`BridgeQuoteCard should render the recommended quote while loading new quotes 1`] = `
-
-
-

- Estimated time -

-
-
- -
-
-
+ Best price +

-

-

- 1 min -

+
+
+
+

+ Bridging +

+
+ Ethereum Mainnet logo +

- Quote rate + OP Mainnet

-
-
+
-

- 1 ETH = 2443.89 USDC + Polygon

+

+ Network fees +

- Total fees + $2.52

-
-
- -
-
-
-
-
-

- 0.001000 ETH -

-
+ · +

- $2.52 + 0.001 ETH

-
-
diff --git a/ui/pages/bridge/quotes/__snapshots__/bridge-quotes-modal.test.tsx.snap b/ui/pages/bridge/quotes/__snapshots__/bridge-quotes-modal.test.tsx.snap index 137dc246864e..2c401feffa78 100644 --- a/ui/pages/bridge/quotes/__snapshots__/bridge-quotes-modal.test.tsx.snap +++ b/ui/pages/bridge/quotes/__snapshots__/bridge-quotes-modal.test.tsx.snap @@ -66,7 +66,7 @@ exports[`BridgeQuotesModal should render the modal 1`] = ` class="mm-box mm-text mm-text--inherit mm-box--color-primary-default" >

Net cost

@@ -77,13 +77,13 @@ exports[`BridgeQuotesModal should render the modal 1`] = ` /> - - - - - ) : null; + + + {t('networkFees')} + + + + {formatCurrencyAmount( + activeQuote.totalNetworkFee?.valueInCurrency, + currency, + 2, + ) ?? + formatTokenAmount( + locale, + activeQuote.totalNetworkFee?.amount, + ticker, + )} + + + {t('bulletpoint')} + + + {activeQuote.totalNetworkFee?.valueInCurrency + ? formatTokenAmount( + locale, + activeQuote.totalNetworkFee?.amount, + ticker, + ) + : undefined} + + + + + + {t('time')} + + + {t('bridgeTimingMinutes', [ + formatEtaInMinutes( + activeQuote.estimatedProcessingTimeInSeconds, + ), + ])} + + + + + {t('rateIncludesMMFee', [BRIDGE_MM_FEE_RATE])} + + + {t('bulletpoint')} + + + {t('bridgeTerms')} + + + + + ) : null} + + ); }; diff --git a/ui/pages/bridge/quotes/bridge-quotes-modal.test.tsx b/ui/pages/bridge/quotes/bridge-quotes-modal.test.tsx index 42c5968bb5b0..ac5e384413d8 100644 --- a/ui/pages/bridge/quotes/bridge-quotes-modal.test.tsx +++ b/ui/pages/bridge/quotes/bridge-quotes-modal.test.tsx @@ -6,6 +6,8 @@ import mockBridgeQuotesErc20Erc20 from '../../../../test/data/bridge/mock-quotes import { createBridgeMockStore } from '../../../../test/jest/mock-store'; import { renderWithProvider } from '../../../../test/lib/render-helpers'; import configureStore from '../../../store/store'; +import { CHAIN_IDS } from '../../../../shared/constants/network'; +import { mockNetworkState } from '../../../../test/stub/networks'; import { BridgeQuotesModal } from './bridge-quotes-modal'; describe('BridgeQuotesModal', () => { @@ -16,6 +18,27 @@ describe('BridgeQuotesModal', () => { getQuotesLastFetched: Date.now(), quotesLoadingStatus: RequestStatus.FETCHED, }, + bridgeSliceOverrides: { + fromTokenExchangeRate: 1, + toTokenExchangeRate: 0.99, + }, + metamaskStateOverrides: { + currencyRates: { + ETH: { + conversionRate: 1, + }, + POL: { + conversionRate: 1, + usdConversionRate: 1, + }, + }, + ...mockNetworkState( + { chainId: CHAIN_IDS.MAINNET }, + { chainId: CHAIN_IDS.LINEA_MAINNET }, + { chainId: CHAIN_IDS.POLYGON }, + { chainId: CHAIN_IDS.OPTIMISM }, + ), + }, }); const { baseElement } = renderWithProvider( diff --git a/ui/pages/bridge/quotes/bridge-quotes-modal.tsx b/ui/pages/bridge/quotes/bridge-quotes-modal.tsx index d251b8ab72f5..e9cb65f612fe 100644 --- a/ui/pages/bridge/quotes/bridge-quotes-modal.tsx +++ b/ui/pages/bridge/quotes/bridge-quotes-modal.tsx @@ -24,7 +24,7 @@ import { formatTokenAmount, } from '../utils/quote'; import { useI18nContext } from '../../../hooks/useI18nContext'; -import { getCurrentCurrency } from '../../../selectors'; +import { getCurrentCurrency, getLocale } from '../../../selectors'; import { setSelectedQuote, setSortOrder } from '../../../ducks/bridge/actions'; import { SortOrder } from '../types'; import { @@ -52,6 +52,7 @@ export const BridgeQuotesModal = ({ const sortOrder = useSelector(getBridgeSortOrder); const currency = useSelector(getCurrentCurrency); const nativeCurrency = useSelector(getNativeCurrency); + const locale = useSelector(getLocale); const trackCrossChainSwapsEvent = useCrossChainSwapsEventTracker(); const { quoteRequestProperties } = useRequestProperties(); @@ -117,15 +118,19 @@ export const BridgeQuotesModal = ({ color={ sortOrder === sortOrderOption ? TextColor.primaryDefault - : TextColor.textAlternative + : TextColor.textAlternativeSoft } > {label} @@ -141,6 +146,7 @@ export const BridgeQuotesModal = ({ estimatedProcessingTimeInSeconds, toTokenAmount, cost, + sentAmount, quote: { destAsset, bridges, requestId }, } = quote; const isQuoteActive = requestId === activeQuote?.quote.requestId; @@ -176,7 +182,7 @@ export const BridgeQuotesModal = ({ paddingInline={4} paddingTop={3} paddingBottom={3} - style={{ position: 'relative', height: 78 }} + style={{ position: 'relative' }} > {isQuoteActive && ( {[ - totalNetworkFee?.valueInCurrency - ? t('quotedNetworkFee', [ + totalNetworkFee?.valueInCurrency && + sentAmount?.valueInCurrency + ? t('quotedTotalCost', [ formatCurrencyAmount( - totalNetworkFee.valueInCurrency, + totalNetworkFee.valueInCurrency.plus( + sentAmount.valueInCurrency, + ), currency, 0, ), ]) - : t('quotedNetworkFee', [ + : t('quotedTotalCost', [ formatTokenAmount( + locale, totalNetworkFee.amount, nativeCurrency, ), ]), - t( - sortOrder === SortOrder.ETA_ASC - ? 'quotedReceivingAmount' - : 'quotedReceiveAmount', - [ - formatCurrencyAmount( - toTokenAmount.valueInCurrency, - currency, - 0, - ) ?? - formatTokenAmount( - toTokenAmount.amount, - destAsset.symbol, - 0, - ), - ], - ), - ] - [sortOrder === SortOrder.ETA_ASC ? 'reverse' : 'slice']() - .map((content) => ( - - {content} - - ))} + t('quotedReceiveAmount', [ + formatCurrencyAmount( + toTokenAmount.valueInCurrency, + currency, + 0, + ) ?? + formatTokenAmount( + locale, + toTokenAmount.amount, + destAsset.symbol, + ), + ]), + ].map((content) => ( + + {content} + + ))} diff --git a/ui/pages/bridge/quotes/index.scss b/ui/pages/bridge/quotes/index.scss index 0d9eafa9ad69..ffd58f48d8fe 100644 --- a/ui/pages/bridge/quotes/index.scss +++ b/ui/pages/bridge/quotes/index.scss @@ -1,68 +1,5 @@ @use "design-system"; -.quote-card { - flex-direction: column; - display: flex; - text-align: center; - - p { - font-size: 12px; - } - - - &__content { - padding: 16px; - gap: 4px; - } - - &__timer { - height: 32px; - - p { - color: var(--color-text-alternative); - } - } - - .bridge-box > &__footer { - gap: 0; - - p { - color: var(--color-text-alternative); - } - } - - &__info-row { - display: flex; - flex-direction: row; - justify-content: space-between; - - &__label { - display: inline-flex; - gap: 4px; - - p { - font-weight: var(--font-weight-medium); - font-size: 14px; - white-space: nowrap; - } - - &__tooltip { - align-items: center; - height: 100%; - } - } - - &__description { - display: inline-flex; - gap: 4px; - - &__secondary { - color: var(--color-text-alternative); - } - } - } -} - .quotes-modal { .mm-modal-content__dialog { display: flex; diff --git a/ui/pages/bridge/quotes/quote-info-row.tsx b/ui/pages/bridge/quotes/quote-info-row.tsx deleted file mode 100644 index d1cccc62ed8e..000000000000 --- a/ui/pages/bridge/quotes/quote-info-row.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import React from 'react'; -import { - Box, - Icon, - IconName, - IconSize, - Text, -} from '../../../components/component-library'; -import Tooltip from '../../../components/ui/tooltip'; -import { IconColor } from '../../../helpers/constants/design-system'; - -export const QuoteInfoRow = ({ - label, - tooltipText, - description, - secondaryDescription, -}: { - label: string; - tooltipText?: string; - description: string; - secondaryDescription?: string; -}) => { - return ( - - - {label} - {tooltipText && ( - - - - )} - - - - - {secondaryDescription} - - {description} - - - ); -}; diff --git a/ui/pages/bridge/types.ts b/ui/pages/bridge/types.ts index db6d7e8e1394..f8aabd51f8e8 100644 --- a/ui/pages/bridge/types.ts +++ b/ui/pages/bridge/types.ts @@ -1,5 +1,10 @@ import { BigNumber } from 'bignumber.js'; import { ChainConfiguration } from '../../../shared/types/bridge'; +import { + AssetWithDisplayData, + ERC20Asset, + NativeAsset, +} from '../../components/multichain/asset-picker-amount/asset-picker-modal/types'; export type L1GasFees = { l1GasFeesInHexWei?: string; // l1 fees for approval and trade in hex wei, appended by controller @@ -23,6 +28,13 @@ export enum SortOrder { ETA_ASC = 'time_descending', } +export type BridgeToken = + | (AssetWithDisplayData & { + aggregators?: string[]; + address: string; + }) + | null; + // Types copied from Metabridge API export enum BridgeFlag { EXTENSION_CONFIG = 'extension-config', diff --git a/ui/pages/bridge/utils/quote.ts b/ui/pages/bridge/utils/quote.ts index 05acc09db520..70d382a73f23 100644 --- a/ui/pages/bridge/utils/quote.ts +++ b/ui/pages/bridge/utils/quote.ts @@ -10,8 +10,10 @@ import { formatCurrency } from '../../../helpers/utils/confirm-tx.util'; import { Numeric } from '../../../../shared/modules/Numeric'; import { EtherDenomination } from '../../../../shared/constants/common'; import { DEFAULT_PRECISION } from '../../../hooks/useCurrencyDisplay'; +import { formatAmount } from '../../confirmations/components/simulation-details/formatAmount'; -export const isNativeAddress = (address?: string) => address === zeroAddress(); +export const isNativeAddress = (address?: string | null) => + address === zeroAddress() || address === '' || !address; export const isValidQuoteRequest = ( partialRequest: Partial, @@ -168,14 +170,24 @@ export const calcCost = ( : null, }); -export const formatEtaInMinutes = (estimatedProcessingTimeInSeconds: number) => - (estimatedProcessingTimeInSeconds / 60).toFixed(); +export const formatEtaInMinutes = ( + estimatedProcessingTimeInSeconds: number, +) => { + if (estimatedProcessingTimeInSeconds < 60) { + return `< 1`; + } + return (estimatedProcessingTimeInSeconds / 60).toFixed(); +}; export const formatTokenAmount = ( + locale: string, amount: BigNumber, - symbol: string, - precision: number = 2, -) => `${amount.toFixed(precision)} ${symbol}`; + symbol: string = '', +) => { + const stringifiedAmount = formatAmount(locale, amount); + + return [stringifiedAmount, symbol].join(' ').trim(); +}; export const formatCurrencyAmount = ( amount: BigNumber | null, @@ -187,7 +199,7 @@ export const formatCurrencyAmount = ( } if (precision === 0) { if (amount.lt(0.01)) { - return `<${formatCurrency('0', currency, precision)}`; + return '<$0.01'; } if (amount.lt(1)) { return formatCurrency(amount.toString(), currency, 2); diff --git a/ui/pages/bridge/utils/validators.ts b/ui/pages/bridge/utils/validators.ts index a07eae493c79..08fc3519ef52 100644 --- a/ui/pages/bridge/utils/validators.ts +++ b/ui/pages/bridge/utils/validators.ts @@ -16,8 +16,9 @@ export const validateResponse = ( validators: Validator[], data: unknown, urlUsed: string, + logError = true, ): data is ExpectedResponse => { - return validateData(validators, data, urlUsed); + return validateData(validators, data, urlUsed, logError); }; export const isValidNumber = (v: unknown): v is number => typeof v === 'number'; @@ -53,6 +54,15 @@ export const FEATURE_FLAG_VALIDATORS = [ }, ]; +export const TOKEN_AGGREGATOR_VALIDATORS = [ + { + property: 'aggregators', + type: 'object', + validator: (v: unknown): v is number[] => + isValidObject(v) && Object.values(v).every(isValidString), + }, +]; + export const TOKEN_VALIDATORS = [ { property: 'decimals', type: 'number' }, { property: 'address', type: 'string', validator: isValidHexAddress }, diff --git a/ui/pages/swaps/mascot-background-animation/mascot-background-animation.js b/ui/pages/swaps/mascot-background-animation/mascot-background-animation.js index a8fddf9943d7..b5e8a04dbc5a 100644 --- a/ui/pages/swaps/mascot-background-animation/mascot-background-animation.js +++ b/ui/pages/swaps/mascot-background-animation/mascot-background-animation.js @@ -1,10 +1,11 @@ /* eslint-disable @metamask/design-tokens/color-no-hex*/ import EventEmitter from 'events'; import React, { useRef } from 'react'; +import PropTypes from 'prop-types'; import Mascot from '../../../components/ui/mascot'; -export default function MascotBackgroundAnimation() { +export default function MascotBackgroundAnimation({ height, width }) { const animationEventEmitter = useRef(new EventEmitter()); return ( @@ -220,11 +221,16 @@ export default function MascotBackgroundAnimation() { >
); } + +MascotBackgroundAnimation.propTypes = { + height: PropTypes.string, + width: PropTypes.string, +};