From de6cb22f216cf18b1effb529de097a85a8dc6dee Mon Sep 17 00:00:00 2001 From: Julink Date: Wed, 3 Dec 2025 14:21:13 +0100 Subject: [PATCH 01/11] chore: add x layer logos (#23540) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This PR is to add the network image for X Layer network and for its native token icon OKB. ## **Changelog** CHANGELOG entry: Added the network and token icons for X Layer ## **Related issues** Fixes: ## **Manual testing steps** 1. Open extension 2. Go to https://chainid.network/chain/196/ 3. Add the X Layer network 4. After accepting, It should add the network to your MetaMask 5. The network icon should show correctly ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [x] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I’ve included tests if applicable - [x] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > Adds X Layer chain ID (0xc4) and maps both the network and OKB token to new X Layer icons. > > - **Assets**: > - Import `X_LAYER` icon and map `OKB` to `x-layer-native.png` in `app/images/image-icons.js`. > - **Networks**: > - Add `X_LAYER` (`0xc4`) to `NETWORK_CHAIN_ID` in `app/util/networks/customNetworks.tsx`. > - Map `NETWORK_CHAIN_ID.X_LAYER` to `x-layer.png` in `CustomNetworkImgMapping`. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 2de5449f064a9a8477b554aea0ca16b81e1414d4. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- app/images/image-icons.js | 2 ++ app/images/x-layer-native.png | Bin 0 -> 7061 bytes app/images/x-layer.png | Bin 0 -> 2332 bytes app/util/networks/customNetworks.tsx | 3 +++ 4 files changed, 5 insertions(+) create mode 100644 app/images/x-layer-native.png create mode 100644 app/images/x-layer.png diff --git a/app/images/image-icons.js b/app/images/image-icons.js index 91602622639..7d41e1dda85 100644 --- a/app/images/image-icons.js +++ b/app/images/image-icons.js @@ -52,6 +52,7 @@ import INJECTIVE from './injective-native.png'; import PLASMA from './plasma-native.png'; import CRONOS from './cronos.png'; import HYPE from './hyperevm.png'; +import X_LAYER from './x-layer-native.png'; /// BEGIN:ONLY_INCLUDE_IF(tron) import TRON from './tron-logo.png'; /// END:ONLY_INCLUDE_IF @@ -118,4 +119,5 @@ export default { XPL: PLASMA, CRO: CRONOS, HYPE, + OKB: X_LAYER, }; diff --git a/app/images/x-layer-native.png b/app/images/x-layer-native.png new file mode 100644 index 0000000000000000000000000000000000000000..863912330eb460636892c38a9b265a192a9a8c82 GIT binary patch literal 7061 zcmXw8cOca7AOGB)L(a^|%8~3*Rw3i;z0clcR#H)j?j)l!GeY)0vN9@#vy1u?hm3R) z85NScg!|onuAe{hjMw}1e!bt%<9XiCr@PjcMl4MHOaK6|m>BEZ0sxfKgaV9olwTfK z1g}tgK4wPx068)3O*a4tSeoeTpd)6#6-GuoEEf*29Z7F$9QMC}n}X|b)E>0DL}m5F4>pik?lEpCZ`Q2I=3E|VXSGay2IFdE*8b; zJreaLtpxIm#8^h{dj%N;?mTJOP$2e+*?%!`E}0uWz8M>ut}k79ExTEtG4dd=g=j7 zQI>OUzss8~;Wu{!G^;&pQ8y$V`z9a4QIe*9!A*nfXAX?(S}lz1)W0`}q8l${3yob| zUgaG2ie+c=gk_g&a0kV)R>ypHEjOLBFN?N#XE1zzhTU|9{Nq?qEmND0QL@lpPR-PH zZW*g`Lf(MBW02C7N%!gC(xOR`fWa;`=~hcb zSV~7Tk~alCz%okm3#@In;e^E}Dd-RTLS+YSg2UuZ6;icbwVcaiK|fB$&-dlu zxz$f9G%|Fr_4TWC*!3|oTy>lowkciko_GCpiCgO7N7yU5-FIR2XZDYDnFvmqekfa@ zW0+~^%CD8se9?1kA*GjPcmmM9a}li_qjw6Is$=}TQp)9M(NQ8~Z>B@1g?5NMi#FYe;jA5{=1+Oo{ESKxui7B{80gzBvXnt$L6*` zhk(spEi;r&ZC2^)*;gfe|Cx2fUNW*Kk(Y{V3zHv;y{y#GlGOFaJ^Jxe$k&h0Z`jeQ z_x=OB%WtQ324C9_hc`xX_+;hoNqR0PXDp*@v26EkUar?gAO1LphQ_ZO)!1BIuZ?8x z&g`a(VhTM_H`5VD_+?S1KKKnb=Yqzil^?RR_*901lJ07{udF^Yw4~>v?)(m zl^X0-Dk7-SAx+|1vkL#d{QQik?i`HRqAoeQ-9jp5&0G7M34Jjv)BcOBZ-3;=`=Z<&B)DsGO7!j%YOLLP zxz2{iF9mV^uA)DXGWqOV zhsR$=#gpdGyWX(R+#KyrXVe?#x<+4`vZwreU*zQVE5s!C;A#zf4mZZ_18lSw{}Th` z^o~A{?iatVV@*1lZN*Kxrm8iKieBP%FYeuys>rIGKdNm1WhX{kA1~06qbvw*Cx@&#gfv` zq;5DQoOT}`@6%^zCOMRF_VISo2XlUciAES4@VyFDL2t5aoKE9siv{Ru(ZHJ z#J@{YwwR=2O)fWV;rkC=SNP*DKd)KotU?9u@GT|cM3 zX^FPX&{*!5I4gywPwQ8|i8*<(`0>9UOQ?QVD~$Hy&hEI^!(#MYaD|2Es{Ru1!sqbv zU`9?SlkA27Z03Y^^E(D7^R}*jc8P{fc9Et6m$zQ&oD&+0FjnhEHRfyki`kx`i8pyy zNy^1zSUaxMgyst(v5+s%(t1`JwMlQ>nQzSPL3AJie|TUE9bFd2_%#XW*Aw&iR@ga0 z4?6)5mSCN;+f&Gh?dx9heGBN!h%ZQu(t9lWR@1y)Bv_g%WwY!Vx;L2WLOiad%jszm zyuOLB6OSWT-4Z<4>$}Oz3*SkAs+`Vdn@eCps|KYDpWl*|Z8sG`k+e@R2WG^67zC zF0plK9F4v+Kp&Wp7|ly|mQ_X}R51zoW%d6QVXb(DnAzp+&GHYjiYS?vNd)33~mQX-M;dT2qvLZ&DPgTo_I}5%W z%TmiWd^@{GwHlgCHu(`t|K1*`d#iBjoD8G%#TQwlL$Z zPx=rd#(2M6Zt0_|pZEc()!FwrX*-Q9(rk6`RT67t!9)zMgOLO_5CB>4d>hLWCfiv6 z^R-zWTRC+MI83~^Ef60z<@xr>&57e0A_ufnut3MC^H={5dOXfH|+P|%BG(_u!>IXb>1iHnrT)?4G z|J8{>j3A@}*wWS=e58DM{OyOmIAF0m^KkR@ngj915zu}|?poJD<4MrMJYEXPplJ$7 z0sB@k1My_CSgj~^qB_=v#S8;^4Dwdr-CYwR-=#~3;xV2`hW2SzP?bwJv+>d=?-o!< z&`M5N3a0(g5I7=ySe^GYJG%(_EE712+CPiET;;p}B8Uxg5=*)a+?93;P?L2`SASB^O3OWswvmgQ5`aPVjZU_8nFeS0H|zP{I)q18oLm&HVRYtVx3Y6cRJlWQhJ?lCEd=2k2ao z1gNfe=Jbci=9AU{CD(G*E-^=Cu$R?Cw<*1tQ&`kfF$X{Z1hO`1Wo&OYsxx6JF~JDy zZ%7E@hNYW2MKP1`7^UZ>2}e=U6B zd;vI6`$J@&=fdsKTr&teYX3$Pu+r}UT#%yrW#z%!H78@*59Y8S&R%Ic)6cW-$=J55IC`j}|GeR6ovVs)6 zxif_zdeXEX!!J@gnE!WV>QgW`CT*!{JT8yr58qi-@&Y+zeO=|T2B5DY1hFdzL39Sg zSb{fIRc?=3!$Hs6P}Le{wqFoZx_aG`V`j*`ChRO=5&ubF!AUV*2wMU z`)mh>P_@G)42es{6KH%9(Y*AS)r*xn@N#zFP*}y%AXi5 z9iAaZ=K6>`Ivwy{Iw}(=9cLwn-DvJN7i#GMiu~agev3HxR*bAd<_CvkC^!XhjRNrW zzMF@3IaF~!b0JVXt;hv%rC6joc>o$mPG?zEf2YGM0Zdrv)`lAD#UPY7S%eFK6ZryE zc<@eNO8`k=RCTRntkIhRj)FuCz8Qp)IIdd*T-bp5k4`{ca~!k;(D04)h3xa4lLVOX zO6|8)55I-c(K@U#PC$ScJ=N5>F#*aO%S|O0Id+x{fATSvoGow68o1#_Stm%ut7(2F zF9ioGd7|v1oflQqL(el@_-jr&5Nkl_Nkb`c{U5YF0s20TMt1WgE^fW~HdRN3<+n;5 zUMjd~+^U0%^2f!5x0BBq^oCLShbPA$HpNnL%ZOAY!jb|TV^XSo@o&s>?bb!AB&)nr zay|4^=UEJuL$pc1yq~VLNoTS8KH8+pxFKyQG9#`>isH%hjM-%`nkUw^%kNQC8Bbhi z@3T;2mhu~i(BLx?$9riESFfCwqzW^u3=^bjO*K`a+1bkdqN}2axr{5P{L6&jfD*d_ zD$h0s^6ZE)H60)6J}U@Q&G&iJkHu0!OUGg(xN|81M*AU=FdIr9yR){{PE#8Llh}bbr~P9bQeHaqjC8250J2`ID(Ll%SPplKE9%pA4`ir!1SAaA_vuhNZW8$1zQts#mpa6 ze4{Pz4;MX%#(mnFUJzg+I?{%1=dA(wzYS1Q3WN+XQI{rth6NsaoVHio=B$B-KC~?~ zmH=Iiqj6}4=?b!&QZm(LQ<~PwLfeq-$~<`Kui&kg*#tl?Q6ycZ3uTGtFHhH>dp(<~}IX(XNH~n5J#0DUdU&q!9nYfMWC(X)zC}G$ zm>Rq%%~vEW5coCU-XVs%+fPas9zdiK4)jY~NV46~Ps(W%fBK+MWx1wor~c&OhTJpJ30 zS}f)Ekiv2bhTIZOpV(yvRrEk3Y!%kdfqV=I;-hA6yftVR2M_J?A;^Pm%AM#q@VJTB}fx&(cz*$`%?9~9$1e+^?Vjy!VG{Wu58V^XI2M_g)hL73V({vAZ zn|5+l7nOqX*#g~m^Z6DaK?P7X;0YX{X;&_mf7c!w6$|DE7?l+HpaX)i&Ud--eDVcH zsaf2C*4QG&YO29|Ij6?OG(JTuNLxIlCRjk8 zPoJwA0L4~N#M#{A!`4hHaa771WCT8kpo0+wE7Roc_pnUFNC%3Ugnl0NbFUz_2cx#qV&EY!>+$fEmyPnjUVh@NT#Ar>(g zXQXl|>1H@YCjHv%KKVh9s#c))2KV!h+GMJhvslgbD&;^{FoVET8M*ZucdB?y{!G1W zrO5=1tDAhmA}=ZhumwoPhV9aJK?Hm1TY;>6wQ~INzvlL{@dm4PRq$=XA~+#&9?Qc? zed$c#qxnihqKc`;NQF&3_R1^cwrT%=@29MVr)1X&v08EPag)yPE*;mFOSRuT zoWscBG8dFbny95#Kn&%%Rr9j+B45Kh|4t5Tf65V3e+t-V+g&5`I+qkbIzMY#2YWWZ zM+uMJ`GgEXZ!^z#R<#b6>r1r^e2F|*Am0tXXTl-de&&{0J(6-jWo-CmFwaUz(kd=B z8%m%|BYxTRtFTO6)iX{US@gRObd6e)EDow$OGwJ=F>UX)q6Os}Yw7&KXm-4`&l;dh z)N#iBH~V@Ka`H86cupmMsWcC}@R8ID&t#|EWkpa&qs<&-^fsLJv2>A!d{5lsT8rc9 zjIpr2T){CGSuqsUJVVrF*azBgl2Ksecqu>!vPcXM&(Kr1VsJ)dd8MYGUp1f3iwa}J zdK2b$mpvc5^p8XP@ts#~`J(a%;DI|cm?}a0%inxrAugXT?PmA!hpa$+0K-so(dB0$ zK6wxquY8R$J<68>rG2!Aq*-Ird!V}6=J@j6y^Tveg2a>8XP$0&Dt1^iIE7ITG=g(~Ru-%7|A926u>F5RK~2R zF0q}1ZtKCfb+HPon&i{i8(HfY6S{RoXHGF{l+S;MY4=VA*qcb=1yWZx`S$F>+fq50 zw{Dkr+>5!vEh<7!3# za{x{|V82T5Th?f-kJs08m_N6WI~*w?4&2@_nCIT}yPuK`ebJUgyd-O&avWb_Lg3j_ znD6^a1mAGrq$fr=!i!EITpY&NIa`O_(wUW7?85AYm8Y`n7z+$37mAoSCR{kCbEkDH zRo)kvXw=%%^uF`z&*fczl6jQyO?zB?q%9`D*p{mI8 z*Ux#~xb@m%PHST+wmL+d|C5k0EgvSS(t^yGZxKdYLA^|+EtYY-@~%ZppVw3~;>T(n zv~C=H8@KMX3>D$J-qcr|vRMKsyvR;Rh%5>(r?qr7SWrr42V><2~z)-P=72UqP z`{*L1#-1>^yj6gd$=SCn8#IfM8pWvWE?K-Ev&I*!Xi$pu~Cc4Zr)nCf^6I? z5g#TfcfO5K{ZHOv8FNr07EX}$;Ju#1rkwVB-`aJ(EV4j>D42*Y`F=-WpeiH_>)ZG7 zzI8dtj0i0CZHZzg?S2Pp`<|Ej8}>Ac-6yNyiHbeOyFj)a{fAkb$&7D!(r|G2=mNUv z38Ng7YkBKE-&ZVOoR@2l+Zf=+CG1~?Kn-mxkH#cm%Lp6H2dJC8YH^ZwTCNf0l9M)e ztvQ*A%>TZsuZ{hYCaV5uy<%;^Z^&p9HEHm4)!-IF-?X!68XYbk{faBbe5dvPyJYNd zbjh_Nf{Ipr{EdFzQ0- z6_+|!H5}UvZh`XO2Y*!3iJk{6!%J&gPdqilGaXjh^^S{jOTSBr9*�tun-+&hc(J zM3@;2+Z*-Xo}Yy0msdwPgfeCC75)el`h@DLDL1^3arpVqrCygy<52h4AEQ6lbx0fi zaoLzjp$s{cR)yX`ma^UVD)>IZKQt;WJH46RD46+*muC*W5VepjqJ> zCgK_{%vGnpdcwlK^i0k8xWLUv_K!7E7Jc3CJeWW&OIjJsdXeVnhT7c4hNrpQ`_KJl zjBmWYm)kII&K(%v&Tq}YJzKln;i;tJ60psQEDfAA>r=MzoY3XIP}q)>ji1Yz!kN#t zS`N6>%qiRS5i;SZSF(P!-3dX-;uosE6QG(kelEDSD3eERc9(;SSy!>FtE)zFF+cme zzfQQ$;@(EtTxhc$`B}oVYWa5Tn6hCXp%}UQeT@vMk$`I7c|&&^p!}E^Sn49-ff~Qq?dy3yeJ+txoJA9_!pv9df5R1n*WbM!Z z;^TMb<>5QM9(O*!&+${vigojFZsjb0VvikC^fi=g{dH!grT1c7kjoMA!zN+i5dgu! zz!ns{9lXsKu-oA<5XB(iR^TXK_@C}4;D_!f;HS|Kz)zzefWNcy0e@%Z3;*o)2jDFB zn3z|9uh+|fjWP0vR-=ZE=DUAT<~dxqa}$9Vi*F>!e;h5xjqrLTeWdzBE`1$Oj-dVP ziIL0YjkRV$V}bWbx)wPlXXb`?$0dXPxC5tBdRJUS2I<_Ek38n;m5$=@s18B91-FG% ziy&AuIXEOV8-$>ZrWa47{NDP@jC8+x7EL=?6;rlu-eW3M)nnWF&QkY6SuE`|M`2j+ zuk|%0BSfNG?O^+dwz$!$*>^o5UkeU)0zs{6IZB_3$?S_>Lc#a3Tg|Z z@ElTTf8U;C7RyX%>S<3^LAfHnsij5#g<%$T0{_q~z{-qq-9rwEVCX9}n)kHMj!>dn zfLYeB+J@-(qr!wyU#@*@)&tpWxkeY~ggjc}sJn-n%H;LCShSea?As7MCkF3jO?qig zUROW>9h8wcWCS51ThLJ0m)uF9RGC)0|M89L{L-v#bw<1Py z1x%`BpT^EgXsGx!SO8+79t4J zq@K!-j+&>87}Bz-)h1XSy&u{rppxk~Rx??Zzod@=NI57rG}pth-E-rH&|d0V;-G z>CpUyY=h;qLq&EnagZ2;C^sG4NDZJ~W8NR5>0NPUdHXMNZ3rXZf^fJj^~Q{8i<2*M zW5cg$lPD4ib>#zoHf^dG10VQ`D;ZvsXSo&FsV3^vZ#wCIQX?Pkt6P}T4-+>foIvlU z&{~Vbhv@{uqfi|R0q-4KI-wHCE305Tr(<85Ly+dTxaP;ar)y!d6T!c9h%1#3%#Vx@ z3R_K?%0(4usF$W4x8a;vHX{*g_Q%CY5=RU6tSe`k6GFJM! z+^nQ{76v8&e1=7(O&%P|9~02+cQOrFsqMA&$8Act|^cu0{4`F$Dk>I-fw4x&B8%r@?`DxVz4ueo;j&M)_!r1t-NQDGM`_-=(9Oo^qP((|dV zg)a|$O%ClH-%AMU1ekt8CvvL2RtE{BqQYa|XrvfS;bk+2rFhGtae(=oTw_$v-hU^_ zZzAKfsVUMp|JD(1yQ+Xk%(kup%btqu8idCD0n)KFVTg4Z%{;^5W5q7eD!q?DmbLQMgj3xui7T*qVKEX{=Nr1u~Dn#SI>9kVJV{BwhykjNx0%dxfiicB>{YKWoJfGCX2 zl{ZX|y;S?y8kX^-Y0##Vu7JAxG)S9J9P^SY2SB+e09zLkpxudPBw8&G;sot;JA(M3 zl~Ei|uNCIy7?4cnc&8+E@LhnpzgLs$e&IVN4TxD_w7%q_oR7H%#hifrh?+AaZ)&xq zrA^4)S8+8Zhr>Fn zvPV$|2~-s-Nu{9FJBM4Y=pd8!(lN<;+C~p8P%EQ$ZmU9o4pLL=Xwo6kms|qnFf~&b zew&;;@`q_XG_1;+iiwV)?a@0R6`J0kzfxW#AYgp1Zau*?vFwZHUYUf!nH%fziQLf9 z>DExKLwV`?LA{G^!E+4>&$VqeteYyds4d4wY>KbWPNveGso&k9gtIJf-)L0$q@ba0 z#z9>pbrTqZ-&o7?+j( = { [NETWORK_CHAIN_ID.PLASMA]: require('../../images/plasma.png'), [NETWORK_CHAIN_ID.CRONOS]: require('../../images/cronos.png'), [NETWORK_CHAIN_ID.HYPE]: require('../../images/hyperevm.png'), + [NETWORK_CHAIN_ID.X_LAYER]: require('../../images/x-layer.png'), }; From 542fe431fb50b94bc59f9b7302bda820c37e7b51 Mon Sep 17 00:00:00 2001 From: khanti42 Date: Wed, 3 Dec 2025 14:28:52 +0100 Subject: [PATCH 02/11] fix: sorting network send modal (#23492) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This PR fixes an issue where Network that have 0 balance are shown after testnets in the send modal. Example below in the screeshots with Injective network. ## **Changelog** CHANGELOG entry: Fixed a bug where mainnet where showing after testnets in the send modal screen ## **Related issues** Fixes: https://github.com/MetaMask/MetaMask-planning/issues/6384 ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** Screenshot 2025-12-01 at 16 51 43 ### **After** Screenshot 2025-12-01 at 16 47 45 ## **Pre-merge author checklist** - [ ] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I’ve included tests if applicable - [ ] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > Networks in the send modal now list mainnets first, then testnets, each group sorted by total fiat value; adds tests covering EVM, Bitcoin, Solana, and zero-balance cases. > > - **Send flow**: > - **Sorting logic**: Group networks by mainnet/testnet using `parseCaipChainId`, `isTestNet`, `BtcScope`, and `SolScope`; compute per-network fiat totals and sort descending; return mainnets first, then testnets. > - **Tests**: > - Add comprehensive cases validating group-first sorting, zero-balance handling, and CAIP networks (Bitcoin mainnet/testnet, Solana devnet). > - Update token fixtures with required fields. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 13824d67bd9ab4fda41692fd29b19e112cd70f39. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../hooks/send/useNetworkFilter.test.ts | 279 ++++++++++++++++++ .../hooks/send/useNetworkFilter.ts | 80 ++++- 2 files changed, 348 insertions(+), 11 deletions(-) diff --git a/app/components/Views/confirmations/hooks/send/useNetworkFilter.test.ts b/app/components/Views/confirmations/hooks/send/useNetworkFilter.test.ts index b2abee455c9..d6a86c67cee 100644 --- a/app/components/Views/confirmations/hooks/send/useNetworkFilter.test.ts +++ b/app/components/Views/confirmations/hooks/send/useNetworkFilter.test.ts @@ -1,4 +1,5 @@ import { renderHook, act } from '@testing-library/react-native'; +import { SolScope, BtcScope } from '@metamask/keyring-api'; import { useNetworkFilter, NETWORK_FILTER_ALL } from './useNetworkFilter'; import { AssetType } from '../../types/token'; import { NetworkInfo } from './useNetworks'; @@ -28,18 +29,36 @@ describe('useNetworkFilter', () => { address: '0x123', symbol: 'ETH', name: 'Ethereum', + aggregators: [], + decimals: 18, + image: '', + balance: '0', + logo: undefined, + isETH: false, } as AssetType, { chainId: '0x1', address: '0x456', symbol: 'USDC', name: 'USD Coin', + aggregators: [], + decimals: 6, + image: '', + balance: '0', + logo: undefined, + isETH: false, } as AssetType, { chainId: '0x89', address: '0x789', symbol: 'MATIC', name: 'Polygon', + aggregators: [], + decimals: 18, + image: '', + balance: '0', + logo: undefined, + isETH: false, } as AssetType, ]; @@ -161,4 +180,264 @@ describe('useNetworkFilter', () => { expect(result.current.filteredTokensByNetwork).toEqual(mockTokens); expect(result.current.filteredTokensByNetwork).toHaveLength(3); }); + + describe('network sorting by mainnet/testnet groups', () => { + it('sorts networks with mainnets first, then testnets, each sorted by value', () => { + // Create networks with mixed mainnets and testnets + const networksWithTestnets: NetworkInfo[] = [ + { + chainId: '0x89', // Polygon Mainnet (lower value) + name: 'Polygon', + image: { uri: 'polygon.png' }, + }, + { + chainId: '0xaa36a7', // Sepolia Testnet (high value) + name: 'Sepolia', + image: { uri: 'sepolia.png' }, + }, + { + chainId: '0x1', // Ethereum Mainnet (high value) + name: 'Ethereum Mainnet', + image: { uri: 'ethereum.png' }, + }, + { + chainId: SolScope.Devnet, // Solana Devnet (lower value) + name: 'Solana Devnet', + image: { uri: 'solana-devnet.png' }, + }, + ]; + + // Create tokens with different fiat values + // Ethereum Mainnet: $5000 (highest mainnet) + // Polygon: $1000 (lower mainnet) + // Sepolia: $2000 (highest testnet) + // Solana Devnet: $500 (lower testnet) + const tokensWithValues: AssetType[] = [ + { + chainId: '0x1', + address: '0x123', + symbol: 'ETH', + name: 'Ethereum', + aggregators: [], + decimals: 18, + image: '', + balance: '0', + logo: undefined, + isETH: false, + fiat: { balance: 5000 }, + } as AssetType, + { + chainId: '0x89', + address: '0x456', + symbol: 'MATIC', + name: 'Polygon', + aggregators: [], + decimals: 18, + image: '', + balance: '0', + logo: undefined, + isETH: false, + fiat: { balance: 1000 }, + } as AssetType, + { + chainId: '0xaa36a7', + address: '0x789', + symbol: 'ETH', + name: 'Sepolia ETH', + aggregators: [], + decimals: 18, + image: '', + balance: '0', + logo: undefined, + isETH: false, + fiat: { balance: 2000 }, + } as AssetType, + { + chainId: SolScope.Devnet, + address: '0xabc', + symbol: 'SOL', + name: 'Solana', + aggregators: [], + decimals: 9, + image: '', + balance: '0', + logo: undefined, + isETH: false, + fiat: { balance: 500 }, + } as AssetType, + ]; + + const { result } = renderHook(() => + useNetworkFilter(tokensWithValues, networksWithTestnets), + ); + + const sortedNetworks = result.current.networksWithTokens; + + // Should have 4 networks + expect(sortedNetworks).toHaveLength(4); + + // Mainnets should come first, sorted by value (descending) + // Ethereum Mainnet ($5000) should be first + expect(sortedNetworks[0].chainId).toBe('0x1'); + expect(sortedNetworks[0].name).toBe('Ethereum Mainnet'); + + // Polygon ($1000) should be second + expect(sortedNetworks[1].chainId).toBe('0x89'); + expect(sortedNetworks[1].name).toBe('Polygon'); + + // Testnets should come after mainnets, sorted by value (descending) + // Sepolia ($2000) should be third + expect(sortedNetworks[2].chainId).toBe('0xaa36a7'); + expect(sortedNetworks[2].name).toBe('Sepolia'); + + // Solana Devnet ($500) should be last + expect(sortedNetworks[3].chainId).toBe(SolScope.Devnet); + expect(sortedNetworks[3].name).toBe('Solana Devnet'); + }); + + it('handles networks with zero balance correctly', () => { + const networksWithZeroBalance: NetworkInfo[] = [ + { + chainId: '0x1', // Ethereum Mainnet + name: 'Ethereum Mainnet', + image: { uri: 'ethereum.png' }, + }, + { + chainId: '0xaa36a7', // Sepolia Testnet + name: 'Sepolia', + image: { uri: 'sepolia.png' }, + }, + ]; + + const tokensWithZeroBalance: AssetType[] = [ + { + chainId: '0x1', + address: '0x123', + symbol: 'ETH', + name: 'Ethereum', + aggregators: [], + decimals: 18, + image: '', + balance: '0', + logo: undefined, + isETH: false, + fiat: { balance: 0 }, + } as AssetType, + { + chainId: '0xaa36a7', + address: '0x789', + symbol: 'ETH', + name: 'Sepolia ETH', + aggregators: [], + decimals: 18, + image: '', + balance: '0', + logo: undefined, + isETH: false, + // No fiat balance + } as AssetType, + ]; + + const { result } = renderHook(() => + useNetworkFilter(tokensWithZeroBalance, networksWithZeroBalance), + ); + + const sortedNetworks = result.current.networksWithTokens; + + // Should have 2 networks + expect(sortedNetworks).toHaveLength(2); + + // Mainnet should come first even with zero balance + expect(sortedNetworks[0].chainId).toBe('0x1'); + expect(sortedNetworks[0].name).toBe('Ethereum Mainnet'); + + // Testnet should come after + expect(sortedNetworks[1].chainId).toBe('0xaa36a7'); + expect(sortedNetworks[1].name).toBe('Sepolia'); + }); + + it('handles Bitcoin testnet networks correctly', () => { + const networksWithBitcoin: NetworkInfo[] = [ + { + chainId: BtcScope.Mainnet, + name: 'Bitcoin Mainnet', + image: { uri: 'bitcoin.png' }, + }, + { + chainId: BtcScope.Testnet, + name: 'Bitcoin Testnet', + image: { uri: 'bitcoin-testnet.png' }, + }, + { + chainId: '0x1', + name: 'Ethereum Mainnet', + image: { uri: 'ethereum.png' }, + }, + ]; + + const tokensWithBitcoin: AssetType[] = [ + { + chainId: BtcScope.Mainnet, + address: '0xbtc1', + symbol: 'BTC', + name: 'Bitcoin', + aggregators: [], + decimals: 8, + image: '', + balance: '0', + logo: undefined, + isETH: false, + fiat: { balance: 3000 }, + } as AssetType, + { + chainId: BtcScope.Testnet, + address: '0xbtc2', + symbol: 'BTC', + name: 'Bitcoin Testnet', + aggregators: [], + decimals: 8, + image: '', + balance: '0', + logo: undefined, + isETH: false, + fiat: { balance: 100 }, + } as AssetType, + { + chainId: '0x1', + address: '0x123', + symbol: 'ETH', + name: 'Ethereum', + aggregators: [], + decimals: 18, + image: '', + balance: '0', + logo: undefined, + isETH: false, + fiat: { balance: 5000 }, + } as AssetType, + ]; + + const { result } = renderHook(() => + useNetworkFilter(tokensWithBitcoin, networksWithBitcoin), + ); + + const sortedNetworks = result.current.networksWithTokens; + + // Should have 3 networks + expect(sortedNetworks).toHaveLength(3); + + // Mainnets should come first, sorted by value + // Ethereum Mainnet ($5000) should be first + expect(sortedNetworks[0].chainId).toBe('0x1'); + expect(sortedNetworks[0].name).toBe('Ethereum Mainnet'); + + // Bitcoin Mainnet ($3000) should be second + expect(sortedNetworks[1].chainId).toBe(BtcScope.Mainnet); + expect(sortedNetworks[1].name).toBe('Bitcoin Mainnet'); + + // Bitcoin Testnet ($100) should come after all mainnets + expect(sortedNetworks[2].chainId).toBe(BtcScope.Testnet); + expect(sortedNetworks[2].name).toBe('Bitcoin Testnet'); + }); + }); }); diff --git a/app/components/Views/confirmations/hooks/send/useNetworkFilter.ts b/app/components/Views/confirmations/hooks/send/useNetworkFilter.ts index f500f14217c..fa604478e7c 100644 --- a/app/components/Views/confirmations/hooks/send/useNetworkFilter.ts +++ b/app/components/Views/confirmations/hooks/send/useNetworkFilter.ts @@ -1,5 +1,8 @@ import { useState, useMemo } from 'react'; import { BigNumber } from 'bignumber.js'; +import { parseCaipChainId, Hex, CaipChainId } from '@metamask/utils'; +import { BtcScope, SolScope } from '@metamask/keyring-api'; +import { isTestNet } from '../../../../../util/networks'; import { AssetType } from '../../types/token'; import { type NetworkInfo } from './useNetworks'; @@ -12,6 +15,52 @@ export interface UseNetworkFilterResult { networksWithTokens: NetworkInfo[]; } +/** + * Check if a network is a testnet based on its chainId. + * Handles both EVM (hex chainIds) and non-EVM (CAIP chainIds) networks. + * + * @param chainId - The chain ID to check (can be hex format or CAIP format) + * @returns True if the network is a testnet, false otherwise + */ +const isNetworkTestnet = (chainId: string): boolean => { + // Check if it's a CAIP chain ID (non-EVM networks) + if (chainId.includes(':')) { + try { + const { namespace, reference } = parseCaipChainId(chainId as CaipChainId); + + // Check EVM testnets using isTestNet helper + if (namespace === 'eip155') { + const hexChainId = `0x${parseInt(reference, 10).toString(16)}` as Hex; + return isTestNet(hexChainId); + } + + // Check Bitcoin testnets using full CAIP IDs from BtcScope + if (namespace === 'bip122') { + return ( + chainId === BtcScope.Testnet || + chainId === BtcScope.Testnet4 || + chainId === BtcScope.Regtest || + chainId === BtcScope.Signet + ); + } + + // Check Solana testnets using full CAIP IDs from SolScope + if (namespace === 'solana') { + return chainId === SolScope.Devnet; + } + + // For other namespaces, assume mainnet if not explicitly a testnet + return false; + } catch { + // If parsing fails, fall back to EVM check + return isTestNet(chainId as Hex); + } + } + + // For hex chainIds (EVM networks), use isTestNet directly + return isTestNet(chainId as Hex); +}; + export const useNetworkFilter = ( tokens: AssetType[], networks: NetworkInfo[], @@ -25,23 +74,32 @@ export const useNetworkFilter = ( tokenChainIds.has(network.chainId), ); - return filteredNetworks.sort((networkA, networkB) => { - const networkATotal = tokens - .filter((token) => token.chainId === networkA.chainId) + // Calculate total fiat value for each network + const networksWithValues = filteredNetworks.map((network) => { + const totalValue = tokens + .filter((token) => token.chainId === network.chainId) .reduce((sum, token) => { const fiatBalance = token.fiat?.balance || '0'; return sum.plus(new BigNumber(fiatBalance)); }, new BigNumber(0)); - const networkBTotal = tokens - .filter((token) => token.chainId === networkB.chainId) - .reduce((sum, token) => { - const fiatBalance = token.fiat?.balance || '0'; - return sum.plus(new BigNumber(fiatBalance)); - }, new BigNumber(0)); - - return networkBTotal.comparedTo(networkATotal) || 0; + return { + network, + totalValue, + isTestnet: isNetworkTestnet(network.chainId), + }; }); + + // Separate into mainnet and testnet groups + const mainnets = networksWithValues.filter((item) => !item.isTestnet); + const testnets = networksWithValues.filter((item) => item.isTestnet); + + // Sort each group by value (descending - highest first) + mainnets.sort((a, b) => b.totalValue.comparedTo(a.totalValue) || 0); + testnets.sort((a, b) => b.totalValue.comparedTo(a.totalValue) || 0); + + // Combine: mainnets first, then testnets + return [...mainnets, ...testnets].map((item) => item.network); }, [tokens, networks]); const filteredTokensByNetwork = useMemo(() => { From b4e5f78260292fa0f933a39c8a840f78ae516df4 Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Wed, 3 Dec 2025 13:56:22 +0000 Subject: [PATCH 03/11] fix: truncate text in predict claim confirmation (#23462) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Truncate long titles and options in Predict claim confirmations, when claiming a single position. ## **Changelog** CHANGELOG entry: Truncate long titles in Predict claim confirmations ## **Related issues** Fixes: #23212 ## **Manual testing steps** ## **Screenshots/Recordings** ### **Before** ### **After** Truncate ## **Pre-merge author checklist** - [x] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I’ve included tests if applicable - [x] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > Truncates long title/outcome text in the single-win Predict claim footer and adds a flex text container to enable proper ellipsis. > > - **Predict Claim Footer (`predict-claim-footer.tsx`)** > - Single-win layout: apply `numberOfLines={1}` to `Text` for `position.title` and amount/outcome line to truncate overflow. > - Use `styles.textContainer` on the text column to constrain width next to the avatar. > - Initialize `useStyles` in `SingleWin` to access styles. > - **Styles (`predict-claim-footer.styles.ts`)** > - Add `textContainer` style with `flex: 1` to allow text truncation within the row. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 26dbf37fc4ab635d3ca2d1cfb57e1d0b89207dc8. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../predict-claim-footer.styles.ts | 4 ++++ .../predict-claim-footer/predict-claim-footer.tsx | 13 ++++++++++--- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/app/components/Views/confirmations/components/predict-confirmations/predict-claim-footer/predict-claim-footer.styles.ts b/app/components/Views/confirmations/components/predict-confirmations/predict-claim-footer/predict-claim-footer.styles.ts index 2bd46b0a5f1..4c400478e06 100644 --- a/app/components/Views/confirmations/components/predict-confirmations/predict-claim-footer/predict-claim-footer.styles.ts +++ b/app/components/Views/confirmations/components/predict-confirmations/predict-claim-footer/predict-claim-footer.styles.ts @@ -22,6 +22,10 @@ const styleSheet = (params: { theme: Theme }) => bottom: { textAlign: 'center', }, + + textContainer: { + flex: 1, + }, }); export default styleSheet; diff --git a/app/components/Views/confirmations/components/predict-confirmations/predict-claim-footer/predict-claim-footer.tsx b/app/components/Views/confirmations/components/predict-confirmations/predict-claim-footer/predict-claim-footer.tsx index 0a23df76696..2ae742ced40 100644 --- a/app/components/Views/confirmations/components/predict-confirmations/predict-claim-footer/predict-claim-footer.tsx +++ b/app/components/Views/confirmations/components/predict-confirmations/predict-claim-footer/predict-claim-footer.tsx @@ -70,6 +70,7 @@ export function PredictClaimFooter({ onPress }: PredictClaimFooterProps) { } function SingleWin({ wonPositions }: { wonPositions: PredictPosition[] }) { + const { styles } = useStyles(styleSheet, {}); const formatFiat = useFiatFormatter({ currency: 'usd' }); const position = wonPositions[0]; @@ -91,9 +92,15 @@ function SingleWin({ wonPositions }: { wonPositions: PredictPosition[] }) { imageSource={{ uri: position.icon }} size={AvatarSize.Lg} /> - - {position.title} - + + + {position.title} + + {amountFormatted} on {position.outcome} From 35fed07a3efbffc351fa9cd272b8380cf8512245 Mon Sep 17 00:00:00 2001 From: khanti42 Date: Wed, 3 Dec 2025 15:01:22 +0100 Subject: [PATCH 04/11] fix: select megaeth when all popular network selected cp-7.60.3 (#23528) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This patch updates the network-enablement-controller configuration to include MegaETH. ## **Changelog** CHANGELOG entry: Fixed MegaETH selection when all popular network selected ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** Screenshot 2025-12-02 at 10 46 19 ### **After** Screenshot 2025-12-02 at 10 43 08 ## **Pre-merge author checklist** - [ ] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I’ve included tests if applicable - [ ] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > Adds MegaETH to `POPULAR_NETWORKS` and enables Arbitrum, BSC, Optimism, Polygon, and Sei by default in `NetworkEnablementController`. > > - **Network enablement**: > - Expand default-enabled EVM networks in `dist/NetworkEnablementController.cjs`: `ArbitrumOne`, `BscMainnet`, `OptimismMainnet`, `PolygonMainnet`, `SeiMainnet`. > - **Constants**: > - Add MegaETH (`0x10e6`) to `POPULAR_NETWORKS` in `dist/constants.cjs`. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 6198105c7ac3f362ed34e5fc250103b5647fab56. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- ...-enablement-controller-npm-3.1.0-1c0cfefdc3.patch | 12 ++++++++++++ yarn.lock | 4 ++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/.yarn/patches/@metamask-network-enablement-controller-npm-3.1.0-1c0cfefdc3.patch b/.yarn/patches/@metamask-network-enablement-controller-npm-3.1.0-1c0cfefdc3.patch index 7d1746545fc..542897a3e9f 100644 --- a/.yarn/patches/@metamask-network-enablement-controller-npm-3.1.0-1c0cfefdc3.patch +++ b/.yarn/patches/@metamask-network-enablement-controller-npm-3.1.0-1c0cfefdc3.patch @@ -14,3 +14,15 @@ index d4a40bea9e4ed3c28e347d96e309efe1ff889e81..fab280760de6bd5cdfdbecf01495c2d5 }, [utils_1.KnownCaipNamespace.Solana]: { [keyring_api_1.SolScope.Mainnet]: true, +diff --git a/dist/constants.cjs b/dist/constants.cjs +index d45d861dd20777a9c767ef6a4272d0b4fd53f895..145d00f5deec1d79b145bdab8a940e4a6c71230e 100644 +--- a/dist/constants.cjs ++++ b/dist/constants.cjs +@@ -15,5 +15,6 @@ exports.POPULAR_NETWORKS = [ + '0x2a15c308d', + '0x3e7', + '0x8f', // Monad (143) ++ '0x10e6', // MegaETH (4326) + ]; + //# sourceMappingURL=constants.cjs.map +\ No newline at end of file diff --git a/yarn.lock b/yarn.lock index cea8774d4f1..b5b29c0249e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8954,7 +8954,7 @@ __metadata: "@metamask/network-enablement-controller@patch:@metamask/network-enablement-controller@npm%3A3.1.0#~/.yarn/patches/@metamask-network-enablement-controller-npm-3.1.0-1c0cfefdc3.patch": version: 3.1.0 - resolution: "@metamask/network-enablement-controller@patch:@metamask/network-enablement-controller@npm%3A3.1.0#~/.yarn/patches/@metamask-network-enablement-controller-npm-3.1.0-1c0cfefdc3.patch::version=3.1.0&hash=0805d0" + resolution: "@metamask/network-enablement-controller@patch:@metamask/network-enablement-controller@npm%3A3.1.0#~/.yarn/patches/@metamask-network-enablement-controller-npm-3.1.0-1c0cfefdc3.patch::version=3.1.0&hash=e5166e" dependencies: "@metamask/base-controller": "npm:^9.0.0" "@metamask/controller-utils": "npm:^11.14.1" @@ -8966,7 +8966,7 @@ __metadata: "@metamask/multichain-network-controller": ^2.0.0 "@metamask/network-controller": ^25.0.0 "@metamask/transaction-controller": ^61.0.0 - checksum: 10/97b00477ec1550b19c7863991cd377ae73936ac466faf149cd2903325a28462f50647cdce9cd9c7aee4395e6cbf0aec8d5417012942c595d8aa3bb69682e8dc9 + checksum: 10/15d3c51ee3ec9bd2c914862915ff444a21626aaa4df15a8df3dc22df1204245e2789786af713f467fdd1b2dfded255ece55973f436c8910ad8c6bfa4439f6e95 languageName: node linkType: hard From 61179b3d59217c1460ee44a224cfbd251e647000 Mon Sep 17 00:00:00 2001 From: Michal Szorad Date: Wed, 3 Dec 2025 15:35:07 +0100 Subject: [PATCH 05/11] fix(perps): preserve decimals in withdrawal progress amount display (#23477) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Fixed a bug in the withdrawal progress component where decimal amounts were being truncated due to `Math.floor()` being applied in the `convertPerpsAmountToUSD` utility function. ## **Changelog** CHANGELOG entry: Fixed withdrawal progress component to display decimal amounts correctly (e.g., $1.30 instead of $1) ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/TAT-2127 ## **Manual testing steps** ```gherkin Feature: Perps withdrawal progress display Scenario: user withdraws amount with decimals Given user has available balance in Perps account And user navigates to Perps home screen When user taps withdraw button And user enters withdrawal amount of $2.30 And user confirms the withdrawal Then withdrawal progress component displays "$1.30" (net amount after $1 fee) And the amount shows with correct decimal precision ``` ## **Screenshots/Recordings** ### **Before** ![IMG_2761C263467E-1](https://github.com/user-attachments/assets/0426ebff-9f02-4378-8163-be98bda2c759) ### **After** Simulator Screenshot - iPhone 16e -
2025-12-01 at 12 51 10 ## **Pre-merge author checklist** - [x] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I’ve included tests if applicable - [x] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > Preserves decimals in `convertPerpsAmountToUSD` (removes flooring), updates tests to reflect precise formatting including small-amount threshold. > > - **Perps Utils**: > - Update `convertPerpsAmountToUSD` to preserve decimals for `"$..."`, numeric strings, and hex wei conversions (remove `Math.floor`). > - Very small decimals now format using threshold (e.g., `<$0.01`). > - **Tests**: > - Revise expectations to keep decimals and thousand separators. > - Add coverage for specific case ensuring `$1.30` remains `$1.30`. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 497b1114e3536a8a1f7e885feea057ec881b54d7. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../UI/Perps/utils/amountConversion.test.ts | 34 ++++++++++--------- .../UI/Perps/utils/amountConversion.ts | 15 ++++---- 2 files changed, 25 insertions(+), 24 deletions(-) diff --git a/app/components/UI/Perps/utils/amountConversion.test.ts b/app/components/UI/Perps/utils/amountConversion.test.ts index b01c92e26db..76571445c33 100644 --- a/app/components/UI/Perps/utils/amountConversion.test.ts +++ b/app/components/UI/Perps/utils/amountConversion.test.ts @@ -8,8 +8,8 @@ describe('convertPerpsAmountToUSD', () => { }); it('handles USD strings correctly', () => { - expect(convertPerpsAmountToUSD('$10.32')).toBe('$10'); // Rounded down using Math.floor() - expect(convertPerpsAmountToUSD('$0.50')).toBe('$0'); // Rounded down using Math.floor() + expect(convertPerpsAmountToUSD('$10.32')).toBe('$10.32'); // Preserves decimals + expect(convertPerpsAmountToUSD('$0.50')).toBe('$0.50'); // Preserves decimals expect(convertPerpsAmountToUSD('$1000')).toBe('$1,000'); }); @@ -20,8 +20,8 @@ describe('convertPerpsAmountToUSD', () => { it('handles numeric strings correctly', () => { expect(convertPerpsAmountToUSD('100')).toBe('$100'); - expect(convertPerpsAmountToUSD('0.5')).toBe('$0'); // Rounded down using Math.floor() - expect(convertPerpsAmountToUSD('1234.56')).toBe('$1,234'); // Rounded down using Math.floor() + expect(convertPerpsAmountToUSD('0.5')).toBe('$0.50'); // Preserves decimals + expect(convertPerpsAmountToUSD('1234.56')).toBe('$1,234.56'); // Preserves decimals }); it('handles edge cases', () => { @@ -34,8 +34,8 @@ describe('convertPerpsAmountToUSD', () => { // Very small wei amount expect(convertPerpsAmountToUSD('0x1')).toBe('$0'); - // Very small decimal - gets threshold formatting - expect(convertPerpsAmountToUSD('0.001')).toBe('$0'); + // Very small decimal - gets threshold formatting from formatPerpsFiat + expect(convertPerpsAmountToUSD('0.001')).toBe('<$0.01'); }); it('handles very large amounts', () => { @@ -46,15 +46,17 @@ describe('convertPerpsAmountToUSD', () => { expect(convertPerpsAmountToUSD('$1000000')).toBe('$1,000,000'); }); - it('rounds down dollar amounts using Math.floor()', () => { - // Test various decimal values to ensure they round down correctly - expect(convertPerpsAmountToUSD('$10.02')).toBe('$10'); - expect(convertPerpsAmountToUSD('$10.99')).toBe('$10'); - expect(convertPerpsAmountToUSD('$0.99')).toBe('$0'); - expect(convertPerpsAmountToUSD('$999.99')).toBe('$999'); - expect(convertPerpsAmountToUSD('10.02')).toBe('$10'); - expect(convertPerpsAmountToUSD('10.99')).toBe('$10'); - expect(convertPerpsAmountToUSD('0.99')).toBe('$0'); - expect(convertPerpsAmountToUSD('999.99')).toBe('$999'); + it('preserves decimal amounts correctly', () => { + // Test various decimal values to ensure they preserve decimals + expect(convertPerpsAmountToUSD('$10.02')).toBe('$10.02'); + expect(convertPerpsAmountToUSD('$10.99')).toBe('$10.99'); + expect(convertPerpsAmountToUSD('$0.99')).toBe('$0.99'); + expect(convertPerpsAmountToUSD('$999.99')).toBe('$999.99'); + expect(convertPerpsAmountToUSD('10.02')).toBe('$10.02'); + expect(convertPerpsAmountToUSD('10.99')).toBe('$10.99'); + expect(convertPerpsAmountToUSD('0.99')).toBe('$0.99'); + expect(convertPerpsAmountToUSD('999.99')).toBe('$999.99'); + // Test the specific bug case: $2.30 withdrawal showing $1.30 + expect(convertPerpsAmountToUSD('1.30')).toBe('$1.30'); }); }); diff --git a/app/components/UI/Perps/utils/amountConversion.ts b/app/components/UI/Perps/utils/amountConversion.ts index 737aceaf3c1..32e535d51e9 100644 --- a/app/components/UI/Perps/utils/amountConversion.ts +++ b/app/components/UI/Perps/utils/amountConversion.ts @@ -20,8 +20,8 @@ export const convertPerpsAmountToUSD = (amount: string): string => { // If it's already a USD string (e.g., "$10.32"), extract numeric value if (amount.startsWith('$')) { const numericValue = parseFloat(amount.replace('$', '')); - const flooredValue = Math.floor(numericValue); - return formatPerpsFiat(flooredValue); + // Preserve decimals for USD amounts + return formatPerpsFiat(numericValue); } // Check if it's a hex value (starts with 0x) - treat as wei @@ -34,16 +34,15 @@ export const convertPerpsAmountToUSD = (amount: string): string => { // In a real implementation, this should come from a price feed const ethPriceUSD = 2000; // TODO: Replace with actual ETH price from price feed const usdValue = ethValue * ethPriceUSD; - const flooredValue = Math.floor(usdValue); - - return formatPerpsFiat(flooredValue); + // Preserve decimals for converted amounts + return formatPerpsFiat(usdValue); } - // Otherwise, treat as a direct USD amount (e.g., "1" = $1) + // Otherwise, treat as a direct USD amount (e.g., "1.30" = $1.30) const numericValue = parseFloat(amount); if (!isNaN(numericValue)) { - const flooredValue = Math.floor(numericValue); - return formatPerpsFiat(flooredValue); + // Preserve decimals for numeric USD amounts + return formatPerpsFiat(numericValue); } // Invalid input - return formatted zero From 930fc1d2cfa7396bec73fea37b985344216e8ae2 Mon Sep 17 00:00:00 2001 From: cmd-ob Date: Wed, 3 Dec 2025 16:14:59 +0000 Subject: [PATCH 06/11] chore: update claude model and prompt for smart e2e (#23604) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** - Small update to prompt to prevent tool from trying to find tags file - Bumped iterations to cater for complex PRs - Fixed some found issues with grep_codebase tool which was only looking in app folder - Use new opus model ## **Changelog** CHANGELOG entry: ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [x] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [ ] I’ve included tests if applicable - [ ] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > Upgrades Claude model and SDK, raises iteration cap, broadens grep search (excluding node_modules), and clarifies the tag selection prompt. > > - **E2E AI Analyzer**: > - **Config (`e2e/tools/e2e-ai-analyzer/config.ts`)**: Update `CLAUDE_CONFIG.model` to `claude-opus-4-5-20251101`; increase `maxIterations` to `20`. > - **Grep Tool (`ai-tools/handlers/grep-codebase.ts`)**: Expand search to `app/`, `e2e/`, `.github/`, `scripts/`; add `--exclude-dir=node_modules` and suppress stderr (`2>/dev/null`). > - **Select Tags Prompt (`modes/select-tags/prompt.ts`)**: Tighten guidance so only provided tags are considered; discourage searching tag files. > - **Dependencies**: > - Bump `@anthropic-ai/sdk` dev dependency to `^0.71.0`. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 8dcac4c599f46af609f3067a7d7ff221749d50b3. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../e2e-ai-analyzer/ai-tools/handlers/grep-codebase.ts | 2 +- e2e/tools/e2e-ai-analyzer/config.ts | 4 ++-- e2e/tools/e2e-ai-analyzer/modes/select-tags/prompt.ts | 2 +- package.json | 2 +- yarn.lock | 10 +++++----- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/e2e/tools/e2e-ai-analyzer/ai-tools/handlers/grep-codebase.ts b/e2e/tools/e2e-ai-analyzer/ai-tools/handlers/grep-codebase.ts index 848c714502c..adb8b3eb4c1 100644 --- a/e2e/tools/e2e-ai-analyzer/ai-tools/handlers/grep-codebase.ts +++ b/e2e/tools/e2e-ai-analyzer/ai-tools/handlers/grep-codebase.ts @@ -40,7 +40,7 @@ export function handleGrepCodebase(input: ToolInput, baseDir: string): string { // Use grep -E for extended regex (supports |, +, ?, etc.) // -E: extended regex, -r: recursive, -n: line numbers, -i: case insensitive - const command = `grep -Erni --include="${filePattern}" "${pattern}" app/ | head -${maxResults}`; + const command = `grep -Erni --include="${filePattern}" --exclude-dir=node_modules "${pattern}" app/ e2e/ .github/ scripts/ 2>/dev/null | head -${maxResults}`; const result = execSync(command, { encoding: 'utf-8', diff --git a/e2e/tools/e2e-ai-analyzer/config.ts b/e2e/tools/e2e-ai-analyzer/config.ts index 51f4a15b317..ddf59923b53 100644 --- a/e2e/tools/e2e-ai-analyzer/config.ts +++ b/e2e/tools/e2e-ai-analyzer/config.ts @@ -12,7 +12,7 @@ export const CLAUDE_CONFIG = { * Claude model to use for analysis * - See available models: https://docs.anthropic.com/en/docs/about-claude/models */ - model: 'claude-sonnet-4-5-20250929' as const, + model: 'claude-opus-4-5-20251101' as const, /** * Maximum tokens allowed for the AI response. Controls the length of reasoning and tool calls @@ -41,7 +41,7 @@ export const CLAUDE_CONFIG = { * - Iteration 2: AI investigates dependencies (2-3 tool calls) * - Iteration 3: AI calls finalize tool (e.g., finalize_tag_selection) → DONE */ - maxIterations: 15, + maxIterations: 20, }; /** diff --git a/e2e/tools/e2e-ai-analyzer/modes/select-tags/prompt.ts b/e2e/tools/e2e-ai-analyzer/modes/select-tags/prompt.ts index 35f4b3fc508..63ed0c09db7 100644 --- a/e2e/tools/e2e-ai-analyzer/modes/select-tags/prompt.ts +++ b/e2e/tools/e2e-ai-analyzer/modes/select-tags/prompt.ts @@ -67,7 +67,7 @@ export function buildTaskPrompt( } const instruction = `Analyze the changed files and the impacted codebase to select the E2E test tags to run so the changes can be verified safely with minimal risk.`; - const tagsSection = `AVAILABLE TEST TAGS (select from these and don't search for additional tags):\n${tagCoverageList}`; + const tagsSection = `AVAILABLE TEST TAGS (these are the ONLY valid tags - do NOT search for tags.ts or any tags file, they are already provided here):\n${tagCoverageList}`; const filesSection = `CHANGED FILES (${ allFiles.length } total):\n${fileList.join('\n')}`; diff --git a/package.json b/package.json index 8563c1c608c..9acd8f73ef9 100644 --- a/package.json +++ b/package.json @@ -477,7 +477,7 @@ "zxcvbn": "4.4.2" }, "devDependencies": { - "@anthropic-ai/sdk": "^0.63.1", + "@anthropic-ai/sdk": "^0.71.0", "@babel/core": "^7.25.2", "@babel/eslint-parser": "^7.25.1", "@babel/preset-env": "^7.25.3", diff --git a/yarn.lock b/yarn.lock index b5b29c0249e..83d636b0ae2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -41,9 +41,9 @@ __metadata: languageName: node linkType: hard -"@anthropic-ai/sdk@npm:^0.63.1": - version: 0.63.1 - resolution: "@anthropic-ai/sdk@npm:0.63.1" +"@anthropic-ai/sdk@npm:^0.71.0": + version: 0.71.0 + resolution: "@anthropic-ai/sdk@npm:0.71.0" dependencies: json-schema-to-ts: "npm:^3.1.1" peerDependencies: @@ -53,7 +53,7 @@ __metadata: optional: true bin: anthropic-ai-sdk: bin/cli - checksum: 10/9ce43fd06a7d3fb62e5f51b78cbf74681ed9c5a5770127b2be62a3b392f2e6fa4c9a8fe38e6f43d33198f79ba37a6ab62568bb77fd11bf9842f5a4b0cb4f02db + checksum: 10/2c4da293d11e0284fe16f909fb59cbaaabe62014cf5f058e225697e4c0bdc029c05171a7a9d9449cb5535abb4d31d653ab96e0edd2443172fa1b272cfd8afa04 languageName: node linkType: hard @@ -35927,7 +35927,7 @@ __metadata: version: 0.0.0-use.local resolution: "metamask@workspace:." dependencies: - "@anthropic-ai/sdk": "npm:^0.63.1" + "@anthropic-ai/sdk": "npm:^0.71.0" "@babel/core": "npm:^7.25.2" "@babel/eslint-parser": "npm:^7.25.1" "@babel/preset-env": "npm:^7.25.3" From 6d456dceed7da83b07aabeaf40287c0c9561a245 Mon Sep 17 00:00:00 2001 From: AugmentedMode <31675118+AugmentedMode@users.noreply.github.com> Date: Wed, 3 Dec 2025 11:26:41 -0500 Subject: [PATCH 07/11] fix: alert row UI to have new simplified design cp-7.61.0 (#23297) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Fixes: [#6078](https://github.com/MetaMask/MetaMask-planning/issues/6078) This PR cleanups the alert UI to match the new design that is implemented in extension! - [x] Make the entire key clickable - [x] Remove the 'Alert' text for ALL alert levels - info, warn and block - [x] For the warning state, change the icon from alert to info - [x] Remove right arrow icon from alert - [x] Remove the background color behind the alert ## **Changelog** CHANGELOG entry: refactor the alert row UI to new simplified design ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** Screenshot 2025-11-26 at 4 52 17 PM Screenshot 2025-12-01 at 10 49
00 AM ## **Pre-merge author checklist** - [ ] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I’ve included tests if applicable - [ ] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > Simplifies confirmation alerts to an icon-only inline alert, makes alert labels clickable to open the modal, and updates tests accordingly. > > - **Confirmations UI**: > - **Inline Alert**: Replaces labeled/arrowed alert with icon-only `InlineAlert`; removes background, label text, and right arrow. Uses `Info` icon for all severities except `Danger`. > - **Alert Row**: Adds label click handling to open alert modal (`setAlertKey`, `showAlertModal`) and track metrics; passes `onLabelClick` to `InfoRow`; keeps inline alert hidden for `Small` variant; adjusts tooltip/icon colors per severity. > - **Info Row**: Adds `onLabelClick` prop to label `Text`. > - **Styles**: Simplifies inline alert styles to `iconContainer` only. > - **Tests**: > - Update expectations to check for `inline-alert-icon` instead of "Alert" text; warning severity now expects `Info` icon. > - Add label-click interaction tests and disabled/no-alert guards. > - Add metrics hook mocks across affected tests (`useConfirmationAlertMetrics`). > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit ef347d81f96883111173cf622695fabe82556e7e. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../BalanceChangeRow.test.tsx | 10 +++ .../BatchApprovalRow.test.tsx | 17 ++++- .../UI/info-row/alert-row/alert-row.test.tsx | 37 +++++++++- .../UI/info-row/alert-row/alert-row.tsx | 15 +++- .../components/UI/info-row/info-row.tsx | 4 +- .../UI/inline-alert/inline-alert.styles.ts | 26 ++----- .../UI/inline-alert/inline-alert.test.tsx | 9 ++- .../UI/inline-alert/inline-alert.tsx | 69 +++++-------------- .../custom-amount-info.test.tsx | 7 ++ .../bridge-fee-row/bridge-fee-row.test.tsx | 7 ++ .../network-and-origin-row.test.tsx | 7 ++ 11 files changed, 122 insertions(+), 86 deletions(-) diff --git a/app/components/UI/SimulationDetails/BalanceChangeRow/BalanceChangeRow.test.tsx b/app/components/UI/SimulationDetails/BalanceChangeRow/BalanceChangeRow.test.tsx index ff254ed18d0..3e2a0daf716 100644 --- a/app/components/UI/SimulationDetails/BalanceChangeRow/BalanceChangeRow.test.tsx +++ b/app/components/UI/SimulationDetails/BalanceChangeRow/BalanceChangeRow.test.tsx @@ -10,6 +10,16 @@ jest.mock('../AssetPill/AssetPill', () => 'AssetPill'); jest.mock('../FiatDisplay/FiatDisplay', () => ({ IndividualFiatDisplay: 'IndividualFiatDisplay', })); +jest.mock( + '../../../Views/confirmations/hooks/metrics/useConfirmationAlertMetrics', + () => ({ + useConfirmationAlertMetrics: () => ({ + trackInlineAlertClicked: jest.fn(), + trackAlertActionClicked: jest.fn(), + trackAlertRendered: jest.fn(), + }), + }), +); const CHAIN_ID_MOCK = '0x123'; diff --git a/app/components/UI/SimulationDetails/BatchApprovalRow/BatchApprovalRow.test.tsx b/app/components/UI/SimulationDetails/BatchApprovalRow/BatchApprovalRow.test.tsx index 012bc5c9a20..a1aff88a20f 100644 --- a/app/components/UI/SimulationDetails/BatchApprovalRow/BatchApprovalRow.test.tsx +++ b/app/components/UI/SimulationDetails/BatchApprovalRow/BatchApprovalRow.test.tsx @@ -17,6 +17,17 @@ import { Severity } from '../../../Views/confirmations/types/alerts'; import { AssetType } from '../types'; import BatchApprovalRow from './BatchApprovalRow'; +jest.mock( + '../../../Views/confirmations/hooks/metrics/useConfirmationAlertMetrics', + () => ({ + useConfirmationAlertMetrics: () => ({ + trackInlineAlertClicked: jest.fn(), + trackAlertActionClicked: jest.fn(), + trackAlertRendered: jest.fn(), + }), + }), +); + const approvalData = [ { asset: { @@ -68,7 +79,7 @@ describe('BatchApprovalRow', () => { expect(getByTestId('edit-spending-cap-button')).toBeTruthy(); }); - it('displays alert if BatchedApprovals alert is present', () => { + it('displays alert icon if BatchedApprovals alert is present', () => { jest .spyOn(BatchApprovalUtils, 'useBatchApproveBalanceChanges') .mockReturnValue({ value: approvalData, pending: false }); @@ -77,10 +88,10 @@ describe('BatchApprovalRow', () => { fieldAlerts: [mockBatchedUnusedApprovalAlert], isAlertConfirmed: () => false, } as unknown as AlertContextFunctions.AlertsContextParams); - const { getByText } = renderWithProvider(, { + const { getByTestId } = renderWithProvider(, { state: getAppStateForConfirmation(upgradeAccountConfirmation), }); - expect(getByText('Alert')).toBeTruthy(); + expect(getByTestId('inline-alert-icon')).toBeTruthy(); }); }); diff --git a/app/components/Views/confirmations/components/UI/info-row/alert-row/alert-row.test.tsx b/app/components/Views/confirmations/components/UI/info-row/alert-row/alert-row.test.tsx index 2e8f4f1f84f..e3632234178 100755 --- a/app/components/Views/confirmations/components/UI/info-row/alert-row/alert-row.test.tsx +++ b/app/components/Views/confirmations/components/UI/info-row/alert-row/alert-row.test.tsx @@ -85,7 +85,7 @@ describe('AlertRow', () => { expect(getByText(LABEL_MOCK)).toBeDefined(); expect(getByText(CHILDREN_MOCK)).toBeDefined(); const icon = getByTestId('inline-alert-icon'); - expect(icon.props.name).toBe(IconName.Danger); + expect(icon.props.name).toBe(IconName.Info); }); it('renders correctly with default alert', () => { @@ -150,6 +150,41 @@ describe('AlertRow', () => { expect(mockTrackInlineAlertClicked).not.toHaveBeenCalled(); }); + it('calls showAlertModal, setAlertKey and trackInlineAlertClicked when label is clicked', () => { + const { getByText } = render(); + + fireEvent.press(getByText(LABEL_MOCK)); + + expect(mockSetAlertKey).toHaveBeenCalledWith(ALERT_KEY_DANGER); + expect(mockShowAlertModal).toHaveBeenCalled(); + expect(mockTrackInlineAlertClicked).toHaveBeenCalledWith( + ALERT_FIELD_DANGER, + ); + }); + + it('does not trigger alert modal when label is clicked and disableAlertInteraction is true', () => { + const { getByText } = render( + , + ); + + fireEvent.press(getByText(LABEL_MOCK)); + + expect(mockSetAlertKey).not.toHaveBeenCalled(); + expect(mockShowAlertModal).not.toHaveBeenCalled(); + expect(mockTrackInlineAlertClicked).not.toHaveBeenCalled(); + }); + + it('does not trigger alert modal when label is clicked and no alert is selected', () => { + const props = { ...baseProps, alertField: 'non_existent_field' }; + const { getByText } = render(); + + fireEvent.press(getByText(LABEL_MOCK)); + + expect(mockSetAlertKey).not.toHaveBeenCalled(); + expect(mockShowAlertModal).not.toHaveBeenCalled(); + expect(mockTrackInlineAlertClicked).not.toHaveBeenCalled(); + }); + it('renders with the given style if provided', () => { const props = { ...baseProps, style: { backgroundColor: 'red' } }; const { getByTestId } = render(); diff --git a/app/components/Views/confirmations/components/UI/info-row/alert-row/alert-row.tsx b/app/components/Views/confirmations/components/UI/info-row/alert-row/alert-row.tsx index 042e0a8eccd..15818f6b431 100755 --- a/app/components/Views/confirmations/components/UI/info-row/alert-row/alert-row.tsx +++ b/app/components/Views/confirmations/components/UI/info-row/alert-row/alert-row.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useCallback } from 'react'; import InlineAlert from '../../inline-alert'; import { useAlerts } from '../../../../context/alert-system-context'; import { Severity } from '../../../../types/alerts'; @@ -7,6 +7,7 @@ import { useStyles } from '../../../../../../../component-library/hooks'; import InfoRow, { InfoRowProps, InfoRowVariant } from '../info-row'; import styleSheet from './alert-row.styles'; import { IconColor } from '../../../../../../../component-library/components/Icons/Icon'; +import { useConfirmationAlertMetrics } from '../../../../hooks/metrics/useConfirmationAlertMetrics'; function getAlertTextColors(severity?: Severity): TextColor { switch (severity) { @@ -44,11 +45,19 @@ const AlertRow = ({ disableAlertInteraction, ...props }: AlertRowProps) => { - const { fieldAlerts } = useAlerts(); + const { fieldAlerts, showAlertModal, setAlertKey } = useAlerts(); + const { trackInlineAlertClicked } = useConfirmationAlertMetrics(); const alertSelected = fieldAlerts.find((a) => a.field === alertField); const { styles } = useStyles(styleSheet, {}); const { rowVariant, style } = props; + const handleLabelClick = useCallback(() => { + if (!alertSelected) return; + setAlertKey(alertSelected.key); + showAlertModal(); + trackInlineAlertClicked(alertSelected.field); + }, [alertSelected, setAlertKey, showAlertModal, trackInlineAlertClicked]); + if (!alertSelected && isShownWithAlertsOnly) { return null; } @@ -61,6 +70,8 @@ const AlertRow = ({ tooltipColor: isSmall ? getAlertIconColors(alertSelected?.severity) : undefined, + onLabelClick: + alertSelected && !disableAlertInteraction ? handleLabelClick : undefined, }; const inlineAlert = diff --git a/app/components/Views/confirmations/components/UI/info-row/info-row.tsx b/app/components/Views/confirmations/components/UI/info-row/info-row.tsx index 765262bb5f9..95aeb8831de 100644 --- a/app/components/Views/confirmations/components/UI/info-row/info-row.tsx +++ b/app/components/Views/confirmations/components/UI/info-row/info-row.tsx @@ -24,6 +24,7 @@ export interface InfoRowProps { label?: string; children?: ReactNode | string; onTooltipPress?: () => void; + onLabelClick?: () => void; tooltip?: ReactNode; tooltipTitle?: string; tooltipColor?: IconColor; @@ -45,6 +46,7 @@ const InfoRow = ({ label, children, onTooltipPress, + onLabelClick, style = {}, labelChildren = null, tooltip, @@ -80,7 +82,7 @@ const InfoRow = ({ > {Boolean(label) && ( - + {label} {labelChildren} diff --git a/app/components/Views/confirmations/components/UI/inline-alert/inline-alert.styles.ts b/app/components/Views/confirmations/components/UI/inline-alert/inline-alert.styles.ts index 1f0cc29ba54..bdde91556c6 100644 --- a/app/components/Views/confirmations/components/UI/inline-alert/inline-alert.styles.ts +++ b/app/components/Views/confirmations/components/UI/inline-alert/inline-alert.styles.ts @@ -1,29 +1,11 @@ import { StyleSheet } from 'react-native'; -import { Theme } from '../../../../../../util/theme/models'; - -const styleSheet = (params: { theme: Theme }) => { - const { theme } = params; - - return StyleSheet.create({ - wrapper: { - display: 'flex', - flexDirection: 'row', - alignItems: 'center', - padding: 4, - backgroundColor: theme.colors.error.default, - borderRadius: 4, +const styleSheet = () => + StyleSheet.create({ + iconContainer: { marginLeft: 4, - }, - inlineContainer: { - flexDirection: 'row', - alignItems: 'center', - flexShrink: 1, - }, - icon: { - marginRight: 4, + marginTop: 1, // Slight adjustment for visual centering with text }, }); -}; export default styleSheet; diff --git a/app/components/Views/confirmations/components/UI/inline-alert/inline-alert.test.tsx b/app/components/Views/confirmations/components/UI/inline-alert/inline-alert.test.tsx index ee09d464ad8..7d9fc5deb19 100755 --- a/app/components/Views/confirmations/components/UI/inline-alert/inline-alert.test.tsx +++ b/app/components/Views/confirmations/components/UI/inline-alert/inline-alert.test.tsx @@ -43,7 +43,6 @@ const mockAlerts = [ ]; describe('InlineAlert', () => { - const INLINE_ALERT_LABEL = 'Alert'; const mockShowAlertModal = jest.fn(); const mockSetAlertKey = jest.fn(); const mockTrackInlineAlertClicked = jest.fn(); @@ -68,12 +67,12 @@ describe('InlineAlert', () => { render(); it('renders correctly with default props', () => { - const { getByTestId, getByText } = renderComponent(); + const { getByTestId } = renderComponent(); const inlineAlert = getByTestId('inline-alert'); - const label = getByText(INLINE_ALERT_LABEL); + const icon = getByTestId('inline-alert-icon'); expect(inlineAlert).toBeDefined(); - expect(label).toBeDefined(); + expect(icon).toBeDefined(); }); it('renders with danger severity', () => { @@ -87,7 +86,7 @@ describe('InlineAlert', () => { const { getByTestId } = renderComponent(mockAlerts[1]); const icon = getByTestId('inline-alert-icon'); - expect(icon.props.name).toBe(IconName.Danger); + expect(icon.props.name).toBe(IconName.Info); }); it('renders with info severity', () => { diff --git a/app/components/Views/confirmations/components/UI/inline-alert/inline-alert.tsx b/app/components/Views/confirmations/components/UI/inline-alert/inline-alert.tsx index 9b0bf8c85ea..41bbed7b0d8 100755 --- a/app/components/Views/confirmations/components/UI/inline-alert/inline-alert.tsx +++ b/app/components/Views/confirmations/components/UI/inline-alert/inline-alert.tsx @@ -1,19 +1,12 @@ import React, { useCallback } from 'react'; -import { TouchableOpacity, View, ViewStyle } from 'react-native'; -import { ThemeColors } from '@metamask/design-tokens'; -import { strings } from '../../../../../../../locales/i18n'; -import { useStyles } from '../../../../../../component-library/hooks'; +import { TouchableOpacity, ViewStyle } from 'react-native'; import Icon, { IconName, IconSize, } from '../../../../../../component-library/components/Icons/Icon'; -import Text, { - TextColor, - TextVariant, -} from '../../../../../../component-library/components/Texts/Text'; +import { TextColor } from '../../../../../../component-library/components/Texts/Text'; +import { useStyles } from '../../../../../../component-library/hooks'; import { AlertTypeIDs } from '../../../../../../../e2e/selectors/Confirmation/ConfirmationView.selectors'; -import { IconSizes } from '../../../../../../component-library/components-temp/KeyValueRow'; -import { useTheme } from '../../../../../../util/theme'; import { Alert, Severity } from '../../../types/alerts'; import { useAlerts } from '../../../context/alert-system-context'; import { useConfirmationAlertMetrics } from '../../../hooks/metrics/useConfirmationAlertMetrics'; @@ -28,17 +21,6 @@ export interface InlineAlertProps { disabled?: boolean; } -const getBackgroundColor = (severity: Severity, colors: ThemeColors) => { - switch (severity) { - case Severity.Danger: - return colors.error.muted; - case Severity.Warning: - return colors.warning.muted; - default: - return colors.info.muted; - } -}; - const getTextColor = (severity: Severity) => { switch (severity) { case Severity.Danger: @@ -57,6 +39,7 @@ export default function InlineAlert({ }: InlineAlertProps) { const { showAlertModal, setAlertKey } = useAlerts(); const { trackInlineAlertClicked } = useConfirmationAlertMetrics(); + const { styles } = useStyles(styleSheet, {}); const handleInlineAlertClick = useCallback(() => { if (!alertObj || disabled) return; @@ -72,39 +55,21 @@ export default function InlineAlert({ ]); const severity = alertObj.severity ?? Severity.Info; - const { colors } = useTheme(); - const { styles } = useStyles(styleSheet, {}); return ( - - - - - {strings('alert_system.inline_alert_label')} - - - - + + ); } diff --git a/app/components/Views/confirmations/components/info/custom-amount-info/custom-amount-info.test.tsx b/app/components/Views/confirmations/components/info/custom-amount-info/custom-amount-info.test.tsx index 7df71eb925c..d062b132bcc 100644 --- a/app/components/Views/confirmations/components/info/custom-amount-info/custom-amount-info.test.tsx +++ b/app/components/Views/confirmations/components/info/custom-amount-info/custom-amount-info.test.tsx @@ -37,6 +37,13 @@ jest.mock('../../../hooks/send/useAccountTokens'); jest.mock('../../../hooks/pay/useTransactionPayAvailableTokens'); jest.mock('../../../hooks/pay/useTransactionPayData'); jest.mock('../../../hooks/transactions/useTransactionConfirm'); +jest.mock('../../../hooks/metrics/useConfirmationAlertMetrics', () => ({ + useConfirmationAlertMetrics: () => ({ + trackInlineAlertClicked: jest.fn(), + trackAlertActionClicked: jest.fn(), + trackAlertRendered: jest.fn(), + }), +})); const mockGoToBuy = jest.fn(); diff --git a/app/components/Views/confirmations/components/rows/bridge-fee-row/bridge-fee-row.test.tsx b/app/components/Views/confirmations/components/rows/bridge-fee-row/bridge-fee-row.test.tsx index a2bfd54f21d..478e83ac66c 100644 --- a/app/components/Views/confirmations/components/rows/bridge-fee-row/bridge-fee-row.test.tsx +++ b/app/components/Views/confirmations/components/rows/bridge-fee-row/bridge-fee-row.test.tsx @@ -22,6 +22,13 @@ import { otherControllersMock } from '../../../__mocks__/controllers/other-contr import { Json } from '@metamask/utils'; jest.mock('../../../hooks/pay/useTransactionPayData'); +jest.mock('../../../hooks/metrics/useConfirmationAlertMetrics', () => ({ + useConfirmationAlertMetrics: () => ({ + trackInlineAlertClicked: jest.fn(), + trackAlertActionClicked: jest.fn(), + trackAlertRendered: jest.fn(), + }), +})); function render() { const state = merge( diff --git a/app/components/Views/confirmations/components/rows/transactions/network-and-origin-row/network-and-origin-row.test.tsx b/app/components/Views/confirmations/components/rows/transactions/network-and-origin-row/network-and-origin-row.test.tsx index d54aa81e974..abdabd73c5b 100644 --- a/app/components/Views/confirmations/components/rows/transactions/network-and-origin-row/network-and-origin-row.test.tsx +++ b/app/components/Views/confirmations/components/rows/transactions/network-and-origin-row/network-and-origin-row.test.tsx @@ -8,6 +8,13 @@ import { MMM_ORIGIN } from '../../../../constants/confirmations'; import { NetworkAndOriginRow } from './network-and-origin-row'; jest.mock('../../../../hooks/metrics/useConfirmationMetricEvents'); +jest.mock('../../../../hooks/metrics/useConfirmationAlertMetrics', () => ({ + useConfirmationAlertMetrics: () => ({ + trackInlineAlertClicked: jest.fn(), + trackAlertActionClicked: jest.fn(), + trackAlertRendered: jest.fn(), + }), +})); jest.mock('../../../../../../../core/Engine', () => ({ context: { GasFeeController: { From 22bacfadc3cd8cdb11c07f04d30465ec6ffc4791 Mon Sep 17 00:00:00 2001 From: infiniteflower <139582705+infiniteflower@users.noreply.github.com> Date: Thu, 4 Dec 2025 01:26:52 +0900 Subject: [PATCH 08/11] chore: update network names in swaps source and dest network pickers (#23541) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This PR updates the network names in the Swap source and destination network pickers. ## **Changelog** CHANGELOG entry: Updated Swap network names ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/SWAPS-3278 ## **Manual testing steps** ```gherkin Feature: Updated Swap network names Scenario: user wants to swap Given user is in Swaps When user opens the source or destination network pickers Then they see the new network names ``` ## **Screenshots/Recordings** ### **Before** ### **After** Screenshot 2025-12-02 at 11 06
48 PM Screenshot 2025-12-02 at 11 06
52 PM Screenshot 2025-12-02 at 11 06
58 PM Screenshot 2025-12-02 at 11 07
02 PM ## **Pre-merge author checklist** - [x] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I’ve included tests if applicable - [x] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > Standardizes displayed network names across bridge source/dest selectors and token selectors using a new `NETWORK_TO_SHORT_NETWORK_NAME_MAP`, and updates tests accordingly. > > - **Bridge UI**: > - Use `NETWORK_TO_SHORT_NETWORK_NAME_MAP` for display names in `BridgeDestNetworkSelector`, `BridgeDestNetworksBar`, `BridgeSourceNetworkSelector`, and `BridgeSourceTokenSelector`. > - Prefer short names (e.g., `Ethereum`, `BNB`, `zkSync`) over longer variants. > - Remove ad-hoc `ShortChainNames` in `BridgeDestNetworksBar`. > - **Token Selector**: > - `getNetworkName` now prioritizes `NETWORK_TO_SHORT_NETWORK_NAME_MAP` before configs/popular list in `BridgeDestTokenSelector`. > - **Tests/Snapshots**: > - Update expectations to short names (e.g., `Ethereum`, `BNB`, `zkSync`). > - Adjust event payload assertions to new `chain_name` values. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 82923bf8bc7a97dd12736b676de84eeb1b46c1c3. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../BridgeDestNetworkSelector.test.tsx | 2 +- .../BridgeDestNetworkSelector/index.tsx | 8 +++- .../components/BridgeDestNetworksBar.tsx | 10 ++--- .../BridgeDestTokenSelector.test.tsx | 41 +++++++++++-------- .../BridgeDestTokenSelector/index.tsx | 2 + .../BridgeSourceNetworkSelector.test.tsx | 8 ++-- .../BridgeSourceNetworkSelector.test.tsx.snap | 2 +- .../BridgeSourceNetworkSelector/index.tsx | 5 ++- .../BridgeSourceTokenSelector/index.tsx | 5 ++- app/constants/bridge.ts | 4 +- 10 files changed, 53 insertions(+), 34 deletions(-) diff --git a/app/components/UI/Bridge/components/BridgeDestNetworkSelector/BridgeDestNetworkSelector.test.tsx b/app/components/UI/Bridge/components/BridgeDestNetworkSelector/BridgeDestNetworkSelector.test.tsx index f08859cc7e5..56deaf15d1c 100644 --- a/app/components/UI/Bridge/components/BridgeDestNetworkSelector/BridgeDestNetworkSelector.test.tsx +++ b/app/components/UI/Bridge/components/BridgeDestNetworkSelector/BridgeDestNetworkSelector.test.tsx @@ -251,6 +251,6 @@ describe('BridgeDestNetworkSelector - ChainPopularity fallback', () => { // Optimism (popularity 10) should appear before Palm and zkSync Era (both Infinity) expect(getByText('Optimism')).toBeTruthy(); expect(getByText('Palm')).toBeTruthy(); - expect(getByText('zkSync Era')).toBeTruthy(); + expect(getByText('zkSync')).toBeTruthy(); }); }); diff --git a/app/components/UI/Bridge/components/BridgeDestNetworkSelector/index.tsx b/app/components/UI/Bridge/components/BridgeDestNetworkSelector/index.tsx index be92828a3bc..822513f4fc3 100644 --- a/app/components/UI/Bridge/components/BridgeDestNetworkSelector/index.tsx +++ b/app/components/UI/Bridge/components/BridgeDestNetworkSelector/index.tsx @@ -18,6 +18,7 @@ import Routes from '../../../../../constants/navigation/Routes'; import { selectChainId } from '../../../../../selectors/networkController'; import { BridgeViewMode } from '../../types'; import { ChainPopularity } from '../BridgeDestNetworksBar'; +import { NETWORK_TO_SHORT_NETWORK_NAME_MAP } from '../../../../../constants/bridge'; export interface BridgeDestNetworkSelectorRouteParams { shouldGoToTokens?: boolean; @@ -80,7 +81,12 @@ export const BridgeDestNetworkSelector: React.FC = () => { onPress={() => handleChainSelect(chain.chainId)} > - + )), diff --git a/app/components/UI/Bridge/components/BridgeDestNetworksBar.tsx b/app/components/UI/Bridge/components/BridgeDestNetworksBar.tsx index 6498301a3a4..00ee2dcce22 100644 --- a/app/components/UI/Bridge/components/BridgeDestNetworksBar.tsx +++ b/app/components/UI/Bridge/components/BridgeDestNetworksBar.tsx @@ -20,6 +20,7 @@ import { import { CHAIN_IDS } from '@metamask/transaction-controller'; import { NETWORKS_CHAIN_ID } from '../../../../constants/network'; import { CaipChainId, Hex } from '@metamask/utils'; +import { NETWORK_TO_SHORT_NETWORK_NAME_MAP } from '../../../../constants/bridge'; import { Box } from '../../Box/Box'; import { getNetworkImageSource } from '../../../../util/networks'; import { AlignItems, FlexDirection } from '../../Box/box.types'; @@ -80,10 +81,6 @@ export const ChainPopularity: Record = { [NETWORKS_CHAIN_ID.MONAD]: 13, }; -const ShortChainNames: Record = { - [CHAIN_IDS.MAINNET]: 'Ethereum', -}; - export const BridgeDestNetworksBar = () => { const navigation = useNavigation(); const dispatch = useDispatch(); @@ -140,7 +137,10 @@ export const BridgeDestNetworksBar = () => { size={AvatarSize.Xs} /> ) : null} - {ShortChainNames[chain.chainId] ?? chain.name} + + {NETWORK_TO_SHORT_NETWORK_NAME_MAP[chain.chainId] ?? + chain.name} + } style={ diff --git a/app/components/UI/Bridge/components/BridgeDestTokenSelector/BridgeDestTokenSelector.test.tsx b/app/components/UI/Bridge/components/BridgeDestTokenSelector/BridgeDestTokenSelector.test.tsx index 78ef0495b16..2d7affb0677 100644 --- a/app/components/UI/Bridge/components/BridgeDestTokenSelector/BridgeDestTokenSelector.test.tsx +++ b/app/components/UI/Bridge/components/BridgeDestTokenSelector/BridgeDestTokenSelector.test.tsx @@ -20,8 +20,6 @@ import { ARBITRUM_DISPLAY_NAME, AVALANCHE_DISPLAY_NAME, BASE_DISPLAY_NAME, - BNB_DISPLAY_NAME, - OPTIMISM_DISPLAY_NAME, } from '../../../../../core/Engine/constants'; const mockNavigate = jest.fn(); @@ -104,7 +102,7 @@ jest.mock('../../../../../util/trace', () => ({ })); describe('getNetworkName', () => { - it('returns network name from network configurations when available', () => { + it('returns short name from NETWORK_TO_SHORT_NETWORK_NAME_MAP when available', () => { const chainId = toHex('1') as Hex; const networkConfigurations: Record< string, @@ -121,8 +119,9 @@ describe('getNetworkName', () => { } as MultichainNetworkConfiguration, }; + // NETWORK_TO_SHORT_NETWORK_NAME_MAP takes priority, returning 'Ethereum' const result = getNetworkName(chainId, networkConfigurations); - expect(result).toBe('Ethereum Mainnet'); + expect(result).toBe('Ethereum'); }); it('returns nickname from PopularList when network not in configurations', () => { @@ -147,15 +146,16 @@ describe('getNetworkName', () => { expect(result).toBe(ARBITRUM_DISPLAY_NAME); }); - it('returns nickname from PopularList for BNB Smart Chain', () => { + it('returns short name from NETWORK_TO_SHORT_NETWORK_NAME_MAP for BNB', () => { const chainId = toHex('56') as Hex; const networkConfigurations: Record< string, MultichainNetworkConfiguration > = {}; + // NETWORK_TO_SHORT_NETWORK_NAME_MAP returns 'BNB' for this chain const result = getNetworkName(chainId, networkConfigurations); - expect(result).toBe(BNB_DISPLAY_NAME); + expect(result).toBe('BNB'); }); it('returns nickname from PopularList for Base', () => { @@ -169,15 +169,16 @@ describe('getNetworkName', () => { expect(result).toBe(BASE_DISPLAY_NAME); }); - it('returns nickname from PopularList for OP', () => { + it('returns short name from NETWORK_TO_SHORT_NETWORK_NAME_MAP for Optimism', () => { const chainId = toHex('10') as Hex; const networkConfigurations: Record< string, MultichainNetworkConfiguration > = {}; + // NETWORK_TO_SHORT_NETWORK_NAME_MAP returns 'Optimism' for this chain const result = getNetworkName(chainId, networkConfigurations); - expect(result).toBe(OPTIMISM_DISPLAY_NAME); + expect(result).toBe('Optimism'); }); it('returns "Unknown Network" when network not found anywhere', () => { @@ -191,7 +192,7 @@ describe('getNetworkName', () => { expect(result).toBe('Unknown Network'); }); - it('prioritizes network configurations over PopularList', () => { + it('prioritizes NETWORK_TO_SHORT_NETWORK_NAME_MAP over network configurations', () => { const chainId = toHex('43114') as Hex; // Avalanche const networkConfigurations: Record< string, @@ -208,30 +209,33 @@ describe('getNetworkName', () => { } as MultichainNetworkConfiguration, }; + // NETWORK_TO_SHORT_NETWORK_NAME_MAP takes priority over network configurations const result = getNetworkName(chainId, networkConfigurations); - expect(result).toBe('Custom Avalanche Name'); + expect(result).toBe('Avalanche'); }); - it('handles undefined network configurations gracefully', () => { + it('returns short name when network configurations is undefined', () => { const chainId = toHex('1') as Hex; const networkConfigurations = undefined as unknown as Record< string, MultichainNetworkConfiguration >; + // NETWORK_TO_SHORT_NETWORK_NAME_MAP takes priority, returning 'Ethereum' const result = getNetworkName(chainId, networkConfigurations); - expect(result).toBe('Unknown Network'); + expect(result).toBe('Ethereum'); }); - it('handles null network configurations gracefully', () => { + it('returns short name when network configurations is null', () => { const chainId = toHex('1') as Hex; const networkConfigurations = null as unknown as Record< string, MultichainNetworkConfiguration >; + // NETWORK_TO_SHORT_NETWORK_NAME_MAP takes priority, returning 'Ethereum' const result = getNetworkName(chainId, networkConfigurations); - expect(result).toBe('Unknown Network'); + expect(result).toBe('Ethereum'); }); it('handles empty string chainId', () => { @@ -245,7 +249,7 @@ describe('getNetworkName', () => { expect(result).toBe('Unknown Network'); }); - it('handles network configuration without name property', () => { + it('returns short name when network configuration lacks name property', () => { const chainId = toHex('1') as Hex; const networkConfigurations = { [chainId]: { @@ -259,8 +263,9 @@ describe('getNetworkName', () => { } as unknown as MultichainNetworkConfiguration, }; + // NETWORK_TO_SHORT_NETWORK_NAME_MAP takes priority, returning 'Ethereum' const result = getNetworkName(chainId, networkConfigurations); - expect(result).toBe('Unknown Network'); + expect(result).toBe('Ethereum'); }); }); @@ -372,7 +377,7 @@ describe('BridgeDestTokenSelector', () => { symbol: 'HELLO', tokenFiatAmount: 200000, }), - networkName: 'Ethereum Mainnet', + networkName: 'Ethereum', }), }); }); @@ -522,7 +527,7 @@ describe('BridgeDestTokenSelector', () => { token_name: 'Hello Token', token_symbol: 'HELLO', token_contract: ethToken2Address, - chain_name: 'Ethereum Mainnet', + chain_name: 'Ethereum', chain_id: '0x1', }, ); diff --git a/app/components/UI/Bridge/components/BridgeDestTokenSelector/index.tsx b/app/components/UI/Bridge/components/BridgeDestTokenSelector/index.tsx index 85aebc57c18..b9054a8b70f 100644 --- a/app/components/UI/Bridge/components/BridgeDestTokenSelector/index.tsx +++ b/app/components/UI/Bridge/components/BridgeDestTokenSelector/index.tsx @@ -34,11 +34,13 @@ import Engine from '../../../../../core/Engine'; import { UnifiedSwapBridgeEventName } from '@metamask/bridge-controller'; import { MultichainNetworkConfiguration } from '@metamask/multichain-network-controller'; import Routes from '../../../../../constants/navigation/Routes'; +import { NETWORK_TO_SHORT_NETWORK_NAME_MAP } from '../../../../../constants/bridge'; export const getNetworkName = ( chainId: Hex, networkConfigurations: Record, ) => + NETWORK_TO_SHORT_NETWORK_NAME_MAP[chainId] ?? networkConfigurations?.[chainId as Hex]?.name ?? PopularList.find((network) => network.chainId === chainId)?.nickname ?? 'Unknown Network'; diff --git a/app/components/UI/Bridge/components/BridgeSourceNetworkSelector/BridgeSourceNetworkSelector.test.tsx b/app/components/UI/Bridge/components/BridgeSourceNetworkSelector/BridgeSourceNetworkSelector.test.tsx index 63dada4772d..f8a06b990e3 100644 --- a/app/components/UI/Bridge/components/BridgeSourceNetworkSelector/BridgeSourceNetworkSelector.test.tsx +++ b/app/components/UI/Bridge/components/BridgeSourceNetworkSelector/BridgeSourceNetworkSelector.test.tsx @@ -64,7 +64,7 @@ describe('BridgeSourceNetworkSelector', () => { // Networks should be visible with fiat values await waitFor(() => { - expect(getByText('Ethereum Mainnet')).toBeTruthy(); + expect(getByText('Ethereum')).toBeTruthy(); expect(getByText('Optimism')).toBeTruthy(); // Check for fiat values @@ -274,7 +274,7 @@ describe('BridgeSourceNetworkSelector', () => { ); await waitFor(() => { - expect(queryByText('Ethereum Mainnet')).toBeNull(); + expect(queryByText('Ethereum')).toBeNull(); expect(getByText('Optimism')).toBeTruthy(); }); }); @@ -304,7 +304,7 @@ describe('BridgeSourceNetworkSelector', () => { ); await waitFor(() => { - expect(getByText('Ethereum Mainnet')).toBeTruthy(); + expect(getByText('Ethereum')).toBeTruthy(); expect(getByText('Optimism')).toBeTruthy(); const labels = queryAllByText(strings('networks.no_network_fee')); @@ -333,7 +333,7 @@ describe('BridgeSourceNetworkSelector', () => { ); await waitFor(() => { - expect(getByText('Ethereum Mainnet')).toBeTruthy(); + expect(getByText('Ethereum')).toBeTruthy(); expect(getByText('Optimism')).toBeTruthy(); expect(queryByText(strings('networks.no_network_fee'))).toBeNull(); }); diff --git a/app/components/UI/Bridge/components/BridgeSourceNetworkSelector/__snapshots__/BridgeSourceNetworkSelector.test.tsx.snap b/app/components/UI/Bridge/components/BridgeSourceNetworkSelector/__snapshots__/BridgeSourceNetworkSelector.test.tsx.snap index aca4fccf242..2b10f897f80 100644 --- a/app/components/UI/Bridge/components/BridgeSourceNetworkSelector/__snapshots__/BridgeSourceNetworkSelector.test.tsx.snap +++ b/app/components/UI/Bridge/components/BridgeSourceNetworkSelector/__snapshots__/BridgeSourceNetworkSelector.test.tsx.snap @@ -745,7 +745,7 @@ exports[`BridgeSourceNetworkSelector renders with initial state and displays net } } > - Ethereum Mainnet + Ethereum diff --git a/app/components/UI/Bridge/components/BridgeSourceNetworkSelector/index.tsx b/app/components/UI/Bridge/components/BridgeSourceNetworkSelector/index.tsx index c9f724e2cd6..b2fb7741cc6 100644 --- a/app/components/UI/Bridge/components/BridgeSourceNetworkSelector/index.tsx +++ b/app/components/UI/Bridge/components/BridgeSourceNetworkSelector/index.tsx @@ -33,6 +33,7 @@ import { CaipChainId, Hex } from '@metamask/utils'; import { selectEvmNetworkConfigurationsByChainId } from '../../../../../selectors/networkController'; import { getNativeSourceToken } from '../../utils/tokenUtils'; import { getGasFeesSponsoredNetworkEnabled } from '../../../../../selectors/featureFlagController/gasFeesSponsored'; +import { NETWORK_TO_SHORT_NETWORK_NAME_MAP } from '../../../../../constants/bridge'; const createStyles = () => StyleSheet.create({ @@ -235,7 +236,9 @@ export const BridgeSourceNetworkSelector: React.FC< /> { const dispatch = useDispatch(); @@ -145,7 +146,9 @@ export const BridgeSourceTokenSelector: React.FC = React.memo(() => { return ; } - const networkName = allNetworkConfigurations[item.chainId]?.name; + const networkName = + NETWORK_TO_SHORT_NETWORK_NAME_MAP[item.chainId] ?? + allNetworkConfigurations[item.chainId]?.name; return ( Date: Wed, 3 Dec 2025 09:31:19 -0700 Subject: [PATCH 09/11] fix(ramps): cp-7.61.0 Fixes KYC redirection bug (#23565) ## **Description** Fixed a bug where the KYC processing screen was redirecting to the main app home page instead of showing the success screen when KYC status was updated to approved. **Root causes:** 1. Multiple calls to `routeAfterAuthentication` when ID proof status became `SUBMITTED`, causing navigation conflicts 2. Navigation errors when `popToBuildQuote` tried to pop to a route that wasn't in the navigation stack **Solution:** 1. Added a `useRef` guard in `KycWebviewModal` to ensure `routeAfterAuthentication` is only called once 2. Updated `popToBuildQuote` to return the current state (no-op) when `BuildQuote` is not found, preventing navigation errors ## **Changelog** CHANGELOG entry: Fixed a bug where the KYC processing screen would redirect to the home page instead of showing the success screen after KYC approval ## **Related issues** Fixes: https://github.com/MetaMask/metamask-mobile/issues/23576 ## **Manual testing steps** Feature: KYC processing flow Scenario: user completes KYC verification Given the user is on the KYC webview modal completing ID verification When the ID proof status becomes SUBMITTED Then the user should be navigated to the KYC processing screen And when KYC status becomes APPROVED, the success screen should be displayed And the user should not be redirected to the home page## **Screenshots/Recordings** ### **Before** Redirected back to build quote after document submission https://github.com/user-attachments/assets/7ca6a4c1-6199-42c9-ba72-c8aaffbc8edb ### **After** Redirected to success page, and then to credit card page https://github.com/user-attachments/assets/60c02a7a-342e-45ae-9611-ce2c3c5d18cf Redirected to failure page and then back to KYC to retry https://github.com/user-attachments/assets/45441a5b-c4a7-4c7b-9f0a-5556eadeeadc ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [ ] I've included tests if applicable - [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > Adds a guard to avoid multiple KYC navigation triggers and makes pop-to-BuildQuote a no-op if the route is absent. > > - **KYC Webview Modal (`KycWebviewModal.tsx`)**: > - Add `useRef` guard (`hasNavigatedRef`) to ensure `routeAfterAuthentication(quote)` runs only once when `idProofStatus === 'SUBMITTED'`. > - **Navigation (`useDepositRouting.ts`)**: > - Update `popToBuildQuote` to return current state (no-op) when `BuildQuote` route is not in the stack. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit e0f1590ae19eb9b8e1a3be406e2a088d4ebe12ed. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../Deposit/Views/Modals/WebviewModal/KycWebviewModal.tsx | 6 ++++-- app/components/UI/Ramp/Deposit/hooks/useDepositRouting.ts | 4 ++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/app/components/UI/Ramp/Deposit/Views/Modals/WebviewModal/KycWebviewModal.tsx b/app/components/UI/Ramp/Deposit/Views/Modals/WebviewModal/KycWebviewModal.tsx index 46888a3cba2..f9fbbaf2264 100644 --- a/app/components/UI/Ramp/Deposit/Views/Modals/WebviewModal/KycWebviewModal.tsx +++ b/app/components/UI/Ramp/Deposit/Views/Modals/WebviewModal/KycWebviewModal.tsx @@ -1,4 +1,4 @@ -import React, { useEffect } from 'react'; +import React, { useEffect, useRef } from 'react'; import { BuyQuote } from '@consensys/native-ramps-sdk'; import WebviewModal, { WebviewModalParams } from './WebviewModal'; @@ -24,6 +24,7 @@ export const createKycWebviewModalNavigationDetails = function KycWebviewModal() { const { quote, workFlowRunId } = useParams(); + const hasNavigatedRef = useRef(false); const { routeAfterAuthentication } = useDepositRouting({ screenLocation: 'KycWebviewModal Screen', @@ -48,7 +49,8 @@ function KycWebviewModal() { }, []); useEffect(() => { - if (idProofStatus === 'SUBMITTED' && quote) { + if (idProofStatus === 'SUBMITTED' && quote && !hasNavigatedRef.current) { + hasNavigatedRef.current = true; routeAfterAuthentication(quote); } }, [idProofStatus, quote, routeAfterAuthentication]); diff --git a/app/components/UI/Ramp/Deposit/hooks/useDepositRouting.ts b/app/components/UI/Ramp/Deposit/hooks/useDepositRouting.ts index c6e05b2e82a..ee8d15b1607 100644 --- a/app/components/UI/Ramp/Deposit/hooks/useDepositRouting.ts +++ b/app/components/UI/Ramp/Deposit/hooks/useDepositRouting.ts @@ -118,6 +118,10 @@ export const useDepositRouting = (config?: UseDepositRoutingConfig) => { (route) => route.name === 'BuildQuote', ); + if (buildQuoteIndex === -1) { + return state; + } + return { payload: { count: state.routes.length - buildQuoteIndex - 1, From 5ba6d92c5df6a2875ce8a40ed64f324332de7b69 Mon Sep 17 00:00:00 2001 From: Michele Esposito <34438276+mikesposito@users.noreply.github.com> Date: Wed, 3 Dec 2025 17:48:48 +0100 Subject: [PATCH 10/11] fix: cp-7.61.0 update events delegated to `ProfileMetricsController` messenger (#23616) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** The `ProfileMetricsController` messenger requires `AccountsController:accountRemoved` event to be delegated from the root messenger, which was missing. ## **Changelog** CHANGELOG entry: null ## **Related issues** Follow-up to https://github.com/MetaMask/metamask-mobile/pull/23247 ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [ ] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I’ve included tests if applicable - [ ] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > Adds delegation of the `AccountsController:accountRemoved` event to the `ProfileMetricsController` messenger. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 0b99e5f7e85b9deb93a401d7563ccc5efd483bde. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../Engine/messengers/profile-metrics-controller-messenger.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/app/core/Engine/messengers/profile-metrics-controller-messenger.ts b/app/core/Engine/messengers/profile-metrics-controller-messenger.ts index ab88f2c4ec0..c96d9f5f310 100644 --- a/app/core/Engine/messengers/profile-metrics-controller-messenger.ts +++ b/app/core/Engine/messengers/profile-metrics-controller-messenger.ts @@ -35,6 +35,7 @@ export function getProfileMetricsControllerMessenger(messenger: RootMessenger) { ], events: [ 'AccountsController:accountAdded', + 'AccountsController:accountRemoved', 'KeyringController:lock', 'KeyringController:unlock', ], From 940287010a0b379f37d2cb6c80d39ddc82b30b3d Mon Sep 17 00:00:00 2001 From: Monte Lai Date: Thu, 4 Dec 2025 01:22:03 +0800 Subject: [PATCH 11/11] fix: metrics for hardware wallets cp-7.61.0 (#22773) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This PR standardises the metrics for hardware wallets to align with the extension. The schemas follow the following - https://www.figma.com/design/9sGBc6mZkQVBKitQzsD0iI/Hardware-Wallet-Metrics?node-id=0-1&p=f&t=Q6IqeowrBX3vu5rF-0 ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/MUL-1241?atlOrigin=eyJpIjoiZTNiNWQzNDEzZGMwNDc0OGFlMTc4YTJmYWJmMzQ5NTMiLCJwIjoiaiJ9 ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** N/a no user facing changes. ### **Before** ### **After** ## **Pre-merge author checklist** - [x] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I’ve included tests if applicable - [x] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > Unifies hardware wallet analytics with new events and properties across QR and Ledger flows, adds device info/count tracking and permission/error metrics, plus comprehensive tests and utilities. > > - **Analytics (Hardware Wallets)**: > - Replace legacy `LEDGER_*`/QR events with unified `HARDWARE_WALLET_*` events (e.g., `CONNECT_HARDWARE_WALLET`, `HARDWARE_WALLET_ADD_ACCOUNT`, `HARDWARE_WALLET_ERROR`, `HARDWARE_WALLET_ACCOUNT_SELECTOR_OPEN`, `HARDWARE_WALLET_CONNECTION_RETRY`, `HARDWARE_WALLET_MARKETING`, `HARDWARE_WALLET_CONNECT_INSTRUCTIONS`). > - Add common properties: `device_type`, `device_model` (incl. BLE UUID mapping), `connected_device_count`, `hd_path`, and detailed error strings. > - Track permission requests/results (`HARDWARE_WALLET_PERMISSION_REQUEST`) and camera/QR scanning errors. > - Update cancel events to `DAPP_TRANSACTION_CANCELLED` where applicable. > - **Flows Updated**: > - QR: `AnimatedQRScanner`, `ConnectQRHardware` (continue, account selector, add account, forget device, error), `ConnectQRHardware/Instruction` (marketing links). > - Ledger: `LedgerConnect` (instructions, found device, continue/retry, marketing), `LedgerConnect/Scan` (permission/BT errors with model), `LedgerConfirmationModal` (errors/cancel), `LedgerSelectAccount` (account selector open/add/forget/error). > - Account screens: `AccountConnect` and `AccountPermissions` track HW connect with device count; `AccountActions` forget-device metrics per keyring. > - Multichain actions: use `ADD_HARDWARE_WALLET` for HW button. > - **Utilities & Hooks**: > - New `getConnectedDevicesCount` (counts connected HW keyrings). > - New `useLedgerDeviceForAccount` hook for per-account Ledger device context. > - New device utilities: `sanitizeDeviceName`, `ledgerDeviceUUIDToModelName`. > - **Testing & Mocks**: > - Extensive test coverage added/updated for all flows; enhanced `react-native-vision-camera` mock to capture callbacks. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 9c9bf99a0dd4f0cbb378bfed2687e170808b62a7. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- app/__mocks__/react-native-vision-camera.ts | 48 +- .../MultichainAddWalletActions.test.tsx | 87 ++ .../MultichainAddWalletActions.tsx | 2 +- .../LedgerConfirmationModal.test.tsx | 66 +- .../LedgerModals/LedgerConfirmationModal.tsx | 12 +- .../UI/QRHardware/AnimatedQRScanner.test.tsx | 920 ++++++++++++++++++ .../UI/QRHardware/AnimatedQRScanner.tsx | 107 +- .../AccountActions/AccountActions.test.tsx | 219 +++++ .../Views/AccountActions/AccountActions.tsx | 22 +- .../AccountConnect/AccountConnect.test.tsx | 54 +- .../Views/AccountConnect/AccountConnect.tsx | 23 +- .../AccountPermissions/AccountPermissions.tsx | 14 +- .../SelectHardware/index.test.tsx | 315 ++++++ .../ConnectHardware/SelectHardware/index.tsx | 58 +- .../Instruction/index.test.tsx | 371 +++++++ .../ConnectQRHardware/Instruction/index.tsx | 109 ++- .../Views/ConnectQRHardware/index.test.tsx | 124 +++ .../Views/ConnectQRHardware/index.tsx | 61 +- .../Views/LedgerConnect/Scan.test.tsx | 90 ++ app/components/Views/LedgerConnect/Scan.tsx | 26 +- .../Views/LedgerConnect/index.test.tsx | 8 +- app/components/Views/LedgerConnect/index.tsx | 65 ++ .../__snapshots__/index.test.tsx.snap | 6 +- .../Views/LedgerSelectAccount/index.test.tsx | 188 +++- .../Views/LedgerSelectAccount/index.tsx | 67 +- .../context/qr-hardware-context/useCamera.ts | 44 +- .../components/PersonalSign/PersonalSign.tsx | 2 +- .../hooks/Ledger/useBluetoothDevices.ts | 1 + .../Ledger/useLedgerDeviceForAccount.test.ts | 432 ++++++++ .../hooks/Ledger/useLedgerDeviceForAccount.ts | 48 + app/core/Analytics/MetaMetrics.events.ts | 103 +- app/core/HardwareWallets/analytics.test.ts | 216 ++++ app/core/HardwareWallets/analytics.ts | 30 + app/core/QrKeyring/QrKeyring.ts | 2 +- .../hardwareWallet/deviceNameUtils.test.ts | 205 ++++ app/util/hardwareWallet/deviceNameUtils.ts | 46 + 36 files changed, 3976 insertions(+), 215 deletions(-) create mode 100644 app/components/UI/QRHardware/AnimatedQRScanner.test.tsx create mode 100644 app/components/Views/ConnectHardware/SelectHardware/index.test.tsx create mode 100644 app/components/Views/ConnectQRHardware/Instruction/index.test.tsx create mode 100644 app/components/hooks/Ledger/useLedgerDeviceForAccount.test.ts create mode 100644 app/components/hooks/Ledger/useLedgerDeviceForAccount.ts create mode 100644 app/core/HardwareWallets/analytics.test.ts create mode 100644 app/core/HardwareWallets/analytics.ts create mode 100644 app/util/hardwareWallet/deviceNameUtils.test.ts create mode 100644 app/util/hardwareWallet/deviceNameUtils.ts diff --git a/app/__mocks__/react-native-vision-camera.ts b/app/__mocks__/react-native-vision-camera.ts index a6f99e6f204..26af9113a84 100644 --- a/app/__mocks__/react-native-vision-camera.ts +++ b/app/__mocks__/react-native-vision-camera.ts @@ -24,15 +24,53 @@ const mockPermission = { requestPermission: jest.fn().mockResolvedValue('granted'), }; -const mockCodeScanner = { - codeTypes: ['qr'], - onCodeScanned: jest.fn(), +let capturedOnCodeScanned: + | ((codes: { value: string; type: string }[]) => Promise | void) + | null = null; +let capturedOnError: ((error: Error) => Promise | void) | null = null; + +export const resetCapturedCallbacks = () => { + capturedOnCodeScanned = null; + capturedOnError = null; }; -const Camera = React.forwardRef(() => null); +export const getCapturedCallbacks = () => ({ + onCodeScanned: capturedOnCodeScanned, + onError: capturedOnError, +}); + +const Camera = React.forwardRef( + ( + props: { + onError?: (error: Error) => Promise | void; + codeScanner?: { + onCodeScanned: ( + codes: { value: string; type: string }[], + ) => Promise | void; + }; + }, + _ref: unknown, + ) => { + if (props.onError) { + capturedOnError = props.onError; + } + if (props.codeScanner?.onCodeScanned) { + capturedOnCodeScanned = props.codeScanner.onCodeScanned; + } + return React.createElement('View', { testID: 'camera-mock' }); + }, +); const useCameraDevice = jest.fn(() => mockDevice); const useCameraPermission = jest.fn(() => mockPermission); -const useCodeScanner = jest.fn(() => mockCodeScanner); +const useCodeScanner = jest.fn((config) => { + if (config?.onCodeScanned) { + capturedOnCodeScanned = config.onCodeScanned; + } + return { + codeTypes: ['qr'], + onCodeScanned: config?.onCodeScanned || jest.fn(), + }; +}); export { Camera, useCameraDevice, useCameraPermission, useCodeScanner }; diff --git a/app/component-library/components-temp/MultichainAccounts/MultichainAddWalletActions/MultichainAddWalletActions.test.tsx b/app/component-library/components-temp/MultichainAccounts/MultichainAddWalletActions/MultichainAddWalletActions.test.tsx index 892c2a6b4fb..2742ccc84c7 100644 --- a/app/component-library/components-temp/MultichainAccounts/MultichainAddWalletActions/MultichainAddWalletActions.test.tsx +++ b/app/component-library/components-temp/MultichainAccounts/MultichainAddWalletActions/MultichainAddWalletActions.test.tsx @@ -6,6 +6,7 @@ import MultichainAddWalletActions from './MultichainAddWalletActions'; import { MOCK_ACCOUNTS_CONTROLLER_STATE } from '../../../../util/test/accountsControllerTestUtils'; import { MOCK_KEYRING_CONTROLLER } from '../../../../selectors/keyringController/testUtils'; import Routes from '../../../../constants/navigation/Routes'; +import { MetaMetricsEvents } from '../../../../core/Analytics'; const mockedNavigate = jest.fn(); jest.mock('@react-navigation/native', () => { @@ -18,6 +19,18 @@ jest.mock('@react-navigation/native', () => { }; }); +const mockTrackEvent = jest.fn(); +const mockCreateEventBuilder = jest.fn((event) => ({ + build: jest.fn(() => event), +})); + +jest.mock('../../../../components/hooks/useMetrics', () => ({ + useMetrics: () => ({ + trackEvent: mockTrackEvent, + createEventBuilder: mockCreateEventBuilder, + }), +})); + const mockInitialState = { engine: { backgroundState: { @@ -147,4 +160,78 @@ describe('MultichainAddWalletActions', () => { expect(mockedNavigate).toHaveBeenCalledWith(Routes.MULTI_SRP.IMPORT); expect(mockProps.onBack).toHaveBeenCalled(); }); + + describe('Analytics', () => { + it('tracks event when import wallet button is pressed', () => { + renderScreen( + () => , + { + name: 'MultichainAddWalletActions', + }, + { + state: mockInitialState, + }, + ); + + const importWalletButton = screen.getByTestId( + AddAccountBottomSheetSelectorsIDs.IMPORT_SRP_BUTTON, + ); + fireEvent.press(importWalletButton); + + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.IMPORT_SECRET_RECOVERY_PHRASE_CLICKED, + ); + expect(mockTrackEvent).toHaveBeenCalledWith( + MetaMetricsEvents.IMPORT_SECRET_RECOVERY_PHRASE_CLICKED, + ); + }); + + it('tracks event when import account button is pressed', () => { + renderScreen( + () => , + { + name: 'MultichainAddWalletActions', + }, + { + state: mockInitialState, + }, + ); + + const importAccountButton = screen.getByTestId( + AddAccountBottomSheetSelectorsIDs.IMPORT_ACCOUNT_BUTTON, + ); + fireEvent.press(importAccountButton); + + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.ACCOUNTS_IMPORTED_NEW_ACCOUNT, + ); + expect(mockTrackEvent).toHaveBeenCalledWith( + MetaMetricsEvents.ACCOUNTS_IMPORTED_NEW_ACCOUNT, + ); + }); + + it('tracks event when hardware wallet button is pressed', () => { + renderScreen( + () => , + { + name: 'MultichainAddWalletActions', + }, + { + state: mockInitialState, + }, + ); + + const hardwareWalletButton = screen.getByTestId( + AddAccountBottomSheetSelectorsIDs.ADD_HARDWARE_WALLET_BUTTON, + ); + fireEvent.press(hardwareWalletButton); + + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.ADD_HARDWARE_WALLET, + ); + expect(mockTrackEvent).toHaveBeenCalledWith( + MetaMetricsEvents.ADD_HARDWARE_WALLET, + ); + }); + }); }); diff --git a/app/component-library/components-temp/MultichainAccounts/MultichainAddWalletActions/MultichainAddWalletActions.tsx b/app/component-library/components-temp/MultichainAccounts/MultichainAddWalletActions/MultichainAddWalletActions.tsx index 4bc474be387..5f5f9d61a3d 100644 --- a/app/component-library/components-temp/MultichainAccounts/MultichainAddWalletActions/MultichainAddWalletActions.tsx +++ b/app/component-library/components-temp/MultichainAccounts/MultichainAddWalletActions/MultichainAddWalletActions.tsx @@ -74,7 +74,7 @@ const MultichainAddWalletActions = ({ iconName: IconName.Usb, testID: AddAccountBottomSheetSelectorsIDs.ADD_HARDWARE_WALLET_BUTTON, isVisible: true, - analyticsEvent: MetaMetricsEvents.CONNECT_HARDWARE_WALLET, + analyticsEvent: MetaMetricsEvents.ADD_HARDWARE_WALLET, navigationAction: () => { navigate(Routes.HW.CONNECT); onBack(); diff --git a/app/components/UI/LedgerModals/LedgerConfirmationModal.test.tsx b/app/components/UI/LedgerModals/LedgerConfirmationModal.test.tsx index 2ed2d7ec256..b6270e7f388 100644 --- a/app/components/UI/LedgerModals/LedgerConfirmationModal.test.tsx +++ b/app/components/UI/LedgerModals/LedgerConfirmationModal.test.tsx @@ -19,8 +19,6 @@ import { strings } from '../../../../locales/i18n'; import { useMetrics } from '../../hooks/useMetrics'; import { MetaMetricsEvents } from '../../../core/Analytics'; import { fireEvent } from '@testing-library/react-native'; -import { HardwareDeviceTypes } from '../../../constants/keyringTypes'; -import { MetricsEventBuilder } from '../../../core/Analytics/MetricsEventBuilder'; jest.mock('../../hooks/Ledger/useBluetooth', () => ({ __esModule: true, @@ -47,9 +45,29 @@ jest.mock('@react-navigation/native', () => ({ jest.mock('../../../components/hooks/useMetrics'); const mockTrackEvent = jest.fn(); +const mockCreateEventBuilder = jest.fn(); describe('LedgerConfirmationModal', () => { beforeEach(() => { + jest.clearAllMocks(); + + // Mock event builder chain + const mockEventBuilder = { + addProperties: jest.fn().mockReturnThis(), + addSensitiveProperties: jest.fn().mockReturnThis(), + removeProperties: jest.fn().mockReturnThis(), + removeSensitiveProperties: jest.fn().mockReturnThis(), + setSaveDataRecording: jest.fn().mockReturnThis(), + build: jest.fn().mockReturnValue({ + name: 'test-event', + properties: {}, + sensitiveProperties: {}, + saveDataRecording: true, + }), + }; + + mockCreateEventBuilder.mockReturnValue(mockEventBuilder); + // Mock hook return value (useBluetoothPermissions as jest.Mock).mockReturnValue({ hasBluetoothPermissions: true, @@ -71,7 +89,7 @@ describe('LedgerConfirmationModal', () => { (useMetrics as jest.MockedFn).mockReturnValue({ trackEvent: mockTrackEvent, - createEventBuilder: MetricsEventBuilder.createEventBuilder, + createEventBuilder: mockCreateEventBuilder, enable: jest.fn(), addTraitsToUser: jest.fn(), createDataDeletionTask: jest.fn(), @@ -143,10 +161,10 @@ describe('LedgerConfirmationModal', () => { expect(toJSON()).toMatchSnapshot(); }); - it('logs LEDGER_HARDWARE_WALLET_ERROR event when the ledger error occurs', async () => { + it('logs HARDWARE_WALLET_ERROR event when the ledger error occurs', async () => { const onConfirmation = jest.fn(); - const ledgerLogicToRun = jest.fn(); + (useLedgerBluetooth as jest.Mock).mockReturnValue({ isSendingLedgerCommands: true, isAppLaunchConfirmationNeeded: false, @@ -166,22 +184,11 @@ describe('LedgerConfirmationModal', () => { />, ); - // eslint-disable-next-line no-empty-function - await act(async () => {}); - expect(onConfirmation).not.toHaveBeenCalled(); - - expect(mockTrackEvent).toHaveBeenNthCalledWith( - 1, - MetricsEventBuilder.createEventBuilder( - MetaMetricsEvents.LEDGER_HARDWARE_WALLET_ERROR, - ) - .addProperties({ - device_type: HardwareDeviceTypes.LEDGER, - error: 'LEDGER_ETH_APP_NOT_INSTALLED', - }) - .build(), + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.HARDWARE_WALLET_ERROR, ); + expect(mockTrackEvent).toHaveBeenCalled(); }); it('renders SearchingForDeviceStep when not sending ledger commands', () => { @@ -351,15 +358,14 @@ describe('LedgerConfirmationModal', () => { />, ); - // eslint-disable-next-line no-empty-function - await act(async () => {}); - expect(ledgerLogicToRun).toHaveBeenCalledTimes(1); const retryButton = getByTestId(RETRY_BUTTON); - fireEvent.press(retryButton); - //Retry will run connectLedger again + await act(async () => { + fireEvent.press(retryButton); + }); + expect(ledgerLogicToRun).toHaveBeenCalledTimes(2); }); @@ -380,13 +386,12 @@ describe('LedgerConfirmationModal', () => { />, ); - // eslint-disable-next-line no-empty-function - await act(async () => {}); - const retryButton = getByTestId(RETRY_BUTTON); - fireEvent.press(retryButton); - //Retry will run connectLedger again + await act(async () => { + fireEvent.press(retryButton); + }); + expect(checkPermissions).toHaveBeenCalledTimes(1); }); @@ -407,9 +412,6 @@ describe('LedgerConfirmationModal', () => { />, ); - // eslint-disable-next-line no-empty-function - await act(async () => {}); - expect(onConfirmation).toHaveBeenCalled(); }); diff --git a/app/components/UI/LedgerModals/LedgerConfirmationModal.tsx b/app/components/UI/LedgerModals/LedgerConfirmationModal.tsx index 33b7f55414b..dc98c68df55 100644 --- a/app/components/UI/LedgerModals/LedgerConfirmationModal.tsx +++ b/app/components/UI/LedgerModals/LedgerConfirmationModal.tsx @@ -79,7 +79,7 @@ const LedgerConfirmationModal = ({ // Handle a super edge case of the user starting a transaction with the device connected // After arriving to confirmation the ETH app is not installed anymore this causes a crash. trackEvent( - createEventBuilder(MetaMetricsEvents.LEDGER_HARDWARE_WALLET_ERROR) + createEventBuilder(MetaMetricsEvents.HARDWARE_WALLET_ERROR) .addProperties({ device_type: HardwareDeviceTypes.LEDGER, error: 'LEDGER_ETH_APP_NOT_INSTALLED', @@ -95,9 +95,7 @@ const LedgerConfirmationModal = ({ onRejection(); } finally { trackEvent( - createEventBuilder( - MetaMetricsEvents.LEDGER_HARDWARE_TRANSACTION_CANCELLED, - ) + createEventBuilder(MetaMetricsEvents.DAPP_TRANSACTION_CANCELLED) .addProperties({ device_type: HardwareDeviceTypes.LEDGER, }) @@ -209,7 +207,7 @@ const LedgerConfirmationModal = ({ } if (ledgerError !== LedgerCommunicationErrors.UserRefusedConfirmation) { trackEvent( - createEventBuilder(MetaMetricsEvents.LEDGER_HARDWARE_WALLET_ERROR) + createEventBuilder(MetaMetricsEvents.HARDWARE_WALLET_ERROR) .addProperties({ device_type: HardwareDeviceTypes.LEDGER, error: `${ledgerError}`, @@ -242,7 +240,7 @@ const LedgerConfirmationModal = ({ } setPermissionErrorShown(true); trackEvent( - createEventBuilder(MetaMetricsEvents.LEDGER_HARDWARE_WALLET_ERROR) + createEventBuilder(MetaMetricsEvents.HARDWARE_WALLET_ERROR) .addProperties({ device_type: HardwareDeviceTypes.LEDGER, error: 'LEDGER_BLUETOOTH_PERMISSION_ERR', @@ -258,7 +256,7 @@ const LedgerConfirmationModal = ({ }); trackEvent( - createEventBuilder(MetaMetricsEvents.LEDGER_HARDWARE_WALLET_ERROR) + createEventBuilder(MetaMetricsEvents.HARDWARE_WALLET_ERROR) .addProperties({ device_type: HardwareDeviceTypes.LEDGER, error: 'LEDGER_BLUETOOTH_CONNECTION_ERR', diff --git a/app/components/UI/QRHardware/AnimatedQRScanner.test.tsx b/app/components/UI/QRHardware/AnimatedQRScanner.test.tsx new file mode 100644 index 00000000000..cda7df5f09a --- /dev/null +++ b/app/components/UI/QRHardware/AnimatedQRScanner.test.tsx @@ -0,0 +1,920 @@ +import React from 'react'; +import { render, waitFor, act } from '@testing-library/react-native'; +import AnimatedQRScannerModal from './AnimatedQRScanner'; +import { QrScanRequestType } from '@metamask/eth-qr-keyring'; +import { URRegistryDecoder } from '@keystonehq/ur-decoder'; +import { MetaMetricsEvents } from '../../../core/Analytics'; +import { SUPPORTED_UR_TYPE } from '../../../constants/qr'; +import { HardwareDeviceTypes } from '../../../constants/keyringTypes'; + +const mockTrackEvent = jest.fn(); +const mockCreateEventBuilder = jest.fn(); +const mockBuild = jest.fn(); +const mockAddProperties = jest.fn(() => ({ build: mockBuild })); + +import { + getCapturedCallbacks, + resetCapturedCallbacks, +} from '../../../__mocks__/react-native-vision-camera'; + +jest.mock('../../../components/hooks/useMetrics', () => { + const actualMetrics = jest.requireActual( + '../../../components/hooks/useMetrics', + ); + return { + ...actualMetrics, + useMetrics: jest.fn(() => ({ + trackEvent: mockTrackEvent, + createEventBuilder: mockCreateEventBuilder.mockReturnValue({ + addProperties: mockAddProperties, + }), + })), + }; +}); + +jest.mock('../../../core/QrKeyring/QrKeyring', () => ({ + withQrKeyring: jest.fn(async (callback) => + callback({ + keyring: { getName: jest.fn().mockResolvedValue('MockDevice') }, + metadata: { id: 'mock-id' }, + }), + ), +})); + +jest.mock('@keystonehq/ur-decoder', () => ({ + URRegistryDecoder: jest.fn().mockImplementation(() => ({ + receivePart: jest.fn(), + getProgress: jest.fn(() => 0.5), + isError: jest.fn(() => false), + isSuccess: jest.fn(() => false), + resultError: jest.fn(() => 'Mock error'), + resultUR: jest.fn(() => ({ + type: 'crypto-hdkey', + cbor: Buffer.from([]), + })), + })), +})); + +jest.mock('../../../../locales/i18n', () => ({ + strings: jest.fn((key: string) => key), +})); + +jest.mock('react-native-vision-camera'); + +jest.mock('react-native-modal', () => { + const { View } = jest.requireActual('react-native'); + let previousVisible: boolean | undefined; + return jest.fn(({ children, isVisible, onModalWillShow, onModalHide }) => { + const wasVisible = previousVisible; + + if (isVisible && wasVisible !== isVisible && onModalWillShow) { + setImmediate(() => { + onModalWillShow(); + }); + } + if (!isVisible && wasVisible === true && onModalHide) { + setImmediate(() => { + onModalHide(); + }); + } + previousVisible = isVisible; + return isVisible ? {children} : null; + }); +}); + +const mockURRegistryDecoder = URRegistryDecoder as jest.MockedClass< + typeof URRegistryDecoder +>; + +describe('AnimatedQRScannerModal - Metrics', () => { + const mockOnScanSuccess = jest.fn(); + const mockOnScanError = jest.fn(); + const mockHideModal = jest.fn(); + const mockPauseQRCode = jest.fn(); + + const defaultProps = { + visible: true, + purpose: QrScanRequestType.PAIR, + onScanSuccess: mockOnScanSuccess, + onScanError: mockOnScanError, + hideModal: mockHideModal, + pauseQRCode: mockPauseQRCode, + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockBuild.mockReturnValue({}); + resetCapturedCallbacks(); + const modalMock = jest.requireMock('react-native-modal'); + modalMock.mockClear(); + }); + + describe('Camera Error Metrics', () => { + it('tracks metrics when camera error occurs', async () => { + render(); + + await waitFor(() => { + const callbacks = getCapturedCallbacks(); + expect(callbacks.onError).not.toBeNull(); + }); + + const callbacks = getCapturedCallbacks(); + if (!callbacks.onError) { + throw new Error('onError callback is null'); + } + + const onError = callbacks.onError; + const mockError = new Error('Camera initialization failed'); + await act(async () => { + await onError(mockError); + }); + + await waitFor(() => { + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.HARDWARE_WALLET_ERROR, + ); + expect(mockAddProperties).toHaveBeenCalledWith({ + error: 'Camera initialization failed', + device_model: 'MockDevice', + device_type: HardwareDeviceTypes.QR, + }); + expect(mockTrackEvent).toHaveBeenCalledWith({}); + expect(mockOnScanError).toHaveBeenCalledWith( + 'Camera initialization failed', + ); + }); + }); + + it('does not track metrics when error is falsy', async () => { + render(); + + await waitFor(() => { + const callbacks = getCapturedCallbacks(); + expect(callbacks.onError).not.toBeNull(); + }); + + const callbacks = getCapturedCallbacks(); + if (!callbacks.onError) { + throw new Error('onError callback is null'); + } + + const onError = callbacks.onError; + await act(async () => { + await onError(null as unknown as Error); + }); + + await waitFor(() => { + expect(mockTrackEvent).not.toHaveBeenCalled(); + expect(mockCreateEventBuilder).not.toHaveBeenCalled(); + expect(mockOnScanError).not.toHaveBeenCalled(); + }); + }); + + it('does not track metrics when error is null or undefined', async () => { + render(); + + await waitFor(() => { + const callbacks = getCapturedCallbacks(); + expect(callbacks.onError).not.toBeNull(); + }); + + const callbacks = getCapturedCallbacks(); + if (!callbacks.onError) { + throw new Error('onError callback is null'); + } + + const onError = callbacks.onError; + await act(async () => { + await onError(null as unknown as Error); + }); + + await waitFor(() => { + expect(mockTrackEvent).not.toHaveBeenCalled(); + expect(mockCreateEventBuilder).not.toHaveBeenCalled(); + expect(mockOnScanError).not.toHaveBeenCalled(); + }); + }); + }); + + describe('QR Code Scanning Metrics', () => { + it('tracks metrics when UR decoder reports an error', async () => { + const mockDecoderInstance = { + receivePart: jest.fn(), + getProgress: jest.fn(() => 0.5), + isError: jest.fn(() => true), + isSuccess: jest.fn(() => false), + resultError: jest.fn(() => 'Invalid UR format'), + resultUR: jest.fn(), + }; + + mockURRegistryDecoder.mockImplementation( + () => mockDecoderInstance as unknown as URRegistryDecoder, + ); + + render(); + + await waitFor(() => { + const callbacks = getCapturedCallbacks(); + expect(callbacks.onCodeScanned).not.toBeNull(); + }); + + const callbacks = getCapturedCallbacks(); + if (!callbacks.onCodeScanned) { + throw new Error('onCodeScanned callback is null'); + } + + const onCodeScanned = callbacks.onCodeScanned; + await act(async () => { + await onCodeScanned([{ value: 'mock-qr-data', type: 'qr' }]); + }); + + await waitFor(() => { + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.HARDWARE_WALLET_ERROR, + ); + expect(mockAddProperties).toHaveBeenCalledWith({ + error: 'Invalid UR format', + device_model: 'MockDevice', + device_type: HardwareDeviceTypes.QR, + }); + expect(mockTrackEvent).toHaveBeenCalledWith({}); + expect(mockOnScanError).toHaveBeenCalledWith( + 'transaction.unknown_qr_code', + ); + }); + }); + + it('tracks metrics for invalid sync QR code during pairing', async () => { + const mockDecoderInstance = { + receivePart: jest.fn(), + getProgress: jest.fn(() => 1), + isError: jest.fn(() => false), + isSuccess: jest.fn(() => true), + resultError: jest.fn(), + resultUR: jest.fn(() => ({ + type: SUPPORTED_UR_TYPE.ETH_SIGNATURE, // Wrong type for pairing + cbor: Buffer.from([]), + })), + }; + + mockURRegistryDecoder.mockImplementation( + () => mockDecoderInstance as unknown as URRegistryDecoder, + ); + + render(); + + await waitFor(() => { + const callbacks = getCapturedCallbacks(); + expect(callbacks.onCodeScanned).not.toBeNull(); + }); + + const callbacks = getCapturedCallbacks(); + if (!callbacks.onCodeScanned) { + throw new Error('onCodeScanned callback is null'); + } + + const onCodeScanned = callbacks.onCodeScanned; + await act(async () => { + await onCodeScanned([{ value: 'mock-qr-data', type: 'qr' }]); + }); + + await waitFor(() => { + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.HARDWARE_WALLET_ERROR, + ); + expect(mockAddProperties).toHaveBeenCalledWith({ + received_ur_type: SUPPORTED_UR_TYPE.ETH_SIGNATURE, + error: 'invalid `sync` qr code', + device_model: 'MockDevice', + device_type: HardwareDeviceTypes.QR, + }); + expect(mockTrackEvent).toHaveBeenCalledWith({}); + expect(mockOnScanError).toHaveBeenCalledWith( + 'transaction.invalid_qr_code_sync', + ); + }); + }); + + it('tracks metrics for invalid sign QR code during signing', async () => { + const mockDecoderInstance = { + receivePart: jest.fn(), + getProgress: jest.fn(() => 1), + isError: jest.fn(() => false), + isSuccess: jest.fn(() => true), + resultError: jest.fn(), + resultUR: jest.fn(() => ({ + type: SUPPORTED_UR_TYPE.CRYPTO_HDKEY, // Wrong type for signing + cbor: Buffer.from([]), + })), + }; + + mockURRegistryDecoder.mockImplementation( + () => mockDecoderInstance as unknown as URRegistryDecoder, + ); + + render( + , + ); + + await waitFor(() => { + const callbacks = getCapturedCallbacks(); + expect(callbacks.onCodeScanned).not.toBeNull(); + }); + + const callbacks = getCapturedCallbacks(); + if (!callbacks.onCodeScanned) { + throw new Error('onCodeScanned callback is null'); + } + + const onCodeScanned = callbacks.onCodeScanned; + await act(async () => { + await onCodeScanned([{ value: 'mock-qr-data', type: 'qr' }]); + }); + + await waitFor(() => { + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.HARDWARE_WALLET_ERROR, + ); + expect(mockAddProperties).toHaveBeenCalledWith({ + error: 'invalid `sign` qr code', + device_model: 'MockDevice', + device_type: HardwareDeviceTypes.QR, + }); + expect(mockTrackEvent).toHaveBeenCalledWith({}); + expect(mockOnScanError).toHaveBeenCalledWith( + 'transaction.invalid_qr_code_sign', + ); + }); + }); + + it('tracks metrics for unknown QR code exception', async () => { + const mockDecoderInstance = { + receivePart: jest.fn(() => { + throw new Error('Unexpected decoding error'); + }), + getProgress: jest.fn(() => 0), + isError: jest.fn(() => false), + isSuccess: jest.fn(() => false), + resultError: jest.fn(), + resultUR: jest.fn(), + }; + + mockURRegistryDecoder.mockImplementation( + () => mockDecoderInstance as unknown as URRegistryDecoder, + ); + + render(); + + await waitFor(() => { + const callbacks = getCapturedCallbacks(); + expect(callbacks.onCodeScanned).not.toBeNull(); + }); + + const callbacks = getCapturedCallbacks(); + if (!callbacks.onCodeScanned) { + throw new Error('onCodeScanned callback is null'); + } + + const onCodeScanned = callbacks.onCodeScanned; + await act(async () => { + await onCodeScanned([{ value: 'mock-qr-data', type: 'qr' }]); + }); + + await waitFor(() => { + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.HARDWARE_WALLET_ERROR, + ); + expect(mockAddProperties).toHaveBeenCalledWith({ + error: 'transaction.unknown_qr_code', + device_model: 'MockDevice', + device_type: HardwareDeviceTypes.QR, + }); + expect(mockTrackEvent).toHaveBeenCalledWith({}); + expect(mockOnScanError).toHaveBeenCalledWith( + 'transaction.unknown_qr_code', + ); + }); + }); + + it('successfully scans valid QR code without tracking error metrics', async () => { + const mockDecoderInstance = { + receivePart: jest.fn(), + getProgress: jest.fn(() => 1), + isError: jest.fn(() => false), + isSuccess: jest.fn(() => true), + resultError: jest.fn(), + resultUR: jest.fn(() => ({ + type: SUPPORTED_UR_TYPE.CRYPTO_HDKEY, + cbor: Buffer.from([]), + })), + }; + + mockURRegistryDecoder.mockImplementation( + () => mockDecoderInstance as unknown as URRegistryDecoder, + ); + + render(); + + await waitFor(() => { + const callbacks = getCapturedCallbacks(); + expect(callbacks.onCodeScanned).not.toBeNull(); + }); + + const callbacks = getCapturedCallbacks(); + if (!callbacks.onCodeScanned) { + throw new Error('onCodeScanned callback is null'); + } + + const onCodeScanned = callbacks.onCodeScanned; + await act(async () => { + await onCodeScanned([{ value: 'mock-qr-data', type: 'qr' }]); + }); + + await waitFor(() => { + expect(mockOnScanSuccess).toHaveBeenCalledWith({ + type: SUPPORTED_UR_TYPE.CRYPTO_HDKEY, + cbor: Buffer.from([]), + }); + expect(mockTrackEvent).not.toHaveBeenCalled(); + expect(mockOnScanError).not.toHaveBeenCalled(); + }); + }); + + it('does not process QR code when modal is not visible', async () => { + const mockDecoderInstance = { + receivePart: jest.fn(), + getProgress: jest.fn(() => 0.5), + isError: jest.fn(() => false), + isSuccess: jest.fn(() => false), + resultError: jest.fn(), + resultUR: jest.fn(), + }; + + mockURRegistryDecoder.mockImplementation( + () => mockDecoderInstance as unknown as URRegistryDecoder, + ); + + render(); + + await waitFor(() => { + const callbacks = getCapturedCallbacks(); + expect(callbacks.onCodeScanned).not.toBeNull(); + }); + + const callbacks = getCapturedCallbacks(); + if (!callbacks.onCodeScanned) { + throw new Error('onCodeScanned callback is null'); + } + + const onCodeScanned = callbacks.onCodeScanned; + await act(async () => { + await onCodeScanned([{ value: 'mock-qr-data', type: 'qr' }]); + }); + + await waitFor(() => { + expect(mockDecoderInstance.receivePart).not.toHaveBeenCalled(); + expect(mockOnScanSuccess).not.toHaveBeenCalled(); + expect(mockOnScanError).not.toHaveBeenCalled(); + expect(mockTrackEvent).not.toHaveBeenCalled(); + }); + }); + + it('does not process QR code when codes array is empty', async () => { + const mockDecoderInstance = { + receivePart: jest.fn(), + getProgress: jest.fn(() => 0.5), + isError: jest.fn(() => false), + isSuccess: jest.fn(() => false), + resultError: jest.fn(), + resultUR: jest.fn(), + }; + + mockURRegistryDecoder.mockImplementation( + () => mockDecoderInstance as unknown as URRegistryDecoder, + ); + + render(); + + await waitFor(() => { + const callbacks = getCapturedCallbacks(); + expect(callbacks.onCodeScanned).not.toBeNull(); + }); + + const callbacks = getCapturedCallbacks(); + if (!callbacks.onCodeScanned) { + throw new Error('onCodeScanned callback is null'); + } + + const onCodeScanned = callbacks.onCodeScanned; + await act(async () => { + await onCodeScanned([]); + }); + + await waitFor(() => { + expect(mockDecoderInstance.receivePart).not.toHaveBeenCalled(); + expect(mockOnScanSuccess).not.toHaveBeenCalled(); + expect(mockOnScanError).not.toHaveBeenCalled(); + expect(mockTrackEvent).not.toHaveBeenCalled(); + }); + }); + + it('does not process QR code when code value is null or undefined', async () => { + const mockDecoderInstance = { + receivePart: jest.fn(), + getProgress: jest.fn(() => 0.5), + isError: jest.fn(() => false), + isSuccess: jest.fn(() => false), + resultError: jest.fn(), + resultUR: jest.fn(), + }; + + mockURRegistryDecoder.mockImplementation( + () => mockDecoderInstance as unknown as URRegistryDecoder, + ); + + render(); + + await waitFor(() => { + const callbacks = getCapturedCallbacks(); + expect(callbacks.onCodeScanned).not.toBeNull(); + }); + + const callbacks = getCapturedCallbacks(); + if (!callbacks.onCodeScanned) { + throw new Error('onCodeScanned callback is null'); + } + + const onCodeScanned = callbacks.onCodeScanned; + await act(async () => { + await onCodeScanned([{ value: null as unknown as string, type: 'qr' }]); + }); + + await waitFor(() => { + expect(mockDecoderInstance.receivePart).not.toHaveBeenCalled(); + expect(mockOnScanSuccess).not.toHaveBeenCalled(); + expect(mockOnScanError).not.toHaveBeenCalled(); + expect(mockTrackEvent).not.toHaveBeenCalled(); + }); + }); + + it('does not process QR code when code value is empty string', async () => { + const mockDecoderInstance = { + receivePart: jest.fn(), + getProgress: jest.fn(() => 0.5), + isError: jest.fn(() => false), + isSuccess: jest.fn(() => false), + resultError: jest.fn(), + resultUR: jest.fn(), + }; + + mockURRegistryDecoder.mockImplementation( + () => mockDecoderInstance as unknown as URRegistryDecoder, + ); + + render(); + + await waitFor(() => { + const callbacks = getCapturedCallbacks(); + expect(callbacks.onCodeScanned).not.toBeNull(); + }); + + const callbacks = getCapturedCallbacks(); + if (!callbacks.onCodeScanned) { + throw new Error('onCodeScanned callback is null'); + } + + const onCodeScanned = callbacks.onCodeScanned; + await act(async () => { + await onCodeScanned([{ value: '', type: 'qr' }]); + }); + + await waitFor(() => { + expect(mockDecoderInstance.receivePart).not.toHaveBeenCalled(); + expect(mockOnScanSuccess).not.toHaveBeenCalled(); + expect(mockOnScanError).not.toHaveBeenCalled(); + expect(mockTrackEvent).not.toHaveBeenCalled(); + }); + }); + }); + + describe('Camera Permission Error', () => { + it('calls onScanError when camera permission is not granted', async () => { + const mockUseCameraPermission = jest.requireMock( + 'react-native-vision-camera', + ).useCameraPermission; + + mockUseCameraPermission.mockReturnValue({ + hasPermission: false, + requestPermission: jest.fn(), + }); + + render(); + + await waitFor(() => { + expect(mockOnScanError).toHaveBeenCalledWith( + 'transaction.no_camera_permission', + ); + }); + }); + + it('does not call onScanError when camera permission is granted', async () => { + const mockUseCameraPermission = jest.requireMock( + 'react-native-vision-camera', + ).useCameraPermission; + + mockUseCameraPermission.mockReturnValue({ + hasPermission: true, + requestPermission: jest.fn(), + }); + + render(); + + await waitFor(() => { + const callbacks = getCapturedCallbacks(); + expect(callbacks.onCodeScanned).not.toBeNull(); + }); + + expect(mockOnScanError).not.toHaveBeenCalled(); + }); + + it('does not call onScanError when modal is not visible', async () => { + const mockUseCameraPermission = jest.requireMock( + 'react-native-vision-camera', + ).useCameraPermission; + + mockUseCameraPermission.mockReturnValue({ + hasPermission: false, + requestPermission: jest.fn(), + }); + + const propsWithoutVisibility = { ...defaultProps, visible: false }; + + render(); + + await waitFor(() => { + expect(mockOnScanError).not.toHaveBeenCalled(); + }); + }); + }); + + describe('Device Information in Metrics', () => { + it('includes device name in all error metrics', async () => { + const mockDecoderInstance = { + receivePart: jest.fn(), + getProgress: jest.fn(() => 0.5), + isError: jest.fn(() => true), + isSuccess: jest.fn(() => false), + resultError: jest.fn(() => 'Test error'), + resultUR: jest.fn(), + }; + + mockURRegistryDecoder.mockImplementation( + () => mockDecoderInstance as unknown as URRegistryDecoder, + ); + + render(); + + await waitFor(() => { + const callbacks = getCapturedCallbacks(); + expect(callbacks.onCodeScanned).not.toBeNull(); + }); + + const callbacks = getCapturedCallbacks(); + if (!callbacks.onCodeScanned) { + throw new Error('onCodeScanned callback is null'); + } + + const onCodeScanned = callbacks.onCodeScanned; + await act(async () => { + await onCodeScanned([{ value: 'mock-qr-data', type: 'qr' }]); + }); + + await waitFor(() => { + expect(mockAddProperties).toHaveBeenCalledWith( + expect.objectContaining({ + device_model: 'MockDevice', + device_type: HardwareDeviceTypes.QR, + }), + ); + }); + }); + + it('uses "Unknown" device name when withQrKeyring fails during camera error', async () => { + const { withQrKeyring } = jest.requireMock( + '../../../core/QrKeyring/QrKeyring', + ); + withQrKeyring.mockRejectedValueOnce(new Error('Keyring not initialized')); + + const mockUseCameraPermission = jest.requireMock( + 'react-native-vision-camera', + ).useCameraPermission; + + mockUseCameraPermission.mockReturnValue({ + hasPermission: true, + requestPermission: jest.fn(), + }); + + render(); + + await waitFor(() => { + const callbacks = getCapturedCallbacks(); + expect(callbacks.onError).not.toBeNull(); + }); + + const callbacks = getCapturedCallbacks(); + if (!callbacks.onError) { + throw new Error('onError callback is null'); + } + + const onError = callbacks.onError; + const mockError = new Error('Camera error'); + await act(async () => { + await onError(mockError); + }); + + await waitFor(() => { + expect(mockAddProperties).toHaveBeenCalledWith({ + error: 'Camera error', + device_model: 'Unknown', + device_type: HardwareDeviceTypes.QR, + }); + expect(mockOnScanError).toHaveBeenCalledWith('Camera error'); + }); + }); + + it('uses "Unknown" device name when withQrKeyring fails during QR scanning', async () => { + const { withQrKeyring } = jest.requireMock( + '../../../core/QrKeyring/QrKeyring', + ); + withQrKeyring.mockRejectedValueOnce(new Error('Keyring not initialized')); + + const mockDecoderInstance = { + receivePart: jest.fn(), + getProgress: jest.fn(() => 0.5), + isError: jest.fn(() => true), + isSuccess: jest.fn(() => false), + resultError: jest.fn(() => 'Decoder error'), + resultUR: jest.fn(), + }; + + mockURRegistryDecoder.mockImplementation( + () => mockDecoderInstance as unknown as URRegistryDecoder, + ); + + render(); + + await waitFor(() => { + const callbacks = getCapturedCallbacks(); + expect(callbacks.onCodeScanned).not.toBeNull(); + }); + + const callbacks = getCapturedCallbacks(); + if (!callbacks.onCodeScanned) { + throw new Error('onCodeScanned callback is null'); + } + + const onCodeScanned = callbacks.onCodeScanned; + await act(async () => { + await onCodeScanned([{ value: 'mock-qr-data', type: 'qr' }]); + }); + + await waitFor(() => { + expect(mockAddProperties).toHaveBeenCalledWith({ + error: 'Decoder error', + device_model: 'Unknown', + device_type: HardwareDeviceTypes.QR, + }); + expect(mockOnScanError).toHaveBeenCalledWith( + 'transaction.unknown_qr_code', + ); + }); + }); + }); + + describe('Modal Lifecycle', () => { + it('calls pauseQRCode with true when modal is shown', async () => { + const propsHidden = { ...defaultProps, visible: false }; + const propsVisible = { ...defaultProps, visible: true }; + + const { rerender } = render(); + + mockPauseQRCode.mockClear(); + rerender(); + + await waitFor(() => { + expect(mockPauseQRCode).toHaveBeenCalledWith(true); + }); + }); + + it('calls pauseQRCode with false and resets state when modal is hidden', async () => { + const propsHidden = { ...defaultProps, visible: false }; + const propsVisible = { ...defaultProps, visible: true }; + + // Start with hidden modal + const { rerender } = render(); + + // Show modal + rerender(); + + // Wait for initial show callback + await waitFor( + () => { + expect(mockPauseQRCode).toHaveBeenCalledWith(true); + }, + { timeout: 2000 }, + ); + + mockPauseQRCode.mockClear(); + + // Hide modal + rerender(); + + await waitFor( + () => { + expect(mockPauseQRCode).toHaveBeenCalledWith(false); + }, + { timeout: 2000 }, + ); + }); + + it('does not throw error when pauseQRCode is not provided', async () => { + const propsWithoutPauseHidden = { + ...defaultProps, + pauseQRCode: undefined, + visible: false, + }; + const propsWithoutPauseVisible = { + ...defaultProps, + pauseQRCode: undefined, + visible: true, + }; + + const { rerender } = render( + , + ); + + expect(() => { + rerender(); + }).not.toThrow(); + + expect(() => { + rerender(); + }).not.toThrow(); + }); + + it('resets progress and decoder when modal is hidden', async () => { + const mockDecoderInstance = { + receivePart: jest.fn(), + getProgress: jest.fn(() => 0.5), + isError: jest.fn(() => false), + isSuccess: jest.fn(() => false), + resultError: jest.fn(), + resultUR: jest.fn(), + }; + + mockURRegistryDecoder.mockImplementation( + () => mockDecoderInstance as unknown as URRegistryDecoder, + ); + + const propsVisible = { ...defaultProps, visible: true }; + const propsHidden = { ...defaultProps, visible: false }; + + const { rerender } = render(); + + // Simulate scanning to set progress + await waitFor(() => { + const callbacks = getCapturedCallbacks(); + expect(callbacks.onCodeScanned).not.toBeNull(); + }); + + const callbacks = getCapturedCallbacks(); + if (!callbacks.onCodeScanned) { + throw new Error('onCodeScanned callback is null'); + } + + const onCodeScanned = callbacks.onCodeScanned; + await act(async () => { + await onCodeScanned([{ value: 'mock-qr-data', type: 'qr' }]); + }); + + // Hide modal to trigger reset + mockPauseQRCode.mockClear(); + rerender(); + + await waitFor(() => { + expect(mockPauseQRCode).toHaveBeenCalledWith(false); + }); + + // When modal is shown again, it should start fresh + mockPauseQRCode.mockClear(); + rerender(); + + await waitFor(() => { + expect(mockPauseQRCode).toHaveBeenCalledWith(true); + }); + }); + }); +}); diff --git a/app/components/UI/QRHardware/AnimatedQRScanner.tsx b/app/components/UI/QRHardware/AnimatedQRScanner.tsx index ffb83949c05..dabdc5391b2 100644 --- a/app/components/UI/QRHardware/AnimatedQRScanner.tsx +++ b/app/components/UI/QRHardware/AnimatedQRScanner.tsx @@ -33,6 +33,8 @@ import Icon, { IconSize, } from '../../../component-library/components/Icons/Icon'; import { QrScanRequestType } from '@metamask/eth-qr-keyring'; +import { withQrKeyring } from '../../../core/QrKeyring/QrKeyring'; +import { HardwareDeviceTypes } from '../../../constants/keyringTypes'; const createStyles = (theme: Theme) => StyleSheet.create({ @@ -164,21 +166,41 @@ const AnimatedQRScannerModal = (props: AnimatedQRScannerProps) => { ); const onError = useCallback( - (error: Error) => { + async (error: Error) => { if (onScanError && error) { - trackEvent( - createEventBuilder(MetaMetricsEvents.HARDWARE_WALLET_ERROR) - .addProperties({ purpose, error: error.message }) - .build(), - ); + // Get device name asynchronously without blocking error handling + withQrKeyring(({ keyring }) => Promise.resolve(keyring.getName())) + .then((deviceName) => { + trackEvent( + createEventBuilder(MetaMetricsEvents.HARDWARE_WALLET_ERROR) + .addProperties({ + error: error.message, + device_model: deviceName, + device_type: HardwareDeviceTypes.QR, + }) + .build(), + ); + }) + .catch(() => { + // If getName fails, send analytics with 'Unknown' + trackEvent( + createEventBuilder(MetaMetricsEvents.HARDWARE_WALLET_ERROR) + .addProperties({ + error: error.message, + device_model: 'Unknown', + device_type: HardwareDeviceTypes.QR, + }) + .build(), + ); + }); onScanError(error.message); } }, - [purpose, onScanError, trackEvent, createEventBuilder], + [onScanError, trackEvent, createEventBuilder], ); const onBarCodeRead = useCallback( - (codes: Code[]) => { + async (codes: Code[]) => { if (!visible || !codes.length) { return; } @@ -186,16 +208,41 @@ const AnimatedQRScannerModal = (props: AnimatedQRScannerProps) => { if (!response.data) { return; } + try { const content = response.data; urDecoder.receivePart(content); setProgress(Math.ceil(urDecoder.getProgress() * 100)); + + // Helper to send analytics with device name + const sendAnalytics = (properties: Record) => { + withQrKeyring(({ keyring }) => Promise.resolve(keyring.getName())) + .then((deviceName) => { + trackEvent( + createEventBuilder(MetaMetricsEvents.HARDWARE_WALLET_ERROR) + .addProperties({ + ...properties, + device_model: deviceName, + device_type: HardwareDeviceTypes.QR, + }) + .build(), + ); + }) + .catch(() => { + trackEvent( + createEventBuilder(MetaMetricsEvents.HARDWARE_WALLET_ERROR) + .addProperties({ + ...properties, + device_model: 'Unknown', + device_type: HardwareDeviceTypes.QR, + }) + .build(), + ); + }); + }; + if (urDecoder.isError()) { - trackEvent( - createEventBuilder(MetaMetricsEvents.HARDWARE_WALLET_ERROR) - .addProperties({ purpose, error: urDecoder.resultError() }) - .build(), - ); + sendAnalytics({ error: urDecoder.resultError() }); onScanError(strings('transaction.unknown_qr_code')); } else if (urDecoder.isSuccess()) { const ur = urDecoder.resultUR(); @@ -204,30 +251,40 @@ const AnimatedQRScannerModal = (props: AnimatedQRScannerProps) => { setProgress(0); setURDecoder(new URRegistryDecoder()); } else if (purpose === QrScanRequestType.PAIR) { + sendAnalytics({ + received_ur_type: ur.type, + error: 'invalid `sync` qr code', + }); + onScanError(strings('transaction.invalid_qr_code_sync')); + } else { + sendAnalytics({ error: 'invalid `sign` qr code' }); + onScanError(strings('transaction.invalid_qr_code_sign')); + } + } + } catch (e) { + withQrKeyring(({ keyring }) => Promise.resolve(keyring.getName())) + .then((deviceName) => { trackEvent( createEventBuilder(MetaMetricsEvents.HARDWARE_WALLET_ERROR) .addProperties({ - purpose, - received_ur_type: ur.type, - error: 'invalid `sync` qr code', + error: strings('transaction.unknown_qr_code'), + device_model: deviceName, + device_type: HardwareDeviceTypes.QR, }) .build(), ); - onScanError(strings('transaction.invalid_qr_code_sync')); - } else { + }) + .catch(() => { trackEvent( createEventBuilder(MetaMetricsEvents.HARDWARE_WALLET_ERROR) .addProperties({ - purpose, - received_ur_type: ur.type, - error: 'invalid `sign` qr code', + error: strings('transaction.unknown_qr_code'), + device_model: 'Unknown', + device_type: HardwareDeviceTypes.QR, }) .build(), ); - onScanError(strings('transaction.invalid_qr_code_sign')); - } - } - } catch (e) { + }); onScanError(strings('transaction.unknown_qr_code')); } }, diff --git a/app/components/Views/AccountActions/AccountActions.test.tsx b/app/components/Views/AccountActions/AccountActions.test.tsx index 2ca426abf36..ea0ae55f85a 100644 --- a/app/components/Views/AccountActions/AccountActions.test.tsx +++ b/app/components/Views/AccountActions/AccountActions.test.tsx @@ -19,6 +19,7 @@ import { import { BITCOIN_WALLET_SNAP_ID } from '../../../core/SnapKeyring/BitcoinWalletSnap'; import { SOLANA_WALLET_SNAP_ID } from '../../../core/SnapKeyring/SolanaWalletSnap'; import { KeyringTypes } from '@metamask/keyring-controller'; +import ExtendedKeyringTypes from '../../../constants/keyringTypes'; import { strings } from '../../../../locales/i18n'; // eslint-disable-next-line import/no-namespace @@ -226,6 +227,11 @@ jest.mock('@react-navigation/native', () => { import { useRoute } from '@react-navigation/native'; import { RootState } from '../../../reducers'; import { InternalAccount } from '@metamask/keyring-internal-api'; +import { forgetLedger } from '../../../core/Ledger/Ledger'; +import { + forgetQrDevice, + withQrKeyring, +} from '../../../core/QrKeyring/QrKeyring'; // Set the implementation after the mock is defined const mockedUseRoute = jest.mocked(useRoute); @@ -325,6 +331,15 @@ jest.mock('../../../core/Multichain/utils', () => ({ isNonEvmChainId: jest.fn(() => false), })); +jest.mock('../../../core/Ledger/Ledger', () => ({ + forgetLedger: jest.fn().mockResolvedValue(undefined), +})); + +jest.mock('../../../core/QrKeyring/QrKeyring', () => ({ + forgetQrDevice: jest.fn().mockResolvedValue(undefined), + withQrKeyring: jest.fn().mockResolvedValue(undefined), +})); + describe('AccountActions', () => { const mockKeyringController = mockEngine.context.KeyringController; @@ -701,4 +716,208 @@ describe('AccountActions', () => { expect(queryByText('Switch to Smart account')).toBeNull(); }); }); + + describe('forgetDeviceIfRequired', () => { + const MOCK_LEDGER_ACCOUNT = { + address: '0xC4966c0D659D99699BFD7EB54D8fafEE40e4a756', + id: '123', + metadata: { + name: 'Ledger Account', + importTime: 1684232000456, + keyring: { + type: ExtendedKeyringTypes.ledger, + }, + }, + options: { + entropySource: 'mock-id', + }, + methods: [ + 'personal_sign', + 'eth_signTransaction', + 'eth_signTypedData_v1', + 'eth_signTypedData_v3', + 'eth_signTypedData_v4', + ], + type: 'eoa', + scopes: ['eip155:1'], + }; + + const MOCK_QR_ACCOUNT = { + address: '0xC4966c0D659D99699BFD7EB54D8fafEE40e4a756', + id: '124', + metadata: { + name: 'QR Account', + importTime: 1684232000456, + keyring: { + type: ExtendedKeyringTypes.qr, + }, + }, + options: { + entropySource: 'mock-id', + }, + methods: [ + 'personal_sign', + 'eth_signTransaction', + 'eth_signTypedData_v1', + 'eth_signTypedData_v3', + 'eth_signTypedData_v4', + ], + type: 'eoa', + scopes: ['eip155:1'], + }; + + beforeEach(() => { + (forgetLedger as jest.Mock).mockClear(); + (forgetQrDevice as jest.Mock).mockClear(); + (withQrKeyring as jest.Mock).mockClear(); + }); + + it('triggers blocking modal when remove hardware account is confirmed for Ledger', async () => { + jest.spyOn(AddressUtils, 'isHardwareAccount').mockReturnValue(true); + + mockedUseRoute.mockImplementationOnce(() => ({ + key: 'mock-key', + name: 'mock-route', + params: { + selectedAccount: MOCK_LEDGER_ACCOUNT, + }, + })); + + Object.assign(mockKeyringController.state, { + keyrings: [ + { + type: ExtendedKeyringTypes.ledger, + accounts: [], + }, + ], + }); + + const { getByTestId, getByText } = renderWithProvider( + , + { + state: initialState, + }, + ); + + fireEvent.press( + getByTestId( + AccountActionsBottomSheetSelectorsIDs.REMOVE_HARDWARE_ACCOUNT, + ), + ); + + const alertFnMock = Alert.alert as jest.MockedFn; + expect(alertFnMock).toHaveBeenCalledWith( + strings('accounts.remove_hardware_account'), + strings('accounts.remove_hw_account_alert_description'), + expect.any(Array), + ); + + await act(async () => { + const alertButtons = alertFnMock.mock.calls[0][2] as AlertButton[]; + if (alertButtons[1].onPress !== undefined) { + alertButtons[1].onPress(); + } + }); + + await waitFor(() => { + expect(getByText(strings('common.please_wait'))).toBeDefined(); + }); + }); + + it('triggers blocking modal when remove hardware account is confirmed for QR', async () => { + jest.spyOn(AddressUtils, 'isHardwareAccount').mockReturnValue(true); + + mockedUseRoute.mockImplementationOnce(() => ({ + key: 'mock-key', + name: 'mock-route', + params: { + selectedAccount: MOCK_QR_ACCOUNT, + }, + })); + + Object.assign(mockKeyringController.state, { + keyrings: [ + { + type: ExtendedKeyringTypes.qr, + accounts: [], + }, + ], + }); + + const { getByTestId, getByText } = renderWithProvider( + , + { + state: initialState, + }, + ); + + fireEvent.press( + getByTestId( + AccountActionsBottomSheetSelectorsIDs.REMOVE_HARDWARE_ACCOUNT, + ), + ); + + const alertFnMock = Alert.alert as jest.MockedFn; + + await act(async () => { + const alertButtons = alertFnMock.mock.calls[0][2] as AlertButton[]; + if (alertButtons[1].onPress !== undefined) { + alertButtons[1].onPress(); + } + }); + + await waitFor(() => { + expect(getByText(strings('common.please_wait'))).toBeDefined(); + }); + }); + + it('sets blockingModalVisible to true when removing hardware account', async () => { + jest.spyOn(AddressUtils, 'isHardwareAccount').mockReturnValue(true); + + mockedUseRoute.mockImplementationOnce(() => ({ + key: 'mock-key', + name: 'mock-route', + params: { + selectedAccount: MOCK_LEDGER_ACCOUNT, + }, + })); + + Object.assign(mockKeyringController.state, { + keyrings: [ + { + type: ExtendedKeyringTypes.ledger, + accounts: [], + }, + ], + }); + + const { getByTestId } = renderWithProvider(, { + state: initialState, + }); + + fireEvent.press( + getByTestId( + AccountActionsBottomSheetSelectorsIDs.REMOVE_HARDWARE_ACCOUNT, + ), + ); + + const alertFnMock = Alert.alert as jest.MockedFn; + + await act(async () => { + const alertButtons = alertFnMock.mock.calls[0][2] as AlertButton[]; + if (alertButtons[1].onPress !== undefined) { + alertButtons[1].onPress(); + } + }); + + await waitFor(() => { + expect(mockKeyringController.state.keyrings).toEqual([ + { + type: ExtendedKeyringTypes.ledger, + accounts: [], + }, + ]); + }); + }); + }); }); diff --git a/app/components/Views/AccountActions/AccountActions.tsx b/app/components/Views/AccountActions/AccountActions.tsx index 85e47029896..333c5786772 100644 --- a/app/components/Views/AccountActions/AccountActions.tsx +++ b/app/components/Views/AccountActions/AccountActions.tsx @@ -47,7 +47,11 @@ import { useEIP7702Networks } from '../confirmations/hooks/7702/useEIP7702Networ import { isEvmAccountType } from '@metamask/keyring-api'; import { toHex } from '@metamask/controller-utils'; import { getMultichainBlockExplorer } from '../../../core/Multichain/networks'; -import { forgetQrDevice } from '../../../core/QrKeyring/QrKeyring'; +import { + forgetQrDevice, + withQrKeyring, +} from '../../../core/QrKeyring/QrKeyring'; +import useLedgerDeviceForAccount from '../../hooks/Ledger/useLedgerDeviceForAccount'; interface AccountActionsParams { selectedAccount: InternalAccount; @@ -83,6 +87,8 @@ const AccountActions = () => { const selectedAddress = selectedAccount?.address; const keyring = selectedAccount?.metadata.keyring; + const { ledgerDevice } = useLedgerDeviceForAccount(selectedAccount); + const blockExplorer: | { url: string; @@ -298,26 +304,35 @@ const AccountActions = () => { } if (requestForgetDevice) { switch (keyringType) { - case ExtendedKeyringTypes.ledger: + case ExtendedKeyringTypes.ledger: { + const ledgerDeviceId = ledgerDevice?.id; await forgetLedger(); trackEvent( createEventBuilder(MetaMetricsEvents.HARDWARE_WALLET_FORGOTTEN) .addProperties({ device_type: HardwareDeviceTypes.LEDGER, + device_model: ledgerDeviceId, }) .build(), ); break; - case ExtendedKeyringTypes.qr: + } + case ExtendedKeyringTypes.qr: { + const deviceName = await withQrKeyring( + // eslint-disable-next-line @typescript-eslint/no-shadow + async ({ keyring }) => await keyring.getName(), + ); await forgetQrDevice(); trackEvent( createEventBuilder(MetaMetricsEvents.HARDWARE_WALLET_FORGOTTEN) .addProperties({ device_type: HardwareDeviceTypes.QR, + device_model: deviceName, }) .build(), ); break; + } default: break; } @@ -325,6 +340,7 @@ const AccountActions = () => { }, [ controllers.KeyringController, keyring?.type, + ledgerDevice?.id, trackEvent, createEventBuilder, ]); diff --git a/app/components/Views/AccountConnect/AccountConnect.test.tsx b/app/components/Views/AccountConnect/AccountConnect.test.tsx index 5cc9bc74772..88dbc5cc875 100644 --- a/app/components/Views/AccountConnect/AccountConnect.test.tsx +++ b/app/components/Views/AccountConnect/AccountConnect.test.tsx @@ -6,7 +6,6 @@ import AccountConnect from './AccountConnect'; import { backgroundState } from '../../../util/test/initial-root-state'; import { RootState } from '../../../reducers'; import { fireEvent, waitFor } from '@testing-library/react-native'; -import AccountConnectMultiSelector from './AccountConnectMultiSelector/AccountConnectMultiSelector'; import Engine from '../../../core/Engine'; import { createMockAccountsControllerState as createMockAccountsControllerStateUtil, @@ -25,6 +24,7 @@ import { KeyringTypes } from '@metamask/keyring-controller'; import { SolScope } from '@metamask/keyring-api'; import { PermissionDoesNotExistError } from '@metamask/permission-controller'; import { ConnectedAccountsSelectorsIDs } from '../../../../e2e/selectors/Browser/ConnectedAccountModal.selectors'; +import AccountConnectMultiSelector from './AccountConnectMultiSelector/AccountConnectMultiSelector'; const MOCK_ACCOUNTS_CONTROLLER_STATE = createMockAccountsControllerStateUtil([ mockAddress1, @@ -55,14 +55,18 @@ const createMockCaip25Permission = ( const mockedNavigate = jest.fn(); const mockedGoBack = jest.fn(); const mockedTrackEvent = jest.fn(); +const mockBuild = jest.fn(); +const mockAddProperties = jest.fn().mockReturnValue({ + build: mockBuild, +}); const mockCreateEventBuilder = jest.fn().mockReturnValue({ - addProperties: jest.fn().mockReturnValue({ - build: jest.fn(), - }), + addProperties: mockAddProperties, + build: mockBuild, }); const mockGetNextAvailableAccountName = jest .fn() .mockReturnValue('Snap Account 1'); +const mockGetConnectedDevicesCount = jest.fn(); jest.mock('@react-navigation/native', () => { const actualNav = jest.requireActual('@react-navigation/native'); @@ -215,6 +219,10 @@ jest.mock('../../../core/AppConstants', () => ({ MM_UNIVERSAL_LINK_HOST: 'metamask.app.link', })); +jest.mock('../../../core/HardwareWallets/analytics', () => ({ + getConnectedDevicesCount: () => mockGetConnectedDevicesCount(), +})); + // Setup test state with proper account data const mockInitialState: DeepPartial = { settings: {}, @@ -744,7 +752,7 @@ describe('AccountConnect', () => { describe('Phishing detection', () => { describe('dapp scanning is enabled', () => { - it('should show phishing modal for phishing URLs', async () => { + it('displays phishing modal when origin is flagged as phishing', async () => { const { findByText } = renderWithProvider( { }); }); + describe('Metrics tracking', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('tracks metrics when user cancels connection', () => { + const { getByTestId } = renderWithProvider( + , + { state: mockInitialState }, + ); + + const cancelButton = getByTestId('cancel-button'); + fireEvent.press(cancelButton); + + expect(mockedTrackEvent).toHaveBeenCalled(); + expect(mockCreateEventBuilder).toHaveBeenCalled(); + }); + }); + describe('Domain title and hostname logic', () => { beforeEach(() => { // Reset mocks before each test diff --git a/app/components/Views/AccountConnect/AccountConnect.tsx b/app/components/Views/AccountConnect/AccountConnect.tsx index 07be7acf86b..d13b62f47b0 100644 --- a/app/components/Views/AccountConnect/AccountConnect.tsx +++ b/app/components/Views/AccountConnect/AccountConnect.tsx @@ -106,6 +106,8 @@ import AddNewAccount from '../AddNewAccount'; import { InternalAccount } from '@metamask/keyring-internal-api'; import { getApiAnalyticsProperties } from '../../../util/metrics/MultichainAPI/getApiAnalyticsProperties'; import { isSnapId } from '@metamask/snaps-utils'; +import { HardwareDeviceTypes } from '../../../constants/keyringTypes'; +import { getConnectedDevicesCount } from '../../../core/HardwareWallets/analytics'; const AccountConnect = (props: AccountConnectProps) => { const { colors } = useTheme(); @@ -654,7 +656,7 @@ const AccountConnect = (props: AccountConnectProps) => { useEffect(() => { if (userIntent === USER_INTENT.None) return; - const handleUserActions = (action: USER_INTENT) => { + const handleUserActions = async (action: USER_INTENT) => { switch (action) { case USER_INTENT.Confirm: { handleConfirm(); @@ -681,11 +683,20 @@ const AccountConnect = (props: AccountConnectProps) => { case USER_INTENT.ConnectHW: { navigation.navigate('ConnectQRHardwareFlow'); // TODO: Confirm if this is where we want to track connecting a hardware wallet or within ConnectQRHardwareFlow screen. - trackEvent( - createEventBuilder( - MetaMetricsEvents.CONNECT_HARDWARE_WALLET, - ).build(), - ); + try { + const connectedDeviceCount = await getConnectedDevicesCount(); + trackEvent( + createEventBuilder(MetaMetricsEvents.CONNECT_HARDWARE_WALLET) + .addProperties({ + device_type: HardwareDeviceTypes.QR, + connected_device_count: connectedDeviceCount, + }) + .build(), + ); + } catch (error) { + // [AccountConnect] Analytics error should not disrupt user flow + console.error('[AccountConnect] Failed to track analytics:', error); + } break; } diff --git a/app/components/Views/AccountPermissions/AccountPermissions.tsx b/app/components/Views/AccountPermissions/AccountPermissions.tsx index bdad4ba3b31..be09c49c7ad 100755 --- a/app/components/Views/AccountPermissions/AccountPermissions.tsx +++ b/app/components/Views/AccountPermissions/AccountPermissions.tsx @@ -89,6 +89,8 @@ import { WalletClientType } from '../../../core/SnapKeyring/MultichainWalletSnap import AddNewAccount from '../AddNewAccount'; import { trace, endTrace, TraceName } from '../../../util/trace'; import { selectAvatarAccountType } from '../../../selectors/settings'; +import { HardwareDeviceTypes } from '../../../constants/keyringTypes'; +import { getConnectedDevicesCount } from '../../../core/HardwareWallets/analytics'; const AccountPermissions = (props: AccountPermissionsProps) => { const { navigate } = useNavigation(); @@ -561,7 +563,7 @@ const AccountPermissions = (props: AccountPermissionsProps) => { useEffect(() => { if (userIntent === USER_INTENT.None) return; - const handleUserActions = (action: USER_INTENT) => { + const handleUserActions = async (action: USER_INTENT) => { switch (action) { case USER_INTENT.Confirm: { hideSheet(() => { @@ -598,10 +600,14 @@ const AccountPermissions = (props: AccountPermissionsProps) => { case USER_INTENT.ConnectHW: { navigate('ConnectQRHardwareFlow'); // Is this where we want to track connecting a hardware wallet or within ConnectQRHardwareFlow screen? + const connectedDeviceCount = await getConnectedDevicesCount(); trackEvent( - createEventBuilder( - MetaMetricsEvents.CONNECT_HARDWARE_WALLET, - ).build(), + createEventBuilder(MetaMetricsEvents.CONNECT_HARDWARE_WALLET) + .addProperties({ + device_type: HardwareDeviceTypes.QR, + connected_device_count: connectedDeviceCount, + }) + .build(), ); break; diff --git a/app/components/Views/ConnectHardware/SelectHardware/index.test.tsx b/app/components/Views/ConnectHardware/SelectHardware/index.test.tsx new file mode 100644 index 00000000000..2ec1f165234 --- /dev/null +++ b/app/components/Views/ConnectHardware/SelectHardware/index.test.tsx @@ -0,0 +1,315 @@ +import React from 'react'; +import { screen } from '@testing-library/react-native'; +import renderWithProvider from '../../../../util/test/renderWithProvider'; +import SelectHardwareWallet from './index'; +import { strings } from '../../../../../locales/i18n'; +import Routes from '../../../../constants/navigation/Routes'; +import { MetaMetricsEvents } from '../../../../core/Analytics'; +import { HardwareDeviceTypes } from '../../../../constants/keyringTypes'; +import { getConnectedDevicesCount } from '../../../../core/HardwareWallets/analytics'; +import { AppThemeKey } from '../../../../util/theme/models'; + +jest.mock('../../../../../locales/i18n', () => ({ + strings: jest.fn((key: string) => key), +})); + +jest.mock('../../../UI/Navbar', () => ({ + getNavigationOptionsTitle: jest.fn(), +})); + +jest.mock('../../../../core/HardwareWallets/analytics'); + +const mockNavigate = jest.fn(); +const mockSetOptions = jest.fn(); +const mockTrackEvent = jest.fn(); +const mockCreateEventBuilder = jest.fn(() => ({ + addProperties: jest.fn().mockReturnThis(), + build: jest.fn().mockReturnValue({}), +})); + +jest.mock('@react-navigation/native', () => ({ + ...jest.requireActual('@react-navigation/native'), + useNavigation: () => ({ + navigate: mockNavigate, + setOptions: mockSetOptions, + }), +})); + +jest.mock('../../../../components/hooks/useMetrics', () => ({ + useMetrics: () => ({ + trackEvent: mockTrackEvent, + createEventBuilder: mockCreateEventBuilder, + }), +})); + +const mockGetConnectedDevicesCount = + getConnectedDevicesCount as jest.MockedFunction< + typeof getConnectedDevicesCount + >; + +const initialState = { + user: { + appTheme: AppThemeKey.light, + }, +}; + +describe('SelectHardwareWallet', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockGetConnectedDevicesCount.mockResolvedValue(0); + // Reset mockCreateEventBuilder to return proper chained object + mockCreateEventBuilder.mockReturnValue({ + addProperties: jest.fn().mockReturnThis(), + build: jest.fn().mockReturnValue({}), + }); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('renders component with correct text', () => { + renderWithProvider(, { state: initialState }); + + expect(strings).toHaveBeenCalledWith('connect_hardware.select_hardware'); + expect(screen.getByText('connect_hardware.select_hardware')).toBeTruthy(); + }); + + it('sets navigation options on mount', () => { + renderWithProvider(, { state: initialState }); + + expect(mockSetOptions).toHaveBeenCalled(); + }); + + describe('Ledger button navigation', () => { + it('tracks event and navigates to Ledger connection when pressed', async () => { + const connectedDeviceCount = 2; + mockGetConnectedDevicesCount.mockResolvedValue(connectedDeviceCount); + + const { getByTestId } = renderWithProvider(, { + state: initialState, + }); + const ledgerButton = getByTestId('ledger-hardware-button'); + + await ledgerButton.props.onPress(); + + expect(mockGetConnectedDevicesCount).toHaveBeenCalled(); + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.CONNECT_HARDWARE_WALLET, + ); + expect(mockTrackEvent).toHaveBeenCalled(); + expect(mockNavigate).toHaveBeenCalledWith(Routes.HW.CONNECT_LEDGER); + }); + + it('includes connected devices count in metrics event', async () => { + const connectedDeviceCount = 5; + mockGetConnectedDevicesCount.mockResolvedValue(connectedDeviceCount); + const mockAddProperties = jest.fn().mockReturnThis(); + const mockBuild = jest.fn().mockReturnValue({}); + mockCreateEventBuilder.mockReturnValue({ + addProperties: mockAddProperties, + build: mockBuild, + }); + + const { getByTestId } = renderWithProvider(, { + state: initialState, + }); + const ledgerButton = getByTestId('ledger-hardware-button'); + + await ledgerButton.props.onPress(); + + expect(mockAddProperties).toHaveBeenCalledWith({ + device_type: HardwareDeviceTypes.LEDGER, + connected_device_count: connectedDeviceCount.toString(), + }); + expect(mockBuild).toHaveBeenCalled(); + }); + + it('handles zero connected devices count', async () => { + mockGetConnectedDevicesCount.mockResolvedValue(0); + const mockAddProperties = jest.fn().mockReturnThis(); + const mockBuild = jest.fn().mockReturnValue({}); + mockCreateEventBuilder.mockReturnValue({ + addProperties: mockAddProperties, + build: mockBuild, + }); + + const { getByTestId } = renderWithProvider(, { + state: initialState, + }); + const ledgerButton = getByTestId('ledger-hardware-button'); + + await ledgerButton.props.onPress(); + + expect(mockAddProperties).toHaveBeenCalledWith({ + device_type: HardwareDeviceTypes.LEDGER, + connected_device_count: '0', + }); + }); + }); + + describe('QR Hardware button navigation', () => { + it('tracks event and navigates to QR device connection when pressed', async () => { + const connectedDeviceCount = 3; + mockGetConnectedDevicesCount.mockResolvedValue(connectedDeviceCount); + + const { getByTestId } = renderWithProvider(, { + state: initialState, + }); + const qrButton = getByTestId('qr-hardware-button'); + + await qrButton.props.onPress(); + + expect(mockGetConnectedDevicesCount).toHaveBeenCalled(); + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.CONNECT_HARDWARE_WALLET, + ); + expect(mockTrackEvent).toHaveBeenCalled(); + expect(mockNavigate).toHaveBeenCalledWith(Routes.HW.CONNECT_QR_DEVICE); + }); + + it('includes connected devices count in metrics event', async () => { + const connectedDeviceCount = 1; + mockGetConnectedDevicesCount.mockResolvedValue(connectedDeviceCount); + const mockAddProperties = jest.fn().mockReturnThis(); + const mockBuild = jest.fn().mockReturnValue({}); + mockCreateEventBuilder.mockReturnValue({ + addProperties: mockAddProperties, + build: mockBuild, + }); + + const { getByTestId } = renderWithProvider(, { + state: initialState, + }); + const qrButton = getByTestId('qr-hardware-button'); + + await qrButton.props.onPress(); + + expect(mockAddProperties).toHaveBeenCalledWith({ + device_type: HardwareDeviceTypes.QR, + connected_device_count: connectedDeviceCount.toString(), + }); + expect(mockBuild).toHaveBeenCalled(); + }); + }); + + describe('useMetrics integration', () => { + it('uses the useMetrics hook', () => { + renderWithProvider(, { state: initialState }); + + expect(mockCreateEventBuilder).toBeDefined(); + expect(mockTrackEvent).toBeDefined(); + }); + + it('creates event builder with correct event type', async () => { + const { getByTestId } = renderWithProvider(, { + state: initialState, + }); + const ledgerButton = getByTestId('ledger-hardware-button'); + + await ledgerButton.props.onPress(); + + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.CONNECT_HARDWARE_WALLET, + ); + }); + }); + + describe('error handling', () => { + it('continues navigation to Ledger when getConnectedDevicesCount fails', async () => { + const error = new Error('Failed to get device count'); + mockGetConnectedDevicesCount.mockRejectedValue(error); + + const { getByTestId } = renderWithProvider(, { + state: initialState, + }); + const ledgerButton = getByTestId('ledger-hardware-button'); + + await ledgerButton.props.onPress(); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.HW.CONNECT_LEDGER); + }); + + it('continues navigation to QR when getConnectedDevicesCount fails', async () => { + const error = new Error('Failed to get device count'); + mockGetConnectedDevicesCount.mockRejectedValue(error); + + const { getByTestId } = renderWithProvider(, { + state: initialState, + }); + const qrButton = getByTestId('qr-hardware-button'); + + await qrButton.props.onPress(); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.HW.CONNECT_QR_DEVICE); + }); + + it('logs error when analytics tracking fails for Ledger', async () => { + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); + const error = new Error('Analytics failure'); + mockGetConnectedDevicesCount.mockRejectedValue(error); + + const { getByTestId } = renderWithProvider(, { + state: initialState, + }); + const ledgerButton = getByTestId('ledger-hardware-button'); + + await ledgerButton.props.onPress(); + + expect(consoleSpy).toHaveBeenCalledWith( + '[SelectHardware] Failed to track analytics:', + error, + ); + + consoleSpy.mockRestore(); + }); + + it('logs error when analytics tracking fails for QR', async () => { + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); + const error = new Error('Analytics failure'); + mockGetConnectedDevicesCount.mockRejectedValue(error); + + const { getByTestId } = renderWithProvider(, { + state: initialState, + }); + const qrButton = getByTestId('qr-hardware-button'); + + await qrButton.props.onPress(); + + expect(consoleSpy).toHaveBeenCalledWith( + '[SelectHardware] Failed to track analytics:', + error, + ); + + consoleSpy.mockRestore(); + }); + + it('does not track analytics event when getConnectedDevicesCount fails for Ledger', async () => { + const error = new Error('Failed to get device count'); + mockGetConnectedDevicesCount.mockRejectedValue(error); + + const { getByTestId } = renderWithProvider(, { + state: initialState, + }); + const ledgerButton = getByTestId('ledger-hardware-button'); + + await ledgerButton.props.onPress(); + + expect(mockTrackEvent).not.toHaveBeenCalled(); + }); + + it('does not track analytics event when getConnectedDevicesCount fails for QR', async () => { + const error = new Error('Failed to get device count'); + mockGetConnectedDevicesCount.mockRejectedValue(error); + + const { getByTestId } = renderWithProvider(, { + state: initialState, + }); + const qrButton = getByTestId('qr-hardware-button'); + + await qrButton.props.onPress(); + + expect(mockTrackEvent).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/app/components/Views/ConnectHardware/SelectHardware/index.tsx b/app/components/Views/ConnectHardware/SelectHardware/index.tsx index 9039d9a4970..09f0c035b6a 100644 --- a/app/components/Views/ConnectHardware/SelectHardware/index.tsx +++ b/app/components/Views/ConnectHardware/SelectHardware/index.tsx @@ -25,6 +25,7 @@ import { import { getNavigationOptionsTitle } from '../../../UI/Navbar'; import { useMetrics } from '../../../../components/hooks/useMetrics'; import { HardwareDeviceTypes } from '../../../../constants/keyringTypes'; +import { getConnectedDevicesCount } from '../../../../core/HardwareWallets/analytics'; // TODO: Replace "any" with type // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -102,33 +103,62 @@ const SelectHardwareWallet = () => { ); }, [navigation, colors]); - const navigateToConnectQRWallet = () => { + const navigateToConnectQRWallet = async () => { + try { + const connectedDeviceCount = await getConnectedDevicesCount(); + trackEvent( + createEventBuilder(MetaMetricsEvents.CONNECT_HARDWARE_WALLET) + .addProperties({ + device_type: HardwareDeviceTypes.QR, + connected_device_count: connectedDeviceCount.toString(), + }) + .build(), + ); + } catch (error) { + // [SelectHardware] Analytics error should not block navigation + console.error('[SelectHardware] Failed to track analytics:', error); + } navigation.navigate(Routes.HW.CONNECT_QR_DEVICE); }; const navigateToConnectLedger = async () => { - trackEvent( - createEventBuilder(MetaMetricsEvents.CONNECT_LEDGER) - .addProperties({ - device_type: HardwareDeviceTypes.LEDGER, - }) - .build(), - ); + try { + const connectedDeviceCount = await getConnectedDevicesCount(); + trackEvent( + createEventBuilder(MetaMetricsEvents.CONNECT_HARDWARE_WALLET) + .addProperties({ + device_type: HardwareDeviceTypes.LEDGER, + connected_device_count: connectedDeviceCount.toString(), + }) + .build(), + ); + } catch (error) { + // [SelectHardware] Analytics error should not block navigation + console.error('[SelectHardware] Failed to track analytics:', error); + } navigation.navigate(Routes.HW.CONNECT_LEDGER); }; // TODO: Replace "any" with type // eslint-disable-next-line @typescript-eslint/no-explicit-any - const renderHardwareButton = (image: any, onPress: any) => ( - + const renderHardwareButton = (image: any, onPress: any, testID?: string) => ( + ); const LedgerButton = () => { const ledgerLogo = useAssetFromTheme(ledgerLogoLight, ledgerLogoDark); - return renderHardwareButton(ledgerLogo, navigateToConnectLedger); + return renderHardwareButton( + ledgerLogo, + navigateToConnectLedger, + 'ledger-hardware-button', + ); }; const QRButton = () => { @@ -136,7 +166,11 @@ const SelectHardwareWallet = () => { qrHardwareLogoLight, qrHardwareLogoDark, ); - return renderHardwareButton(qrHardwareLogo, navigateToConnectQRWallet); + return renderHardwareButton( + qrHardwareLogo, + navigateToConnectQRWallet, + 'qr-hardware-button', + ); }; return ( diff --git a/app/components/Views/ConnectQRHardware/Instruction/index.test.tsx b/app/components/Views/ConnectQRHardware/Instruction/index.test.tsx new file mode 100644 index 00000000000..9b4a4ce88b1 --- /dev/null +++ b/app/components/Views/ConnectQRHardware/Instruction/index.test.tsx @@ -0,0 +1,371 @@ +import React from 'react'; +import { Text } from 'react-native'; +import { fireEvent } from '@testing-library/react-native'; +import renderWithProvider from '../../../../util/test/renderWithProvider'; +import ConnectQRInstruction from './index'; +import { MetaMetricsEvents } from '../../../../core/Analytics'; +import { + HARDWARE_WALLET_BUTTON_TYPE, + HARDWARE_WALLET_DEVICE_TYPE, +} from '../../../../core/Analytics/MetaMetrics.events'; +import { + KEYSTONE_LEARN_MORE, + KEYSTONE_SUPPORT, + KEYSTONE_SUPPORT_VIDEO, + NGRAVE_BUY, + NGRAVE_LEARN_MORE, +} from '../../../../constants/urls'; +import { QR_CONTINUE_BUTTON } from '../../../../../wdio/screen-objects/testIDs/Components/ConnectQRHardware.testIds'; +import { AppThemeKey } from '../../../../util/theme/models'; + +jest.mock('../../../../../locales/i18n', () => ({ + strings: jest.fn((key: string) => key), +})); + +const mockTrackEvent = jest.fn(); +const mockAddProperties = jest.fn(); +const mockBuild = jest.fn(); +const mockCreateEventBuilder = jest.fn(); + +jest.mock('../../../../components/hooks/useMetrics', () => { + const actualMetrics = jest.requireActual('../../../../core/Analytics'); + const actualHooks = jest.requireActual( + '../../../../components/hooks/useMetrics', + ); + return { + ...actualHooks, + useMetrics: () => ({ + trackEvent: mockTrackEvent, + createEventBuilder: mockCreateEventBuilder, + }), + MetaMetricsEvents: actualMetrics.MetaMetricsEvents, + }; +}); + +const mockNavigate = jest.fn(); +const mockOnConnect = jest.fn(); +const mockRenderAlert = jest.fn(() => <>); + +const mockNavigation = { + navigate: mockNavigate, +}; + +const initialState = { + user: { + appTheme: AppThemeKey.light, + }, +}; + +describe('ConnectQRInstruction', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockAddProperties.mockReturnValue({ + build: mockBuild, + }); + mockBuild.mockReturnValue({}); + mockCreateEventBuilder.mockReturnValue({ + addProperties: mockAddProperties, + build: mockBuild, + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('renders correctly', () => { + const { getByText } = renderWithProvider( + , + { state: initialState }, + ); + + expect(getByText('connect_qr_hardware.title')).toBeTruthy(); + }); + + it('renders all description text elements', () => { + const { getByText } = renderWithProvider( + , + { state: initialState }, + ); + + expect(getByText('connect_qr_hardware.description1')).toBeTruthy(); + expect(getByText('connect_qr_hardware.description3')).toBeTruthy(); + }); + + it('renders Keystone section label', () => { + const { getByText } = renderWithProvider( + , + { state: initialState }, + ); + + expect(getByText('connect_qr_hardware.keystone')).toBeTruthy(); + }); + + it('renders Ngrave Zero section label', () => { + const { getByText } = renderWithProvider( + , + { state: initialState }, + ); + + expect(getByText('connect_qr_hardware.ngravezero')).toBeTruthy(); + }); + + it('renders alert component from renderAlert prop', () => { + const mockRenderAlertWithContent = jest.fn(() => ( + Alert Content + )); + + const { getByTestId } = renderWithProvider( + , + { state: initialState }, + ); + + expect(mockRenderAlertWithContent).toHaveBeenCalled(); + expect(getByTestId('test-alert')).toBeTruthy(); + }); + + it('renders continue button with correct text', () => { + const { getByText } = renderWithProvider( + , + { state: initialState }, + ); + + expect(getByText('connect_qr_hardware.button_continue')).toBeTruthy(); + }); + + it('calls onConnect when continue button is pressed', () => { + const { getByTestId } = renderWithProvider( + , + { state: initialState }, + ); + + const continueButton = getByTestId(QR_CONTINUE_BUTTON); + fireEvent.press(continueButton); + + expect(mockOnConnect).toHaveBeenCalledTimes(1); + }); + + describe('Keystone marketing metrics', () => { + it('tracks metrics and navigates when Keystone tutorial video link is pressed', () => { + const { getByText } = renderWithProvider( + , + { state: initialState }, + ); + + const tutorialVideoLink = getByText('connect_qr_hardware.description2'); + fireEvent.press(tutorialVideoLink); + + expect(mockTrackEvent).toHaveBeenCalled(); + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.HARDWARE_WALLET_MARKETING, + ); + expect(mockAddProperties).toHaveBeenCalledWith({ + device_type: HARDWARE_WALLET_DEVICE_TYPE.Keystone, + button_type: HARDWARE_WALLET_BUTTON_TYPE.TUTORIAL, + }); + expect(mockBuild).toHaveBeenCalled(); + expect(mockNavigate).toHaveBeenCalledWith('Webview', { + screen: 'SimpleWebview', + params: { + url: KEYSTONE_SUPPORT_VIDEO, + title: 'connect_qr_hardware.description2', + }, + }); + }); + + it('tracks metrics and navigates when Keystone Learn More link is pressed', () => { + const { getAllByText } = renderWithProvider( + , + { state: initialState }, + ); + + const learnMoreLinks = getAllByText('connect_qr_hardware.learnMore'); + const keystoneLearnMore = learnMoreLinks[0]; + fireEvent.press(keystoneLearnMore); + + expect(mockTrackEvent).toHaveBeenCalled(); + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.HARDWARE_WALLET_MARKETING, + ); + expect(mockAddProperties).toHaveBeenCalledWith({ + device_type: HARDWARE_WALLET_DEVICE_TYPE.Keystone, + button_type: HARDWARE_WALLET_BUTTON_TYPE.LEARN_MORE, + }); + expect(mockBuild).toHaveBeenCalled(); + expect(mockNavigate).toHaveBeenCalledWith('Webview', { + screen: 'SimpleWebview', + params: { + url: KEYSTONE_LEARN_MORE, + title: 'connect_qr_hardware.keystone', + }, + }); + }); + + it('tracks metrics and navigates when Keystone tutorial link is pressed', () => { + const { getByText } = renderWithProvider( + , + { state: initialState }, + ); + + const tutorialLink = getByText('connect_qr_hardware.tutorial'); + fireEvent.press(tutorialLink); + + expect(mockTrackEvent).toHaveBeenCalled(); + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.HARDWARE_WALLET_MARKETING, + ); + expect(mockAddProperties).toHaveBeenCalledWith({ + device_type: HARDWARE_WALLET_DEVICE_TYPE.Keystone, + button_type: HARDWARE_WALLET_BUTTON_TYPE.TUTORIAL, + }); + expect(mockBuild).toHaveBeenCalled(); + expect(mockNavigate).toHaveBeenCalledWith('Webview', { + screen: 'SimpleWebview', + params: { + url: KEYSTONE_SUPPORT, + title: 'connect_qr_hardware.description4', + }, + }); + }); + }); + + describe('Ngrave marketing metrics', () => { + it('tracks metrics and navigates when Ngrave Learn More link is pressed', () => { + const { getAllByText } = renderWithProvider( + , + { state: initialState }, + ); + + const learnMoreLinks = getAllByText('connect_qr_hardware.learnMore'); + const ngraveLearnMore = learnMoreLinks[1]; + fireEvent.press(ngraveLearnMore); + + expect(mockTrackEvent).toHaveBeenCalled(); + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.HARDWARE_WALLET_MARKETING, + ); + expect(mockAddProperties).toHaveBeenCalledWith({ + device_type: HARDWARE_WALLET_DEVICE_TYPE.NgraveZero, + button_type: HARDWARE_WALLET_BUTTON_TYPE.LEARN_MORE, + }); + expect(mockBuild).toHaveBeenCalled(); + expect(mockNavigate).toHaveBeenCalledWith('Webview', { + screen: 'SimpleWebview', + params: { + url: NGRAVE_LEARN_MORE, + title: 'connect_qr_hardware.ngravezero', + }, + }); + }); + + it('tracks metrics and navigates when Ngrave Buy Now link is pressed', () => { + const { getByText } = renderWithProvider( + , + { state: initialState }, + ); + + const buyNowLink = getByText('connect_qr_hardware.buyNow'); + fireEvent.press(buyNowLink); + + expect(mockTrackEvent).toHaveBeenCalled(); + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.HARDWARE_WALLET_MARKETING, + ); + expect(mockAddProperties).toHaveBeenCalledWith({ + device_type: HARDWARE_WALLET_DEVICE_TYPE.NgraveZero, + button_type: HARDWARE_WALLET_BUTTON_TYPE.BUY_NOW, + }); + expect(mockBuild).toHaveBeenCalled(); + expect(mockNavigate).toHaveBeenCalledWith('Webview', { + screen: 'SimpleWebview', + params: { + url: NGRAVE_BUY, + title: 'connect_qr_hardware.ngravezero', + }, + }); + }); + }); + + describe('useMetrics integration', () => { + it('uses the useMetrics hook correctly', () => { + renderWithProvider( + , + { state: initialState }, + ); + + expect(mockCreateEventBuilder).toBeDefined(); + expect(mockTrackEvent).toBeDefined(); + }); + + it('creates event builder with correct event type for marketing events', () => { + const { getByText } = renderWithProvider( + , + { state: initialState }, + ); + + const buyNowLink = getByText('connect_qr_hardware.buyNow'); + fireEvent.press(buyNowLink); + + expect(mockTrackEvent).toHaveBeenCalled(); + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.HARDWARE_WALLET_MARKETING, + ); + }); + }); +}); diff --git a/app/components/Views/ConnectQRHardware/Instruction/index.tsx b/app/components/Views/ConnectQRHardware/Instruction/index.tsx index 0a0f6d08f14..b37ab8c2af4 100644 --- a/app/components/Views/ConnectQRHardware/Instruction/index.tsx +++ b/app/components/Views/ConnectQRHardware/Instruction/index.tsx @@ -17,6 +17,11 @@ import { createStyles } from './styles'; import StyledButton from '../../../UI/StyledButton'; import generateTestId from '../../../../../wdio/utils/generateTestId'; import { QR_CONTINUE_BUTTON } from '../../../../../wdio/screen-objects/testIDs/Components/ConnectQRHardware.testIds'; +import { MetaMetricsEvents, useMetrics } from '../../../hooks/useMetrics'; +import { + HARDWARE_WALLET_BUTTON_TYPE, + HARDWARE_WALLET_DEVICE_TYPE, +} from '../../../../core/Analytics/MetaMetrics.events'; interface IConnectQRInstructionProps { // TODO: Replace "any" with type @@ -30,11 +35,31 @@ interface IConnectQRInstructionProps { const ConnectQRInstruction = (props: IConnectQRInstructionProps) => { const { onConnect, renderAlert, navigation } = props; + const { trackEvent, createEventBuilder } = useMetrics(); const theme = useTheme(); const insets = useSafeAreaInsets(); const styles = createStyles(theme, insets); - const navigateTo = (url: string, title: string) => { + interface NavigateOptions { + url: string; + title: string; + trackingProperties?: { + device_type?: string; + button_type?: string; + }; + } + + const navigateToWebview = (options: NavigateOptions) => { + const { url, title, trackingProperties } = options; + + if (trackingProperties) { + trackEvent( + createEventBuilder(MetaMetricsEvents.HARDWARE_WALLET_MARKETING) + .addProperties(trackingProperties) + .build(), + ); + } + navigation.navigate('Webview', { screen: 'SimpleWebview', params: { @@ -43,34 +68,6 @@ const ConnectQRInstruction = (props: IConnectQRInstructionProps) => { }, }); }; - - const navigateToVideo = () => { - navigation.navigate('Webview', { - screen: 'SimpleWebview', - params: { - url: KEYSTONE_SUPPORT_VIDEO, - title: strings('connect_qr_hardware.description2'), - }, - }); - }; - const navigateToLearnMoreKeystone = () => { - navigation.navigate('Webview', { - screen: 'SimpleWebview', - params: { - url: KEYSTONE_LEARN_MORE, - title: strings('connect_qr_hardware.keystone'), - }, - }); - }; - const navigateToTutorial = () => { - navigation.navigate('Webview', { - screen: 'SimpleWebview', - params: { - url: KEYSTONE_SUPPORT, - title: strings('connect_qr_hardware.description4'), - }, - }); - }; return ( { {strings('connect_qr_hardware.description1')} - + + navigateToWebview({ + url: KEYSTONE_SUPPORT_VIDEO, + title: 'connect_qr_hardware.description2', + trackingProperties: { + device_type: HARDWARE_WALLET_DEVICE_TYPE.Keystone, + button_type: HARDWARE_WALLET_BUTTON_TYPE.TUTORIAL, + }, + }) + } + > {strings('connect_qr_hardware.description2')} @@ -95,13 +104,31 @@ const ConnectQRInstruction = (props: IConnectQRInstructionProps) => { + navigateToWebview({ + url: KEYSTONE_LEARN_MORE, + title: 'connect_qr_hardware.keystone', + trackingProperties: { + device_type: HARDWARE_WALLET_DEVICE_TYPE.Keystone, + button_type: HARDWARE_WALLET_BUTTON_TYPE.LEARN_MORE, + }, + }) + } > {strings('connect_qr_hardware.learnMore')} + navigateToWebview({ + url: KEYSTONE_SUPPORT, + title: 'connect_qr_hardware.description4', + trackingProperties: { + device_type: HARDWARE_WALLET_DEVICE_TYPE.Keystone, + button_type: HARDWARE_WALLET_BUTTON_TYPE.TUTORIAL, + }, + }) + } > {strings('connect_qr_hardware.tutorial')} @@ -113,7 +140,14 @@ const ConnectQRInstruction = (props: IConnectQRInstructionProps) => { - navigateTo(NGRAVE_LEARN_MORE, 'connect_qr_hardware.ngravezero') + navigateToWebview({ + url: NGRAVE_LEARN_MORE, + title: 'connect_qr_hardware.ngravezero', + trackingProperties: { + device_type: HARDWARE_WALLET_DEVICE_TYPE.NgraveZero, + button_type: HARDWARE_WALLET_BUTTON_TYPE.LEARN_MORE, + }, + }) } > {strings('connect_qr_hardware.learnMore')} @@ -121,7 +155,14 @@ const ConnectQRInstruction = (props: IConnectQRInstructionProps) => { - navigateTo(NGRAVE_BUY, 'connect_qr_hardware.ngravezero') + navigateToWebview({ + url: NGRAVE_BUY, + title: 'connect_qr_hardware.ngravezero', + trackingProperties: { + device_type: HARDWARE_WALLET_DEVICE_TYPE.NgraveZero, + button_type: HARDWARE_WALLET_BUTTON_TYPE.BUY_NOW, + }, + }) } > {strings('connect_qr_hardware.buyNow')} diff --git a/app/components/Views/ConnectQRHardware/index.test.tsx b/app/components/Views/ConnectQRHardware/index.test.tsx index 5a94be652e6..1919f40d5d3 100644 --- a/app/components/Views/ConnectQRHardware/index.test.tsx +++ b/app/components/Views/ConnectQRHardware/index.test.tsx @@ -13,6 +13,8 @@ import { } from '../../../../wdio/screen-objects/testIDs/Components/AccountSelector.testIds'; import { QrKeyringBridge } from '@metamask/eth-qr-keyring'; import { removeAccountsFromPermissions } from '../../../core/Permissions'; +import { MetaMetricsEvents } from '../../../core/Analytics'; +import { HardwareDeviceTypes } from '../../../constants/keyringTypes'; jest.mock('../../../core/Permissions', () => ({ removeAccountsFromPermissions: jest.fn(), @@ -22,6 +24,19 @@ const MockRemoveAccountsFromPermissions = jest.mocked( removeAccountsFromPermissions, ); +const mockTrackEvent = jest.fn(); +const mockCreateEventBuilder = jest.fn(() => ({ + addProperties: jest.fn().mockReturnThis(), + build: jest.fn().mockReturnValue({}), +})); + +jest.mock('../../../components/hooks/useMetrics', () => ({ + useMetrics: () => ({ + trackEvent: mockTrackEvent, + createEventBuilder: mockCreateEventBuilder, + }), +})); + const mockedNavigate = { pop: jest.fn(), goBack: jest.fn(), @@ -98,6 +113,7 @@ const mockQrKeyring = { getNextPage: jest.fn(), getPreviousPage: jest.fn(), forgetDevice: jest.fn(), + getName: jest.fn().mockResolvedValue('KeystoneDevice'), getAccounts: jest .fn() .mockReturnValue([ @@ -128,6 +144,7 @@ jest.mock('../../../core/Engine', () => ({ keyrings: [], }, getAccounts: jest.fn(), + getKeyringsByType: jest.fn().mockResolvedValue([]), withKeyring: (_selector: unknown, operation: (args: unknown) => void) => operation({ keyring: mockQrKeyring, @@ -180,6 +197,8 @@ describe('ConnectQRHardware', () => { beforeEach(() => { jest.clearAllMocks(); + mockTrackEvent.mockClear(); + mockCreateEventBuilder.mockClear(); }); it('renders correctly to match snapshot', () => { @@ -306,4 +325,109 @@ describe('ConnectQRHardware', () => { ]); expect(mockQrKeyring.forgetDevice).toHaveBeenCalled(); }); + + it('tracks hardware wallet continue connection event when continue button is pressed', async () => { + mockKeyringController.getAccounts.mockResolvedValue([]); + + const { getByTestId } = renderWithProvider( + , + { state: mockInitialState }, + ); + + const button = getByTestId(QR_CONTINUE_BUTTON); + + await act(async () => { + fireEvent.press(button); + }); + + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.HARDWARE_WALLET_CONTINUE_CONNECTION, + ); + expect(mockTrackEvent).toHaveBeenCalled(); + }); + + it('tracks hardware wallet add account event with QR device type when accounts are unlocked', async () => { + mockKeyringController.getAccounts.mockResolvedValue([]); + + const { getByTestId, getByText } = renderWithProvider( + , + { state: mockInitialState }, + ); + + const button = getByTestId(QR_CONTINUE_BUTTON); + + await act(async () => { + fireEvent.press(button); + }); + + const checkbox = getByText(mockPage0Accounts[0].shortenedAddress); + + await act(async () => { + fireEvent.press(checkbox); + }); + + const unlockButton = getByText('Unlock'); + + await act(async () => { + fireEvent.press(unlockButton); + }); + + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.HARDWARE_WALLET_ADD_ACCOUNT, + ); + expect(mockTrackEvent).toHaveBeenCalled(); + }); + + it('tracks hardware wallet forgotten event with QR device type when device is forgotten', async () => { + mockKeyringController.getAccounts.mockResolvedValue([]); + + const { getByTestId } = renderWithProvider( + , + { state: mockInitialState }, + ); + + const button = getByTestId(QR_CONTINUE_BUTTON); + + await act(async () => { + fireEvent.press(button); + }); + + const forgetButton = getByTestId(ACCOUNT_SELECTOR_FORGET_BUTTON); + + await act(async () => { + fireEvent.press(forgetButton); + }); + + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.HARDWARE_WALLET_FORGOTTEN, + ); + expect(mockTrackEvent).toHaveBeenCalled(); + }); + + it('includes device type property in continue connection event', async () => { + mockKeyringController.getAccounts.mockResolvedValue([]); + + const mockAddProperties = jest.fn().mockReturnThis(); + const mockBuild = jest.fn().mockReturnValue({}); + mockCreateEventBuilder.mockReturnValue({ + addProperties: mockAddProperties, + build: mockBuild, + }); + + const { getByTestId } = renderWithProvider( + , + { state: mockInitialState }, + ); + + const button = getByTestId(QR_CONTINUE_BUTTON); + + await act(async () => { + fireEvent.press(button); + }); + + expect(mockAddProperties).toHaveBeenCalledWith({ + device_type: HardwareDeviceTypes.QR, + }); + expect(mockBuild).toHaveBeenCalled(); + }); }); diff --git a/app/components/Views/ConnectQRHardware/index.tsx b/app/components/Views/ConnectQRHardware/index.tsx index 752c4331bea..798d46decdc 100644 --- a/app/components/Views/ConnectQRHardware/index.tsx +++ b/app/components/Views/ConnectQRHardware/index.tsx @@ -34,6 +34,7 @@ import { ThemeColors } from '@metamask/design-tokens'; import { QrScanRequestType } from '@metamask/eth-qr-keyring'; import { withQrKeyring } from '../../../core/QrKeyring/QrKeyring'; import { getChecksumAddress } from '@metamask/utils'; +import { getConnectedDevicesCount } from '../../../core/HardwareWallets/analytics'; interface IConnectQRHardwareProps { // TODO: Replace "any" with type @@ -112,7 +113,7 @@ const ConnectQRHardware = ({ navigation }: IConnectQRHardwareProps) => { const onConnectHardware = async () => { trackEvent( - createEventBuilder(MetaMetricsEvents.CONTINUE_QR_HARDWARE_WALLET) + createEventBuilder(MetaMetricsEvents.HARDWARE_WALLET_CONTINUE_CONNECTION) .addProperties({ device_type: HardwareDeviceTypes.QR, }) @@ -128,6 +129,19 @@ const ConnectQRHardware = ({ navigation }: IConnectQRHardwareProps) => { // TODO: Add `balance` to the QR Keyring accounts or remove it from the expected type firstAccountsPage.map((account) => ({ ...account, balance: '0x0' })), ); + const deviceName = await withQrKeyring( + async ({ keyring }) => await keyring.getName(), + ); + trackEvent( + createEventBuilder( + MetaMetricsEvents.HARDWARE_WALLET_ACCOUNT_SELECTOR_OPEN, + ) + .addProperties({ + device_type: HardwareDeviceTypes.QR, + device_model: deviceName, + }) + .build(), + ); } finally { setIsScanning(false); } @@ -136,20 +150,13 @@ const ConnectQRHardware = ({ navigation }: IConnectQRHardwareProps) => { const onScanSuccess = useCallback( async (ur: UR) => { setIsScanning(false); - trackEvent( - createEventBuilder(MetaMetricsEvents.CONNECT_HARDWARE_WALLET_SUCCESS) - .addProperties({ - device_type: HardwareDeviceTypes.QR, - }) - .build(), - ); Engine.getQrKeyringScanner().resolvePendingScan({ type: ur.type, cbor: ur.cbor.toString('hex'), }); resetError(); }, - [resetError, trackEvent, createEventBuilder], + [resetError], ); const onScanError = useCallback(async (error: string) => { @@ -204,6 +211,21 @@ const ConnectQRHardware = ({ navigation }: IConnectQRHardwareProps) => { } return lastAccount; }); + const deviceName = await withQrKeyring( + async ({ keyring }) => await keyring.getName(), + ); + trackEvent( + createEventBuilder(MetaMetricsEvents.HARDWARE_WALLET_ADD_ACCOUNT) + .addProperties({ + device_type: HardwareDeviceTypes.QR, + device_model: deviceName, + hd_path: null, + connected_device_count: ( + await getConnectedDevicesCount() + ).toString(), + }) + .build(), + ); if (accountToSelect) { Engine.setSelectedAddress(accountToSelect); @@ -214,10 +236,21 @@ const ConnectQRHardware = ({ navigation }: IConnectQRHardwareProps) => { setBlockingModalVisible(false); navigation.pop(2); }, - [navigation, resetError], + [createEventBuilder, navigation, resetError, trackEvent], ); const onForget = useCallback(async () => { + const deviceName = await withQrKeyring( + async ({ keyring }) => await keyring.getName(), + ); + trackEvent( + createEventBuilder(MetaMetricsEvents.HARDWARE_WALLET_FORGOTTEN) + .addProperties({ + device_type: HardwareDeviceTypes.QR, + device_model: deviceName, + }) + .build(), + ); resetError(); const remainingAccounts = KeyringController.state.keyrings .filter((keyring) => keyring.type !== ExtendedKeyringTypes.qr) @@ -236,7 +269,13 @@ const ConnectQRHardware = ({ navigation }: IConnectQRHardwareProps) => { return existingQrAccounts; }); navigation.pop(2); - }, [KeyringController, navigation, resetError]); + }, [ + KeyringController.state.keyrings, + createEventBuilder, + navigation, + resetError, + trackEvent, + ]); const renderAlert = () => errorMsg !== '' ? ( diff --git a/app/components/Views/LedgerConnect/Scan.test.tsx b/app/components/Views/LedgerConnect/Scan.test.tsx index 85f6c6e70d8..0a6e92be6cb 100644 --- a/app/components/Views/LedgerConnect/Scan.test.tsx +++ b/app/components/Views/LedgerConnect/Scan.test.tsx @@ -69,6 +69,7 @@ describe('Scan', () => { const selectedDevice = { id: 'device1', name: 'Device 1', + serviceUUIDs: ['service1'], }; jest.mocked(useBluetoothDevices).mockReturnValue({ @@ -192,10 +193,12 @@ describe('Scan', () => { const device1 = { id: 'device1', name: 'Device 1', + serviceUUIDs: ['service1'], }; const device2 = { id: 'device2', name: 'Device 2', + serviceUUIDs: ['service2'], }; const onDeviceSelected = jest.fn(); @@ -225,4 +228,91 @@ describe('Scan', () => { expect(onDeviceSelected).toHaveBeenCalledWith(device2); }); + + it('clears error state when all errors are resolved', () => { + const onScanningErrorStateChanged = jest.fn(); + + jest.mocked(useBluetoothPermissions).mockReturnValue({ + hasBluetoothPermissions: true, + bluetoothPermissionError: undefined, + checkPermissions: jest.fn(), + }); + + jest.mocked(useBluetooth).mockReturnValue({ + bluetoothOn: true, + bluetoothConnectionError: false, + }); + + jest.mocked(useBluetoothDevices).mockReturnValue({ + devices: [], + deviceScanError: false, + }); + + renderWithProvider( + , + ); + + expect(onScanningErrorStateChanged).toHaveBeenCalledWith(undefined); + }); + + it('does not display devices when bluetooth is off', () => { + const bluetoothDevice = { + id: 'device1', + name: 'Device 1', + serviceUUIDs: ['service1'], + }; + + jest.mocked(useBluetooth).mockReturnValue({ + bluetoothOn: false, + bluetoothConnectionError: false, + }); + + jest.mocked(useBluetoothDevices).mockReturnValue({ + devices: [bluetoothDevice], + deviceScanError: false, + }); + + const { queryByTestId } = renderWithProvider( + , + ); + + expect(queryByTestId(SELECT_DROP_DOWN)).toBeNull(); + }); + + it('does not display devices when permissions are not granted', () => { + const bluetoothDevice = { + id: 'device1', + name: 'Device 1', + serviceUUIDs: ['service1'], + }; + + jest.mocked(useBluetoothPermissions).mockReturnValue({ + hasBluetoothPermissions: false, + bluetoothPermissionError: undefined, + checkPermissions: jest.fn(), + }); + + jest.mocked(useBluetoothDevices).mockReturnValue({ + devices: [bluetoothDevice], + deviceScanError: false, + }); + + const { queryByTestId } = renderWithProvider( + , + ); + + expect(queryByTestId(SELECT_DROP_DOWN)).toBeNull(); + }); }); diff --git a/app/components/Views/LedgerConnect/Scan.tsx b/app/components/Views/LedgerConnect/Scan.tsx index af0bdf45a63..c32a491a3f6 100644 --- a/app/components/Views/LedgerConnect/Scan.tsx +++ b/app/components/Views/LedgerConnect/Scan.tsx @@ -16,6 +16,9 @@ import { LedgerCommunicationErrors, } from '../../../core/Ledger/ledgerErrors'; import SelectOptionSheet, { ISelectOption } from '../../UI/SelectOptionSheet'; +import { MetaMetricsEvents, useMetrics } from '../../hooks/useMetrics'; +import { HardwareDeviceTypes } from '../../../constants/keyringTypes'; +import { ledgerDeviceUUIDToModelName } from '../../../util/hardwareWallet/deviceNameUtils'; const createStyles = (colors: Colors) => StyleSheet.create({ @@ -49,6 +52,7 @@ const Scan = ({ ledgerError, }: ScanProps) => { const { colors } = useAppThemeFromContext() || mockTheme; + const { trackEvent, createEventBuilder } = useMetrics(); const styles = useMemo(() => createStyles(colors), [colors]); const [selectedDevice, setSelectedDevice] = useState< BluetoothDevice | undefined @@ -67,6 +71,14 @@ const Scan = ({ ); const [permissionErrorShown, setPermissionErrorShown] = useState(false); + const ledgerModelName = useMemo(() => { + if (selectedDevice) { + const [bluetoothServiceId] = selectedDevice.serviceUUIDs; + return ledgerDeviceUUIDToModelName(bluetoothServiceId); + } + return undefined; + }, [selectedDevice]); + useEffect(() => { if ( !bluetoothPermissionError && @@ -109,7 +121,16 @@ const Scan = ({ }, }); break; - case BluetoothPermissionErrors.BluetoothAccessBlocked: + case BluetoothPermissionErrors.BluetoothAccessBlocked: { + trackEvent( + createEventBuilder(MetaMetricsEvents.HARDWARE_WALLET_ERROR) + .addProperties({ + device_type: HardwareDeviceTypes.LEDGER, + device_model: ledgerModelName, + error: 'LEDGER_BLUETOOTH_PERMISSION_ERR', + }) + .build(), + ); onScanningErrorStateChanged({ errorTitle: strings('ledger.bluetooth_access_blocked'), errorSubtitle: strings('ledger.bluetooth_access_blocked_message'), @@ -121,6 +142,7 @@ const Scan = ({ }, }); break; + } case BluetoothPermissionErrors.NearbyDevicesAccessBlocked: onScanningErrorStateChanged({ errorTitle: strings('ledger.nearbyDevices_access_blocked'), @@ -172,6 +194,8 @@ const Scan = ({ bluetoothPermissionError, bluetoothConnectionError, permissionErrorShown, + selectedDevice, + ledgerModelName, ]); useEffect(() => { diff --git a/app/components/Views/LedgerConnect/index.test.tsx b/app/components/Views/LedgerConnect/index.test.tsx index 285265f1373..542fdf69345 100644 --- a/app/components/Views/LedgerConnect/index.test.tsx +++ b/app/components/Views/LedgerConnect/index.test.tsx @@ -112,7 +112,11 @@ describe('LedgerConnect', () => { const onConfirmationComplete = jest.fn(); const ledgerLogicToRun = jest.fn(); - const selectedDevice: BluetoothDevice = { id: '1', name: 'Ledger device' }; + const selectedDevice: BluetoothDevice = { + id: '1', + name: 'Ledger device', + serviceUUIDs: ['service1'], + }; const setSelectedDevice = jest.fn(); const checkLedgerCommunicationErrorFlow = function ( @@ -159,7 +163,7 @@ describe('LedgerConnect', () => { ( useBluetoothDevices as jest.MockedFunction<() => UseBluetoothDevicesHook> ).mockReturnValue({ - devices: [{ id: '1', name: 'Ledger device' }], + devices: [{ id: '1', name: 'Ledger device', serviceUUIDs: ['service1'] }], deviceScanError: false, }); diff --git a/app/components/Views/LedgerConnect/index.tsx b/app/components/Views/LedgerConnect/index.tsx index a0b83cc8413..f49212f852d 100644 --- a/app/components/Views/LedgerConnect/index.tsx +++ b/app/components/Views/LedgerConnect/index.tsx @@ -40,6 +40,10 @@ import { BluetoothInterface, } from '../../hooks/Ledger/useBluetoothDevices'; import { getDeviceId } from '../../../core/Ledger/Ledger'; +import { HardwareDeviceTypes } from '../../../constants/keyringTypes'; +import { MetaMetricsEvents, useMetrics } from '../../hooks/useMetrics'; +import { HARDWARE_WALLET_BUTTON_TYPE } from '../../../core/Analytics/MetaMetrics.events'; +import { ledgerDeviceUUIDToModelName } from '../../../util/hardwareWallet/deviceNameUtils'; interface LedgerConnectProps { onConnectLedger: () => void; @@ -75,6 +79,38 @@ const LedgerConnect = ({ const [retryTimes, setRetryTimes] = useState(0); const dispatch = useDispatch(); const deviceOSVersion = Number(getSystemVersion()) || 0; + const { trackEvent, createEventBuilder } = useMetrics(); + + const ledgerModelName = useMemo(() => { + if (selectedDevice) { + const [bluetoothServiceId] = selectedDevice.serviceUUIDs; + return ledgerDeviceUUIDToModelName(bluetoothServiceId); + } + return undefined; + }, [selectedDevice]); + + useEffect(() => { + trackEvent( + createEventBuilder(MetaMetricsEvents.HARDWARE_WALLET_CONNECT_INSTRUCTIONS) + .addProperties({ + device_type: HardwareDeviceTypes.LEDGER, + }) + .build(), + ); + }, [trackEvent, createEventBuilder]); + + useEffect(() => { + if (selectedDevice) { + trackEvent( + createEventBuilder(MetaMetricsEvents.HARDWARE_WALLET_FOUND) + .addProperties({ + device_type: HardwareDeviceTypes.LEDGER, + device_model: ledgerModelName, + }) + .build(), + ); + } + }, [selectedDevice, trackEvent, createEventBuilder, ledgerModelName]); useEffect(() => { navigation.setOptions( @@ -84,6 +120,14 @@ const LedgerConnect = ({ const connectLedger = () => { setLoading(true); + trackEvent( + createEventBuilder(MetaMetricsEvents.HARDWARE_WALLET_CONTINUE_CONNECTION) + .addProperties({ + device_type: HardwareDeviceTypes.LEDGER, + device_model: ledgerModelName, + }) + .build(), + ); ledgerLogicToRun(async () => { onConnectLedger(); }); @@ -110,7 +154,20 @@ const LedgerConnect = ({ primaryButtonConfig: { title: strings('ledger.retry'), onPress: () => { + const retryCount = retryTimes + 1; + trackEvent( + createEventBuilder( + MetaMetricsEvents.HARDWARE_WALLET_CONNECTION_RETRY, + ) + .addProperties({ + device_type: HardwareDeviceTypes.LEDGER, + device_model: ledgerModelName, + retry_count: retryCount, + }) + .build(), + ); setErrorDetails(undefined); + setRetryTimes(retryCount); connectLedger(); }, }, @@ -125,6 +182,14 @@ const LedgerConnect = ({ }, [deviceOSVersion]); const openHowToInstallEthApp = () => { + trackEvent( + createEventBuilder(MetaMetricsEvents.HARDWARE_WALLET_MARKETING) + .addProperties({ + device_type: HardwareDeviceTypes.LEDGER, + button_type: HARDWARE_WALLET_BUTTON_TYPE.TUTORIAL, + }) + .build(), + ); navigation.navigate('Webview', { screen: 'SimpleWebview', params: { diff --git a/app/components/Views/LedgerSelectAccount/__snapshots__/index.test.tsx.snap b/app/components/Views/LedgerSelectAccount/__snapshots__/index.test.tsx.snap index c5faed12f05..ad8a16ec05a 100644 --- a/app/components/Views/LedgerSelectAccount/__snapshots__/index.test.tsx.snap +++ b/app/components/Views/LedgerSelectAccount/__snapshots__/index.test.tsx.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`LedgerSelectAccount renders correctly to match snapshot 1`] = ` +exports[`LedgerSelectAccount Initial Rendering renders LedgerConnect when ledger error exists 1`] = ` `; -exports[`LedgerSelectAccount renders correctly to match snapshot when getAccounts return valid accounts 1`] = ` +exports[`LedgerSelectAccount Initial Rendering renders LedgerConnect when no accounts are loaded 1`] = ` ({ + addProperties: jest.fn().mockReturnThis(), + build: jest.fn().mockReturnValue({}), +})); jest.mock('../../hooks/Ledger/useLedgerBluetooth', () => ({ __esModule: true, @@ -16,6 +26,14 @@ jest.mock('../../hooks/Ledger/useLedgerBluetooth', () => ({ })), })); +jest.mock('../../hooks/useMetrics/useMetrics', () => ({ + __esModule: true, + default: jest.fn(() => ({ + trackEvent: mockTrackEvent, + createEventBuilder: mockCreateEventBuilder, + })), +})); + jest.mock('@react-navigation/native', () => { const actualNav = jest.requireActual('@react-navigation/native'); return { @@ -23,10 +41,42 @@ jest.mock('@react-navigation/native', () => { useNavigation: () => ({ navigate: mockedNavigate, setOptions: jest.fn(), + goBack: jest.fn(), + pop: mockedPop, + dispatch: jest.fn(), }), + StackActions: { + pop: jest.fn(), + }, }; }); +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useDispatch: () => mockedDispatch, +})); + +jest.mock('../../../core/Ledger/Ledger', () => ({ + forgetLedger: jest.fn(), + getHDPath: jest.fn(), + getLedgerAccounts: jest.fn(), + getLedgerAccountsByOperation: jest.fn(), + setHDPath: jest.fn(), + unlockLedgerWalletAccount: jest.fn(), +})); + +jest.mock('../../../core/HardwareWallets/analytics', () => ({ + getConnectedDevicesCount: jest.fn(), +})); + +jest.mock('../../../util/hardwareWallet/deviceNameUtils', () => ({ + sanitizeDeviceName: jest.fn((name: string) => name), +})); + +jest.mock('../../../util/address', () => ({ + toFormattedAddress: jest.fn((address: string) => address), +})); + jest.mock('../../../core/Engine', () => ({ context: { KeyringController: { @@ -45,9 +95,14 @@ const MockEngine = jest.mocked(Engine); describe('LedgerSelectAccount', () => { const mockKeyringController = MockEngine.context.KeyringController; + const mockExistingAccounts = [ + '0xd0a1e359811322d97991e03f863a0c30c2cf029c', + '0xa1e359811322d97991e03f863a0c30c2cf029cd', + ]; beforeEach(() => { jest.clearAllMocks(); + mockKeyringController.getAccounts.mockResolvedValue(mockExistingAccounts); ( useLedgerBluetooth as unknown as jest.MockedFunction< @@ -64,20 +119,131 @@ describe('LedgerSelectAccount', () => { })); }); - it('renders correctly to match snapshot', () => { - mockKeyringController.getAccounts.mockResolvedValue([]); - const wrapper = renderWithProvider(); + describe('Initial Rendering', () => { + it('renders LedgerConnect when no accounts are loaded', () => { + mockKeyringController.getAccounts.mockResolvedValue([]); + const { toJSON } = renderWithProvider(); + + expect(toJSON()).toMatchSnapshot(); + }); + + it('renders LedgerConnect when ledger error exists', () => { + ( + useLedgerBluetooth as unknown as jest.MockedFunction< + typeof useLedgerBluetooth + > + ).mockImplementation(() => ({ + isSendingLedgerCommands: false, + isAppLaunchConfirmationNeeded: false, + ledgerLogicToRun: jest.fn(), + error: LedgerCommunicationErrors.LedgerDisconnected, + cleanupBluetoothConnection(): void { + throw new Error('Function not implemented.'); + }, + })); + + const { toJSON } = renderWithProvider(); - expect(wrapper).toMatchSnapshot(); + expect(toJSON()).toMatchSnapshot(); + }); }); - it('renders correctly to match snapshot when getAccounts return valid accounts', () => { - mockKeyringController.getAccounts.mockResolvedValue([ - '0xd0a1e359811322d97991e03f863a0c30c2cf029c', - '0xa1e359811322d97991e03f863a0c30c2cf029cd', - ]); - const wrapper = renderWithProvider(); + describe('Account Loading', () => { + it('loads existing accounts on mount', async () => { + renderWithProvider(); + + await waitFor(() => { + expect(mockKeyringController.getAccounts).toHaveBeenCalled(); + }); + }); + + it('formats existing account addresses', async () => { + const mockToFormattedAddress = jest.requireMock( + '../../../util/address', + ).toFormattedAddress; + + renderWithProvider(); + + await waitFor(() => { + expect(mockToFormattedAddress).toHaveBeenCalledWith( + mockExistingAccounts[0], + 0, + mockExistingAccounts, + ); + expect(mockToFormattedAddress).toHaveBeenCalledWith( + mockExistingAccounts[1], + 1, + mockExistingAccounts, + ); + }); + }); + }); + + describe('Metrics Tracking', () => { + it('tracks metrics when rendering with LedgerConnect', () => { + mockKeyringController.getAccounts.mockResolvedValue([]); + + renderWithProvider(); + + expect(mockCreateEventBuilder).toHaveBeenCalled(); + expect(mockTrackEvent).toHaveBeenCalled(); + }); + + it('includes hardware device type when tracking hardware wallet instructions', () => { + mockKeyringController.getAccounts.mockResolvedValue([]); + const mockBuilder = { + addProperties: jest.fn().mockReturnThis(), + build: jest.fn().mockReturnValue({ event: 'instructions_viewed' }), + }; + mockCreateEventBuilder.mockReturnValue(mockBuilder); + + renderWithProvider(); + + expect(mockBuilder.addProperties).toHaveBeenCalledWith( + expect.objectContaining({ + device_type: HardwareDeviceTypes.LEDGER, + }), + ); + }); + + it('calls trackEvent with built event object', () => { + mockKeyringController.getAccounts.mockResolvedValue([]); + const mockEvent = { event: 'test_event' }; + const mockBuilder = { + addProperties: jest.fn().mockReturnThis(), + build: jest.fn().mockReturnValue(mockEvent), + }; + mockCreateEventBuilder.mockReturnValue(mockBuilder); + + renderWithProvider(); + + expect(mockTrackEvent).toHaveBeenCalledWith(mockEvent); + }); + }); + + describe('Error Handling', () => { + it('hides blocking modal when ledger error occurs', async () => { + const { rerender } = renderWithProvider(); + + ( + useLedgerBluetooth as unknown as jest.MockedFunction< + typeof useLedgerBluetooth + > + ).mockImplementation(() => ({ + isSendingLedgerCommands: false, + isAppLaunchConfirmationNeeded: false, + ledgerLogicToRun: jest.fn(), + error: LedgerCommunicationErrors.LedgerDisconnected, + cleanupBluetoothConnection(): void { + throw new Error('Function not implemented.'); + }, + })); + + rerender(); - expect(wrapper).toMatchSnapshot(); + await waitFor(() => { + expect(useLedgerBluetooth).toHaveBeenCalled(); + }); + }); }); }); diff --git a/app/components/Views/LedgerSelectAccount/index.tsx b/app/components/Views/LedgerSelectAccount/index.tsx index 330322e54c0..1ac0961a4f0 100644 --- a/app/components/Views/LedgerSelectAccount/index.tsx +++ b/app/components/Views/LedgerSelectAccount/index.tsx @@ -28,6 +28,7 @@ import { HardwareDeviceTypes } from '../../../constants/keyringTypes'; import MaterialIcon from 'react-native-vector-icons/MaterialIcons'; import PAGINATION_OPERATIONS from '../../../constants/pagination'; import { Device as LedgerDevice } from '@ledgerhq/react-native-hw-transport-ble/lib/types'; +import { ledgerDeviceUUIDToModelName } from '../../../util/hardwareWallet/deviceNameUtils'; import useLedgerBluetooth from '../../hooks/Ledger/useLedgerBluetooth'; import { LEDGER_BIP44_PATH, @@ -41,6 +42,7 @@ import { import SelectOptionSheet from '../../UI/SelectOptionSheet'; import { AccountsController } from '@metamask/accounts-controller'; import { toFormattedAddress } from '../../../util/address'; +import { getConnectedDevicesCount } from '../../../core/HardwareWallets/analytics'; interface OptionType { key: string; @@ -61,6 +63,14 @@ const LedgerSelectAccount = () => { ledgerDeviceDarkImage, ); + const ledgerModelName = useMemo(() => { + if (selectedDevice) { + const [bluetoothServiceId] = selectedDevice.serviceUUIDs; + return ledgerDeviceUUIDToModelName(bluetoothServiceId); + } + return undefined; + }, [selectedDevice]); + const ledgerPathOptions: OptionType[] = useMemo( () => [ { @@ -138,22 +148,35 @@ const LedgerSelectAccount = () => { setBlockingModalVisible(true); }; + useEffect(() => { + if (selectedDevice && accounts.length > 0) { + trackEvent( + createEventBuilder( + MetaMetricsEvents.HARDWARE_WALLET_ACCOUNT_SELECTOR_OPEN, + ) + .addProperties({ + device_type: HardwareDeviceTypes.LEDGER, + device_model: ledgerModelName, + }) + .build(), + ); + } + }, [ + trackEvent, + createEventBuilder, + selectedDevice, + accounts, + ledgerModelName, + ]); + const onConnectHardware = useCallback(async () => { setErrorMsg(null); - trackEvent( - createEventBuilder(MetaMetricsEvents.CONTINUE_LEDGER_HARDWARE_WALLET) - .addProperties({ - device_type: HardwareDeviceTypes.LEDGER, - }) - .build(), - ); - const _accounts = await getLedgerAccountsByOperation( PAGINATION_OPERATIONS.GET_FIRST_PAGE, ); setAccounts(_accounts); - }, [trackEvent, createEventBuilder]); + }, []); useEffect(() => { if (accounts.length > 0 && selectedOption) { @@ -226,34 +249,47 @@ const LedgerSelectAccount = () => { const onUnlock = useCallback( async (accountIndexes: number[]) => { showLoadingModal(); + try { for (const index of accountIndexes) { await unlockLedgerWalletAccount(index); } - + const numberOfConnectedDevices = await getConnectedDevicesCount(); await updateNewLegacyAccountsLabel(); trackEvent( - createEventBuilder(MetaMetricsEvents.CONNECT_LEDGER_SUCCESS) + createEventBuilder(MetaMetricsEvents.HARDWARE_WALLET_ADD_ACCOUNT) .addProperties({ device_type: HardwareDeviceTypes.LEDGER, + device_model: ledgerModelName, hd_path: getPathString(selectedOption.value), + connected_device_count: numberOfConnectedDevices.toString(), }) .build(), ); navigation.pop(2); } catch (err) { + trackEvent( + createEventBuilder(MetaMetricsEvents.HARDWARE_WALLET_ERROR) + .addProperties({ + device_type: HardwareDeviceTypes.LEDGER, + device_model: ledgerModelName, + error: (err as Error).message, + }) + .build(), + ); setErrorMsg((err as Error).message); } finally { setBlockingModalVisible(false); } }, [ - navigation, - selectedOption.value, - trackEvent, updateNewLegacyAccountsLabel, + ledgerModelName, + trackEvent, createEventBuilder, + selectedOption.value, + navigation, ], ); @@ -265,12 +301,13 @@ const LedgerSelectAccount = () => { createEventBuilder(MetaMetricsEvents.HARDWARE_WALLET_FORGOTTEN) .addProperties({ device_type: HardwareDeviceTypes.LEDGER, + device_model: ledgerModelName, }) .build(), ); setBlockingModalVisible(false); navigation.dispatch(StackActions.pop(2)); - }, [dispatch, navigation, trackEvent, createEventBuilder]); + }, [dispatch, trackEvent, createEventBuilder, ledgerModelName, navigation]); const onAnimationCompleted = useCallback(async () => { if (!blockingModalVisible) { diff --git a/app/components/Views/confirmations/context/qr-hardware-context/useCamera.ts b/app/components/Views/confirmations/context/qr-hardware-context/useCamera.ts index 0bb2c717b48..7c4f66c1f62 100644 --- a/app/components/Views/confirmations/context/qr-hardware-context/useCamera.ts +++ b/app/components/Views/confirmations/context/qr-hardware-context/useCamera.ts @@ -4,8 +4,15 @@ import { PermissionsAndroid, AppStateStatus, AppState } from 'react-native'; import { strings } from '../../../../../../locales/i18n'; import Device from '../../../../../util/device'; +import { MetaMetricsEvents, useMetrics } from '../../../../hooks/useMetrics'; +import { HardwareDeviceTypes } from '../../../../../constants/keyringTypes'; +import { + PERMISSION_RESULT, + PERMISSION_TYPE, +} from '../../../../../core/Analytics/MetaMetrics.events'; export const useCamera = (isSigningQRObject: boolean) => { + const { trackEvent, createEventBuilder } = useMetrics(); // todo: integrate with alert system const [cameraError, setCameraError] = useState(); @@ -16,16 +23,51 @@ export const useCamera = (isSigningQRObject: boolean) => { if (Device.isAndroid() && !hasCameraPermission) { PermissionsAndroid.check(PermissionsAndroid.PERMISSIONS.CAMERA).then( (_hasPermission) => { + trackEvent( + createEventBuilder( + MetaMetricsEvents.HARDWARE_WALLET_PERMISSION_REQUEST, + ) + .addProperties({ + permission: PERMISSION_TYPE.CAMERA, + result: _hasPermission + ? PERMISSION_RESULT.GRANTED + : PERMISSION_RESULT.DENIED, + device_type: HardwareDeviceTypes.QR, + }) + .build(), + ); setCameraPermission(_hasPermission); if (!_hasPermission) { + trackEvent( + createEventBuilder( + MetaMetricsEvents.HARDWARE_WALLET_PERMISSION_REQUEST, + ) + .addProperties({ + permission: PERMISSION_TYPE.CAMERA, + result: PERMISSION_RESULT.LIMITED, + device_type: HardwareDeviceTypes.QR, + }) + .build(), + ); setCameraError(strings('transaction.no_camera_permission_android')); } else { + trackEvent( + createEventBuilder( + MetaMetricsEvents.HARDWARE_WALLET_PERMISSION_REQUEST, + ) + .addProperties({ + permission: PERMISSION_TYPE.CAMERA, + result: PERMISSION_RESULT.UNAVAILABLE, + device_type: HardwareDeviceTypes.QR, + }) + .build(), + ); setCameraError(undefined); } }, ); } - }, [hasCameraPermission]); + }, [hasCameraPermission, trackEvent, createEventBuilder]); const handleAppState = useCallback( (appState: AppStateStatus) => { diff --git a/app/components/Views/confirmations/legacy/components/PersonalSign/PersonalSign.tsx b/app/components/Views/confirmations/legacy/components/PersonalSign/PersonalSign.tsx index 755a265b93e..6eeb7e765a8 100644 --- a/app/components/Views/confirmations/legacy/components/PersonalSign/PersonalSign.tsx +++ b/app/components/Views/confirmations/legacy/components/PersonalSign/PersonalSign.tsx @@ -127,7 +127,7 @@ const PersonalSign = ({ const onSignatureError = ({ error }: { error: Error }) => { if (error?.message.startsWith(KEYSTONE_TX_CANCELED)) { trackEvent( - createEventBuilder(MetaMetricsEvents.QR_HARDWARE_TRANSACTION_CANCELED) + createEventBuilder(MetaMetricsEvents.DAPP_TRANSACTION_CANCELLED) .addProperties(getAnalyticsParams()) .build(), ); diff --git a/app/components/hooks/Ledger/useBluetoothDevices.ts b/app/components/hooks/Ledger/useBluetoothDevices.ts index 0f749d1b045..3e0d8946193 100644 --- a/app/components/hooks/Ledger/useBluetoothDevices.ts +++ b/app/components/hooks/Ledger/useBluetoothDevices.ts @@ -5,6 +5,7 @@ import { Observable, Observer, Subscription } from 'rxjs'; export interface BluetoothDevice { id: string; name: string; + serviceUUIDs: string[]; } // Works with any Bluetooth Interface that provides a listen method diff --git a/app/components/hooks/Ledger/useLedgerDeviceForAccount.test.ts b/app/components/hooks/Ledger/useLedgerDeviceForAccount.test.ts new file mode 100644 index 00000000000..09f97055b5c --- /dev/null +++ b/app/components/hooks/Ledger/useLedgerDeviceForAccount.test.ts @@ -0,0 +1,432 @@ +import { renderHook } from '@testing-library/react-hooks'; +import { InternalAccount } from '@metamask/keyring-internal-api'; +import useLedgerDeviceForAccount from './useLedgerDeviceForAccount'; +import useBluetoothDevices, { BluetoothDevice } from './useBluetoothDevices'; +import useBluetoothPermissions from '../useBluetoothPermissions'; +import useBluetooth from './useBluetooth'; +import ExtendedKeyringTypes from '../../../constants/keyringTypes'; +import { BluetoothPermissionErrors } from '../../../core/Ledger/ledgerErrors'; + +jest.mock('./useBluetoothDevices'); +jest.mock('../useBluetoothPermissions'); +jest.mock('./useBluetooth'); + +const mockUseBluetoothDevices = useBluetoothDevices as jest.MockedFunction< + typeof useBluetoothDevices +>; +const mockUseBluetoothPermissions = + useBluetoothPermissions as jest.MockedFunction< + typeof useBluetoothPermissions + >; +const mockUseBluetooth = useBluetooth as jest.MockedFunction< + typeof useBluetooth +>; + +describe('useLedgerDeviceForAccount', () => { + const mockCheckPermissions = jest.fn(); + + const createMockAccount = ( + keyringType: string = ExtendedKeyringTypes.hd, + ): InternalAccount => + ({ + id: 'test-account-id', + address: '0x123', + metadata: { + name: 'Test Account', + keyring: { + type: keyringType, + }, + }, + }) as InternalAccount; + + const createMockDevice = (id = 'device-1'): BluetoothDevice => ({ + id, + name: 'Ledger Nano X', + serviceUUIDs: ['uuid-1'], + }); + + beforeEach(() => { + jest.clearAllMocks(); + + mockUseBluetoothPermissions.mockReturnValue({ + hasBluetoothPermissions: false, + checkPermissions: mockCheckPermissions, + bluetoothPermissionError: undefined, + }); + + mockUseBluetooth.mockReturnValue({ + bluetoothOn: false, + bluetoothConnectionError: undefined, + }); + + mockUseBluetoothDevices.mockReturnValue({ + devices: [], + deviceScanError: false, + }); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('when account is not a Ledger account', () => { + it('returns undefined for all Ledger-specific properties', () => { + const account = createMockAccount(ExtendedKeyringTypes.hd); + + const { result } = renderHook(() => useLedgerDeviceForAccount(account)); + + expect(result.current.ledgerDevice).toBeUndefined(); + expect(result.current.hasBluetoothPermissions).toBeUndefined(); + expect(result.current.bluetoothOn).toBeUndefined(); + expect(result.current.checkPermissions).toBeUndefined(); + expect(result.current.bluetoothPermissionError).toBeUndefined(); + expect(result.current.bluetoothConnectionError).toBeUndefined(); + expect(result.current.deviceScanError).toBeUndefined(); + }); + + it('returns undefined when account has no keyring metadata', () => { + const account = { + id: 'test-account-id', + address: '0x123', + metadata: {}, + } as InternalAccount; + + const { result } = renderHook(() => useLedgerDeviceForAccount(account)); + + expect(result.current.ledgerDevice).toBeUndefined(); + expect(result.current.hasBluetoothPermissions).toBeUndefined(); + }); + + it('returns undefined when account keyring type is simple', () => { + const account = createMockAccount(ExtendedKeyringTypes.simple); + + const { result } = renderHook(() => useLedgerDeviceForAccount(account)); + + expect(result.current.ledgerDevice).toBeUndefined(); + }); + + it('returns undefined when account keyring type is qr', () => { + const account = createMockAccount(ExtendedKeyringTypes.qr); + + const { result } = renderHook(() => useLedgerDeviceForAccount(account)); + + expect(result.current.ledgerDevice).toBeUndefined(); + }); + }); + + describe('when account is a Ledger account', () => { + describe('with available devices', () => { + it('returns first device when one device is available', () => { + const account = createMockAccount(ExtendedKeyringTypes.ledger); + const mockDevice = createMockDevice(); + mockUseBluetoothDevices.mockReturnValue({ + devices: [mockDevice], + deviceScanError: false, + }); + mockUseBluetoothPermissions.mockReturnValue({ + hasBluetoothPermissions: true, + checkPermissions: mockCheckPermissions, + bluetoothPermissionError: undefined, + }); + mockUseBluetooth.mockReturnValue({ + bluetoothOn: true, + bluetoothConnectionError: false, + }); + + const { result } = renderHook(() => useLedgerDeviceForAccount(account)); + + expect(result.current.ledgerDevice).toEqual(mockDevice); + expect(result.current.hasBluetoothPermissions).toBe(true); + expect(result.current.bluetoothOn).toBe(true); + }); + + it('returns first device when multiple devices are available', () => { + const account = createMockAccount(ExtendedKeyringTypes.ledger); + const device1 = createMockDevice('device-1'); + const device2 = createMockDevice('device-2'); + mockUseBluetoothDevices.mockReturnValue({ + devices: [device1, device2], + deviceScanError: false, + }); + + const { result } = renderHook(() => useLedgerDeviceForAccount(account)); + + expect(result.current.ledgerDevice).toEqual(device1); + }); + + it('returns checkPermissions function', () => { + const account = createMockAccount(ExtendedKeyringTypes.ledger); + mockUseBluetoothPermissions.mockReturnValue({ + hasBluetoothPermissions: true, + checkPermissions: mockCheckPermissions, + bluetoothPermissionError: undefined, + }); + + const { result } = renderHook(() => useLedgerDeviceForAccount(account)); + + expect(result.current.checkPermissions).toBe(mockCheckPermissions); + }); + }); + + describe('without available devices', () => { + it('returns undefined for ledgerDevice when devices array is empty', () => { + const account = createMockAccount(ExtendedKeyringTypes.ledger); + mockUseBluetoothDevices.mockReturnValue({ + devices: [], + deviceScanError: false, + }); + + const { result } = renderHook(() => useLedgerDeviceForAccount(account)); + + expect(result.current.ledgerDevice).toBeUndefined(); + }); + + it('returns Bluetooth state even when no devices are found', () => { + const account = createMockAccount(ExtendedKeyringTypes.ledger); + mockUseBluetoothPermissions.mockReturnValue({ + hasBluetoothPermissions: true, + checkPermissions: mockCheckPermissions, + bluetoothPermissionError: undefined, + }); + mockUseBluetooth.mockReturnValue({ + bluetoothOn: true, + bluetoothConnectionError: false, + }); + mockUseBluetoothDevices.mockReturnValue({ + devices: [], + deviceScanError: false, + }); + + const { result } = renderHook(() => useLedgerDeviceForAccount(account)); + + expect(result.current.ledgerDevice).toBeUndefined(); + expect(result.current.hasBluetoothPermissions).toBe(true); + expect(result.current.bluetoothOn).toBe(true); + }); + }); + + describe('permission states', () => { + it('returns false when Bluetooth permissions are denied', () => { + const account = createMockAccount(ExtendedKeyringTypes.ledger); + mockUseBluetoothPermissions.mockReturnValue({ + hasBluetoothPermissions: false, + checkPermissions: mockCheckPermissions, + bluetoothPermissionError: + BluetoothPermissionErrors.BluetoothAccessBlocked, + }); + + const { result } = renderHook(() => useLedgerDeviceForAccount(account)); + + expect(result.current.hasBluetoothPermissions).toBe(false); + expect(result.current.bluetoothPermissionError).toBe( + BluetoothPermissionErrors.BluetoothAccessBlocked, + ); + }); + + it('returns nearby devices permission error on Android 12+', () => { + const account = createMockAccount(ExtendedKeyringTypes.ledger); + mockUseBluetoothPermissions.mockReturnValue({ + hasBluetoothPermissions: false, + checkPermissions: mockCheckPermissions, + bluetoothPermissionError: + BluetoothPermissionErrors.NearbyDevicesAccessBlocked, + }); + + const { result } = renderHook(() => useLedgerDeviceForAccount(account)); + + expect(result.current.bluetoothPermissionError).toBe( + BluetoothPermissionErrors.NearbyDevicesAccessBlocked, + ); + }); + + it('returns location permission error on Android below 12', () => { + const account = createMockAccount(ExtendedKeyringTypes.ledger); + mockUseBluetoothPermissions.mockReturnValue({ + hasBluetoothPermissions: false, + checkPermissions: mockCheckPermissions, + bluetoothPermissionError: + BluetoothPermissionErrors.LocationAccessBlocked, + }); + + const { result } = renderHook(() => useLedgerDeviceForAccount(account)); + + expect(result.current.bluetoothPermissionError).toBe( + BluetoothPermissionErrors.LocationAccessBlocked, + ); + }); + }); + + describe('Bluetooth connection states', () => { + it('returns true when Bluetooth is turned on', () => { + const account = createMockAccount(ExtendedKeyringTypes.ledger); + mockUseBluetooth.mockReturnValue({ + bluetoothOn: true, + bluetoothConnectionError: false, + }); + + const { result } = renderHook(() => useLedgerDeviceForAccount(account)); + + expect(result.current.bluetoothOn).toBe(true); + expect(result.current.bluetoothConnectionError).toBe(false); + }); + + it('returns false when Bluetooth is turned off', () => { + const account = createMockAccount(ExtendedKeyringTypes.ledger); + mockUseBluetooth.mockReturnValue({ + bluetoothOn: false, + bluetoothConnectionError: true, + }); + + const { result } = renderHook(() => useLedgerDeviceForAccount(account)); + + expect(result.current.bluetoothOn).toBe(false); + expect(result.current.bluetoothConnectionError).toBe(true); + }); + + it('returns undefined for Bluetooth connection error when not set', () => { + const account = createMockAccount(ExtendedKeyringTypes.ledger); + mockUseBluetooth.mockReturnValue({ + bluetoothOn: false, + bluetoothConnectionError: undefined, + }); + + const { result } = renderHook(() => useLedgerDeviceForAccount(account)); + + expect(result.current.bluetoothConnectionError).toBeUndefined(); + }); + }); + + describe('device scan errors', () => { + it('returns true when device scan encounters an error', () => { + const account = createMockAccount(ExtendedKeyringTypes.ledger); + mockUseBluetoothDevices.mockReturnValue({ + devices: [], + deviceScanError: true, + }); + + const { result } = renderHook(() => useLedgerDeviceForAccount(account)); + + expect(result.current.deviceScanError).toBe(true); + }); + + it('returns false when device scan completes without error', () => { + const account = createMockAccount(ExtendedKeyringTypes.ledger); + mockUseBluetoothDevices.mockReturnValue({ + devices: [], + deviceScanError: false, + }); + + const { result } = renderHook(() => useLedgerDeviceForAccount(account)); + + expect(result.current.deviceScanError).toBe(false); + }); + }); + + describe('hook dependencies', () => { + it('passes hasBluetoothPermissions to useBluetooth', () => { + const account = createMockAccount(ExtendedKeyringTypes.ledger); + mockUseBluetoothPermissions.mockReturnValue({ + hasBluetoothPermissions: true, + checkPermissions: mockCheckPermissions, + bluetoothPermissionError: undefined, + }); + + renderHook(() => useLedgerDeviceForAccount(account)); + + expect(mockUseBluetooth).toHaveBeenCalledWith(true); + }); + + it('passes hasBluetoothPermissions and bluetoothOn to useBluetoothDevices', () => { + const account = createMockAccount(ExtendedKeyringTypes.ledger); + mockUseBluetoothPermissions.mockReturnValue({ + hasBluetoothPermissions: true, + checkPermissions: mockCheckPermissions, + bluetoothPermissionError: undefined, + }); + mockUseBluetooth.mockReturnValue({ + bluetoothOn: true, + bluetoothConnectionError: false, + }); + + renderHook(() => useLedgerDeviceForAccount(account)); + + expect(mockUseBluetoothDevices).toHaveBeenCalledWith(true, true); + }); + + it('passes false values when permissions are denied', () => { + const account = createMockAccount(ExtendedKeyringTypes.ledger); + mockUseBluetoothPermissions.mockReturnValue({ + hasBluetoothPermissions: false, + checkPermissions: mockCheckPermissions, + bluetoothPermissionError: + BluetoothPermissionErrors.BluetoothAccessBlocked, + }); + + renderHook(() => useLedgerDeviceForAccount(account)); + + expect(mockUseBluetooth).toHaveBeenCalledWith(false); + }); + }); + + describe('complete error scenario', () => { + it('returns all error states when everything fails', () => { + const account = createMockAccount(ExtendedKeyringTypes.ledger); + mockUseBluetoothPermissions.mockReturnValue({ + hasBluetoothPermissions: false, + checkPermissions: mockCheckPermissions, + bluetoothPermissionError: + BluetoothPermissionErrors.BluetoothAccessBlocked, + }); + mockUseBluetooth.mockReturnValue({ + bluetoothOn: false, + bluetoothConnectionError: true, + }); + mockUseBluetoothDevices.mockReturnValue({ + devices: [], + deviceScanError: true, + }); + + const { result } = renderHook(() => useLedgerDeviceForAccount(account)); + + expect(result.current.ledgerDevice).toBeUndefined(); + expect(result.current.hasBluetoothPermissions).toBe(false); + expect(result.current.bluetoothOn).toBe(false); + expect(result.current.bluetoothPermissionError).toBe( + BluetoothPermissionErrors.BluetoothAccessBlocked, + ); + expect(result.current.bluetoothConnectionError).toBe(true); + expect(result.current.deviceScanError).toBe(true); + }); + }); + + describe('complete success scenario', () => { + it('returns all success states when everything works', () => { + const account = createMockAccount(ExtendedKeyringTypes.ledger); + const mockDevice = createMockDevice(); + mockUseBluetoothPermissions.mockReturnValue({ + hasBluetoothPermissions: true, + checkPermissions: mockCheckPermissions, + bluetoothPermissionError: undefined, + }); + mockUseBluetooth.mockReturnValue({ + bluetoothOn: true, + bluetoothConnectionError: false, + }); + mockUseBluetoothDevices.mockReturnValue({ + devices: [mockDevice], + deviceScanError: false, + }); + + const { result } = renderHook(() => useLedgerDeviceForAccount(account)); + + expect(result.current.ledgerDevice).toEqual(mockDevice); + expect(result.current.hasBluetoothPermissions).toBe(true); + expect(result.current.bluetoothOn).toBe(true); + expect(result.current.checkPermissions).toBe(mockCheckPermissions); + expect(result.current.bluetoothPermissionError).toBeUndefined(); + expect(result.current.bluetoothConnectionError).toBe(false); + expect(result.current.deviceScanError).toBe(false); + }); + }); + }); +}); diff --git a/app/components/hooks/Ledger/useLedgerDeviceForAccount.ts b/app/components/hooks/Ledger/useLedgerDeviceForAccount.ts new file mode 100644 index 00000000000..c18ffa0ae19 --- /dev/null +++ b/app/components/hooks/Ledger/useLedgerDeviceForAccount.ts @@ -0,0 +1,48 @@ +import { InternalAccount } from '@metamask/keyring-internal-api'; +import ExtendedKeyringTypes from '../../../constants/keyringTypes'; +import useBluetoothDevices from './useBluetoothDevices'; +import useBluetoothPermissions from '../useBluetoothPermissions'; +import useBluetooth from './useBluetooth'; + +/** + * Hook to get Ledger device information for an account + * Returns device info only for Ledger accounts + */ +const useLedgerDeviceForAccount = (selectedAccount: InternalAccount) => { + const isLedgerAccount = + selectedAccount?.metadata?.keyring?.type === ExtendedKeyringTypes.ledger; + + const { + hasBluetoothPermissions, + checkPermissions, + bluetoothPermissionError, + } = useBluetoothPermissions(); + + const { bluetoothOn, bluetoothConnectionError } = useBluetooth( + hasBluetoothPermissions, + ); + + const { devices, deviceScanError } = useBluetoothDevices( + hasBluetoothPermissions, + bluetoothOn, + ); + + return { + ledgerDevice: + isLedgerAccount && devices.length > 0 ? devices[0] : undefined, + hasBluetoothPermissions: isLedgerAccount + ? hasBluetoothPermissions + : undefined, + bluetoothOn: isLedgerAccount ? bluetoothOn : undefined, + checkPermissions: isLedgerAccount ? checkPermissions : undefined, + bluetoothPermissionError: isLedgerAccount + ? bluetoothPermissionError + : undefined, + bluetoothConnectionError: isLedgerAccount + ? bluetoothConnectionError + : undefined, + deviceScanError: isLedgerAccount ? deviceScanError : undefined, + }; +}; + +export default useLedgerDeviceForAccount; diff --git a/app/core/Analytics/MetaMetrics.events.ts b/app/core/Analytics/MetaMetrics.events.ts index 79633cf8af5..e2b237978d0 100644 --- a/app/core/Analytics/MetaMetrics.events.ts +++ b/app/core/Analytics/MetaMetrics.events.ts @@ -200,12 +200,19 @@ enum EVENT_NAME { // Key Management ANDROID_HARDWARE_KEYSTORE = 'Android Hardware Keystore', - // QR Hardware Wallet - CONNECT_HARDWARE_WALLET = 'Clicked Connect Hardware Wallet', - CONTINUE_QR_HARDWARE_WALLET = 'Clicked Continue QR Hardware Wallet', - CONNECT_HARDWARE_WALLET_SUCCESS = 'Connected Account with hardware wallet', - QR_HARDWARE_TRANSACTION_CANCELED = 'User canceled QR hardware transaction', - HARDWARE_WALLET_ERROR = 'Hardware wallet error', + // Common Hardware Wallet + ADD_HARDWARE_WALLET = 'Add Hardware Wallet Clicked', + CONNECT_HARDWARE_WALLET = 'Connect Hardware Wallet Clicked', + HARDWARE_WALLET_FOUND = 'Connect Hardware Wallet Device Found', + HARDWARE_WALLET_CONTINUE_CONNECTION = 'Connect Hardware Wallet Continue Button Clicked', + HARDWARE_WALLET_PERMISSION_REQUEST = 'Hardware Wallet Permission Request Clicked', + HARDWARE_WALLET_ACCOUNT_SELECTOR_OPEN = 'Connect Hardware Wallet Account Selector Viewed', + HARDWARE_WALLET_MARKETING = 'Hardware Wallet Marketing Button Clicked', + HARDWARE_WALLET_CONNECT_INSTRUCTIONS = 'Connect Hardware Wallet Instructions Viewed', + HARDWARE_WALLET_CONNECTION_RETRY = 'Hardware Wallet Connection Error Retry Button Clicked', + HARDWARE_WALLET_ADD_ACCOUNT = 'Hardware Wallet Account Connected', + HARDWARE_WALLET_FORGOTTEN = 'Hardware Wallet Forgotten', + HARDWARE_WALLET_ERROR = 'Hardware Wallet Connection Failed', // Tokens TOKEN_DETECTED = 'Token Detected', @@ -445,16 +452,6 @@ enum EVENT_NAME { // Edit account name ACCOUNT_RENAMED = 'Account Renamed', - //Ledger - CONNECT_LEDGER = 'Clicked Connect Ledger', - CONTINUE_LEDGER_HARDWARE_WALLET = 'Clicked Continue Ledger Hardware Wallet', - CONNECT_LEDGER_SUCCESS = 'Connected Account with hardware wallet', - LEDGER_HARDWARE_TRANSACTION_CANCELLED = 'User canceled Ledger hardware transaction', - LEDGER_HARDWARE_WALLET_ERROR = 'Ledger hardware wallet error', - - // common hardware wallet - HARDWARE_WALLET_FORGOTTEN = 'Hardware wallet forgotten', - // Remove an account ACCOUNT_REMOVED = 'Account removed', ACCOUNT_REMOVE_FAILED = 'Account remove failed', @@ -597,6 +594,24 @@ enum EVENT_NAME { QR_SCANNED = 'QR Scanned', } +export enum HARDWARE_WALLET_BUTTON_TYPE { + TUTORIAL = 'Tutorial', + PICKER = 'Picker', + BUY_NOW = 'Buy Now', + LEARN_MORE = 'Learn More', +} + +export enum HARDWARE_WALLET_DEVICE_TYPE { + LEDGER = 'Ledger', + Keystone = 'Keystone', + NgraveZero = 'Ngrave Zero', + AIRGAP_VAULT = 'AirGap Vault', + COOL_WALLET = 'Cool Wallet', + DCENT = 'DCent', + GRID_PLUS = 'Grid Plus', + IMToken = 'IMToken', +} + enum ACTIONS { // Navigation Drawer NAVIGATION_DRAWER = 'Navigation Drawer', @@ -634,6 +649,19 @@ enum ACTIONS { SELECTS_ANNOUCEMENTS_NOTIFICATIONS = 'Selects Annoucements Notifications', } +export enum PERMISSION_RESULT { + GRANTED = 'granted', + DENIED = 'denied', + BLOCKED = 'blocked', + LIMITED = 'limited', + UNAVAILABLE = 'unavailable', +} + +export enum PERMISSION_TYPE { + CAMERA = 'Camera', + BLUETOOTH = 'Bluetooth', +} + const events = { APP_OPENED: generateOpt(EVENT_NAME.APP_OPENED), ERROR_SCREEN_VIEWED: generateOpt(EVENT_NAME.ERROR_SCREEN_VIEWED), @@ -833,17 +861,34 @@ const events = { EVENT_NAME.REVEAL_PRIVATE_KEY_COMPLETED, ), ANDROID_HARDWARE_KEYSTORE: generateOpt(EVENT_NAME.ANDROID_HARDWARE_KEYSTORE), + + // Hardware Wallet + ADD_HARDWARE_WALLET: generateOpt(EVENT_NAME.ADD_HARDWARE_WALLET), CONNECT_HARDWARE_WALLET: generateOpt(EVENT_NAME.CONNECT_HARDWARE_WALLET), - CONTINUE_QR_HARDWARE_WALLET: generateOpt( - EVENT_NAME.CONTINUE_QR_HARDWARE_WALLET, + + HARDWARE_WALLET_MARKETING: generateOpt(EVENT_NAME.HARDWARE_WALLET_MARKETING), + HARDWARE_WALLET_PERMISSION_REQUEST: generateOpt( + EVENT_NAME.HARDWARE_WALLET_PERMISSION_REQUEST, + ), + HARDWARE_WALLET_CONNECT_INSTRUCTIONS: generateOpt( + EVENT_NAME.HARDWARE_WALLET_CONNECT_INSTRUCTIONS, + ), + HARDWARE_WALLET_FOUND: generateOpt(EVENT_NAME.HARDWARE_WALLET_FOUND), + HARDWARE_WALLET_CONTINUE_CONNECTION: generateOpt( + EVENT_NAME.HARDWARE_WALLET_CONTINUE_CONNECTION, ), - CONNECT_HARDWARE_WALLET_SUCCESS: generateOpt( - EVENT_NAME.CONNECT_HARDWARE_WALLET_SUCCESS, + HARDWARE_WALLET_CONNECTION_RETRY: generateOpt( + EVENT_NAME.HARDWARE_WALLET_CONNECTION_RETRY, ), - QR_HARDWARE_TRANSACTION_CANCELED: generateOpt( - EVENT_NAME.QR_HARDWARE_TRANSACTION_CANCELED, + HARDWARE_WALLET_ACCOUNT_SELECTOR_OPEN: generateOpt( + EVENT_NAME.HARDWARE_WALLET_ACCOUNT_SELECTOR_OPEN, ), + HARDWARE_WALLET_ADD_ACCOUNT: generateOpt( + EVENT_NAME.HARDWARE_WALLET_ADD_ACCOUNT, + ), + HARDWARE_WALLET_FORGOTTEN: generateOpt(EVENT_NAME.HARDWARE_WALLET_FORGOTTEN), HARDWARE_WALLET_ERROR: generateOpt(EVENT_NAME.HARDWARE_WALLET_ERROR), + TOKEN_DETECTED: generateOpt(EVENT_NAME.TOKEN_DETECTED), TOKEN_IMPORT_CLICKED: generateOpt(EVENT_NAME.TOKEN_IMPORT_CLICKED), TOKEN_IMPORT_CANCELED: generateOpt(EVENT_NAME.TOKEN_IMPORT_CANCELED), @@ -1098,20 +1143,6 @@ const events = { // Experimental Settings SETTINGS_SECURITY_ALERTS_ENABLED: generateOpt(EVENT_NAME.SETTINGS_UPDATED), - // Ledger - CONNECT_LEDGER: generateOpt(EVENT_NAME.CONNECT_LEDGER), - CONTINUE_LEDGER_HARDWARE_WALLET: generateOpt( - EVENT_NAME.CONTINUE_LEDGER_HARDWARE_WALLET, - ), - CONNECT_LEDGER_SUCCESS: generateOpt(EVENT_NAME.CONNECT_LEDGER_SUCCESS), - LEDGER_HARDWARE_TRANSACTION_CANCELLED: generateOpt( - EVENT_NAME.LEDGER_HARDWARE_TRANSACTION_CANCELLED, - ), - LEDGER_HARDWARE_WALLET_ERROR: generateOpt( - EVENT_NAME.LEDGER_HARDWARE_WALLET_ERROR, - ), - HARDWARE_WALLET_FORGOTTEN: generateOpt(EVENT_NAME.HARDWARE_WALLET_FORGOTTEN), - // Remove an account ACCOUNT_REMOVED: generateOpt(EVENT_NAME.ACCOUNT_REMOVED), ACCOUNT_REMOVE_FAILED: generateOpt(EVENT_NAME.ACCOUNT_REMOVE_FAILED), diff --git a/app/core/HardwareWallets/analytics.test.ts b/app/core/HardwareWallets/analytics.test.ts new file mode 100644 index 00000000000..7727547adf8 --- /dev/null +++ b/app/core/HardwareWallets/analytics.test.ts @@ -0,0 +1,216 @@ +import { KeyringTypes } from '@metamask/keyring-controller'; +import { getConnectedDevicesCount } from './analytics'; + +jest.mock('../Engine', () => ({ + __esModule: true, + default: { + context: { + KeyringController: { + getKeyringsByType: jest.fn(), + }, + }, + }, +})); + +import Engine from '../Engine'; + +describe('getConnectedDevicesCount', () => { + const mockKeyringController = Engine.context.KeyringController; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns the total count of all hardware wallets', async () => { + (mockKeyringController.getKeyringsByType as jest.Mock).mockImplementation( + (type: KeyringTypes) => { + switch (type) { + case KeyringTypes.ledger: + return Promise.resolve([ + { type: 'ledger', getAccounts: () => Promise.resolve(['0x123']) }, + ]); + case KeyringTypes.qr: + return Promise.resolve([ + { type: 'qr', getAccounts: () => Promise.resolve(['0x456']) }, + ]); + default: + return Promise.resolve([]); + } + }, + ); + + const result = await getConnectedDevicesCount(); + + expect(result).toBe(2); + }); + + it('returns 0 when all keyring types have no devices', async () => { + (mockKeyringController.getKeyringsByType as jest.Mock).mockResolvedValue( + [], + ); + + const result = await getConnectedDevicesCount(); + + expect(result).toBe(0); + }); + + it('handles rejected promises gracefully and counts only successful ones', async () => { + (mockKeyringController.getKeyringsByType as jest.Mock).mockImplementation( + (type: KeyringTypes) => { + switch (type) { + case KeyringTypes.ledger: + return Promise.reject(new Error('Ledger not available')); + case KeyringTypes.qr: + return Promise.resolve([ + { type: 'qr', getAccounts: () => Promise.resolve(['0x456']) }, + ]); + default: + return Promise.resolve([]); + } + }, + ); + + const result = await getConnectedDevicesCount(); + + expect(result).toBe(1); + }); + + it('handles all promises being rejected', async () => { + (mockKeyringController.getKeyringsByType as jest.Mock).mockRejectedValue( + new Error('Keyring error'), + ); + + const result = await getConnectedDevicesCount(); + + expect(result).toBe(0); + }); + + it('returns 0 when keyrings have no accounts', async () => { + (mockKeyringController.getKeyringsByType as jest.Mock).mockImplementation( + (type: KeyringTypes) => { + switch (type) { + case KeyringTypes.ledger: + return Promise.resolve([ + { type: 'ledger', getAccounts: () => Promise.resolve([]) }, + ]); + case KeyringTypes.qr: + return Promise.resolve([ + { type: 'qr', getAccounts: () => Promise.resolve([]) }, + ]); + default: + return Promise.resolve([]); + } + }, + ); + + const result = await getConnectedDevicesCount(); + + expect(result).toBe(0); + }); + + it('returns 0 when getAccounts returns non-array', async () => { + (mockKeyringController.getKeyringsByType as jest.Mock).mockImplementation( + (type: KeyringTypes) => { + switch (type) { + case KeyringTypes.ledger: + return Promise.resolve([ + { type: 'ledger', getAccounts: () => Promise.resolve(null) }, + ]); + case KeyringTypes.qr: + return Promise.resolve([ + { type: 'qr', getAccounts: () => Promise.resolve(undefined) }, + ]); + default: + return Promise.resolve([]); + } + }, + ); + + const result = await getConnectedDevicesCount(); + + expect(result).toBe(0); + }); + + it('counts all hardware wallet types with accounts', async () => { + (mockKeyringController.getKeyringsByType as jest.Mock).mockImplementation( + (type: KeyringTypes) => { + switch (type) { + case KeyringTypes.ledger: + return Promise.resolve([ + { type: 'ledger', getAccounts: () => Promise.resolve(['0x123']) }, + { type: 'ledger', getAccounts: () => Promise.resolve(['0x789']) }, + ]); + case KeyringTypes.qr: + return Promise.resolve([ + { type: 'qr', getAccounts: () => Promise.resolve(['0x456']) }, + ]); + case KeyringTypes.lattice: + return Promise.resolve([ + { + type: 'lattice', + getAccounts: () => Promise.resolve(['0xabc']), + }, + ]); + case KeyringTypes.trezor: + return Promise.resolve([ + { type: 'trezor', getAccounts: () => Promise.resolve(['0xdef']) }, + ]); + case KeyringTypes.oneKey: + return Promise.resolve([ + { type: 'oneKey', getAccounts: () => Promise.resolve(['0x111']) }, + ]); + default: + return Promise.resolve([]); + } + }, + ); + + const result = await getConnectedDevicesCount(); + + expect(result).toBe(6); + }); + + it('filters out keyrings with null accounts', async () => { + (mockKeyringController.getKeyringsByType as jest.Mock).mockImplementation( + (type: KeyringTypes) => { + switch (type) { + case KeyringTypes.ledger: + return Promise.resolve([ + { type: 'ledger', getAccounts: () => Promise.resolve(['0x123']) }, + { type: 'ledger', getAccounts: () => Promise.resolve(null) }, + ]); + case KeyringTypes.qr: + return Promise.resolve([ + { type: 'qr', getAccounts: () => Promise.resolve(['0x456']) }, + ]); + default: + return Promise.resolve([]); + } + }, + ); + + const result = await getConnectedDevicesCount(); + + expect(result).toBe(2); + }); + + it('filters out keyrings with undefined accounts', async () => { + (mockKeyringController.getKeyringsByType as jest.Mock).mockImplementation( + (type: KeyringTypes) => { + switch (type) { + case KeyringTypes.ledger: + return Promise.resolve([ + { type: 'ledger', getAccounts: () => Promise.resolve(['0x123']) }, + { type: 'ledger', getAccounts: () => Promise.resolve(undefined) }, + ]); + default: + return Promise.resolve([]); + } + }, + ); + + const result = await getConnectedDevicesCount(); + + expect(result).toBe(1); + }); +}); diff --git a/app/core/HardwareWallets/analytics.ts b/app/core/HardwareWallets/analytics.ts new file mode 100644 index 00000000000..77a0576029b --- /dev/null +++ b/app/core/HardwareWallets/analytics.ts @@ -0,0 +1,30 @@ +import { KeyringTypes } from '@metamask/keyring-controller'; +import Engine from '../Engine'; +import { Json, Keyring } from '@metamask/utils'; + +export const getConnectedDevicesCount = async (): Promise => { + const { KeyringController } = Engine.context; + + const keyringResults = await Promise.allSettled([ + KeyringController.getKeyringsByType(KeyringTypes.ledger), + KeyringController.getKeyringsByType(KeyringTypes.qr), + KeyringController.getKeyringsByType(KeyringTypes.lattice), + KeyringController.getKeyringsByType(KeyringTypes.trezor), + KeyringController.getKeyringsByType(KeyringTypes.oneKey), + ]); + + let count = 0; + for (const result of keyringResults) { + if (result.status === 'fulfilled' && Array.isArray(result.value)) { + // TODO: use type from keyring-utils + const keyrings = result.value as unknown as Keyring[]; + for (const keyring of keyrings) { + const accounts = await keyring.getAccounts(); + if (Array.isArray(accounts) && accounts.length > 0) { + count++; + } + } + } + } + return count; +}; diff --git a/app/core/QrKeyring/QrKeyring.ts b/app/core/QrKeyring/QrKeyring.ts index 077897912b7..3cd0cbadeed 100644 --- a/app/core/QrKeyring/QrKeyring.ts +++ b/app/core/QrKeyring/QrKeyring.ts @@ -21,7 +21,7 @@ export const withQrKeyring = async ( metadata: KeyringMetadata; }) => Promise, ): Promise => - Engine.context.KeyringController.withKeyring( + await Engine.context.KeyringController.withKeyring( { type: ExtendedKeyringTypes.qr }, operation, // TODO: Refactor this to stop creating the keyring on-demand diff --git a/app/util/hardwareWallet/deviceNameUtils.test.ts b/app/util/hardwareWallet/deviceNameUtils.test.ts new file mode 100644 index 00000000000..2b7cba11167 --- /dev/null +++ b/app/util/hardwareWallet/deviceNameUtils.test.ts @@ -0,0 +1,205 @@ +import { + sanitizeDeviceName, + ledgerDeviceUUIDToModelName, + LEDGER_DEVICE_BLE_UUIDS_TO_MODEL_NAME, +} from './deviceNameUtils'; + +describe('sanitizeDeviceName', () => { + describe('Ledger Flex devices', () => { + it('returns "Ledger Flex" when device name is "Ledger Flex"', () => { + const result = sanitizeDeviceName('Ledger Flex'); + + expect(result).toBe('Ledger Flex'); + }); + + it('returns "Ledger Flex" when device name includes version number', () => { + const result = sanitizeDeviceName('Ledger Flex 1.0.0'); + + expect(result).toBe('Ledger Flex'); + }); + + it('returns "Ledger Flex" when device name includes additional text', () => { + const result = sanitizeDeviceName('Ledger Flex Plus Edition'); + + expect(result).toBe('Ledger Flex'); + }); + }); + + describe('Ledger Nano X devices', () => { + it('returns "Ledger Nano X" when device name is "Ledger Nano X"', () => { + const result = sanitizeDeviceName('Ledger Nano X'); + + expect(result).toBe('Ledger Nano X'); + }); + + it('returns "Ledger Nano X" when device name includes version number', () => { + const result = sanitizeDeviceName('Ledger Nano X 2.1.0'); + + expect(result).toBe('Ledger Nano X'); + }); + + it('returns "Ledger Nano X" when device name includes additional text', () => { + const result = sanitizeDeviceName('Ledger Nano X Special Edition'); + + expect(result).toBe('Ledger Nano X'); + }); + }); + + describe('Ledger Nano devices', () => { + it('returns "Ledger Nano" when device name is "Ledger Nano"', () => { + const result = sanitizeDeviceName('Ledger Nano'); + + expect(result).toBe('Ledger Nano'); + }); + + it('returns "Ledger Nano" when device name is "Ledger Nano S"', () => { + const result = sanitizeDeviceName('Ledger Nano S'); + + expect(result).toBe('Ledger Nano'); + }); + + it('returns "Ledger Nano" when device name is "Ledger Nano S Plus"', () => { + const result = sanitizeDeviceName('Ledger Nano S Plus'); + + expect(result).toBe('Ledger Nano'); + }); + + it('returns "Ledger Nano" when device name includes version number', () => { + const result = sanitizeDeviceName('Ledger Nano 1.6.1'); + + expect(result).toBe('Ledger Nano'); + }); + }); + + describe('non-Ledger devices', () => { + it('returns original name for Keystone devices', () => { + const result = sanitizeDeviceName('Keystone Pro'); + + expect(result).toBe('Keystone Pro'); + }); + + it('returns original name for AirGap Vault devices', () => { + const result = sanitizeDeviceName('AirGap Vault'); + + expect(result).toBe('AirGap Vault'); + }); + + it('returns original name for other QR-based devices', () => { + const result = sanitizeDeviceName('CoolWallet'); + + expect(result).toBe('CoolWallet'); + }); + }); + + describe('edge cases', () => { + it('returns empty string when device name is undefined', () => { + const result = sanitizeDeviceName(undefined); + + expect(result).toBe(''); + }); + + it('returns empty string when device name is empty string', () => { + const result = sanitizeDeviceName(''); + + expect(result).toBe(''); + }); + + it('returns original name when device name does not match known patterns', () => { + const result = sanitizeDeviceName('Unknown Device'); + + expect(result).toBe('Unknown Device'); + }); + }); + + describe('priority order', () => { + it('matches "Ledger Flex" before "Ledger Nano X"', () => { + const result = sanitizeDeviceName('Ledger Flex X'); + + expect(result).toBe('Ledger Flex'); + }); + + it('matches "Ledger Nano X" before "Ledger Nano"', () => { + const result = sanitizeDeviceName('Ledger Nano X S'); + + expect(result).toBe('Ledger Nano X'); + }); + }); +}); + +describe('ledgerDeviceUUIDToModelName', () => { + describe('known device UUIDs', () => { + it('returns UUID for Ledger Nano X device', () => { + const uuid = LEDGER_DEVICE_BLE_UUIDS_TO_MODEL_NAME.LEDGER_NANO_X; + + const result = ledgerDeviceUUIDToModelName(uuid); + + expect(result).toBe('13d63400-2c97-0004-0000-4c6564676572'); + }); + + it('returns UUID for Ledger Nano STAx device', () => { + const uuid = LEDGER_DEVICE_BLE_UUIDS_TO_MODEL_NAME.LEDGER_NANO_STAx; + + const result = ledgerDeviceUUIDToModelName(uuid); + + expect(result).toBe('13d63400-2c97-6004-0000-4c6564676572'); + }); + + it('returns UUID for Ledger Flex device', () => { + const uuid = LEDGER_DEVICE_BLE_UUIDS_TO_MODEL_NAME.LEDGER_FLEX; + + const result = ledgerDeviceUUIDToModelName(uuid); + + expect(result).toBe('13d63400-2c97-3004-0000-4c6564676572'); + }); + }); + + describe('unknown device UUIDs', () => { + it('returns "Unknown" for unrecognized UUID', () => { + const unknownUuid = '00000000-0000-0000-0000-000000000000'; + + const result = ledgerDeviceUUIDToModelName(unknownUuid); + + expect(result).toBe('Unknown'); + }); + + it('returns "Unknown" for random string', () => { + const randomString = 'not-a-valid-uuid'; + + const result = ledgerDeviceUUIDToModelName(randomString); + + expect(result).toBe('Unknown'); + }); + + it('returns "Unknown" for empty string', () => { + const result = ledgerDeviceUUIDToModelName(''); + + expect(result).toBe('Unknown'); + }); + }); + + describe('edge cases', () => { + it('returns "Unknown" for partial UUID match', () => { + const partialUuid = '13d63400-2c97'; + + const result = ledgerDeviceUUIDToModelName(partialUuid); + + expect(result).toBe('Unknown'); + }); + + it('returns "Unknown" for UUID with different casing', () => { + const uppercaseUuid = '13D63400-2C97-0004-0000-4C6564676572'; + + const result = ledgerDeviceUUIDToModelName(uppercaseUuid); + + expect(result).toBe('Unknown'); + }); + + it('returns "Unknown" for UUID with extra whitespace', () => { + const uuidWithSpace = ' 13d63400-2c97-0004-0000-4c6564676572 '; + + const result = ledgerDeviceUUIDToModelName(uuidWithSpace); + + expect(result).toBe('Unknown'); + }); + }); +}); diff --git a/app/util/hardwareWallet/deviceNameUtils.ts b/app/util/hardwareWallet/deviceNameUtils.ts new file mode 100644 index 00000000000..b5a0eadda1c --- /dev/null +++ b/app/util/hardwareWallet/deviceNameUtils.ts @@ -0,0 +1,46 @@ +/** + * Sanitizes device names for analytics tracking by removing any additional text + * after known base device names. This ensures consistent device_model tracking + * across different firmware versions or device variants. + * + * @param deviceName - The raw device name string from the hardware device + * @returns The sanitized device name with any suffix removed for known devices + * + * @example + * sanitizeDeviceName('Ledger Nano X 2.1.0') // returns 'Ledger Nano X' + * sanitizeDeviceName('Ledger Flex 1.0') // returns 'Ledger Flex' + * sanitizeDeviceName('Ledger Nano S Plus') // returns 'Ledger Nano' (strips "S Plus") + * sanitizeDeviceName('Keystone Pro') // returns 'Keystone Pro' (unchanged) + */ +export const sanitizeDeviceName = (deviceName: string | undefined): string => { + if (!deviceName) { + return ''; + } + + const baseDeviceNames = ['Ledger Flex', 'Ledger Nano X', 'Ledger Nano']; + + for (const baseName of baseDeviceNames) { + if (deviceName.startsWith(baseName)) { + return baseName; + } + } + + return deviceName; +}; + +export enum LEDGER_DEVICE_BLE_UUIDS_TO_MODEL_NAME { + LEDGER_NANO_X = '13d63400-2c97-0004-0000-4c6564676572', + LEDGER_NANO_STAx = '13d63400-2c97-6004-0000-4c6564676572', + LEDGER_FLEX = '13d63400-2c97-3004-0000-4c6564676572', +} + +export const ledgerDeviceUUIDToModelName = (deviceUUID: string): string => { + const deviceModelName = Object.values( + LEDGER_DEVICE_BLE_UUIDS_TO_MODEL_NAME, + ).find((uuid) => uuid === deviceUUID); + if (deviceModelName) { + return deviceModelName; + } + + return 'Unknown'; +};