DkrxhaAdQ~;`2X>#sG**Ex>(jqR#vXQ1 zQpz<=6*}iEs}t=2MJoZJ(Q3~13roK)0DJiLbAsT0Et9)vE<2YTO~sv_h?nx;@kyCc zD<&JTS8v|4Z<4$I5Fkt=iofNj^EFXdbap_0Q9}XcQ7aU7KL=pEW0hcNfISU!o>9LN zcc87%ixC2w{JH5{Snf{2N~H#K0%w?!=_Wb$A8xFbptH58L}DD_a_q9$W@x7D#?!|V zi6kX$<()`nt#!QQa=17BA-;W{t9NKv$NSzrQCkygDhcB-Jw_Q@yNW}#O^?{D(fz0x zWt0XgDasW|qbel=)S+a!FFp$n{-d+YS)jZybg|b!qE)q|j$^3TnK>PEwLuZs^p(E%(1*WZ@v5%kG0NFu<(|8vF?^6q>vLS8%VIf; z8$@)1sU2Uh^=ODp(ut_mMYR@_$Hfzg=bgfOvbNuES6N`Z^0!KPY<~UIbY}2XvzG>s zoeF-V-LB~b0dcmXk^4S?YsTm!tCOMu$;>+G6l3bUjP@6n`fa(>AQ5vdcLo-|wDvq! z)csVPt;_e#IyN{o{JXG`L}*Scm|wpuw=TJZ3DjhT; +Yel4r&byZRRe^zlTT #zn6dMy4Hu5Y|erF-Uo z9I;1J9d6welOwDVnKz?0ktn{;Gx_(iDeUbs_kTL%zzw7OKe#@DQqb8n+6Kt;g0tzl z34Z|_wzJZ9ve4O#9 iVAvy0u@LGY(&Z8-r$D=N+lA5=ZO z%QF#gTPn*Rr{ec6zwNnr!N{aZvSf37)bMO+J;da-s7>{+XA)zgH4qV?u3~}2k5u@= z>N9;4_l_noqAPBZ14lHin@)2 D}0`P>;xG2G+^x^Lh|&hw~{;P8XC85peGU3jc;eVqDzb?(U-M&W#0F7Ov>P zSu2^(Sj9)r#Xq`37lv;XGbVO%-;?=zbN}aE2f65y)Z}41>1|%%knqcA`2{E)?k%Qj zWUb{mn>0i_=73M$30&ZwT;4<+4bT2rBw_j9d*+g?)Kosm8Rykz19!ae3AVW9ZR~v{ zOK+tU(4>wMVrDzL08x;emey2}g@VQ`Kt}gtI;-RR#($wjeovm1U(B+~MSr%b_mv6} zI(gCj{uT4=VW#K|O8IE-32Lvee?ppY20xyQ-{k8|IT(m^KdBazwxRuO@z7iM_~d9| zXWUEux-L||q(W;;m Ph@t4-uY?R)aO}nC>oKVxV4vkU(2f(hcU3KEjekwHW z=W&LJ61!RuxCv^_9cg^VX!56tI@eLw1~k-E>252<`IQ61pCHYq?|@rMVc*TVjDEs8 zQXRqh$b 7#!#j9s$eegJW5& z8HXBj@mwRn
#m|i_=g4zZb{`^94IXJc9s&nXmA5@&?F@Ud?OD!ojz1nd znX!VPpK75|7GK}O3?iM4dqC~Z#&MQV+JWbd7YqXhtVPxhkS=h8*4<}99;M>div*e3 zI|1%CG233NvES pW?2zCK!zY1QKZRHm+ZLAe6WHuP;D)W3T&ZT$)7e4A zWeU>$_f16+B>DlYe+tl5ab 5FQMKY~=FG9Zl^Ve13GxPIPnTpY_kS%c$-nhqq{LfrkOe$W-##oUH-$4I zuKG8pHl4bfCg-R5G2#Nm@JGlIF7odvWS#SfdX`6nu743}E@3ackXQXyo}`vvL?D+q z?_Gs^SvFfna9MlbmutW44M=gYl@OJ*!g@rcnvDZALOwQ8gN>WDl{!#8px=|5uIEM* zNRsGgzo3RE_Z4oyO@-Zf@a^B!C%ghpq4~fBfL6msFsE2sf5E3lOU^J0VmVt0;O|pd zn>SM3acu$cS;d7hGxwv};!2wjk~sP;KPbfja^Z<$* +b3RCRcdw$c{qlyTzpiK4)PVCHFn~AWM8F4Bl~b6}*NCJAb@tr?$jh zEY2~cj=FX63IGtYy>`3I@68Z3ysvJCty350P__II^|h1U8wp+>oJ>7PBr@PX&Sej) z %g(tm@zd2mH}Y!>*2Vdl87j@|Y{S za@d4nmuH-r`d46vwv{19QyFy>H{$rMdL=6C;2D57-5Z~4zD~5Q-qgpZj?O7I9O3IH z9!k(Ii;x{&h!t)B#IX$R=|9>6u!E|8@^LNp5D6%@8QP;OOGUH8AH}s+WZm^*J&}$m ze8$*jI#NdUolcS&p~W%U?IvboRG&L`c*od}VJ wg+e!Et7nUpl19 zf(6D#`8_>to@iBuxtL1HLgO<;|M)m*bHK|532T=4@Bo^1OiWCf8W6hSM;|3^r#G2i zf?8D;l%NO$EKcN)&Is;g@~S0euEQ6W8SAeYojMYu$p^ uGf>TzJY{l0lH3pX^JF3Um+Z@3{7*seREGZu z7pHwaSiM6K3(m2Q--ND>H9!D;@6%Yv$pC&3g+PI`osO7t-qgcMArhs(>5gAuM_pA+ zpokfjfTTMk8LO|@!@o@QY`?qlw@Y 8qiYID^{v^P~HXZNnE}>Q?qjAM6q9Bh#{S$nh`W*e#`Ds{`#TWov^csCW z#q6SoSTg%tK`I(kY=F2xO95lxz8ZxUwKQ&Em#w9utiUdH6je`$Sf_|I9tr*mkw%gX zDKhlocVF{2F3Fot!uvftxiP_APz lP=xnr{MzV(aqNxEFt6;xy_0#SB1|Xhdd=sM*mD)$-~sG^^IPOQPW2$B%U& zNBF57r60lc$11~>9=~LF#JT<;ta_Fb-J6H%M`A!X9lqM9GaA0wYb$%OC62TCj8b)( zg=-Zm;-1Oqf2;h06Uo;3>NO|^LtdbFF-!ta-4K%I$f&!{H=RvWRY5>uMHvqK$eu>G zD#5ciolr|v9c>E(*8uz1KyK&y5M?g7eR-=zHj&Ek%}kcw@%W7IMO!4Zvpair=RUsx zmb;;lt)#N&H709SYmPnyfAlj=6l;Fl1rKHLN5YZOIYm1~LJcsT5fjNUCwOE))vL$) zFk!jVD(5zpGg1cl-3`xb72nn9?4ds)M8Yo_Glgs_`ALb~v%`=MnvkNdOd?2P ct^ithz=x%{ zD*2B9lBt;)4O(9sYpx9j6bogk7oO?N9i2O^)j5_2L~}I<_tqadfr&OuVx1AtV_TAs z&sCk>#~pOS!9SS$gEnnrrN;u8W1L+@{TpcJ6x*UrWMaADkC_Sp)}zsEZ>AHXIe_lY zhpO({mRzQxcX+5#Dlk-W??4=n<=e@FPn;b`uyv*inhT0VmDt0>0NpL$eGEr;+SrAk zG#%=dH`P&FXFq7<(AM@HP|=@$JLL_KAapIvxC`N5obSYr$NMw@Jjeh*%PL)N4=}~9 zcoF65Zg?YN{%&lF5n)<{o0i6BUvgk*1c-I)`FPASF<5TA1B~P3q@<)^;jG40eep+t z$+tiw`Akd_6G%{fI68f5ywJ+p8AI?@uydr?* kLu(|%SuX0UWs&tM6%TfaWcQxFy>s#=4iW0!PeRWUb@+Ym{ZBp8Z&PlH(Q z>?7%0kxldGXiJaE5bX*hpw@4QqCdCeQC{%8YY`M8nX6iIG(GzRoc+Zqh48+`{MRZg z?j$LuTC2W{-XDHEAqbYTn^j9@&kuR0WH+1HrH^Mxd1*>9t1<7bE9*XIXZs`eQ#ZdD ztipCbf%C-K(I+wgVgC4$W{&mWdrLH8Us&1}6AMd3r;km)j1GB}f7<9Arr$ OM0 @)L3qfUM|0x?0o+LOp!*KYy(v3>=wwiopt=?$ z2X)f$dm#;T?ANL#9NF(*Rk&igN%7?k{|D#er#}e2hr7qQUc!|#j{LZwyA6py65%PM z|6Gnb1wwfAu)$w!>zvf;O>`Ar?0>GgaQt!;?)t;&=~N=6>gKM7%&gAMEBt26IMM2l zW^r(JtSG^m!d!b|fNXypqfeQ;5+#6A_|L($_-r1Z=jVA%PUL6uaRBX_TVR?W;%&5R z>hIDPnU>PXYS^q1Qxf#p74@+KAubOrP#|4z-xxA-?dR%!e}Q-+;8%Il`%*DI>GqS5 zR!ik Bs$TIX}y$G%+8*-w;5s!Z<|sv(8lUaA|?S?+|)X3Cr|9xl0SMzdS8SVdu}Q zIhccfP*ijj>=(XX^9z%`(LSnaA;O {$kKax4YS-0633Q;lZJ*phQe7ZV5PH>QIJRV1wx$`eu)2`gy9a1oTBau%c zHfC3u{V(eAG_J~l)<@{ps#)q+=guqPk@_8RzQ^B>@^j+n;qz6Zk22a8c97UX%s8W0 z*@+BxiC=i`&w*nS57#|Cy>CbJK1wBaJQ6k5qK nu_dRLA(2embN oS4 z5F;!RwJldB*vR>)>-o2PkH*?Z*-dm;g+s1=`|E-_Gao76s;MHilq>+kv&<}mH!&lZ zD+F7zigzVX&Er!LCs)zr##-cW-|~hEv7RN0L`d49>uc)Ed}*+ht77S4>B2@EoheQw zH(dEDQ=y@dqH;Gf{TM!H!;eMd;_LP2@-i7I>c1ZF<}&S<`2AVX=k;=x+^P~Q;hgE$ z!CyYi)glg8mGJ+VikcRw;iu#dNIO;NJZT>}wP2K{L-nI#F+Lij4pS7KNa@VvOsP5q z=Zjk#I)Ya31Oq*sJvv$|U)1k)M-G5;jQe|7!W<@s1(_bt^sc68fnWP? J;6 z9Q3(hO_aH?$SSxi2`DExszVyc m;!l6Jx)IP<9FJ z11Hec=9k#K>Dx3n2#m}NZvNe>PdPX>hjiI|H(2p~;>&WX@^Ju yzFWnll5Wk4%7fm+<1{G^0oPxy;|BVL&{&kz1i#r**-U^)b@e%9$i5T=# zt;0m_+CHYF#ydLmz-fo3-_Q<4?8b^+eR&q{O^e(v*V}JT^Y|-(EatiuHXpsI9LQZa zJNx6gxRWV&b;Y%HDl0Vddr}yU)i{$m`>Kz#xpefCdyzoznWQ}2Ws}G?ump)pG&ptP zj}FT@?VhBe3!OH<_ELpliH^>OT}p}OAR;v-%zafF(;8h;AUWJYCkW-+`%2p*SXyLa zMhbMLv8>Yx IjpmcD%w#hO@-kvSU{{}Gl#X4qyJZKv#GPd zl5t?k9ZIMZDy!_6R88xt*b6Dtv;#+6=jYZpGz2Z(33|a1*QW)yM+2+OMS2PBAHp1{ zX#C3e$9N6NhL&o))PhRRRYJW~O*qCu`cZVK=uiq14gRhXGRi$eb9Xkf89808UP7JF zgvXSsjK6Fqa&%I&UZ1MMoO zA61jFz;y9D2=pwXWyUactD?^El=wu)UWDD+FEi#a^panMB++g+tG9W~B?CPL#O*#V sTWSJ_-T_UTSpT0ts{LQ_|Lxs}2(~ai8&s{l`u9XpR#m26$~^4<0KxdH^Z)<= diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 01a9c526cf6..a9da799146a 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -414,8 +414,8 @@ PODS: - hermes-engine (0.76.9): - hermes-engine/Pre-built (= 0.76.9) - hermes-engine/Pre-built (0.76.9) - - libavif/core (1.0.0) - - libavif/libdav1d (1.0.0): + - libavif/core (0.11.1) + - libavif/libdav1d (0.11.1): - libavif/core - libdav1d (>= 0.6.0) - libdav1d (1.2.0) @@ -2097,9 +2097,6 @@ PODS: - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - Yoga - - react-native-sdk (11.2.0): - - React - - VeriffSDK (= 8.12.0) - react-native-slider (4.5.6): - DoubleConversion - glog @@ -2945,7 +2942,7 @@ PODS: - SDWebImage (5.19.7): - SDWebImage/Core (= 5.19.7) - SDWebImage/Core (5.19.7) - - SDWebImageAVIFCoder (0.11.1): + - SDWebImageAVIFCoder (0.11.0): - libavif/core (>= 0.11.0) - SDWebImage (~> 5.10) - SDWebImageSVGCoder (1.7.0): @@ -2957,7 +2954,6 @@ PODS: - SocketRocket (0.7.1) - sovran-react-native (1.0.4): - React-Core - - VeriffSDK (8.12.0) - VisionCamera (4.6.4): - VisionCamera/Core (= 4.6.4) - VisionCamera/React (= 4.6.4) @@ -3067,7 +3063,6 @@ DEPENDENCIES: - react-native-release-profiler (from `../node_modules/react-native-release-profiler`) - react-native-render-html (from `../node_modules/react-native-render-html`) - react-native-safe-area-context (from `../node_modules/react-native-safe-area-context`) - - "react-native-sdk (from `../node_modules/@veriff/react-native-sdk`)" - "react-native-slider (from `../node_modules/@react-native-community/slider`)" - react-native-tcp-socket (from `../node_modules/react-native-tcp-socket`) - react-native-video (from `../node_modules/react-native-video`) @@ -3162,7 +3157,6 @@ SPEC REPOS: - SDWebImageSVGCoder - Sentry - SocketRocket - - VeriffSDK - YttriumWrapper EXTERNAL SOURCES: @@ -3353,8 +3347,6 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native-render-html" react-native-safe-area-context: :path: "../node_modules/react-native-safe-area-context" - react-native-sdk: - :path: "../node_modules/@veriff/react-native-sdk" react-native-slider: :path: "../node_modules/@react-native-community/slider" react-native-tcp-socket: @@ -3531,7 +3523,7 @@ SPEC CHECKSUMS: GZIP: 3c0abf794bfce8c7cb34ea05a1837752416c8868 GzipSwift: 893f3e48e597a1a4f62fafcb6514220fcf8287fa hermes-engine: 9e868dc7be781364296d6ee2f56d0c1a9ef0bb11 - libavif: 5f8e715bea24debec477006f21ef9e95432e254d + libavif: 84bbb62fb232c3018d6f1bab79beea87e35de7b7 libdav1d: 23581a4d8ec811ff171ed5e2e05cd27bad64c39f lottie-ios: e047b1d2e6239b787cc5e9755b988869cf190494 lottie-react-native: 7f3fc3f396b1d6c7b1454b77596bd2ad3151871e @@ -3597,7 +3589,6 @@ SPEC CHECKSUMS: react-native-release-profiler: fdca7c73a6e6a03fa2a343f5088fce4787e8d4ee react-native-render-html: 5afc4751f1a98621b3009432ef84c47019dcb2bd react-native-safe-area-context: c68127652d8b9a26a28ac9597167a3ad90bcd713 - react-native-sdk: 6eb6c9de60e510a9ea93ddc09a3822cddf04f8cb react-native-slider: e4b7f9d0616032ec2909ba073731eabcde242256 react-native-tcp-socket: 120072c8020262032773f80f0daaf3964aaa08a1 react-native-video: 3bb92b90b2774144fac7d43d52d11b936e8a14ec @@ -3658,13 +3649,12 @@ SPEC CHECKSUMS: RNSVG: 3a1cce2e940268a7d3554e3cf2bbd2195871f4fe RNVectorIcons: 3bf5f38dcb1aaf587c4101e9f3fcad5c8f5a88b2 SDWebImage: 8a6b7b160b4d710e2a22b6900e25301075c34cb3 - SDWebImageAVIFCoder: afe194a084e851f70228e4be35ef651df0fc5c57 + SDWebImageAVIFCoder: 00310d246aab3232ce77f1d8f0076f8c4b021d90 SDWebImageSVGCoder: 15a300a97ec1c8ac958f009c02220ac0402e936c segment-analytics-react-native: 6f98edf18246782ee7428c5380c6519a3d2acf5e Sentry: 2cbbe3592f30050c60e916c63c7f5a2fa584005e SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748 sovran-react-native: e4721a564ee6ef5b5a0d901bc677018cf371ea01 - VeriffSDK: 4323cc7d0152c107f40795881c92a41cf5a80f29 VisionCamera: f56eaedde0d3fa095143b78374d29e89e71735f9 Yoga: feb4910aba9742cfedc059e2b2902e22ffe9954a YttriumWrapper: cbddb60c835ebc4232d9f57064084ab30686a18e diff --git a/jest.config.js b/jest.config.js index 9eaa1b8901b..de7a67804c0 100644 --- a/jest.config.js +++ b/jest.config.js @@ -29,7 +29,7 @@ const config = { setupFilesAfterEnv: [' /app/util/test/testSetup.js'], testEnvironment: 'jest-environment-node', transformIgnorePatterns: [ - 'node_modules/(?!((@metamask/)?(@react-native|react-native|redux-persist-filesystem|@react-navigation|@react-native-community|@react-native-masked-view|react-navigation|react-navigation-redux-helpers|@sentry|d3-color|d3-shape|d3-path|d3-scale|d3-array|d3-time|d3-format|d3-interpolate|d3-selection|d3-axis|d3-transition|internmap|react-native-wagmi-charts|react-native-nitro-modules|@notifee|expo-file-system|expo-modules-core|expo(nent)?|@expo(nent)?/.*)|@noble/.*|@nktkas/hyperliquid|@metamask/design-system-twrnc-preset|@metamask/design-system-react-native|@metamask/native-utils|@metamask/smart-transactions-controller|@tommasini/react-native-scrollable-tab-view|@veriff/react-native-sdk))', + 'node_modules/(?!((@metamask/)?(@react-native|react-native|redux-persist-filesystem|@react-navigation|@react-native-community|@react-native-masked-view|react-navigation|react-navigation-redux-helpers|@sentry|d3-color|d3-shape|d3-path|d3-scale|d3-array|d3-time|d3-format|d3-interpolate|d3-selection|d3-axis|d3-transition|internmap|react-native-wagmi-charts|react-native-nitro-modules|@notifee|expo-file-system|expo-modules-core|expo(nent)?|@expo(nent)?/.*)|@noble/.*|@nktkas/hyperliquid|@metamask/design-system-twrnc-preset|@metamask/design-system-react-native|@metamask/native-utils|@metamask/smart-transactions-controller|@tommasini/react-native-scrollable-tab-view))', ], transform: { '^.+\\.[jt]sx?$': ['babel-jest', { configFile: './babel.config.tests.js' }], diff --git a/package.json b/package.json index 1411b231f28..90abdc8bcc8 100644 --- a/package.json +++ b/package.json @@ -338,7 +338,6 @@ "@types/he": "^1.2.3", "@types/react-test-renderer": "^18.0.0", "@types/readable-stream": "^2.3.9", - "@veriff/react-native-sdk": "^11.2.0", "@viem/anvil": "^0.0.10", "@walletconnect/core": "^2.23.0", "@walletconnect/react-native-compat": "^2.23.0", diff --git a/yarn.lock b/yarn.lock index e72241965c2..cb7ebdcdf5d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -19747,16 +19747,6 @@ __metadata: languageName: node linkType: hard -"@veriff/react-native-sdk@npm:^11.2.0": - version: 11.2.0 - resolution: "@veriff/react-native-sdk@npm:11.2.0" - peerDependencies: - react: ^19.1.0 - react-native: ^0.81.0 - checksum: 10/39f2f546db15a1360cef131d868143462610555413410df669b6266c1d28f42f82c675c1ff1f3ce060f2ed9fba6166a2332e2c2e64c9dc976ff7b28feec8434b - languageName: node - linkType: hard - "@viem/anvil@npm:^0.0.10": version: 0.0.10 resolution: "@viem/anvil@npm:0.0.10" @@ -35789,7 +35779,6 @@ __metadata: "@types/valid-url": "npm:^1.0.4" "@typescript-eslint/eslint-plugin": "npm:^7.10.0" "@typescript-eslint/parser": "npm:^7.10.0" - "@veriff/react-native-sdk": "npm:^11.2.0" "@viem/anvil": "npm:^0.0.10" "@walletconnect/core": "npm:^2.23.0" "@walletconnect/react-native-compat": "npm:^2.23.0" From 2757e5591ffd86c57b08148a28672b1838e9ab0d Mon Sep 17 00:00:00 2001 From: metamaskbot Date: Fri, 27 Feb 2026 21:56:10 +0000 Subject: [PATCH 063/131] [skip ci] Bump version number to 3843 --- android/app/build.gradle | 2 +- bitrise.yml | 4 ++-- ios/MetaMask.xcodeproj/project.pbxproj | 12 ++++++------ 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index 1088502c8d5..7ab2b94773b 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -188,7 +188,7 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion versionName "7.67.0" - versionCode 3842 + versionCode 3843 testBuildType System.getProperty('testBuildType', 'debug') testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" manifestPlaceholders.MM_BRANCH_KEY_TEST = "$System.env.MM_BRANCH_KEY_TEST" diff --git a/bitrise.yml b/bitrise.yml index 77a2900ec2c..9c11a62bb77 100644 --- a/bitrise.yml +++ b/bitrise.yml @@ -3519,13 +3519,13 @@ app: VERSION_NAME: 7.67.0 - opts: is_expand: false - VERSION_NUMBER: 3842 + VERSION_NUMBER: 3843 - opts: is_expand: false FLASK_VERSION_NAME: 7.67.0 - opts: is_expand: false - FLASK_VERSION_NUMBER: 3842 + FLASK_VERSION_NUMBER: 3843 - opts: is_expand: false ANDROID_APK_LINK: '' diff --git a/ios/MetaMask.xcodeproj/project.pbxproj b/ios/MetaMask.xcodeproj/project.pbxproj index 07e762cf190..dd4cc9b8838 100644 --- a/ios/MetaMask.xcodeproj/project.pbxproj +++ b/ios/MetaMask.xcodeproj/project.pbxproj @@ -1281,7 +1281,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 3842; + CURRENT_PROJECT_VERSION = 3843; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1350,7 +1350,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 3842; + CURRENT_PROJECT_VERSION = 3843; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1416,7 +1416,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 3842; + CURRENT_PROJECT_VERSION = 3843; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1483,7 +1483,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 3842; + CURRENT_PROJECT_VERSION = 3843; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1646,7 +1646,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 3842; + CURRENT_PROJECT_VERSION = 3843; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1716,7 +1716,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 3842; + CURRENT_PROJECT_VERSION = 3843; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; From b1c3e3e9b847fe9890063dac400db8686ddc9818 Mon Sep 17 00:00:00 2001 From: metamaskbot Date: Fri, 27 Feb 2026 22:03:50 +0000 Subject: [PATCH 064/131] [skip ci] Bump version number to 3844 --- android/app/build.gradle | 2 +- bitrise.yml | 4 ++-- ios/MetaMask.xcodeproj/project.pbxproj | 12 ++++++------ 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index 7ab2b94773b..532cbb43739 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -188,7 +188,7 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion versionName "7.67.0" - versionCode 3843 + versionCode 3844 testBuildType System.getProperty('testBuildType', 'debug') testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" manifestPlaceholders.MM_BRANCH_KEY_TEST = "$System.env.MM_BRANCH_KEY_TEST" diff --git a/bitrise.yml b/bitrise.yml index 9c11a62bb77..f0f5aa5dccf 100644 --- a/bitrise.yml +++ b/bitrise.yml @@ -3519,13 +3519,13 @@ app: VERSION_NAME: 7.67.0 - opts: is_expand: false - VERSION_NUMBER: 3843 + VERSION_NUMBER: 3844 - opts: is_expand: false FLASK_VERSION_NAME: 7.67.0 - opts: is_expand: false - FLASK_VERSION_NUMBER: 3843 + FLASK_VERSION_NUMBER: 3844 - opts: is_expand: false ANDROID_APK_LINK: '' diff --git a/ios/MetaMask.xcodeproj/project.pbxproj b/ios/MetaMask.xcodeproj/project.pbxproj index dd4cc9b8838..71640427dcf 100644 --- a/ios/MetaMask.xcodeproj/project.pbxproj +++ b/ios/MetaMask.xcodeproj/project.pbxproj @@ -1281,7 +1281,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 3843; + CURRENT_PROJECT_VERSION = 3844; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1350,7 +1350,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 3843; + CURRENT_PROJECT_VERSION = 3844; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1416,7 +1416,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 3843; + CURRENT_PROJECT_VERSION = 3844; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1483,7 +1483,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 3843; + CURRENT_PROJECT_VERSION = 3844; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1646,7 +1646,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 3843; + CURRENT_PROJECT_VERSION = 3844; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1716,7 +1716,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 3843; + CURRENT_PROJECT_VERSION = 3844; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; From 0c239919032de027a049fd35a1f8234f1840ccc2 Mon Sep 17 00:00:00 2001 From: MetaMask Bot <37885440+metamaskbot@users.noreply.github.com> Date: Fri, 27 Feb 2026 19:22:23 -0330 Subject: [PATCH 065/131] release: release-changelog/7.67.0 (#26313) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR updates the change log for 7.67.0. (Hotfix - no test plan generated.) --------- Co-authored-by: metamaskbot Co-authored-by: João Loureiro <175489935+joaoloureirop@users.noreply.github.com> --- CHANGELOG.md | 135 ++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 134 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b07aa287c25..77836eec4e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,138 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [7.67.0] + +### Uncategorized + +- chore: merge stable into release 7.67.0 branch (#26496) +- chore: make OTA Version Display more robust (#26295) +- Bump new assets controller to v2.0.0 (#26166) +- Updated assets controllers to 99.4.0 (#26261) +- Added code fencing for gh actions defined at builds.yml (#26159) +- Fixed OTA version display (#26204) +- Remove npx in favor of yarn in sync script (#26233) +- Remove opt out button from Rewards settings (#26189) +- Removed notifications for all swap/bridge txs (#25919) +- Use chain-agnostic gas fee estimates source for bridging (#26047) +- Stop using portfolio API to fetch contentful sites (#26003) +- chore(release): sync stable to main for version 7.66.0 (#25916) +- Updated OTA modal user interface (#25867) +- Adds FAST_NETWORKS filter for gas-speed component and returns " < 1 Sec" when speed is < 1000ms (#25825) + Modifies toHumanEstimatedTimeRange in utils/time.ts to + handle "fast network" filter and " < 1 Sec" display +- Use `StorageService` in Snap Controller (#25672) +- Fixed the limit price row in Perps order form so it no longer shows rounded bottom borders when the Pay with row is visible (#25834) + below it. +- Replace modal with bottom sheet on 'Account added' click (#25770) +- chore(deps): bump the npm_and_yarn group across 1 directory with 2 updates (#25696) +- chore(release): sync stable to main for version 7.66.0 (#25802) + +### Added + +- Gas sponsorship UI (#26252) +- Replaced webview-based Veriff KYC flow with native Veriff SDK integration, featuring MetaMask-branded UI with dynamic (#26138) + light/dark theme support, custom fonts, and fox logo +- Add market close bottom sheet to stop user perform trade. (#25157) +- Added trust signal icons to address displays in confirmations, showing verified, warning, or malicious indicators based (#25154) + on address scan results. +- Moved notifications and QR scanner from home screen header to Account Menu and added Deposit quick action (#26100) +- Added card freeze/unfreeze toggle to the Card Home screen, allowing users to temporarily disable and re-enable their card. (#26246) +- `[ADDED]` Native Transak v2 purchase flow with in-app email/OTP authentication, KYC handling, order creation, and payment (#26033) + processing. +- Force enable explore feature (#26128) +- Add support for wallet connect verify api (#26070) +- Increased the browser tab limit from 5 to 20 and improved tab switching performance by keeping only the 5 most recently (#26143) + used tabs live in memory +- Prefill country of residence from geolocation on Card onboarding SignUp and extract reusable SelectField component across (#26136) + onboarding screens +- Predict withdrawal to any token (happy path) (#25441) +- Display amount row when simulation fails (#25716) +- Improved claim bonus responsiveness by caching Merkl API responses and fixed claim bonus button in token list V2 layout (#26016) +- Preloaded Perps market and user data at startup for instant rendering (#26061) +- Added network pill overflow with "+X more" button that opens a full network list in the bridge token selector (#25893) +- Revamp swaps keypad (#25845) +- Init the new assets controller under a feature flag (#25957) +- Adds a page for changing preferred ramp provider (#25860) +- Add asset overview deeplinks (#25447) +- Restored the previously selected "Pay with" token when returning to the Perps order view within 5 minutes. (#25938) +- Fixed predict transaction toast notifications not appearing when navigating away from the Predict tab (#25863) +- Added new Accounts Menu screen to organize settings navigation with Settings, Manage, and Resources sections (#25611) +- Adds Bridge and Swap feature to `MegaETH` (#25906) +- Adds chiliz.png as network logo and enables it in metamask mobile (#25437) +- Always display learn more about perps link (#25958) +- Created new token list item v2 (#25824) +- Added custom claim transaction request screen for mUSD bonus claims with improved UX flow (#25837) +- Added an "Ending soon" tab to prediction markets feed showing markets sorted by end date (#25868) +- Removed legacy homepage script injection and related RPC methods (#25620) +- Add google/web search inside browser search bar (#25897) +- Homogenize spacing on Explore page for perps items (#25894) +- Added 1st interaction alert to warn users when interacting with an address for the first time. (#25575) +- Added icons to the bridge token selector network pills (#25851) +- Create feature flag for the new unified assets state (#25891) +- Adds Bridge and Swap feature to HyperEVM (#25769) +- Added lightweight position display and one-click Long/Short trading on token details page for perps-enabled assets (#25685) +- Improved browser tab switching performance by keeping tabs mounted (#25702) +- Validation errors from non-EVM transaction snaps will now be displayed to users during send flow. (#25648) +- Added detailed transaction display for mUSD reward claims showing claimed amount, network fee, and received total (#25452) +- Adds functionality for selecting a payment method (#25681) +- Base setup for in-app provisioning (#25669) + +### Fixed + +- Adds analytics instrumentation for Token Details V2 layout A/B test (#25844) +- Fix issues with balance rounding, localization formatting and decimal representation on source swap asset balance. (#26267) +- Keypad bottom border is visible occasionally on Android (#26229) +- Adds location property to swap events. (#26067) +- Fixed a bug where add/remove network confirmation toasts appeared during Bridge flows. (#26239) +- Fixed an iOS bug where scanning a MetaMask universal link QR code opened Safari and redirected to the App Store instead of (#25739) + handling the link in-app. +- Fixed DeFi tab not appearing when switching from non-EVM networks to "All Popular Networks" (#26193) +- Set height of quick pick buttons the same as confirm cta (#26170) +- Fixed Bridge token selectors to show all supported networks, persist selected network pills, and auto-add missing networks (#26174) + on token selection. +- Fixed token prices not displaying for non-EVM tokens in the V2 token list layout. (#26132) +- Prevent full app reload when editing Trending files (#26135) +- Fixed excessive ENS API calls when opening the bridge/swaps flow that scaled with the number of accounts (#26126) +- Fixed a bug where tapping a token’s info icon in Swaps could open the wrong asset details page. (#26123) +- Fixed perpetual trading margin display showing $0 when placing orders from the Token Details page (#26105) +- Start rendering confirm button loading state on input change (#26107) +- Fixed issue that triggered account creation during onboarding using pre BIP-44 flow when switching networks (#26088) +- Fixed a UI issue where buttons in the signature message details view were overlapping. (#26040) +- Keep keypad state on flip and close it when dest token input is pressed (#26068) +- Updated mUSD claim bonus subtitle copy (#26019) +- Fixed intermittent placeholder text alignment and clipping in text inputs on iOS. (#26049) +- Fall back to priceImpact or destTokenAmount for swap quote sorting (#25928) +- Remove deeplink interstitial on dApp deeplinks (#25963) +- Multiple fixes on import token flow (#25962) +- Fixed decimal precision calculation for Tron's staked balance (#25430) +- Fixed intermittent "Failed to fetch market data" errors on Perps by switching market data fetches from WebSocket to HTTP (#26014) + transport +- Fixed `x-us-env` header being incorrectly set to `false` for US Card users when geolocation requests fail (#25971) +- Fix #24546 with human readable message (#25555) +- Removed "Add funds to start trading perps" banner from Perps market details and allow opening trades (Long/Short) when perps (#25960) + balance is zero. +- Fixed long token names pushing balance off screen in Send flow and MM Pay token picker (#25338) +- Fix #25693 styling issue in for ledger devices (#25758) +- Fixed navigation error and token buyability checks when purchasing crypto with cash using unified buy V2 (#25617) +- Fixed Predictions tab not hiding monetary values when privacy mode is enabled (#25887) +- Fixed Perps deposit+order flow so the pending deposit toast auto-dismisses after a few seconds and the "deposit taking longer" (#25939) + message appears after 30 seconds. +- Fixed header height to scale properly with larger accessibility font sizes (#25855) +- Activity header symbol fallback (#25821) +- Fixed the Perps order pay row not appearing until margin was loaded. (#25836) +- When passoword oudated, it navigate to oauthRehydrate screen when reopen app (#25687) +- Fixed notification and transaction display for EIP-7702 transactions without nonces (#25646) +- Adds event for when token details page is opened. (#25780) +- Added error screens when wallet creation fails, allowing users to retry or contact support instead of being redirected (#25564) + to login. +- Remove toggle switch from login screen (#25424) +- Fixed minor button layout issues (#25771) +- Fixed long account names overflowing in the Deposit Buy screen by enabling proper text truncation (#25715) +- Remove subtitle in token details (#25726) +- Fixed flow for "Cash buy X" button on the new token details layout (#25719) +- Pass assetID to the on ramp buy screen. (#25709) + ## [7.66.1] ### Fixed @@ -10621,7 +10753,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - [#957](https://github.com/MetaMask/metamask-mobile/pull/957): fix timeouts (#957) - [#954](https://github.com/MetaMask/metamask-mobile/pull/954): Bugfix: onboarding navigation (#954) -[Unreleased]: https://github.com/MetaMask/metamask-mobile/compare/v7.66.1...HEAD +[Unreleased]: https://github.com/MetaMask/metamask-mobile/compare/v7.67.0...HEAD +[7.67.0]: https://github.com/MetaMask/metamask-mobile/compare/v7.66.1...v7.67.0 [7.66.1]: https://github.com/MetaMask/metamask-mobile/compare/v7.66.0...v7.66.1 [7.66.0]: https://github.com/MetaMask/metamask-mobile/compare/v7.65.0...v7.66.0 [7.65.0]: https://github.com/MetaMask/metamask-mobile/compare/v7.64.1...v7.65.0 From c4acb0b62642314c4553bf2bbe9334782d65be4a Mon Sep 17 00:00:00 2001 From: metamaskbot Date: Mon, 2 Mar 2026 16:03:19 +0000 Subject: [PATCH 066/131] bump semvar version to 7.67.1 && build version to 3854 --- android/app/build.gradle | 2 +- bitrise.yml | 4 ++-- ios/MetaMask.xcodeproj/project.pbxproj | 12 ++++++------ package.json | 2 +- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index 532cbb43739..264825108e8 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -187,7 +187,7 @@ android { applicationId "io.metamask" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionName "7.67.0" + versionName "7.67.1" versionCode 3844 testBuildType System.getProperty('testBuildType', 'debug') testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" diff --git a/bitrise.yml b/bitrise.yml index f0f5aa5dccf..9072b94fa78 100644 --- a/bitrise.yml +++ b/bitrise.yml @@ -3516,13 +3516,13 @@ app: PROJECT_LOCATION_IOS: ios - opts: is_expand: false - VERSION_NAME: 7.67.0 + VERSION_NAME: 7.67.1 - opts: is_expand: false VERSION_NUMBER: 3844 - opts: is_expand: false - FLASK_VERSION_NAME: 7.67.0 + FLASK_VERSION_NAME: 7.67.1 - opts: is_expand: false FLASK_VERSION_NUMBER: 3844 diff --git a/ios/MetaMask.xcodeproj/project.pbxproj b/ios/MetaMask.xcodeproj/project.pbxproj index 71640427dcf..12e75ad47a4 100644 --- a/ios/MetaMask.xcodeproj/project.pbxproj +++ b/ios/MetaMask.xcodeproj/project.pbxproj @@ -1319,7 +1319,7 @@ "${inherited}", ); LLVM_LTO = YES; - MARKETING_VERSION = 7.67.0; + MARKETING_VERSION = 7.67.1; ONLY_ACTIVE_ARCH = YES; OTHER_CFLAGS = "$(inherited)"; OTHER_LDFLAGS = ( @@ -1385,7 +1385,7 @@ "${inherited}", ); LLVM_LTO = YES; - MARKETING_VERSION = 7.67.0; + MARKETING_VERSION = 7.67.1; ONLY_ACTIVE_ARCH = NO; OTHER_CFLAGS = "$(inherited)"; OTHER_LDFLAGS = ( @@ -1454,7 +1454,7 @@ "\"$(SRCROOT)/MetaMask/System/Library/Frameworks\"", ); LLVM_LTO = YES; - MARKETING_VERSION = 7.67.0; + MARKETING_VERSION = 7.67.1; ONLY_ACTIVE_ARCH = YES; OTHER_CFLAGS = "$(inherited)"; OTHER_LDFLAGS = ( @@ -1518,7 +1518,7 @@ "\"$(SRCROOT)/MetaMask/System/Library/Frameworks\"", ); LLVM_LTO = YES; - MARKETING_VERSION = 7.67.0; + MARKETING_VERSION = 7.67.1; ONLY_ACTIVE_ARCH = NO; OTHER_CFLAGS = "$(inherited)"; OTHER_LDFLAGS = ( @@ -1684,7 +1684,7 @@ "\"$(SRCROOT)/MetaMask/System/Library/Frameworks\"", ); LLVM_LTO = YES; - MARKETING_VERSION = 7.67.0; + MARKETING_VERSION = 7.67.1; ONLY_ACTIVE_ARCH = YES; OTHER_CFLAGS = ( "$(inherited)", @@ -1751,7 +1751,7 @@ "\"$(SRCROOT)/MetaMask/System/Library/Frameworks\"", ); LLVM_LTO = YES; - MARKETING_VERSION = 7.67.0; + MARKETING_VERSION = 7.67.1; ONLY_ACTIVE_ARCH = NO; OTHER_CFLAGS = ( "$(inherited)", diff --git a/package.json b/package.json index 90abdc8bcc8..b6a461aeea4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "metamask", - "version": "7.67.0", + "version": "7.67.1", "private": true, "scripts": { "install:foundryup": "yarn mm-foundryup", From 35d793b7e1d2e4f30eac824eb2833396a3c78be9 Mon Sep 17 00:00:00 2001 From: "runway-github[bot]" <73448015+runway-github[bot]@users.noreply.github.com> Date: Mon, 2 Mar 2026 17:29:56 +0000 Subject: [PATCH 067/131] chore(runway): cherry-pick chore: Bump `json-rpc-engine` cp-7.68.0 (#26796) - chore: Bump `json-rpc-engine` cp-7.68.0 (#26777) ## **Description** Bump `json-rpc-engine` to the latest version which includes a fix for a bug that caused issues when creating Snap accounts. ## **Changelog** CHANGELOG entry: null --- > [!NOTE] > **Low Risk** > Low risk dependency bump limited to `@metamask/json-rpc-engine`; behavior changes are confined to upstream library updates and could only surface via JSON-RPC request/response handling regressions. > > **Overview** > Updates the `@metamask/json-rpc-engine` dependency from `^10.2.1` to `^10.2.3` and refreshes `yarn.lock` to resolve to `10.2.3`. > > No application code changes; this is a dependency-only update intended to pick up upstream fixes (notably around Snap account creation per PR description). > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 9c848f4aca54efb9b1a40291f530644272d583cf. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). [67269fe](https://github.com/MetaMask/metamask-mobile/commit/67269fe0bdf9a653f48b6ab92085a8dc1a6facac) Co-authored-by: Frederik Bolding --- package.json | 2 +- yarn.lock | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 93a15f5fe2a..eb696f97f17 100644 --- a/package.json +++ b/package.json @@ -234,7 +234,7 @@ "@metamask/gas-fee-controller": "^25.0.0", "@metamask/gator-permissions-controller": "^0.3.0", "@metamask/hw-wallet-sdk": "^0.4.0", - "@metamask/json-rpc-engine": "^10.2.1", + "@metamask/json-rpc-engine": "^10.2.3", "@metamask/json-rpc-middleware-stream": "^8.0.7", "@metamask/key-tree": "patch:@metamask/key-tree@npm%3A10.1.1#~/.yarn/patches/@metamask-key-tree-npm-10.1.1-0bfab435ac.patch", "@metamask/keyring-api": "^21.5.0", diff --git a/yarn.lock b/yarn.lock index 7cd9637d686..849ccebbbbc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8713,9 +8713,9 @@ __metadata: languageName: node linkType: hard -"@metamask/json-rpc-engine@npm:^10.0.0, @metamask/json-rpc-engine@npm:^10.0.2, @metamask/json-rpc-engine@npm:^10.0.3, @metamask/json-rpc-engine@npm:^10.1.0, @metamask/json-rpc-engine@npm:^10.1.1, @metamask/json-rpc-engine@npm:^10.2.0, @metamask/json-rpc-engine@npm:^10.2.1, @metamask/json-rpc-engine@npm:^10.2.2": - version: 10.2.2 - resolution: "@metamask/json-rpc-engine@npm:10.2.2" +"@metamask/json-rpc-engine@npm:^10.0.0, @metamask/json-rpc-engine@npm:^10.0.2, @metamask/json-rpc-engine@npm:^10.0.3, @metamask/json-rpc-engine@npm:^10.1.0, @metamask/json-rpc-engine@npm:^10.1.1, @metamask/json-rpc-engine@npm:^10.2.0, @metamask/json-rpc-engine@npm:^10.2.1, @metamask/json-rpc-engine@npm:^10.2.2, @metamask/json-rpc-engine@npm:^10.2.3": + version: 10.2.3 + resolution: "@metamask/json-rpc-engine@npm:10.2.3" dependencies: "@metamask/rpc-errors": "npm:^7.0.2" "@metamask/safe-event-emitter": "npm:^3.0.0" @@ -8723,7 +8723,7 @@ __metadata: "@types/deep-freeze-strict": "npm:^1.1.0" deep-freeze-strict: "npm:^1.1.1" klona: "npm:^2.0.6" - checksum: 10/e2449e80f8ca3aed58d0778c220eba6c98e0848359da2703bcb68879c1b315774a1a8a90b2a7cd8d3eb3e0f022f9d0e30503e75a2645ec32cbfc5ba2e537f807 + checksum: 10/8895ffcfc0dbf5542476dfd9771cb288feaf6fd7e9628e02c10232b3b8f0feabe3a0ad3e3480e3260a69aaafcf8f58d1d89410e7f43e97a08350b3ec3e767b1d languageName: node linkType: hard @@ -35411,7 +35411,7 @@ __metadata: "@metamask/gas-fee-controller": "npm:^25.0.0" "@metamask/gator-permissions-controller": "npm:^0.3.0" "@metamask/hw-wallet-sdk": "npm:^0.4.0" - "@metamask/json-rpc-engine": "npm:^10.2.1" + "@metamask/json-rpc-engine": "npm:^10.2.3" "@metamask/json-rpc-middleware-stream": "npm:^8.0.7" "@metamask/key-tree": "patch:@metamask/key-tree@npm%3A10.1.1#~/.yarn/patches/@metamask-key-tree-npm-10.1.1-0bfab435ac.patch" "@metamask/keyring-api": "npm:^21.5.0" From eefde10780f06a5b9b0098c81fc6e448783d934f Mon Sep 17 00:00:00 2001 From: Wei Sun Date: Mon, 2 Mar 2026 09:30:37 -0800 Subject: [PATCH 068/131] revert version number back to 7.67.0 --- android/app/build.gradle | 2 +- bitrise.yml | 4 ++-- ios/MetaMask.xcodeproj/project.pbxproj | 12 ++++++------ package.json | 2 +- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index 264825108e8..532cbb43739 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -187,7 +187,7 @@ android { applicationId "io.metamask" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionName "7.67.1" + versionName "7.67.0" versionCode 3844 testBuildType System.getProperty('testBuildType', 'debug') testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" diff --git a/bitrise.yml b/bitrise.yml index 9072b94fa78..f0f5aa5dccf 100644 --- a/bitrise.yml +++ b/bitrise.yml @@ -3516,13 +3516,13 @@ app: PROJECT_LOCATION_IOS: ios - opts: is_expand: false - VERSION_NAME: 7.67.1 + VERSION_NAME: 7.67.0 - opts: is_expand: false VERSION_NUMBER: 3844 - opts: is_expand: false - FLASK_VERSION_NAME: 7.67.1 + FLASK_VERSION_NAME: 7.67.0 - opts: is_expand: false FLASK_VERSION_NUMBER: 3844 diff --git a/ios/MetaMask.xcodeproj/project.pbxproj b/ios/MetaMask.xcodeproj/project.pbxproj index 12e75ad47a4..71640427dcf 100644 --- a/ios/MetaMask.xcodeproj/project.pbxproj +++ b/ios/MetaMask.xcodeproj/project.pbxproj @@ -1319,7 +1319,7 @@ "${inherited}", ); LLVM_LTO = YES; - MARKETING_VERSION = 7.67.1; + MARKETING_VERSION = 7.67.0; ONLY_ACTIVE_ARCH = YES; OTHER_CFLAGS = "$(inherited)"; OTHER_LDFLAGS = ( @@ -1385,7 +1385,7 @@ "${inherited}", ); LLVM_LTO = YES; - MARKETING_VERSION = 7.67.1; + MARKETING_VERSION = 7.67.0; ONLY_ACTIVE_ARCH = NO; OTHER_CFLAGS = "$(inherited)"; OTHER_LDFLAGS = ( @@ -1454,7 +1454,7 @@ "\"$(SRCROOT)/MetaMask/System/Library/Frameworks\"", ); LLVM_LTO = YES; - MARKETING_VERSION = 7.67.1; + MARKETING_VERSION = 7.67.0; ONLY_ACTIVE_ARCH = YES; OTHER_CFLAGS = "$(inherited)"; OTHER_LDFLAGS = ( @@ -1518,7 +1518,7 @@ "\"$(SRCROOT)/MetaMask/System/Library/Frameworks\"", ); LLVM_LTO = YES; - MARKETING_VERSION = 7.67.1; + MARKETING_VERSION = 7.67.0; ONLY_ACTIVE_ARCH = NO; OTHER_CFLAGS = "$(inherited)"; OTHER_LDFLAGS = ( @@ -1684,7 +1684,7 @@ "\"$(SRCROOT)/MetaMask/System/Library/Frameworks\"", ); LLVM_LTO = YES; - MARKETING_VERSION = 7.67.1; + MARKETING_VERSION = 7.67.0; ONLY_ACTIVE_ARCH = YES; OTHER_CFLAGS = ( "$(inherited)", @@ -1751,7 +1751,7 @@ "\"$(SRCROOT)/MetaMask/System/Library/Frameworks\"", ); LLVM_LTO = YES; - MARKETING_VERSION = 7.67.1; + MARKETING_VERSION = 7.67.0; ONLY_ACTIVE_ARCH = NO; OTHER_CFLAGS = ( "$(inherited)", diff --git a/package.json b/package.json index b6a461aeea4..90abdc8bcc8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "metamask", - "version": "7.67.1", + "version": "7.67.0", "private": true, "scripts": { "install:foundryup": "yarn mm-foundryup", From 6e7c0ff39854a1f0d400058d22ecfc432a8d2125 Mon Sep 17 00:00:00 2001 From: "runway-github[bot]" <73448015+runway-github[bot]@users.noreply.github.com> Date: Mon, 2 Mar 2026 17:30:45 +0000 Subject: [PATCH 069/131] chore(runway): cherry-pick fix: recipient list display in send flow cp-7.68.0 (#26788) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fix: recipient list display in send flow cp-7.68.0 (#26771) ## **Description** Fix recipient list display in send flow. ## **Changelog** CHANGELOG entry: ## **Related issues** Fixes: https://github.com/MetaMask/metamask-mobile/issues/26684 ## **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** ## **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] > **Low Risk** > Low risk UI-only change that swaps the row wrapper component and tweaks padding/accessibility; main risk is minor touch/spacing regressions in the recipient list. > > **Overview** > Fixes the recipient list row rendering in the send/confirmation flow by replacing the design-system `ButtonBase` wrapper with React Native `Pressable`. > > Updates the row styling to include horizontal padding (`px-4`) and explicitly sets `accessibilityRole="button"` while preserving the existing pressed/selected background behavior. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit e5b33b4e7cd874d52ee1358712480dcc9d57cbd4. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). [c859ce0](https://github.com/MetaMask/metamask-mobile/commit/c859ce032042e2c23e70c9d7159fa4235cd1bd73) Co-authored-by: Jyoti Puri
--- .../confirmations/components/UI/recipient/recipient.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/app/components/Views/confirmations/components/UI/recipient/recipient.tsx b/app/components/Views/confirmations/components/UI/recipient/recipient.tsx index f26cdda96f0..13b7d4ca873 100644 --- a/app/components/Views/confirmations/components/UI/recipient/recipient.tsx +++ b/app/components/Views/confirmations/components/UI/recipient/recipient.tsx @@ -1,11 +1,11 @@ import React, { useCallback } from 'react'; +import { Pressable } from 'react-native'; import { KeyringAccountType } from '@metamask/keyring-api'; import { Box, FontWeight, Text, TextVariant, - ButtonBase, } from '@metamask/design-system-react-native'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; @@ -73,7 +73,7 @@ export function Recipient({ ACCOUNT_TYPE_LABELS[recipient.accountType as KeyringAccountType]; return ( - tw.style( - 'w-full flex-row items-center justify-between h-18 rounded-none', + 'w-full flex-row items-center justify-between h-18 rounded-none px-4', pressed || isSelected ? 'bg-pressed' : 'bg-transparent', ) } onPress={handlePressRecipient} + accessibilityRole="button" > + ); } From d0be88d9a5c804e347fb1b59e288c959a5628863 Mon Sep 17 00:00:00 2001 From: metamaskbot- @@ -120,6 +121,6 @@ export function Recipient({ Date: Mon, 2 Mar 2026 17:32:42 +0000 Subject: [PATCH 070/131] [skip ci] Bump version number to 3855 --- android/app/build.gradle | 2 +- bitrise.yml | 4 ++-- ios/MetaMask.xcodeproj/project.pbxproj | 12 ++++++------ 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index b66555641ef..1ed86b4b054 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -188,7 +188,7 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion versionName "7.68.0" - versionCode 3837 + versionCode 3855 testBuildType System.getProperty('testBuildType', 'debug') testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" manifestPlaceholders.MM_BRANCH_KEY_TEST = "$System.env.MM_BRANCH_KEY_TEST" diff --git a/bitrise.yml b/bitrise.yml index d624b046ece..bcf72eb99c8 100644 --- a/bitrise.yml +++ b/bitrise.yml @@ -3519,13 +3519,13 @@ app: VERSION_NAME: 7.68.0 - opts: is_expand: false - VERSION_NUMBER: 3837 + VERSION_NUMBER: 3855 - opts: is_expand: false FLASK_VERSION_NAME: 7.68.0 - opts: is_expand: false - FLASK_VERSION_NUMBER: 3837 + FLASK_VERSION_NUMBER: 3855 - opts: is_expand: false ANDROID_APK_LINK: '' diff --git a/ios/MetaMask.xcodeproj/project.pbxproj b/ios/MetaMask.xcodeproj/project.pbxproj index e6d7e243028..096fbcd4845 100644 --- a/ios/MetaMask.xcodeproj/project.pbxproj +++ b/ios/MetaMask.xcodeproj/project.pbxproj @@ -1281,7 +1281,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 3837; + CURRENT_PROJECT_VERSION = 3855; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1350,7 +1350,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 3837; + CURRENT_PROJECT_VERSION = 3855; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1416,7 +1416,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 3837; + CURRENT_PROJECT_VERSION = 3855; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1483,7 +1483,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 3837; + CURRENT_PROJECT_VERSION = 3855; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1646,7 +1646,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 3837; + CURRENT_PROJECT_VERSION = 3855; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1716,7 +1716,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 3837; + CURRENT_PROJECT_VERSION = 3855; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; From d34662285a800d720b891f5ba25cee2872b5ee19 Mon Sep 17 00:00:00 2001 From: "runway-github[bot]" <73448015+runway-github[bot]@users.noreply.github.com> Date: Mon, 2 Mar 2026 18:55:09 +0000 Subject: [PATCH 071/131] chore(runway): cherry-pick fix(perps): recover connection after app state changes (#26809) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fix(perps): recover connection after app state changes cp-7.67.1 (#26780) ## **Description** Fixes Perps WebSocket connectivity issues when: 1. **App returns from background** — after a few minutes in background, the OS silently kills the WebSocket but `PerpsConnectionManager` still reports `isConnected = true`. No code path detected the stale connection or triggered reconnection. 2. **WiFi/network drops and restores** — toggling WiFi, airplane mode, or losing cellular signal kills the WebSocket, but since the app stays in `active` state, the existing `AppState`-based recovery (if any) never fires. ### Root Cause `PerpsConnectionManager` had no lifecycle awareness of: - **React Native `AppState` transitions** (background → foreground) - **Network connectivity changes** (offline → online via `@react-native-community/netinfo`) Arthur's prior fix ([#26334](https://github.com/MetaMask/metamask-mobile/pull/26334)) made `StreamChannel.ensureReady()` connection-aware to avoid blind polling on slow connections, but it only helps when a reconnection is **already in progress** (`isConnecting = true`). After background resume or WiFi restore, nobody triggers the reconnection in the first place. ### Fix - **`AppState` listener** — on `active`, cancels any pending grace period and runs `validateAndReconnect()` - **`NetInfo` listener** — tracks `wasOffline` state; on offline → online transition, runs `validateAndReconnect()` - **`validateAndReconnect(context)`** — shared method that sends a lightweight `ping()` health check to the active provider. If the ping fails (stale WebSocket), marks the connection as lost and triggers `reconnectWithNewContext({ force: true })` which reinitializes the controller, validates with a fresh health check, and preloads all stream subscriptions. - **Cleanup** — both listeners are properly removed in `cleanupStateMonitoring()` ## **Changelog** CHANGELOG entry: Fixed Perps WebSocket not reconnecting after app resume from background or WiFi/network toggle ## **Related issues** Fixes: connectivity loss after backgrounding app, WiFi off/on not recovering Perps data ## **Manual testing steps** ```gherkin Feature: Perps connection recovery Scenario: App returns from background after several minutes Given the user has navigated to the Perps trading screen And the user has an open position When the user backgrounds the app for 3+ minutes And the user returns to the app Then the Perps WebSocket reconnects automatically And positions, prices, and account data resume updating Scenario: WiFi is toggled off and back on Given the user is viewing live Perps positions And WiFi is connected When the user turns WiFi off And waits a few seconds And turns WiFi back on Then the Perps WebSocket reconnects after network is restored And live data resumes without requiring navigation away Scenario: Airplane mode is toggled Given the user is on the Perps trading screen When the user enables airplane mode And then disables airplane mode Then the connection recovers and live data resumes ``` ## **Screenshots/Recordings** ### **Before** After backgrounding or WiFi toggle, Perps shows stale data with no automatic recovery. User must navigate away and back to restore the connection. ### **After** Connection automatically recovers via health-check ping and force reconnection. Live data resumes within seconds. ## **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 - [ ] 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** - [x] 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] > **Medium Risk** > Touches Perps connection lifecycle and reconnection paths; regressions could cause reconnect loops or delayed/stuck loading during flaky connectivity. > > **Overview** > Improves Perps WebSocket resilience by adding AppState and NetInfo listeners in `PerpsConnectionManager` to detect background→foreground and offline→online transitions, validate the connection via provider `ping()`, and force a reconnect when stale. > > Adds network-restore retry/backoff knobs (`NetworkRestoreMaxRetries`, `NetworkRestoreRetryBaseMs`) and ensures cleanup of new subscriptions/timers on teardown; reconnection now explicitly calls `PerpsController.disconnect()` before `init()` to avoid skipping re-init on a dead socket. > > Updates `usePerpsHomeData` to treat WebSocket-backed sections (positions/orders/activity) as loading while `isConnecting`, preventing brief empty-state flashes during reconnection. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit b3aab14cd5c66e00f6cb80762f5f7b67f2ccbfe6. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). [c4f83e4](https://github.com/MetaMask/metamask-mobile/commit/c4f83e4148417219ccde8dc0f59442d46aca07de) Co-authored-by: Alejandro Garcia Anglada --- .../UI/Perps/hooks/usePerpsHomeData.ts | 10 +- .../Perps/services/PerpsConnectionManager.ts | 197 ++++++++++++++++++ .../perps/constants/perpsConfig.ts | 2 + 3 files changed, 205 insertions(+), 4 deletions(-) diff --git a/app/components/UI/Perps/hooks/usePerpsHomeData.ts b/app/components/UI/Perps/hooks/usePerpsHomeData.ts index c1060a82c6e..e8fabae5051 100644 --- a/app/components/UI/Perps/hooks/usePerpsHomeData.ts +++ b/app/components/UI/Perps/hooks/usePerpsHomeData.ts @@ -63,7 +63,7 @@ export const usePerpsHomeData = ({ searchQuery = '', }: UsePerpsHomeDataParams = {}): UsePerpsHomeDataReturn => { // Get connection state to guard REST calls that require an initialized controller - const { isConnected, isInitialized } = usePerpsConnection(); + const { isConnected, isInitialized, isConnecting } = usePerpsConnection(); // Fetch positions via WebSocket with throttling for performance const { positions, isInitialLoading: isPositionsLoading } = @@ -365,12 +365,14 @@ export const usePerpsHomeData = ({ recentActivity: limitedActivity, sortBy, isLoading: { - positions: isPositionsLoading, - orders: isOrdersLoading, + // During reconnection, treat WebSocket-backed data as loading so the UI + // shows skeletons instead of briefly flashing "no positions" → positions. + positions: isPositionsLoading || isConnecting, + orders: isOrdersLoading || isConnecting, markets: isMarketsLoading, // Only wait for WebSocket fills (fast ~100ms), not REST fills (slow 3s+) // REST fills merge in background via mergedFills without blocking initial render - activity: isFillsLoading, + activity: isFillsLoading || isConnecting, }, refresh, }; diff --git a/app/components/UI/Perps/services/PerpsConnectionManager.ts b/app/components/UI/Perps/services/PerpsConnectionManager.ts index dff4bf9695b..4632db78eb2 100644 --- a/app/components/UI/Perps/services/PerpsConnectionManager.ts +++ b/app/components/UI/Perps/services/PerpsConnectionManager.ts @@ -1,3 +1,8 @@ +import { AppState, type AppStateStatus } from 'react-native'; +import { + addEventListener as netInfoAddEventListener, + type NetInfoState, +} from '@react-native-community/netinfo'; import { captureException, setMeasurement } from '@sentry/react-native'; import BackgroundTimer from 'react-native-background-timer'; import performance from 'react-native-performance'; @@ -57,6 +62,13 @@ class PerpsConnectionManagerClass { private isInGracePeriod = false; private pendingReconnectPromise: Promise | null = null; private connectionTimeoutRef: ReturnType | null = null; + private appStateSubscription: ReturnType< + typeof AppState.addEventListener + > | null = null; + private netInfoUnsubscribe: (() => void) | null = null; + private wasOffline = false; + private networkRestoreRetryTimer: ReturnType | null = null; + private networkRestoreRetryCount = 0; private constructor() { // Private constructor to enforce singleton pattern @@ -178,13 +190,188 @@ class PerpsConnectionManagerClass { this.previousHip3Version = currentHip3Version; }); + // Listen for app state changes to reconnect after background + if (!this.appStateSubscription) { + this.appStateSubscription = AppState.addEventListener( + 'change', + (nextAppState: AppStateStatus) => { + this.handleAppStateChange(nextAppState).catch((error) => { + Logger.error( + ensureError(error, 'PerpsConnectionManager.appStateListener'), + { + tags: { feature: PERPS_CONSTANTS.FeatureName }, + context: { + name: 'PerpsConnectionManager.appStateListener', + data: { message: 'Error handling app state change' }, + }, + }, + ); + }); + }, + ); + } + + // Listen for connectivity changes (WiFi off/on, airplane mode, etc.) + if (!this.netInfoUnsubscribe) { + this.netInfoUnsubscribe = netInfoAddEventListener( + (netState: NetInfoState) => { + const isOnline = netState.isInternetReachable === true; + + if (isOnline && this.wasOffline) { + DevLogger.log( + 'PerpsConnectionManager: Network restored - validating connection', + ); + this.reconnectAfterNetworkRestore(); + } + + if (!isOnline) { + this.wasOffline = true; + } else if (isOnline && this.isConnected) { + this.wasOffline = false; + } + }, + ); + } + DevLogger.log('PerpsConnectionManager: State monitoring set up'); } + /** + * Validate the WebSocket connection and force-reconnect if it is stale. + * Shared by both AppState (background→foreground) and NetInfo (offline→online) + * recovery paths. + * + * @param context - Caller identifier for error reporting + * @param skipPing - Skip ping and force reconnect after known network loss + */ + private async validateAndReconnect( + context: string, + skipPing = false, + ): Promise { + if (this.connectionRefCount <= 0 || this.isConnecting) { + return; + } + + if (this.isConnected && !skipPing) { + try { + const provider = Engine.context.PerpsController.getActiveProvider(); + await provider.ping(); + DevLogger.log( + `PerpsConnectionManager: ${context} - connection healthy`, + ); + return; + } catch { + DevLogger.log( + `PerpsConnectionManager: ${context} - connection stale, triggering reconnection`, + ); + } + } + + this.isConnected = false; + this.isInitialized = false; + + await this.reconnectWithNewContext({ force: true }); + DevLogger.log( + `PerpsConnectionManager: ${context} - reconnection successful`, + ); + } + + /** + * Attempt reconnection after network restore with exponential backoff. + * WebSocket endpoints may not be reachable immediately even though + * NetInfo reports isInternetReachable: true. + */ + private reconnectAfterNetworkRestore(): void { + this.cancelNetworkRestoreRetry(); + this.networkRestoreRetryCount = 0; + this.attemptNetworkRestoreReconnect(); + } + + private attemptNetworkRestoreReconnect(): void { + this.validateAndReconnect('PerpsConnectionManager.netInfoListener', true) + .then(() => { + this.wasOffline = false; + this.networkRestoreRetryCount = 0; + }) + .catch((error) => { + this.networkRestoreRetryCount++; + if ( + this.networkRestoreRetryCount < + PERPS_CONSTANTS.NetworkRestoreMaxRetries + ) { + const delay = + PERPS_CONSTANTS.NetworkRestoreRetryBaseMs * + this.networkRestoreRetryCount; + DevLogger.log( + `PerpsConnectionManager: Network restore retry ${this.networkRestoreRetryCount} in ${delay}ms`, + ); + this.networkRestoreRetryTimer = setTimeout(() => { + this.networkRestoreRetryTimer = null; + this.attemptNetworkRestoreReconnect(); + }, delay); + } else { + Logger.error( + ensureError(error, 'PerpsConnectionManager.netInfoListener'), + { + tags: { feature: PERPS_CONSTANTS.FeatureName }, + context: { + name: 'PerpsConnectionManager.netInfoListener', + data: { + message: + 'Network restore reconnection failed after max retries', + }, + }, + }, + ); + } + }); + } + + private cancelNetworkRestoreRetry(): void { + if (this.networkRestoreRetryTimer) { + clearTimeout(this.networkRestoreRetryTimer); + this.networkRestoreRetryTimer = null; + } + this.networkRestoreRetryCount = 0; + } + + /** + * Handle app state transitions to recover from stale WebSocket connections. + * When the app returns from background, the OS may have silently killed the + * WebSocket. A health-check ping detects this and triggers reconnection. + */ + private async handleAppStateChange( + nextAppState: AppStateStatus, + ): Promise { + if (nextAppState !== 'active') { + return; + } + + // Cancel any pending grace period — user is back + if (this.isInGracePeriod) { + DevLogger.log( + 'PerpsConnectionManager: App resumed - cancelling grace period', + ); + this.cancelGracePeriod(); + } + + await this.validateAndReconnect('appResume'); + } + /** * Clean up state monitoring */ private cleanupStateMonitoring(): void { + if (this.appStateSubscription) { + this.appStateSubscription.remove(); + this.appStateSubscription = null; + } + if (this.netInfoUnsubscribe) { + this.netInfoUnsubscribe(); + this.netInfoUnsubscribe = null; + this.wasOffline = false; + } + this.cancelNetworkRestoreRetry(); if (this.unsubscribeFromStore) { this.unsubscribeFromStore(); this.unsubscribeFromStore = null; @@ -747,7 +934,17 @@ class PerpsConnectionManagerClass { this.clearError(); // Stage 2: Force the controller to reinitialize with new context + // Disconnect first so the controller resets its isInitialized flag — + // without this, init() silently skips when the controller thinks it's + // already initialized (even though the underlying WebSocket is dead). const reinitStart = performance.now(); + try { + await Engine.context.PerpsController.disconnect(); + } catch { + DevLogger.log( + 'PerpsConnectionManager: disconnect before reinit failed (continuing)', + ); + } await Engine.context.PerpsController.init(); setMeasurement( PerpsMeasurementName.PerpsControllerReinit, diff --git a/app/controllers/perps/constants/perpsConfig.ts b/app/controllers/perps/constants/perpsConfig.ts index c12fe232d0e..d19c3184b20 100644 --- a/app/controllers/perps/constants/perpsConfig.ts +++ b/app/controllers/perps/constants/perpsConfig.ts @@ -30,6 +30,8 @@ export const PERPS_CONSTANTS = { ReconnectionDelayAndroidMs: 300, // Android-specific reconnection delay for better reliability on slower devices ReconnectionDelayIosMs: 100, // iOS-specific reconnection delay for optimal performance ReconnectionRetryDelayMs: 5_000, // 5 seconds delay between reconnection attempts + NetworkRestoreMaxRetries: 8, // Max retry attempts when reconnecting after WiFi/network restore + NetworkRestoreRetryBaseMs: 1_500, // Base delay (ms) between network restore retries (multiplied by attempt number) // Connection manager timing constants BalanceUpdateThrottleMs: 15000, // Update at most every 15 seconds to reduce state updates in PerpsConnectionManager From 8155e01575330e09c3a6f161f5ace1e83f005a22 Mon Sep 17 00:00:00 2001 From: Wei Sun Date: Mon, 2 Mar 2026 12:24:09 -0800 Subject: [PATCH 072/131] update OTA version --- app/constants/ota.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/constants/ota.ts b/app/constants/ota.ts index 70e0dd691f3..e43c9c02236 100644 --- a/app/constants/ota.ts +++ b/app/constants/ota.ts @@ -6,7 +6,7 @@ import otaConfig from '../../ota.config.js'; * Reset to v0 when releasing a new native build * We keep this OTA_VERSION here to because changes in ota.config.js will affect the fingerprint and break the workflow in Github Actions */ -export const OTA_VERSION: string = 'v7.65.1'; +export const OTA_VERSION: string = 'v7.67.1'; export const RUNTIME_VERSION = otaConfig.RUNTIME_VERSION; export const PROJECT_ID = otaConfig.PROJECT_ID; export const UPDATE_URL = otaConfig.UPDATE_URL; From dec6a1d5984ed0aaa01024ef2330fe7697b22fb6 Mon Sep 17 00:00:00 2001 From: "runway-github[bot]" <73448015+runway-github[bot]@users.noreply.github.com> Date: Tue, 3 Mar 2026 03:39:49 +0000 Subject: [PATCH 073/131] chore(runway): cherry-pick feat(card): cp-7.68.0 Add View PIN option (#26767) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - feat(card): cp-7.68.0 Add View PIN option (#26646) ## **Description** Adds a "View PIN" option to the Card Home manage card section, allowing users to securely view their card PIN through a PCI-compliant image-based display. **Why**: Users need to retrieve their card PIN (e.g. for ATM use or in-store transactions). The PIN is never transmitted as plain text — it is rendered as an image via a time-limited, single-use secure token from the `POST /v1/card/pin/token` endpoint, ensuring PCI compliance. **What changed**: - **New SDK method** (`CardSDK.generateCardPinToken`): Calls `POST /v1/card/pin/token` with optional `customCss` for theming the PIN image. Mirrors the existing `generateCardDetailsToken` pattern with proper error handling. - **React Query integration**: New `cardQueries.pin` key factory and `pinTokenMutationFn` following the established React Query patterns from the codebase. - **`useCardPinToken` hook**: Wraps `useMutation` for PIN token generation. Automatically applies dark/light theme-aware `customCss` (background and text colors) so the PIN image matches the app appearance. - **`ViewPinBottomSheet` component**: Displays the PIN image in a bottom sheet with a skeleton loader and `CardScreenshotDeterrent` enabled to prevent screenshots of sensitive data. - **`CardHome` integration**: New `ManageCardListItem` for "View PIN" with biometric authentication gating (matching the "View Card Details" flow). Falls back to password bottom sheet with a PIN-specific description when biometrics are not configured. Visible for US users (all card types) and international users with non-virtual cards. - **Analytics**: Added `VIEW_PIN_BUTTON` action to `CardActions` enum, tracked via `CARD_BUTTON_CLICKED` event. - **Navigation**: Registered `CardViewPinModal` route and added the `ViewPinBottomSheet` screen to `CardModalsRoutes`. - **Tests**: Added tests across 5 files — SDK method tests, query layer tests, hook tests, bottom sheet snapshot/render tests, and 11 new CardHome integration tests covering visibility conditions, biometric auth flow, password fallback, and loading guards. ## **Changelog** CHANGELOG entry: Added "View PIN" option to the Card Home screen, allowing users to securely view their card PIN via biometric or password authentication. ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: View card PIN Scenario: View PIN button is visible for eligible users Given the user is authenticated with an active card And the user is a US user OR has a non-virtual (metal) card When the user navigates to the Card Home screen Then a "View PIN" option is displayed in the manage card section Scenario: View PIN button is hidden for international virtual card users Given the user is an international user with a virtual card When the user navigates to the Card Home screen Then the "View PIN" option is NOT displayed Scenario: View PIN with biometric authentication Given the user has biometric authentication configured When the user taps "View PIN" Then a biometric prompt is displayed And upon successful authentication, the card PIN is shown as an image in a bottom sheet And the PIN image matches the current theme (light/dark background) Scenario: View PIN with password fallback Given the user does NOT have biometric authentication configured When the user taps "View PIN" Then a password bottom sheet appears with the message "Enter your wallet password to view your card PIN." And upon entering the correct password, the card PIN is shown in a bottom sheet Scenario: View PIN biometric cancellation Given the user has biometric authentication configured When the user taps "View PIN" and cancels the biometric prompt Then no PIN is displayed and the user returns to Card Home Scenario: View PIN error handling Given the user taps "View PIN" and authentication succeeds When the PIN token request fails Then an error toast is shown with "Failed to load PIN. Please try again." Scenario: Screenshot prevention Given the card PIN bottom sheet is displayed When the user attempts to take a screenshot Then the screenshot deterrent is active and prevents capture of the PIN ``` ## **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] > **Medium Risk** > Adds a new authenticated flow to fetch and display a sensitive card PIN image via a new SDK endpoint and modal UI, with biometric/password gating and error handling. Risk is mainly around auth/error-state handling and the new network call/token lifecycle. > > **Overview** > Adds a new **“View PIN”** manage-card action on `CardHome`, shown only for eligible users (authenticated, has a card, not loading; US users or non-virtual cards), gated by `reauthenticate()` with a password-bottom-sheet fallback when biometrics aren’t configured and guarded against concurrent loads. > > Introduces PIN-token generation plumbing: `CardSDK.generateCardPinToken` calling `POST /v1/card/pin/token`, React Query `cardQueries.pin` + `useCardPinToken` (theme-aware `customCss`), plus a new `ViewPinBottomSheet` modal route (`Routes.CARD.MODALS.VIEW_PIN`) that renders the PIN image with a skeleton loader and `CardScreenshotDeterrent` enabled. > > Updates analytics (`CardActions.VIEW_PIN_BUTTON`), test IDs, and English strings, and adds comprehensive tests for the SDK/query/hook, the new bottom sheet (snapshot/render), and CardHome visibility/auth/error flows. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 514ae0c60c2eebb4d3d7e1d2aaef334797c503b5. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). [ab07f46](https://github.com/MetaMask/metamask-mobile/commit/ab07f46934834c3a794b92e17f567b99ca8dffcd) Co-authored-by: Bruno Nascimento --- .../UI/Card/Views/CardHome/CardHome.test.tsx | 330 +++++++++ .../Card/Views/CardHome/CardHome.testIds.ts | 1 + .../UI/Card/Views/CardHome/CardHome.tsx | 106 +++ .../ViewPinBottomSheet.test.tsx | 140 ++++ .../ViewPinBottomSheet.testIds.ts | 5 + .../ViewPinBottomSheet/ViewPinBottomSheet.tsx | 80 +++ .../ViewPinBottomSheet.test.tsx.snap | 626 ++++++++++++++++++ .../components/ViewPinBottomSheet/index.ts | 2 + .../UI/Card/hooks/useCardPinToken.test.ts | 168 +++++ .../UI/Card/hooks/useCardPinToken.ts | 56 ++ app/components/UI/Card/queries/index.ts | 6 +- app/components/UI/Card/queries/pin.test.ts | 56 ++ app/components/UI/Card/queries/pin.ts | 14 + app/components/UI/Card/routes/index.tsx | 5 + app/components/UI/Card/sdk/CardSDK.test.ts | 135 ++++ app/components/UI/Card/sdk/CardSDK.ts | 41 ++ app/components/UI/Card/types.ts | 19 + app/components/UI/Card/util/metrics.ts | 1 + app/constants/navigation/Routes.ts | 1 + locales/languages/en.json | 7 + 20 files changed, 1798 insertions(+), 1 deletion(-) create mode 100644 app/components/UI/Card/components/ViewPinBottomSheet/ViewPinBottomSheet.test.tsx create mode 100644 app/components/UI/Card/components/ViewPinBottomSheet/ViewPinBottomSheet.testIds.ts create mode 100644 app/components/UI/Card/components/ViewPinBottomSheet/ViewPinBottomSheet.tsx create mode 100644 app/components/UI/Card/components/ViewPinBottomSheet/__snapshots__/ViewPinBottomSheet.test.tsx.snap create mode 100644 app/components/UI/Card/components/ViewPinBottomSheet/index.ts create mode 100644 app/components/UI/Card/hooks/useCardPinToken.test.ts create mode 100644 app/components/UI/Card/hooks/useCardPinToken.ts create mode 100644 app/components/UI/Card/queries/pin.test.ts create mode 100644 app/components/UI/Card/queries/pin.ts diff --git a/app/components/UI/Card/Views/CardHome/CardHome.test.tsx b/app/components/UI/Card/Views/CardHome/CardHome.test.tsx index a29a56f4770..f5d80659410 100644 --- a/app/components/UI/Card/Views/CardHome/CardHome.test.tsx +++ b/app/components/UI/Card/Views/CardHome/CardHome.test.tsx @@ -99,6 +99,7 @@ import { } from '../../../../../core/redux/slices/card'; import { useIsSwapEnabledForPriorityToken } from '../../hooks/useIsSwapEnabledForPriorityToken'; import useCardDetailsToken from '../../hooks/useCardDetailsToken'; +import useCardPinToken from '../../hooks/useCardPinToken'; const mockNavigate = jest.fn(); const mockGoBack = jest.fn(); @@ -274,6 +275,26 @@ jest.mock('../../hooks/useCardDetailsToken', () => ({ })), })); +const mockGeneratePinToken = jest.fn(); +const mockResetPinToken = jest.fn(); +jest.mock('../../hooks/useCardPinToken', () => ({ + __esModule: true, + default: jest.fn(() => ({ + generatePinToken: mockGeneratePinToken, + isLoading: false, + error: null, + imageUrl: null, + reset: mockResetPinToken, + })), +})); + +jest.mock('../../components/ViewPinBottomSheet', () => ({ + createViewPinBottomSheetNavigationDetails: jest.fn((params) => [ + 'CardModals', + { screen: 'CardViewPinModal', params }, + ]), +})); + // Mock useAuthentication for biometric verification const mockReauthenticate = jest.fn(); jest.mock('../../../../../core/Authentication/hooks/useAuthentication', () => @@ -503,6 +524,13 @@ jest.mock('../../../../../../locales/i18n', () => ({ 'Authentication required to resume spending on your card.', 'card.password_bottomsheet.description_unfreeze': 'Enter your wallet password to unfreeze your card.', + 'card.card_home.manage_card_options.view_pin': 'View PIN', + 'card.card_home.manage_card_options.view_pin_description': + 'View your card PIN securely', + 'card.card_home.manage_card_options.view_pin_error': + 'Failed to load PIN. Please try again.', + 'card.password_bottomsheet.description_view_pin': + 'Enter your wallet password to view your card PIN.', }; return strings[key] || key; }, @@ -3938,6 +3966,308 @@ describe('CardHome Component', () => { }); }); }); + + describe('View PIN Button', () => { + beforeEach(() => { + mockGeneratePinToken.mockClear(); + mockResetPinToken.mockClear(); + mockReauthenticate.mockClear(); + mockReauthenticate.mockResolvedValue(undefined); + }); + + it('does not show view pin button when user is not authenticated', () => { + // Given: User is not authenticated + setupMockSelectors({ isAuthenticated: false }); + setupLoadCardDataMock({ + isAuthenticated: false, + isBaanxLoginEnabled: true, + cardDetails: { type: CardType.VIRTUAL }, + isLoading: false, + }); + + // When: component renders + render(); + + // Then: view pin button is not shown + expect( + screen.queryByTestId(CardHomeSelectors.VIEW_PIN_BUTTON), + ).toBeNull(); + }); + + it('does not show view pin button when user has no card', () => { + // Given: Authenticated user without card + setupMockSelectors({ isAuthenticated: true }); + setupLoadCardDataMock({ + isAuthenticated: true, + isBaanxLoginEnabled: true, + cardDetails: null, + warning: CardStateWarning.NoCard, + isLoading: false, + kycStatus: { verificationState: 'VERIFIED', userId: 'user-123' }, + }); + + // When: component renders + render(); + + // Then: view pin button is not shown + expect( + screen.queryByTestId(CardHomeSelectors.VIEW_PIN_BUTTON), + ).toBeNull(); + }); + + it('does not show view pin button while loading', () => { + // Given: Loading state + setupMockSelectors({ isAuthenticated: true }); + setupLoadCardDataMock({ + isAuthenticated: true, + isBaanxLoginEnabled: true, + cardDetails: { type: CardType.VIRTUAL }, + isLoading: true, + }); + + // When: component renders + render(); + + // Then: view pin button is not shown + expect( + screen.queryByTestId(CardHomeSelectors.VIEW_PIN_BUTTON), + ).toBeNull(); + }); + + it('does not show view pin button for international virtual card', () => { + // Given: International user with virtual card + setupMockSelectors({ + isAuthenticated: true, + userLocation: 'international', + }); + setupLoadCardDataMock({ + isAuthenticated: true, + isBaanxLoginEnabled: true, + cardDetails: { type: CardType.VIRTUAL }, + isLoading: false, + kycStatus: { verificationState: 'VERIFIED', userId: 'user-123' }, + }); + + // When: component renders + render(); + + // Then: view pin button is not shown + expect( + screen.queryByTestId(CardHomeSelectors.VIEW_PIN_BUTTON), + ).toBeNull(); + }); + + it('shows view pin button for US user with virtual card', () => { + // Given: US user with virtual card + setupMockSelectors({ isAuthenticated: true, userLocation: 'us' }); + setupLoadCardDataMock({ + isAuthenticated: true, + isBaanxLoginEnabled: true, + cardDetails: { type: CardType.VIRTUAL }, + isLoading: false, + kycStatus: { verificationState: 'VERIFIED', userId: 'user-123' }, + }); + + // When: component renders + render(); + + // Then: view pin button is shown + expect( + screen.getByTestId(CardHomeSelectors.VIEW_PIN_BUTTON), + ).toBeOnTheScreen(); + }); + + it('shows view pin button for international user with metal card', () => { + // Given: International user with metal card + setupMockSelectors({ + isAuthenticated: true, + userLocation: 'international', + }); + setupLoadCardDataMock({ + isAuthenticated: true, + isBaanxLoginEnabled: true, + cardDetails: { type: CardType.METAL }, + isLoading: false, + kycStatus: { verificationState: 'VERIFIED', userId: 'user-123' }, + }); + + // When: component renders + render(); + + // Then: view pin button is shown (non-virtual card) + expect( + screen.getByTestId(CardHomeSelectors.VIEW_PIN_BUTTON), + ).toBeOnTheScreen(); + }); + + it('calls generatePinToken and navigates to ViewPinBottomSheet after biometric auth', async () => { + // Given: Authenticated US user with card and biometric auth succeeds + setupMockSelectors({ isAuthenticated: true, userLocation: 'us' }); + setupLoadCardDataMock({ + isAuthenticated: true, + isBaanxLoginEnabled: true, + cardDetails: { type: CardType.VIRTUAL }, + isLoading: false, + kycStatus: { verificationState: 'VERIFIED', userId: 'user-123' }, + }); + + mockReauthenticate.mockResolvedValueOnce(undefined); + mockGeneratePinToken.mockResolvedValueOnce({ + token: 'pin-token-123', + imageUrl: 'https://cards.baanx.com/pin-image?token=pin-token-123', + }); + + // When: component renders and button is pressed + render(); + const button = screen.getByTestId(CardHomeSelectors.VIEW_PIN_BUTTON); + fireEvent.press(button); + + // Then: reauthenticate is called first, then generatePinToken, then navigation + await waitFor(() => { + expect(mockReauthenticate).toHaveBeenCalled(); + }); + await waitFor(() => { + expect(mockGeneratePinToken).toHaveBeenCalled(); + }); + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith('CardModals', { + screen: 'CardViewPinModal', + params: { + imageUrl: 'https://cards.baanx.com/pin-image?token=pin-token-123', + }, + }); + }); + }); + + it('resets pin token after successful navigation', async () => { + // Given: Authenticated US user with card + setupMockSelectors({ isAuthenticated: true, userLocation: 'us' }); + setupLoadCardDataMock({ + isAuthenticated: true, + isBaanxLoginEnabled: true, + cardDetails: { type: CardType.VIRTUAL }, + isLoading: false, + kycStatus: { verificationState: 'VERIFIED', userId: 'user-123' }, + }); + + mockReauthenticate.mockResolvedValueOnce(undefined); + mockGeneratePinToken.mockResolvedValueOnce({ + token: 'pin-token-123', + imageUrl: 'https://cards.baanx.com/pin-image?token=pin-token-123', + }); + + // When: button is pressed + render(); + fireEvent.press(screen.getByTestId(CardHomeSelectors.VIEW_PIN_BUTTON)); + + // Then: resetPinToken is called after navigation + await waitFor(() => { + expect(mockResetPinToken).toHaveBeenCalled(); + }); + }); + + it('does not call generatePinToken when already loading', async () => { + // Given: Hook reports loading + setupMockSelectors({ isAuthenticated: true, userLocation: 'us' }); + setupLoadCardDataMock({ + isAuthenticated: true, + isBaanxLoginEnabled: true, + cardDetails: { type: CardType.VIRTUAL }, + isLoading: false, + kycStatus: { verificationState: 'VERIFIED', userId: 'user-123' }, + }); + + (useCardPinToken as jest.Mock).mockReturnValueOnce({ + generatePinToken: mockGeneratePinToken, + isLoading: true, + error: null, + imageUrl: null, + reset: mockResetPinToken, + }); + + // When: button is pressed while loading + render(); + fireEvent.press(screen.getByTestId(CardHomeSelectors.VIEW_PIN_BUTTON)); + + // Then: reauthenticate is not called + await waitFor(() => { + expect(mockReauthenticate).not.toHaveBeenCalled(); + }); + expect(mockGeneratePinToken).not.toHaveBeenCalled(); + }); + + describe('Biometric Authentication', () => { + it('does not fetch pin when biometric authentication fails', async () => { + // Given: Authenticated US user with card but biometric auth fails + setupMockSelectors({ isAuthenticated: true, userLocation: 'us' }); + setupLoadCardDataMock({ + isAuthenticated: true, + isBaanxLoginEnabled: true, + cardDetails: { type: CardType.VIRTUAL }, + isLoading: false, + kycStatus: { verificationState: 'VERIFIED', userId: 'user-123' }, + }); + + mockReauthenticate.mockRejectedValueOnce( + new Error('BIOMETRIC_ERROR: User cancelled'), + ); + + // When: component renders and button is pressed + render(); + fireEvent.press( + screen.getByTestId(CardHomeSelectors.VIEW_PIN_BUTTON), + ); + + // Then: reauthenticate is called but generatePinToken is NOT called + await waitFor(() => { + expect(mockReauthenticate).toHaveBeenCalled(); + }); + expect(mockGeneratePinToken).not.toHaveBeenCalled(); + }); + + it('navigates to password bottom sheet with view pin description when biometrics not configured', async () => { + // Given: Authenticated US user with card but biometrics not configured + setupMockSelectors({ isAuthenticated: true, userLocation: 'us' }); + setupLoadCardDataMock({ + isAuthenticated: true, + isBaanxLoginEnabled: true, + cardDetails: { type: CardType.VIRTUAL }, + isLoading: false, + kycStatus: { verificationState: 'VERIFIED', userId: 'user-123' }, + }); + + mockReauthenticate.mockRejectedValueOnce( + new Error( + 'PASSWORD_NOT_SET_WITH_BIOMETRICS: Biometrics not configured', + ), + ); + + // When: component renders and button is pressed + render(); + fireEvent.press( + screen.getByTestId(CardHomeSelectors.VIEW_PIN_BUTTON), + ); + + // Then: navigation to password bottom sheet is triggered with view pin description + await waitFor(() => { + expect(mockReauthenticate).toHaveBeenCalled(); + }); + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith( + Routes.CARD.MODALS.ID, + expect.objectContaining({ + screen: Routes.CARD.MODALS.PASSWORD, + params: expect.objectContaining({ + onSuccess: expect.any(Function), + description: + 'Enter your wallet password to view your card PIN.', + }), + }), + ); + }); + }); + }); + }); }); describe('Freeze Card Toggle', () => { diff --git a/app/components/UI/Card/Views/CardHome/CardHome.testIds.ts b/app/components/UI/Card/Views/CardHome/CardHome.testIds.ts index 528adfdb857..94fd58f65e6 100644 --- a/app/components/UI/Card/Views/CardHome/CardHome.testIds.ts +++ b/app/components/UI/Card/Views/CardHome/CardHome.testIds.ts @@ -30,4 +30,5 @@ export const CardHomeSelectors = { ORDER_METAL_CARD_ITEM: 'order-metal-card-item', CASHBACK_ITEM: 'cashback-item', FREEZE_CARD_TOGGLE: 'freeze-card-toggle', + VIEW_PIN_BUTTON: 'view-pin-button', }; diff --git a/app/components/UI/Card/Views/CardHome/CardHome.tsx b/app/components/UI/Card/Views/CardHome/CardHome.tsx index 814a21b1e98..8ddfdbb1168 100644 --- a/app/components/UI/Card/Views/CardHome/CardHome.tsx +++ b/app/components/UI/Card/Views/CardHome/CardHome.tsx @@ -84,6 +84,7 @@ import { removeCardBaanxToken } from '../../util/cardTokenVault'; import useLoadCardData from '../../hooks/useLoadCardData'; import useCardFreeze from '../../hooks/useCardFreeze'; import useCardDetailsToken from '../../hooks/useCardDetailsToken'; +import useCardPinToken from '../../hooks/useCardPinToken'; import useAuthentication from '../../../../../core/Authentication/hooks/useAuthentication'; import { ReauthenticateErrorType } from '../../../../../core/Authentication/types'; import { CardActions } from '../../util/metrics'; @@ -104,6 +105,7 @@ import { import { AddToWalletButton } from '@expensify/react-native-wallet'; import { CardScreenshotDeterrent } from '../../components/CardScreenshotDeterrent'; import { createPasswordBottomSheetNavigationDetails } from '../../components/PasswordBottomSheet'; +import { createViewPinBottomSheetNavigationDetails } from '../../components/ViewPinBottomSheet'; import { buildProvisioningUserAddress, buildShippingAddress, @@ -149,6 +151,11 @@ const CardHome = () => { imageUrl: cardDetailsImageUrl, clearImageUrl: clearCardDetailsImageUrl, } = useCardDetailsToken(); + const { + generatePinToken, + isLoading: isPinLoading, + reset: resetPinToken, + } = useCardPinToken(); const { reauthenticate } = useAuthentication(); const hasTrackedCardHomeView = useRef(false); const hasLoadedCardHomeView = useRef(false); @@ -830,6 +837,91 @@ const CardHome = () => { createEventBuilder, ]); + const fetchAndShowPin = useCallback(async () => { + trackEvent( + createEventBuilder(MetaMetricsEvents.CARD_BUTTON_CLICKED) + .addProperties({ + action: CardActions.VIEW_PIN_BUTTON, + }) + .build(), + ); + + try { + const response = await generatePinToken(); + navigation.navigate( + ...createViewPinBottomSheetNavigationDetails({ + imageUrl: response.imageUrl, + }), + ); + } catch { + toastRef?.current?.showToast({ + variant: ToastVariants.Icon, + labelOptions: [ + { + label: strings('card.card_home.manage_card_options.view_pin_error'), + }, + ], + hasNoTimeout: false, + iconName: IconName.Warning, + }); + } finally { + resetPinToken(); + } + }, [ + generatePinToken, + navigation, + toastRef, + resetPinToken, + trackEvent, + createEventBuilder, + ]); + + const viewPinAction = useCallback(async () => { + if (isPinLoading) { + return; + } + + try { + await reauthenticate(); + await fetchAndShowPin(); + } catch (error) { + const errorMessage = (error as Error).message; + + if ( + errorMessage.includes( + ReauthenticateErrorType.PASSWORD_NOT_SET_WITH_BIOMETRICS, + ) + ) { + navigation.navigate( + ...createPasswordBottomSheetNavigationDetails({ + onSuccess: () => { + fetchAndShowPin(); + }, + description: strings( + 'card.password_bottomsheet.description_view_pin', + ), + }), + ); + return; + } + + if (errorMessage.includes(ReauthenticateErrorType.BIOMETRIC_ERROR)) { + return; + } + + toastRef?.current?.showToast({ + variant: ToastVariants.Icon, + labelOptions: [ + { + label: strings('card.card_home.biometric_verification_required'), + }, + ], + hasNoTimeout: false, + iconName: IconName.Warning, + }); + } + }, [isPinLoading, reauthenticate, fetchAndShowPin, navigation, toastRef]); + const cardSetupState = useMemo(() => { const needsSetup = isBaanxLoginEnabled && @@ -1350,6 +1442,20 @@ const CardHome = () => { testID={CardHomeSelectors.VIEW_CARD_DETAILS_BUTTON} /> )} + {isAuthenticated && + !isLoading && + cardDetails && + (userLocation === 'us' || cardDetails.type !== CardType.VIRTUAL) && ( + + )} {isAuthenticated && !isLoading && cardDetails?.isFreezable && diff --git a/app/components/UI/Card/components/ViewPinBottomSheet/ViewPinBottomSheet.test.tsx b/app/components/UI/Card/components/ViewPinBottomSheet/ViewPinBottomSheet.test.tsx new file mode 100644 index 00000000000..365643eb250 --- /dev/null +++ b/app/components/UI/Card/components/ViewPinBottomSheet/ViewPinBottomSheet.test.tsx @@ -0,0 +1,140 @@ +import React from 'react'; +import { Linking } from 'react-native'; +import { fireEvent, waitFor } from '@testing-library/react-native'; +import ViewPinBottomSheet from './ViewPinBottomSheet'; +import { renderScreen } from '../../../../../util/test/renderWithProvider'; +import { backgroundState } from '../../../../../util/test/initial-root-state'; +import { ViewPinBottomSheetSelectors } from './ViewPinBottomSheet.testIds'; + +const mockUseParams = jest.fn(); +const mockGoBack = jest.fn(); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(Linking as any).removeEventListener = jest.fn(); + +jest.mock('../../../../../util/navigation/navUtils', () => ({ + useParams: () => mockUseParams(), + createNavigationDetails: jest.fn((stackId, screenName) => + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (params?: any) => [stackId, { screen: screenName, params }], + ), +})); + +jest.mock('@react-navigation/native', () => ({ + ...jest.requireActual('@react-navigation/native'), + useNavigation: () => ({ + goBack: mockGoBack, + }), +})); + +jest.mock('../CardScreenshotDeterrent/CardScreenshotDeterrent', () => { + const { View } = jest.requireActual('react-native'); + return (props: Record ) => ( + + ); +}); + +const TEST_IMAGE_URL = + 'https://cards.baanx.com/details-image?token=test-pin-token'; + +const renderWithProvider = (component: React.ComponentType) => + renderScreen( + component, + { + name: 'ViewPinBottomSheet', + }, + { + state: { + engine: { + backgroundState, + }, + }, + }, + ); + +describe('ViewPinBottomSheet', () => { + beforeEach(() => { + jest.clearAllMocks(); + + mockUseParams.mockReturnValue({ + imageUrl: TEST_IMAGE_URL, + }); + }); + + it('renders correctly and matches snapshot', () => { + const { toJSON } = renderWithProvider(() => ); + + expect(toJSON()).toMatchSnapshot(); + }); + + it('displays the title', () => { + const { getByText } = renderWithProvider(() => ); + + expect(getByText('Your Card PIN')).toBeOnTheScreen(); + }); + + it('renders the PIN image with correct source', () => { + const { getByTestId } = renderWithProvider(() => ); + + const pinImage = getByTestId(ViewPinBottomSheetSelectors.PIN_IMAGE); + + expect(pinImage).toBeOnTheScreen(); + expect(pinImage.props.source).toEqual({ uri: TEST_IMAGE_URL }); + }); + + it('displays skeleton while image is loading', () => { + const { getByTestId } = renderWithProvider(() => ); + + expect( + getByTestId(ViewPinBottomSheetSelectors.PIN_IMAGE_SKELETON), + ).toBeOnTheScreen(); + }); + + it('hides skeleton after image loads', async () => { + const { getByTestId, queryByTestId } = renderWithProvider(() => ( + + )); + + const pinImage = getByTestId(ViewPinBottomSheetSelectors.PIN_IMAGE); + + fireEvent(pinImage, 'load'); + + await waitFor(() => { + expect( + queryByTestId(ViewPinBottomSheetSelectors.PIN_IMAGE_SKELETON), + ).toBeNull(); + }); + }); + + it('hides skeleton on image error', async () => { + const { getByTestId, queryByTestId } = renderWithProvider(() => ( + + )); + + const pinImage = getByTestId(ViewPinBottomSheetSelectors.PIN_IMAGE); + + fireEvent(pinImage, 'error'); + + await waitFor(() => { + expect( + queryByTestId(ViewPinBottomSheetSelectors.PIN_IMAGE_SKELETON), + ).toBeNull(); + }); + }); + + it('renders CardScreenshotDeterrent as enabled', () => { + const { getByTestId } = renderWithProvider(() => ); + + const deterrent = getByTestId('card-screenshot-deterrent'); + + expect(deterrent.props.enabled).toBe(true); + }); + + it('renders the bottom sheet with correct testID', () => { + const { getByTestId } = renderWithProvider(() => ); + + expect( + getByTestId(ViewPinBottomSheetSelectors.BOTTOM_SHEET), + ).toBeOnTheScreen(); + }); +}); diff --git a/app/components/UI/Card/components/ViewPinBottomSheet/ViewPinBottomSheet.testIds.ts b/app/components/UI/Card/components/ViewPinBottomSheet/ViewPinBottomSheet.testIds.ts new file mode 100644 index 00000000000..758fc8893f7 --- /dev/null +++ b/app/components/UI/Card/components/ViewPinBottomSheet/ViewPinBottomSheet.testIds.ts @@ -0,0 +1,5 @@ +export const ViewPinBottomSheetSelectors = { + BOTTOM_SHEET: 'view-pin-bottom-sheet', + PIN_IMAGE: 'view-pin-image', + PIN_IMAGE_SKELETON: 'view-pin-image-skeleton', +}; diff --git a/app/components/UI/Card/components/ViewPinBottomSheet/ViewPinBottomSheet.tsx b/app/components/UI/Card/components/ViewPinBottomSheet/ViewPinBottomSheet.tsx new file mode 100644 index 00000000000..fc37ff080f4 --- /dev/null +++ b/app/components/UI/Card/components/ViewPinBottomSheet/ViewPinBottomSheet.tsx @@ -0,0 +1,80 @@ +import React, { useCallback, useRef, useState } from 'react'; +import { Image } from 'react-native'; +import { Box, Text, TextVariant } from '@metamask/design-system-react-native'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import BottomSheet, { + BottomSheetRef, +} from '../../../../../component-library/components/BottomSheets/BottomSheet'; +import BottomSheetHeader from '../../../../../component-library/components/BottomSheets/BottomSheetHeader'; +import { Skeleton } from '../../../../../component-library/components/Skeleton'; +import { + createNavigationDetails, + useParams, +} from '../../../../../util/navigation/navUtils'; +import Routes from '../../../../../constants/navigation/Routes'; +import { strings } from '../../../../../../locales/i18n'; +import CardScreenshotDeterrent from '../CardScreenshotDeterrent/CardScreenshotDeterrent'; +import { ViewPinBottomSheetSelectors } from './ViewPinBottomSheet.testIds'; + +interface ViewPinBottomSheetParams { + imageUrl: string; +} + +export const createViewPinBottomSheetNavigationDetails = + createNavigationDetails ( + Routes.CARD.MODALS.ID, + Routes.CARD.MODALS.VIEW_PIN, + ); + +const ViewPinBottomSheet: React.FC = () => { + const sheetRef = useRef (null); + const { imageUrl } = useParams (); + const tw = useTailwind(); + const [isImageLoading, setIsImageLoading] = useState(true); + + const handleClose = useCallback(() => { + sheetRef.current?.onCloseBottomSheet(); + }, []); + + return ( + + + ); +}; + +export default ViewPinBottomSheet; diff --git a/app/components/UI/Card/components/ViewPinBottomSheet/__snapshots__/ViewPinBottomSheet.test.tsx.snap b/app/components/UI/Card/components/ViewPinBottomSheet/__snapshots__/ViewPinBottomSheet.test.tsx.snap new file mode 100644 index 00000000000..6e4be5d814c --- /dev/null +++ b/app/components/UI/Card/components/ViewPinBottomSheet/__snapshots__/ViewPinBottomSheet.test.tsx.snap @@ -0,0 +1,626 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ViewPinBottomSheet renders correctly and matches snapshot 1`] = ` ++ + ++ {strings('card.view_pin_bottomsheet.title')} + ++ + ++ {isImageLoading && ( + ++ )} + setIsImageLoading(false)} + onError={() => setIsImageLoading(false)} + testID={ViewPinBottomSheetSelectors.PIN_IMAGE} + /> + + + +`; diff --git a/app/components/UI/Card/components/ViewPinBottomSheet/index.ts b/app/components/UI/Card/components/ViewPinBottomSheet/index.ts new file mode 100644 index 00000000000..06a370940cf --- /dev/null +++ b/app/components/UI/Card/components/ViewPinBottomSheet/index.ts @@ -0,0 +1,2 @@ +export { default } from './ViewPinBottomSheet'; +export { createViewPinBottomSheetNavigationDetails } from './ViewPinBottomSheet'; diff --git a/app/components/UI/Card/hooks/useCardPinToken.test.ts b/app/components/UI/Card/hooks/useCardPinToken.test.ts new file mode 100644 index 00000000000..6558b661310 --- /dev/null +++ b/app/components/UI/Card/hooks/useCardPinToken.test.ts @@ -0,0 +1,168 @@ +import { renderHook, act } from '@testing-library/react-hooks'; +import { useMutation } from '@tanstack/react-query'; +import { useCardSDK } from '../sdk'; +import useCardPinToken from './useCardPinToken'; + +jest.mock('@tanstack/react-query', () => ({ + useMutation: jest.fn(), +})); + +jest.mock('../sdk', () => ({ + useCardSDK: jest.fn(), +})); + +const mockUseTheme = jest.fn(); +jest.mock('../../../../util/theme', () => ({ + useTheme: (...args: unknown[]) => mockUseTheme(...args), +})); + +jest.mock('../queries', () => ({ + cardQueries: { + pin: { + keys: { token: () => ['card', 'pin', 'token'] }, + tokenMutationFn: jest.fn(() => jest.fn()), + }, + }, +})); + +const mockUseCardSDK = useCardSDK as jest.MockedFunction+ ++ ++ ++ ++ + ++ + ++ ++ ViewPinBottomSheet + ++ ++ ++ + ++ ++ ++ + ++ ++ ++ ++ ++ + ++ ++ ++ + ++ ++ + ++ Your Card PIN + ++ ++ ++ ++ + ++ ++ ++ + + ; + +describe('useCardPinToken', () => { + const mockMutateAsync = jest.fn(); + const mockReset = jest.fn(); + + const mockTokenResponse = { + token: 'pin-token-123', + imageUrl: 'https://cards.baanx.com/details-image?token=pin-token-123', + }; + + beforeEach(() => { + jest.clearAllMocks(); + + mockUseTheme.mockReturnValue({ themeAppearance: 'light' }); + + mockUseCardSDK.mockReturnValue({ + sdk: {} as never, + isLoading: false, + user: null, + setUser: jest.fn(), + logoutFromProvider: jest.fn(), + fetchUserData: jest.fn(), + isReturningSession: false, + }); + + mockMutateAsync.mockResolvedValue(mockTokenResponse); + + (useMutation as jest.Mock).mockReturnValue({ + mutateAsync: mockMutateAsync, + isPending: false, + error: null, + data: null, + reset: mockReset, + }); + }); + + describe('Initial State', () => { + it('initializes with correct default values', () => { + const { result } = renderHook(() => useCardPinToken()); + + expect(result.current.isLoading).toBe(false); + expect(result.current.error).toBeNull(); + expect(result.current.imageUrl).toBeNull(); + expect(typeof result.current.generatePinToken).toBe('function'); + expect(typeof result.current.reset).toBe('function'); + }); + }); + + describe('generatePinToken', () => { + it('calls mutateAsync with light theme CSS', async () => { + const { result } = renderHook(() => useCardPinToken()); + + await act(async () => { + await result.current.generatePinToken(); + }); + + expect(mockMutateAsync).toHaveBeenCalledWith({ + customCss: { backgroundColor: '#FFF', textColor: '#000' }, + }); + }); + + it('returns the mutation response', async () => { + const { result } = renderHook(() => useCardPinToken()); + + let response; + await act(async () => { + response = await result.current.generatePinToken(); + }); + + expect(response).toEqual(mockTokenResponse); + }); + }); + + describe('Loading State', () => { + it('reflects isPending from mutation', () => { + (useMutation as jest.Mock).mockReturnValue({ + mutateAsync: mockMutateAsync, + isPending: true, + error: null, + data: null, + reset: mockReset, + }); + + const { result } = renderHook(() => useCardPinToken()); + + expect(result.current.isLoading).toBe(true); + }); + }); + + describe('Error Handling', () => { + it('reflects error from mutation', () => { + const testError = new Error('Network error'); + (useMutation as jest.Mock).mockReturnValue({ + mutateAsync: mockMutateAsync, + isPending: false, + error: testError, + data: null, + reset: mockReset, + }); + + const { result } = renderHook(() => useCardPinToken()); + + expect(result.current.error).toBe(testError); + }); + }); + + describe('imageUrl', () => { + it('returns imageUrl from successful mutation data', () => { + (useMutation as jest.Mock).mockReturnValue({ + mutateAsync: mockMutateAsync, + isPending: false, + error: null, + data: mockTokenResponse, + reset: mockReset, + }); + + const { result } = renderHook(() => useCardPinToken()); + + expect(result.current.imageUrl).toBe(mockTokenResponse.imageUrl); + }); + + it('returns null when no data', () => { + const { result } = renderHook(() => useCardPinToken()); + + expect(result.current.imageUrl).toBeNull(); + }); + }); + + describe('reset', () => { + it('delegates to mutation reset', () => { + const { result } = renderHook(() => useCardPinToken()); + + act(() => { + result.current.reset(); + }); + + expect(mockReset).toHaveBeenCalled(); + }); + }); +}); diff --git a/app/components/UI/Card/hooks/useCardPinToken.ts b/app/components/UI/Card/hooks/useCardPinToken.ts new file mode 100644 index 00000000000..f1117a4ebf7 --- /dev/null +++ b/app/components/UI/Card/hooks/useCardPinToken.ts @@ -0,0 +1,56 @@ +import { useCallback, useMemo } from 'react'; +import { useMutation } from '@tanstack/react-query'; +import { useCardSDK } from '../sdk'; +import { cardQueries } from '../queries'; +import { useTheme } from '../../../../util/theme'; +import type { CardPinTokenResponse } from '../types'; + +/* eslint-disable @metamask/design-tokens/color-no-hex */ +const PIN_CSS = { + dark: { backgroundColor: '#121314', textColor: '#FFF' }, + light: { backgroundColor: '#FFF', textColor: '#000' }, +} as const; + +interface UseCardPinTokenResult { + generatePinToken: () => Promise ; + isLoading: boolean; + error: Error | null; + imageUrl: string | null; + reset: () => void; +} + +/** + * Hook to generate a secure token for viewing the card PIN as an image. + * Uses React Query useMutation since this is a one-off POST (not cached data). + * The token is time-limited (~10 minutes) and single-use. + * Automatically applies dark/light theme styling to the PIN image. + */ +const useCardPinToken = (): UseCardPinTokenResult => { + const { sdk } = useCardSDK(); + const theme = useTheme(); + + const customCss = useMemo( + () => (theme.themeAppearance === 'dark' ? PIN_CSS.dark : PIN_CSS.light), + [theme.themeAppearance], + ); + + const { mutateAsync, isPending, error, data, reset } = useMutation({ + mutationKey: cardQueries.pin.keys.token(), + mutationFn: cardQueries.pin.tokenMutationFn(sdk), + }); + + const generatePinToken = useCallback( + () => mutateAsync({ customCss }), + [mutateAsync, customCss], + ); + + return { + generatePinToken, + isLoading: isPending, + error, + imageUrl: data?.imageUrl ?? null, + reset, + }; +}; + +export default useCardPinToken; diff --git a/app/components/UI/Card/queries/index.ts b/app/components/UI/Card/queries/index.ts index c8c77d04628..5ba4e742b2c 100644 --- a/app/components/UI/Card/queries/index.ts +++ b/app/components/UI/Card/queries/index.ts @@ -1,10 +1,14 @@ +import { pinKeys, pinTokenMutationFn } from './pin'; import { cashbackKeys, cashbackWalletOptions, cashbackWithdrawEstimationOptions, } from './cashback'; - export const cardQueries = { + pin: { + keys: pinKeys, + tokenMutationFn: pinTokenMutationFn, + }, cashback: { keys: cashbackKeys, walletOptions: cashbackWalletOptions, diff --git a/app/components/UI/Card/queries/pin.test.ts b/app/components/UI/Card/queries/pin.test.ts new file mode 100644 index 00000000000..175983e1362 --- /dev/null +++ b/app/components/UI/Card/queries/pin.test.ts @@ -0,0 +1,56 @@ +import { CardSDK } from '../sdk/CardSDK'; +import { pinKeys, pinTokenMutationFn } from './pin'; + +describe('pinKeys', () => { + it('returns the base key for all pin queries', () => { + expect(pinKeys.all()).toEqual(['card', 'pin']); + }); + + it('returns the token mutation key', () => { + expect(pinKeys.token()).toEqual(['card', 'pin', 'token']); + }); +}); + +describe('pinTokenMutationFn', () => { + const mockSdk = { + generateCardPinToken: jest.fn(), + } as unknown as CardSDK; + + it('calls sdk.generateCardPinToken with request', async () => { + const mockResponse = { + token: 'test-token-uuid', + imageUrl: 'https://cards.baanx.com/details-image?token=test-token-uuid', + }; + (mockSdk.generateCardPinToken as jest.Mock).mockResolvedValue(mockResponse); + + const mutationFn = pinTokenMutationFn(mockSdk); + const result = await mutationFn({ + customCss: { backgroundColor: '#FFFFFF', textColor: '#000000' }, + }); + + expect(mockSdk.generateCardPinToken).toHaveBeenCalledWith({ + customCss: { backgroundColor: '#FFFFFF', textColor: '#000000' }, + }); + expect(result).toEqual(mockResponse); + }); + + it('calls sdk.generateCardPinToken without request', async () => { + const mockResponse = { + token: 'test-token-uuid', + imageUrl: 'https://cards.baanx.com/details-image?token=test-token-uuid', + }; + (mockSdk.generateCardPinToken as jest.Mock).mockResolvedValue(mockResponse); + + const mutationFn = pinTokenMutationFn(mockSdk); + const result = await mutationFn(); + + expect(mockSdk.generateCardPinToken).toHaveBeenCalledWith(undefined); + expect(result).toEqual(mockResponse); + }); + + it('throws when sdk is null', async () => { + const mutationFn = pinTokenMutationFn(null); + + await expect(mutationFn()).rejects.toThrow('CardSDK not available'); + }); +}); diff --git a/app/components/UI/Card/queries/pin.ts b/app/components/UI/Card/queries/pin.ts new file mode 100644 index 00000000000..ac53ca5dbdb --- /dev/null +++ b/app/components/UI/Card/queries/pin.ts @@ -0,0 +1,14 @@ +import { CardSDK } from '../sdk/CardSDK'; +import type { CardPinTokenRequest, CardPinTokenResponse } from '../types'; + +export const pinKeys = { + all: () => ['card', 'pin'] as const, + token: () => [...pinKeys.all(), 'token'] as const, +}; + +export const pinTokenMutationFn = + (sdk: CardSDK | null) => + async (request?: CardPinTokenRequest): Promise => { + if (!sdk) throw new Error('CardSDK not available'); + return sdk.generateCardPinToken(request); + }; diff --git a/app/components/UI/Card/routes/index.tsx b/app/components/UI/Card/routes/index.tsx index 92d37921177..f76776460ee 100644 --- a/app/components/UI/Card/routes/index.tsx +++ b/app/components/UI/Card/routes/index.tsx @@ -27,6 +27,7 @@ import RegionSelectorModal from '../components/Onboarding/RegionSelectorModal'; import ConfirmModal from '../components/Onboarding/ConfirmModal'; import RecurringFeeModal from '../components/RecurringFeeModal/RecurringFeeModal'; import DaimoPayModal from '../components/DaimoPayModal/DaimoPayModal'; +import ViewPinBottomSheet from '../components/ViewPinBottomSheet'; import OrderCompleted from '../Views/OrderCompleted/OrderCompleted'; import Cashback from '../Views/Cashback/Cashback'; import { @@ -231,6 +232,10 @@ const CardModalsRoutes = () => ( name={Routes.CARD.MODALS.DAIMO_PAY} component={DaimoPayModal} /> + ); diff --git a/app/components/UI/Card/sdk/CardSDK.test.ts b/app/components/UI/Card/sdk/CardSDK.test.ts index 3670b98b91a..1e3b1864a32 100644 --- a/app/components/UI/Card/sdk/CardSDK.test.ts +++ b/app/components/UI/Card/sdk/CardSDK.test.ts @@ -4105,6 +4105,141 @@ describe('CardSDK', () => { }); }); + describe('generateCardPinToken', () => { + const mockPinTokenResponse = { + token: 'pin-token-uuid-123', + imageUrl: + 'https://cards.baanx.com/details-image?token=pin-token-uuid-123', + }; + + beforeEach(() => { + (getCardBaanxToken as jest.Mock).mockResolvedValue({ + success: true, + tokenData: { accessToken: 'test-access-token' }, + }); + }); + + it('generates card PIN token successfully', async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValue(mockPinTokenResponse), + }); + + const result = await cardSDK.generateCardPinToken(); + + expect(result).toEqual(mockPinTokenResponse); + }); + + it('generates card PIN token with custom CSS', async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValue(mockPinTokenResponse), + }); + + const customCss = { + backgroundColor: '#FFFFFF', + textColor: '#000000', + }; + + const result = await cardSDK.generateCardPinToken({ customCss }); + + expect(result).toEqual(mockPinTokenResponse); + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining('/v1/card/pin/token'), + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ customCss }), + }), + ); + }); + + it('throws INVALID_CREDENTIALS error on 401 response', async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: false, + status: 401, + statusText: 'Unauthorized', + }); + + try { + await cardSDK.generateCardPinToken(); + fail('Expected error to be thrown'); + } catch (error) { + expect(error).toBeInstanceOf(CardError); + expect((error as CardError).type).toBe( + CardErrorType.INVALID_CREDENTIALS, + ); + } + }); + + it('throws INVALID_CREDENTIALS error on 403 response', async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: false, + status: 403, + statusText: 'Forbidden', + }); + + try { + await cardSDK.generateCardPinToken(); + fail('Expected error to be thrown'); + } catch (error) { + expect(error).toBeInstanceOf(CardError); + expect((error as CardError).type).toBe( + CardErrorType.INVALID_CREDENTIALS, + ); + } + }); + + it('throws NO_CARD error on 404 response', async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: false, + status: 404, + statusText: 'Not Found', + }); + + try { + await cardSDK.generateCardPinToken(); + fail('Expected error to be thrown'); + } catch (error) { + expect(error).toBeInstanceOf(CardError); + expect((error as CardError).type).toBe(CardErrorType.NO_CARD); + } + }); + + it('throws SERVER_ERROR on 500 response', async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: false, + status: 500, + statusText: 'Internal Server Error', + }); + + try { + await cardSDK.generateCardPinToken(); + fail('Expected error to be thrown'); + } catch (error) { + expect(error).toBeInstanceOf(CardError); + expect((error as CardError).type).toBe(CardErrorType.SERVER_ERROR); + } + }); + + it('sends authenticated request with bearer token', async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValue(mockPinTokenResponse), + }); + + await cardSDK.generateCardPinToken(); + + expect(global.fetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'Bearer test-access-token', + }), + }), + ); + }); + }); + describe('createOrder', () => { const mockOrderResponse = { orderId: 'order-123', diff --git a/app/components/UI/Card/sdk/CardSDK.ts b/app/components/UI/Card/sdk/CardSDK.ts index 0e6811d0ea8..fc9a8db8a23 100644 --- a/app/components/UI/Card/sdk/CardSDK.ts +++ b/app/components/UI/Card/sdk/CardSDK.ts @@ -49,6 +49,8 @@ import { GetOnboardingConsentResponse, CardDetailsTokenRequest, CardDetailsTokenResponse, + CardPinTokenRequest, + CardPinTokenResponse, CreateOrderRequest, CreateOrderResponse, GetOrderStatusResponse, @@ -1141,6 +1143,45 @@ export class CardSDK { return (await response.json()) as CardDetailsTokenResponse; }; + /** + * Generate a secure token for viewing the card PIN through an image-based display. + * The token is time-limited (~10 minutes) and single-use. + * The PIN is never transmitted as plain text, ensuring PCI compliance. + * + * @param request - Optional customization for the PIN image appearance + * @returns Promise containing the token and imageUrl for displaying the card PIN + */ + generateCardPinToken = async ( + request?: CardPinTokenRequest, + ): Promise => { + const response = await this.makeRequest('/v1/card/pin/token', { + fetchOptions: { + method: 'POST', + ...(request && { body: JSON.stringify(request) }), + }, + authenticated: true, + }); + + if (!response.ok) { + const errorType = + response.status === 401 || response.status === 403 + ? CardErrorType.INVALID_CREDENTIALS + : response.status === 404 + ? CardErrorType.NO_CARD + : CardErrorType.SERVER_ERROR; + + throw this.logAndCreateError( + errorType, + 'Failed to generate card PIN token. Please try again.', + 'generateCardPinToken', + 'card/pin/token', + response.status, + ); + } + + return (await response.json()) as CardPinTokenResponse; + }; + getCardExternalWalletDetails = async ( delegationSettings: DelegationSettingsNetwork[], ): Promise => { diff --git a/app/components/UI/Card/types.ts b/app/components/UI/Card/types.ts index 22417f66212..ec70bdbac89 100644 --- a/app/components/UI/Card/types.ts +++ b/app/components/UI/Card/types.ts @@ -464,6 +464,25 @@ export interface CardDetailsTokenResponse { imageUrl: string; } +/** + * Request body for generating card PIN token + * Used to customize the visual appearance of the PIN image + */ +export interface CardPinTokenRequest { + customCss?: { + backgroundColor?: string; + textColor?: string; + }; +} + +/** + * Response from generating card PIN token + */ +export interface CardPinTokenResponse { + token: string; + imageUrl: string; +} + /** * Payment methods supported for orders */ diff --git a/app/components/UI/Card/util/metrics.ts b/app/components/UI/Card/util/metrics.ts index a0a5c1fdf85..97497367280 100644 --- a/app/components/UI/Card/util/metrics.ts +++ b/app/components/UI/Card/util/metrics.ts @@ -78,6 +78,7 @@ enum CardActions { ADD_TO_WALLET_BUTTON = 'ADD_TO_WALLET_BUTTON', FREEZE_CARD_BUTTON = 'FREEZE_CARD_BUTTON', UNFREEZE_CARD_BUTTON = 'UNFREEZE_CARD_BUTTON', + VIEW_PIN_BUTTON = 'VIEW_PIN_BUTTON', CASHBACK_BUTTON = 'CASHBACK_BUTTON', } diff --git a/app/constants/navigation/Routes.ts b/app/constants/navigation/Routes.ts index 56552a0cd8e..84d69e111c1 100644 --- a/app/constants/navigation/Routes.ts +++ b/app/constants/navigation/Routes.ts @@ -453,6 +453,7 @@ const Routes = { PASSWORD: 'CardPasswordModal', RECURRING_FEE: 'CardRecurringFeeModal', DAIMO_PAY: 'CardDaimoPayModal', + VIEW_PIN: 'CardViewPinModal', }, }, SEND: { diff --git a/locales/languages/en.json b/locales/languages/en.json index 57ebeb0f55a..832ea7949a7 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -6871,12 +6871,16 @@ "title": "Enter password", "description": "Enter your wallet password to view card details.", "description_unfreeze": "Enter your wallet password to resume spending on your card.", + "description_view_pin": "Enter your wallet password to view your card PIN.", "placeholder": "Password", "confirm": "Confirm", "cancel": "Cancel", "error_empty": "Please enter your password", "error_incorrect": "Incorrect password. Please try again." }, + "view_pin_bottomsheet": { + "title": "Your Card PIN" + }, "choose_your_card": { "title": "Choose your card", "upgrade_title": "Upgrade to Metal", @@ -7192,6 +7196,9 @@ "view_card_details": "View card details", "hide_card_details": "Hide card details", "view_card_details_description": "Card number, expiration and CVV", + "view_pin": "View PIN", + "view_pin_description": "View your card PIN securely", + "view_pin_error": "Failed to load PIN. Please try again.", "manage_spending_limit": "Manage limit", "manage_spending_limit_description_restricted": "Limited spending is on", "manage_spending_limit_description_full": "Full access is on", From c0576fc3fe4c0df451ad942f13783bf190722bbd Mon Sep 17 00:00:00 2001 From: metamaskbot Date: Tue, 3 Mar 2026 03:41:23 +0000 Subject: [PATCH 074/131] [skip ci] Bump version number to 3868 --- android/app/build.gradle | 2 +- bitrise.yml | 4 ++-- ios/MetaMask.xcodeproj/project.pbxproj | 12 ++++++------ 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index 1ed86b4b054..1f752db5447 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -188,7 +188,7 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion versionName "7.68.0" - versionCode 3855 + versionCode 3868 testBuildType System.getProperty('testBuildType', 'debug') testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" manifestPlaceholders.MM_BRANCH_KEY_TEST = "$System.env.MM_BRANCH_KEY_TEST" diff --git a/bitrise.yml b/bitrise.yml index bcf72eb99c8..4f8062c7cdb 100644 --- a/bitrise.yml +++ b/bitrise.yml @@ -3519,13 +3519,13 @@ app: VERSION_NAME: 7.68.0 - opts: is_expand: false - VERSION_NUMBER: 3855 + VERSION_NUMBER: 3868 - opts: is_expand: false FLASK_VERSION_NAME: 7.68.0 - opts: is_expand: false - FLASK_VERSION_NUMBER: 3855 + FLASK_VERSION_NUMBER: 3868 - opts: is_expand: false ANDROID_APK_LINK: '' diff --git a/ios/MetaMask.xcodeproj/project.pbxproj b/ios/MetaMask.xcodeproj/project.pbxproj index 096fbcd4845..7795389f7e5 100644 --- a/ios/MetaMask.xcodeproj/project.pbxproj +++ b/ios/MetaMask.xcodeproj/project.pbxproj @@ -1281,7 +1281,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 3855; + CURRENT_PROJECT_VERSION = 3868; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1350,7 +1350,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 3855; + CURRENT_PROJECT_VERSION = 3868; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1416,7 +1416,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 3855; + CURRENT_PROJECT_VERSION = 3868; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1483,7 +1483,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 3855; + CURRENT_PROJECT_VERSION = 3868; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1646,7 +1646,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 3855; + CURRENT_PROJECT_VERSION = 3868; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1716,7 +1716,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 3855; + CURRENT_PROJECT_VERSION = 3868; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; From 6a6fc2018365415d9593175a9eec01afeb72127e Mon Sep 17 00:00:00 2001 From: "runway-github[bot]" <73448015+runway-github[bot]@users.noreply.github.com> Date: Tue, 3 Mar 2026 14:54:15 +0000 Subject: [PATCH 075/131] chore(runway): cherry-pick fix: Bump @metamask/transaction-pay-controller to ^16.1.1 (#26897) - fix: Bump @metamask/transaction-pay-controller to ^16.1.1 cp-7.68.0 (#26782) ## **Description** Bumps @metamask/transaction-pay-controller to ^16.1.1. It has support for gasless predict withdraw. ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: ## **Manual testing steps** ## **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] > **Medium Risk** > Although this is a patch-level dependency bump, it touches `transaction-pay` behavior and updates transitive controller versions (`bridge-controller`, `remote-feature-flag-controller`), which could impact transaction/payment flows. > > **Overview** > Updates the `@metamask/transaction-pay-controller` dependency from `^16.1.0` to `^16.1.1`. > > The lockfile is refreshed to pull in the new `transaction-pay-controller` release and its updated transitive requirements, including newer `@metamask/bridge-controller` and `@metamask/remote-feature-flag-controller` versions. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 4cbee1104a48bb64921551bb6bf833db69d3a76b. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). Signed-off-by: dan437 <80175477+dan437@users.noreply.github.com> [ba615cf](https://github.com/MetaMask/metamask-mobile/commit/ba615cf5a06e3975d62fe341fdf2406e1db0c1f2) Signed-off-by: dan437 <80175477+dan437@users.noreply.github.com> Co-authored-by: dan437 <80175477+dan437@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 40 ++++++++++++++++++++-------------------- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/package.json b/package.json index eb696f97f17..db870557be1 100644 --- a/package.json +++ b/package.json @@ -294,7 +294,7 @@ "@metamask/swappable-obj-proxy": "^2.1.0", "@metamask/swaps-controller": "^15.0.0", "@metamask/transaction-controller": "^62.19.0", - "@metamask/transaction-pay-controller": "^16.1.0", + "@metamask/transaction-pay-controller": "^16.1.1", "@metamask/tron-wallet-snap": "^1.21.1", "@metamask/utils": "^11.8.1", "@myx-trade/sdk": "^0.1.265", diff --git a/yarn.lock b/yarn.lock index 849ccebbbbc..869fde2c193 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7698,7 +7698,7 @@ __metadata: languageName: node linkType: hard -"@metamask/assets-controllers@npm:100.0.3, @metamask/assets-controllers@npm:^100.0.2, @metamask/assets-controllers@npm:^100.0.3": +"@metamask/assets-controllers@npm:100.0.3, @metamask/assets-controllers@npm:^100.0.3": version: 100.0.3 resolution: "@metamask/assets-controllers@npm:100.0.3" dependencies: @@ -7910,9 +7910,9 @@ __metadata: languageName: node linkType: hard -"@metamask/bridge-controller@npm:^67.1.1, @metamask/bridge-controller@npm:^67.2.0": - version: 67.2.0 - resolution: "@metamask/bridge-controller@npm:67.2.0" +"@metamask/bridge-controller@npm:^67.1.1, @metamask/bridge-controller@npm:^67.4.0": + version: 67.4.0 + resolution: "@metamask/bridge-controller@npm:67.4.0" dependencies: "@ethersproject/address": "npm:^5.7.0" "@ethersproject/bignumber": "npm:^5.7.0" @@ -7920,7 +7920,7 @@ __metadata: "@ethersproject/contracts": "npm:^5.7.0" "@ethersproject/providers": "npm:^5.7.0" "@metamask/accounts-controller": "npm:^36.0.1" - "@metamask/assets-controllers": "npm:^100.0.2" + "@metamask/assets-controllers": "npm:^100.0.3" "@metamask/base-controller": "npm:^9.0.0" "@metamask/controller-utils": "npm:^11.19.0" "@metamask/gas-fee-controller": "npm:^26.0.3" @@ -7931,14 +7931,14 @@ __metadata: "@metamask/network-controller": "npm:^30.0.0" "@metamask/polling-controller": "npm:^16.0.3" "@metamask/profile-sync-controller": "npm:^27.1.0" - "@metamask/remote-feature-flag-controller": "npm:^4.0.0" + "@metamask/remote-feature-flag-controller": "npm:^4.1.0" "@metamask/snaps-controllers": "npm:^17.2.0" - "@metamask/transaction-controller": "npm:^62.18.0" + "@metamask/transaction-controller": "npm:^62.19.0" "@metamask/utils": "npm:^11.9.0" bignumber.js: "npm:^9.1.2" reselect: "npm:^5.1.1" uuid: "npm:^8.3.2" - checksum: 10/524ed3663b3e1f87a34171dc0b43b7b5751288d410d90b5d266923e71c974b47f3ec4e87d95baf6aa83ff41120b7625488f563e1c63048657241b24711089a95 + checksum: 10/a3db92c7a4212e693edb16b081dc24b4108ce7d81a94688e1de3512580b7e1c2ea38147e5b43e98b606a1556692af9f18d81a39669483284dc6c3dabe5f093b6 languageName: node linkType: hard @@ -9516,16 +9516,16 @@ __metadata: languageName: node linkType: hard -"@metamask/remote-feature-flag-controller@npm:^4.0.0": - version: 4.0.0 - resolution: "@metamask/remote-feature-flag-controller@npm:4.0.0" +"@metamask/remote-feature-flag-controller@npm:^4.0.0, @metamask/remote-feature-flag-controller@npm:^4.1.0": + version: 4.1.0 + resolution: "@metamask/remote-feature-flag-controller@npm:4.1.0" dependencies: "@metamask/base-controller": "npm:^9.0.0" - "@metamask/controller-utils": "npm:^11.17.0" + "@metamask/controller-utils": "npm:^11.19.0" "@metamask/messenger": "npm:^0.3.0" "@metamask/utils": "npm:^11.9.0" uuid: "npm:^8.3.2" - checksum: 10/5cb0e380d9b68666564f8e34add9a3c4cab1907bc3e0abddf37d1c06a6311f07cc6718baf4091aeab44c2d2cde378bb072e07a713546be3d5c9358fda5d75ab8 + checksum: 10/30122c316e788adc2abb6875eefef189946e2af469c1b217f8617ade17693666cde896e043fcb2a65874b2e62d4499b05456345dd2425dee6f9ea92f1f2d12e3 languageName: node linkType: hard @@ -10155,30 +10155,30 @@ __metadata: languageName: node linkType: hard -"@metamask/transaction-pay-controller@npm:^16.1.0": - version: 16.1.0 - resolution: "@metamask/transaction-pay-controller@npm:16.1.0" +"@metamask/transaction-pay-controller@npm:^16.1.1": + version: 16.1.1 + resolution: "@metamask/transaction-pay-controller@npm:16.1.1" dependencies: "@ethersproject/abi": "npm:^5.7.0" "@ethersproject/contracts": "npm:^5.7.0" "@ethersproject/providers": "npm:^5.7.0" "@metamask/assets-controllers": "npm:^100.0.3" "@metamask/base-controller": "npm:^9.0.0" - "@metamask/bridge-controller": "npm:^67.2.0" + "@metamask/bridge-controller": "npm:^67.4.0" "@metamask/bridge-status-controller": "npm:^67.0.1" "@metamask/controller-utils": "npm:^11.19.0" "@metamask/gas-fee-controller": "npm:^26.0.3" "@metamask/messenger": "npm:^0.3.0" "@metamask/metamask-eth-abis": "npm:^3.1.1" "@metamask/network-controller": "npm:^30.0.0" - "@metamask/remote-feature-flag-controller": "npm:^4.0.0" + "@metamask/remote-feature-flag-controller": "npm:^4.1.0" "@metamask/transaction-controller": "npm:^62.19.0" "@metamask/utils": "npm:^11.9.0" bignumber.js: "npm:^9.1.2" bn.js: "npm:^5.2.1" immer: "npm:^9.0.6" lodash: "npm:^4.17.21" - checksum: 10/bfba59abf458bd7af18741b7697d2f0aedb3cc6a86c275626d4cbcac4f4b5b6b96ceb28809b7d1316e466e96c913f8cef3b2f1e73f154ea9aa3eb66856e5bc3d + checksum: 10/23f537240235afb68bb968f19ccbbcd638fbc6e8e04db2e9d6edaec873739de12261b55aff532b5f533b184a22b46e99ed76840287c5f729907367852a5be001 languageName: node linkType: hard @@ -35477,7 +35477,7 @@ __metadata: "@metamask/test-dapp-multichain": "npm:^0.17.1" "@metamask/test-dapp-solana": "npm:^0.3.0" "@metamask/transaction-controller": "npm:^62.19.0" - "@metamask/transaction-pay-controller": "npm:^16.1.0" + "@metamask/transaction-pay-controller": "npm:^16.1.1" "@metamask/tron-wallet-snap": "npm:^1.21.1" "@metamask/utils": "npm:^11.8.1" "@myx-trade/sdk": "npm:^0.1.265" From 5c7c134205c0aa6f99bee6df491b60dd8076af5f Mon Sep 17 00:00:00 2001 From: metamaskbot Date: Tue, 3 Mar 2026 14:55:52 +0000 Subject: [PATCH 076/131] [skip ci] Bump version number to 3873 --- android/app/build.gradle | 2 +- bitrise.yml | 4 ++-- ios/MetaMask.xcodeproj/project.pbxproj | 12 ++++++------ 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index 1f752db5447..3a72ec7fa79 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -188,7 +188,7 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion versionName "7.68.0" - versionCode 3868 + versionCode 3873 testBuildType System.getProperty('testBuildType', 'debug') testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" manifestPlaceholders.MM_BRANCH_KEY_TEST = "$System.env.MM_BRANCH_KEY_TEST" diff --git a/bitrise.yml b/bitrise.yml index 4f8062c7cdb..f03f9903849 100644 --- a/bitrise.yml +++ b/bitrise.yml @@ -3519,13 +3519,13 @@ app: VERSION_NAME: 7.68.0 - opts: is_expand: false - VERSION_NUMBER: 3868 + VERSION_NUMBER: 3873 - opts: is_expand: false FLASK_VERSION_NAME: 7.68.0 - opts: is_expand: false - FLASK_VERSION_NUMBER: 3868 + FLASK_VERSION_NUMBER: 3873 - opts: is_expand: false ANDROID_APK_LINK: '' diff --git a/ios/MetaMask.xcodeproj/project.pbxproj b/ios/MetaMask.xcodeproj/project.pbxproj index 7795389f7e5..daaee676e75 100644 --- a/ios/MetaMask.xcodeproj/project.pbxproj +++ b/ios/MetaMask.xcodeproj/project.pbxproj @@ -1281,7 +1281,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 3868; + CURRENT_PROJECT_VERSION = 3873; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1350,7 +1350,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 3868; + CURRENT_PROJECT_VERSION = 3873; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1416,7 +1416,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 3868; + CURRENT_PROJECT_VERSION = 3873; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1483,7 +1483,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 3868; + CURRENT_PROJECT_VERSION = 3873; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1646,7 +1646,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 3868; + CURRENT_PROJECT_VERSION = 3873; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1716,7 +1716,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 3868; + CURRENT_PROJECT_VERSION = 3873; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; From 5f8c7c04007a36e7862f885e34e69cc4d87f957a Mon Sep 17 00:00:00 2001 From: "runway-github[bot]" <73448015+runway-github[bot]@users.noreply.github.com> Date: Tue, 3 Mar 2026 15:58:23 +0000 Subject: [PATCH 077/131] chore(runway): cherry-pick feat: stocks section in explore cp-7.68.0 (#26768) - feat: stocks section in explore cp-7.68.0 (#26426) ## **Description** This PR aims to add the stocks section in the explore page. In order to get this PR to the finish line, I have: - Updated the core package [here](https://github.com/MetaMask/core/pull/8019) to fix an issue with the `limit` param in the search endpoint request and support for a higher limit when calling with the QueryString `Ondo` - Asked the design team to add the corporate icon [here](https://github.com/MetaMask/metamask-mobile/pull/26492) - Raised the following issues in api-platform and got a fix - https://consensys.slack.com/archives/C03MLR70YSK/p1771489684959419 - https://consensys.slack.com/archives/C03MLR70YSK/p1771850443811719 - Added Geo-blocking (hardcoded for now but it should live on the API at some point) - Modified order of sections in explore page following @chaoticgoodpanda guidance - Modified predictions section to not be a carousel ## **Changelog** CHANGELOG entry: added stocks section to explore page ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/ASSETS-2635 & https://consensyssoftware.atlassian.net/browse/ASSETS-2632 ## **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** [Here](https://consensys.slack.com/archives/C07NF2K42LE/p1771849520486939) is a full video explaining the e2e functionality. https://github.com/user-attachments/assets/ac8c7c52-30c6-4913-9e69-7b8fe8e0db52 ## **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] > **Medium Risk** > Adds a new Explore section and full-screen view backed by a new search hook with geo-restriction logic and new navigation routes, which could affect what data users see and how filtering/refetch behaves. Refactors Trending Tokens full view into shared layout/components, so regressions could impact existing trending-token filtering UI and bottom sheets. > > **Overview** > Adds a new **Stocks** section to Explore, including a new `RWATokensFullView` screen/route and `useRwaTokens` hook that queries Ondo RWA assets (with production geo-blocking) and supports search, network, and sort filters. > > Refactors `TrendingTokensFullView` into the `UI/Trending` area and introduces shared `TokenListPageLayout`, `FilterBar`, and `useTokenListFilters` to unify header/search/filter behavior across token list full views; updates the network bottom sheet to take an explicit `networks` prop and adjusts token sorting to push missing market data to the end. Explore/QuickActions/predictions presentation, navigation, mocks, and smoke tests are updated to include the new Stocks section and new predictions row rendering. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 2be0c5a0f95d227967addb09b5779495b5fa66d5. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). [f8c1bda](https://github.com/MetaMask/metamask-mobile/commit/f8c1bdab58fa27a50ac10c286337cce82f5a0bae) Co-authored-by: Juanmi <95381763+juanmigdr@users.noreply.github.com> --- app/components/Nav/Main/MainNavigator.js | 8 +- .../__snapshots__/MainNavigator.test.tsx.snap | 30 + .../RWATokensFullView.test.tsx | 214 ++++++ .../RWATokensFullView/RWATokensFullView.tsx | 64 ++ .../TrendingTokensFullView.test.tsx | 20 +- .../TrendingTokensFullView.tsx | 265 ++++++++ .../components/FilterBar/FilterBar.tsx | 117 ++++ .../TokenListPageLayout.tsx | 129 ++++ .../TrendingTokenRowItem.tsx | 10 - .../TrendingTokenNetworkBottomSheet.test.tsx | 101 ++- .../TrendingTokenNetworkBottomSheet.tsx | 5 +- .../useNetworkName/useNetworkName.test.ts | 89 +++ .../hooks/useNetworkName/useNetworkName.ts | 50 ++ .../hooks/useRwaTokens/useRwaTokens.test.ts | 291 +++++++++ .../hooks/useRwaTokens/useRwaTokens.ts | 147 +++++ .../useTokenListFilters.test.ts | 317 +++++++++ .../useTokenListFilters.ts | 235 +++++++ .../useTrendingSearch/useTrendingSearch.ts | 40 +- .../Trending/utils/sortTrendingTokens.test.ts | 302 +++------ .../UI/Trending/utils/sortTrendingTokens.ts | 73 ++- .../UI/Trending/utils/trendingNetworksList.ts | 15 + .../TrendingTokensFullView.tsx | 617 ------------------ .../ExploreSearchResults.test.tsx | 35 +- .../components/QuickActions/QuickActions.tsx | 46 +- .../hooks/useExploreSearch.test.ts | 17 +- .../Views/TrendingView/sections.config.tsx | 77 ++- app/constants/navigation/Routes.ts | 1 + app/core/NavigationService/types.ts | 1 + locales/languages/en.json | 1 + .../mock-responses/trending-api-mocks.ts | 26 + tests/component-view/renderers/trending.ts | 7 +- .../Trending/TrendingView.selectors.ts | 5 +- tests/page-objects/Trending/TrendingView.ts | 11 +- tests/smoke/trending/trending-feed.spec.ts | 47 +- 34 files changed, 2417 insertions(+), 996 deletions(-) create mode 100644 app/components/UI/Trending/Views/RWATokensFullView/RWATokensFullView.test.tsx create mode 100644 app/components/UI/Trending/Views/RWATokensFullView/RWATokensFullView.tsx rename app/components/{Views/TrendingTokens => UI/Trending/Views}/TrendingTokensFullView/TrendingTokensFullView.test.tsx (95%) create mode 100644 app/components/UI/Trending/Views/TrendingTokensFullView/TrendingTokensFullView.tsx create mode 100644 app/components/UI/Trending/components/FilterBar/FilterBar.tsx create mode 100644 app/components/UI/Trending/components/TokenListPageLayout/TokenListPageLayout.tsx create mode 100644 app/components/UI/Trending/hooks/useNetworkName/useNetworkName.test.ts create mode 100644 app/components/UI/Trending/hooks/useNetworkName/useNetworkName.ts create mode 100644 app/components/UI/Trending/hooks/useRwaTokens/useRwaTokens.test.ts create mode 100644 app/components/UI/Trending/hooks/useRwaTokens/useRwaTokens.ts create mode 100644 app/components/UI/Trending/hooks/useTokenListFilters/useTokenListFilters.test.ts create mode 100644 app/components/UI/Trending/hooks/useTokenListFilters/useTokenListFilters.ts delete mode 100644 app/components/Views/TrendingTokens/TrendingTokensFullView/TrendingTokensFullView.tsx diff --git a/app/components/Nav/Main/MainNavigator.js b/app/components/Nav/Main/MainNavigator.js index 809e9e923a0..85ba523818f 100644 --- a/app/components/Nav/Main/MainNavigator.js +++ b/app/components/Nav/Main/MainNavigator.js @@ -30,8 +30,9 @@ import AssetDetails from '../../Views/AssetDetails'; import AddAsset from '../../Views/AddAsset/AddAsset'; import NftFullView from '../../Views/NftFullView'; import TokensFullView from '../../Views/TokensFullView'; -import TrendingTokensFullView from '../../Views/TrendingTokens/TrendingTokensFullView/TrendingTokensFullView'; import DeFiFullView from '../../Views/DeFiFullView'; +import TrendingTokensFullView from '../../UI/Trending/Views/TrendingTokensFullView/TrendingTokensFullView'; +import RWATokensFullView from '../../UI/Trending/Views/RWATokensFullView/RWATokensFullView'; import { RevealPrivateCredential } from '../../Views/RevealPrivateCredential'; import WalletConnectSessions from '../../Views/WalletConnectSessions'; import OfflineMode from '../../Views/OfflineMode'; @@ -1009,6 +1010,11 @@ const MainNavigator = () => { component={TrendingTokensFullView} options={slideFromRightAnimation} /> + + + + ({ + useNavigation: () => ({ + navigate: mockNavigate, + goBack: mockGoBack, + }), + createNavigatorFactory: () => ({}), +})); + +jest.mock('../../hooks/useRwaTokens/useRwaTokens'); +const mockUseRwaTokens = jest.mocked(useRwaTokens); + +jest.mock( + '../../components/TrendingTokensList/TrendingTokensList', + (): typeof TrendingTokensList => { + const { View, Text, ScrollView } = jest.requireActual('react-native'); + return ({ trendingTokens, refreshControl }) => ( + + {trendingTokens.map((token, index) => ( + + ); + }, +); + +const createMockToken = ( + overrides: Partial+ + ))} +{token.name} += {}, +): TrendingAsset => ({ + assetId: 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + name: 'OUSG Token', + symbol: 'OUSG', + decimals: 18, + price: '100.50', + aggregatedUsdVolume: 500000, + marketCap: 1000000000, + priceChangePct: { h24: '1.5' }, + ...overrides, +}); + +const arrangeMocks = () => { + const mockRefetch = jest.fn(); + + const setRwaTokensMock = (options: { + data?: TrendingAsset[]; + isLoading?: boolean; + }) => { + mockUseRwaTokens.mockReturnValue({ + data: options.data ?? [], + isLoading: options.isLoading ?? false, + refetch: mockRefetch, + }); + }; + + setRwaTokensMock({}); + + return { + mockRefetch, + mockGoBack, + mockNavigate, + setRwaTokensMock, + }; +}; + +describe('RWATokensFullView', () => { + const renderRWAFullView = () => + renderWithProvider( + + , + { state: mockState }, + false, + ); + + beforeEach(() => { + jest.clearAllMocks(); + arrangeMocks(); + }); + + it('renders header with Stocks title', () => { + const { getByText } = renderRWAFullView(); + + expect(getByText('Stocks')).toBeOnTheScreen(); + }); + + it('does not render the time filter button', () => { + const { queryByTestId } = renderRWAFullView(); + + expect(queryByTestId('24h-button')).toBeNull(); + }); + + it('renders price-change and network filter buttons', () => { + const { getByTestId } = renderRWAFullView(); + + expect(getByTestId('price-change-button')).toBeOnTheScreen(); + expect(getByTestId('all-networks-button')).toBeOnTheScreen(); + }); + + it('navigates back when back button is pressed', async () => { + const mocks = arrangeMocks(); + const { getByTestId } = renderRWAFullView(); + + const backButton = getByTestId('rwa-tokens-header-back-button'); + await userEvent.press(backButton); + + expect(mocks.mockGoBack).toHaveBeenCalled(); + }); + + it('displays skeleton loader when loading', () => { + const mocks = arrangeMocks(); + mocks.setRwaTokensMock({ data: [], isLoading: true }); + + const { getByTestId } = renderRWAFullView(); + + expect(getByTestId(TEST_IDS.skeleton)).toBeOnTheScreen(); + }); + + it('displays empty error state when results are empty', () => { + const mocks = arrangeMocks(); + mocks.setRwaTokensMock({ data: [] }); + + const { getByTestId } = renderRWAFullView(); + + expect(getByTestId(TEST_IDS.emptyErrorState)).toBeOnTheScreen(); + }); + + it('displays stocks data from useRwaTokens', () => { + const stockTokens = [ + createMockToken({ + name: 'OUSG Token', + assetId: 'eip155:1/erc20:0xstock1', + }), + createMockToken({ + name: 'USDY Token', + assetId: 'eip155:1/erc20:0xstock2', + }), + ]; + + const mocks = arrangeMocks(); + mocks.setRwaTokensMock({ data: stockTokens }); + + const { getByText, getByTestId } = renderRWAFullView(); + + expect(getByTestId(TEST_IDS.tokensList)).toBeOnTheScreen(); + expect(getByText('OUSG Token')).toBeOnTheScreen(); + expect(getByText('USDY Token')).toBeOnTheScreen(); + }); + + it('calls refetch when pull-to-refresh is triggered', () => { + const stockTokens = [ + createMockToken({ name: 'OUSG', assetId: 'eip155:1/erc20:0xstock1' }), + ]; + + const mocks = arrangeMocks(); + mocks.setRwaTokensMock({ data: stockTokens }); + + const { getByTestId, UNSAFE_getByType } = renderRWAFullView(); + + expect(getByTestId(TEST_IDS.tokensList)).toBeOnTheScreen(); + const { RefreshControl } = jest.requireActual('react-native'); + const refreshControl = UNSAFE_getByType(RefreshControl); + fireEvent(refreshControl, 'refresh'); + + expect(mocks.mockRefetch).toHaveBeenCalledTimes(1); + }); + + it('opens network bottom sheet when button is pressed', async () => { + const { getByTestId } = renderRWAFullView(); + + const networkButton = getByTestId('all-networks-button'); + await userEvent.press(networkButton); + + expect( + getByTestId('trending-token-network-bottom-sheet'), + ).toBeOnTheScreen(); + }); + + it('opens price change bottom sheet when button is pressed', async () => { + const { getByTestId } = renderRWAFullView(); + + const priceChangeButton = getByTestId('price-change-button'); + await userEvent.press(priceChangeButton); + + expect( + getByTestId('trending-token-price-change-bottom-sheet'), + ).toBeOnTheScreen(); + }); +}); diff --git a/app/components/UI/Trending/Views/RWATokensFullView/RWATokensFullView.tsx b/app/components/UI/Trending/Views/RWATokensFullView/RWATokensFullView.tsx new file mode 100644 index 00000000000..f008e21f776 --- /dev/null +++ b/app/components/UI/Trending/Views/RWATokensFullView/RWATokensFullView.tsx @@ -0,0 +1,64 @@ +import React, { useCallback, useMemo } from 'react'; +import { strings } from '../../../../../../locales/i18n'; +import type { TrendingAsset } from '@metamask/assets-controllers'; +import { + PriceChangeOption, + TimeOption, +} from '../../components/TrendingTokensBottomSheet'; +import { useRwaTokens } from '../../hooks/useRwaTokens/useRwaTokens'; +import { useTokenListFilters } from '../../hooks/useTokenListFilters/useTokenListFilters'; +import TokenListPageLayout from '../../components/TokenListPageLayout/TokenListPageLayout'; +import { RWA_NETWORKS_LIST } from '../../utils/trendingNetworksList'; + +const RWATokensFullView = () => { + const filters = useTokenListFilters({ + timeOption: TimeOption.TwentyFourHours, + }); + + const { + data: searchResults, + isLoading, + refetch: refetchStocks, + } = useRwaTokens({ + searchQuery: filters.searchQuery || undefined, + chainIds: filters.selectedNetwork, + sortTrendingTokensOptions: { + option: + filters.selectedPriceChangeOption ?? PriceChangeOption.PriceChange, + direction: filters.priceChangeSortDirection, + }, + }); + + const trendingTokens = useMemo+ ( + () => (searchResults.length === 0 ? [] : searchResults), + [searchResults], + ); + + const handleRefresh = useCallback(async () => { + filters.setRefreshing(true); + try { + refetchStocks?.(); + } catch (error) { + console.warn('Failed to refresh stocks:', error); + } finally { + filters.setRefreshing(false); + } + }, [refetchStocks, filters]); + + return ( + + ); +}; + +RWATokensFullView.displayName = 'RWATokensFullView'; + +export default RWATokensFullView; diff --git a/app/components/Views/TrendingTokens/TrendingTokensFullView/TrendingTokensFullView.test.tsx b/app/components/UI/Trending/Views/TrendingTokensFullView/TrendingTokensFullView.test.tsx similarity index 95% rename from app/components/Views/TrendingTokens/TrendingTokensFullView/TrendingTokensFullView.test.tsx rename to app/components/UI/Trending/Views/TrendingTokensFullView/TrendingTokensFullView.test.tsx index 4f72a2278c2..37b97b7dbb2 100644 --- a/app/components/Views/TrendingTokens/TrendingTokensFullView/TrendingTokensFullView.test.tsx +++ b/app/components/UI/Trending/Views/TrendingTokensFullView/TrendingTokensFullView.test.tsx @@ -1,22 +1,22 @@ import React from 'react'; import { render, userEvent, fireEvent } from '@testing-library/react-native'; import { Metrics, SafeAreaProvider } from 'react-native-safe-area-context'; -import renderWithProvider from '../../../../util/test/renderWithProvider'; +import renderWithProvider from '../../../../../util/test/renderWithProvider'; import TrendingTokensFullView, { TrendingTokensData, TrendingTokensDataProps, } from './TrendingTokensFullView'; import type { TrendingAsset } from '@metamask/assets-controllers'; -import { useTrendingSearch } from '../../../UI/Trending/hooks/useTrendingSearch/useTrendingSearch'; +import { useTrendingSearch } from '../../hooks/useTrendingSearch/useTrendingSearch'; import { TimeOption, PriceChangeOption, -} from '../../../UI/Trending/components/TrendingTokensBottomSheet'; -import { TrendingFilterContext } from '../../../UI/Trending/components/TrendingTokensList/TrendingTokensList'; +} from '../../components/TrendingTokensBottomSheet'; +import { TrendingFilterContext } from '../../components/TrendingTokensList/TrendingTokensList'; -import { useTrendingRequest } from '../../../UI/Trending/hooks/useTrendingRequest/useTrendingRequest'; -import type TrendingTokensList from '../../../UI/Trending/components/TrendingTokensList'; -import mockState from '../../../../util/test/initial-root-state'; +import { useTrendingRequest } from '../../hooks/useTrendingRequest/useTrendingRequest'; +import type TrendingTokensList from '../../components/TrendingTokensList'; +import mockState from '../../../../../util/test/initial-root-state'; const TEST_IDS = { skeleton: 'trending-tokens-skeleton', @@ -42,14 +42,14 @@ jest.mock('@react-navigation/native', () => ({ createNavigatorFactory: () => ({}), })); -jest.mock('../../../UI/Trending/hooks/useTrendingRequest/useTrendingRequest'); +jest.mock('../../hooks/useTrendingRequest/useTrendingRequest'); const mockUseTrendingRequest = jest.mocked(useTrendingRequest); -jest.mock('../../../UI/Trending/hooks/useTrendingSearch/useTrendingSearch'); +jest.mock('../../hooks/useTrendingSearch/useTrendingSearch'); const mockUseTrendingSearch = jest.mocked(useTrendingSearch); jest.mock( - '../../../UI/Trending/components/TrendingTokensList/TrendingTokensList', + '../../components/TrendingTokensList/TrendingTokensList', (): typeof TrendingTokensList => { const { View, Text, ScrollView } = jest.requireActual('react-native'); return ({ trendingTokens, refreshControl }) => ( diff --git a/app/components/UI/Trending/Views/TrendingTokensFullView/TrendingTokensFullView.tsx b/app/components/UI/Trending/Views/TrendingTokensFullView/TrendingTokensFullView.tsx new file mode 100644 index 00000000000..3d711c9d538 --- /dev/null +++ b/app/components/UI/Trending/Views/TrendingTokensFullView/TrendingTokensFullView.tsx @@ -0,0 +1,265 @@ +import React, { useCallback, useMemo, useState } from 'react'; +import { View, TouchableOpacity, RefreshControl } from 'react-native'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { strings } from '../../../../../../locales/i18n'; +import TrendingTokensList, { + TrendingFilterContext, +} from '../../components/TrendingTokensList/TrendingTokensList'; +import TrendingTokensSkeleton from '../../components/TrendingTokenSkeleton/TrendingTokensSkeleton'; +import { + SortTrendingBy, + type TrendingAsset, +} from '@metamask/assets-controllers'; +import Icon, { + IconName, + IconColor, + IconSize, +} from '../../../../../component-library/components/Icons/Icon'; +import Text from '../../../../../component-library/components/Texts/Text'; +import { + TrendingTokenTimeBottomSheet, + PriceChangeOption, + TimeOption, +} from '../../components/TrendingTokensBottomSheet'; +import { sortTrendingTokens } from '../../utils/sortTrendingTokens'; +import { useTrendingSearch } from '../../hooks/useTrendingSearch/useTrendingSearch'; +import { useTokenListFilters } from '../../hooks/useTokenListFilters/useTokenListFilters'; +import EmptyErrorTrendingState from '../../../../Views/TrendingView/components/EmptyErrorState/EmptyErrorTrendingState'; +import EmptySearchResultState from '../../../../Views/TrendingView/components/EmptyErrorState/EmptySearchResultState'; +import TrendingFeedSessionManager from '../../services/TrendingFeedSessionManager'; +import { useSearchTracking } from '../../hooks/useSearchTracking/useSearchTracking'; +import TokenListPageLayout from '../../components/TokenListPageLayout/TokenListPageLayout'; +import { TRENDING_NETWORKS_LIST } from '../../utils/trendingNetworksList'; +import type { Theme } from '../../../../../util/theme/models'; + +export interface TrendingTokensDataProps { + isLoading: boolean; + refreshing: boolean; + trendingTokens: TrendingAsset[]; + handleRefresh: () => void; + selectedTimeOption: TimeOption; + filterContext: TrendingFilterContext; + theme: Theme; + + search: { + searchResults: TrendingAsset[]; + searchQuery: string; + }; +} + +export const TrendingTokensData = (props: TrendingTokensDataProps) => { + const { + isLoading, + refreshing, + trendingTokens, + search, + handleRefresh, + selectedTimeOption, + filterContext, + theme, + } = props; + + const tw = useTailwind(); + + const isSearching = search.searchQuery.trim().length > 0; + const hasSearchResults = search.searchResults.length > 0; + + if (isLoading) { + return ( + + {Array.from({ length: 12 }).map((_, index) => ( + + ); + } + + if (isSearching && !hasSearchResults) { + return+ ))} + ; + } + + if (!isSearching && !hasSearchResults) { + return ; + } + + return ( + + + ); +}; + +const TrendingTokensFullView = () => { + const tw = useTailwind(); + const sessionManager = TrendingFeedSessionManager.getInstance(); + const filters = useTokenListFilters(); + + const [sortBy, setSortBy] = useState+ } + /> + (undefined); + const [showTimeBottomSheet, setShowTimeBottomSheet] = useState(false); + + const { + data: searchResults, + isLoading, + refetch: refetchTokensSection, + } = useTrendingSearch({ + searchQuery: filters.searchQuery || undefined, + sortBy, + chainIds: filters.selectedNetwork, + }); + + const trendingTokens = useMemo(() => { + if (searchResults.length === 0) { + return []; + } + + if (filters.searchQuery?.trim()) { + return searchResults; + } + + if (!filters.selectedPriceChangeOption) { + return searchResults; + } + + return sortTrendingTokens( + searchResults, + filters.selectedPriceChangeOption, + filters.priceChangeSortDirection, + filters.selectedTimeOption, + ); + }, [ + searchResults, + filters.searchQuery, + filters.selectedPriceChangeOption, + filters.priceChangeSortDirection, + filters.selectedTimeOption, + ]); + + useSearchTracking({ + searchQuery: filters.searchQuery, + resultsCount: trendingTokens.length, + isLoading, + timeFilter: filters.selectedTimeOption, + sortOption: + filters.selectedPriceChangeOption || PriceChangeOption.PriceChange, + networkFilter: + filters.selectedNetwork && filters.selectedNetwork.length > 0 + ? filters.selectedNetwork[0] + : 'all', + }); + + const { + selectedTimeOption, + selectedPriceChangeOption, + selectedNetwork, + setSelectedTimeOption, + } = filters; + + const handleTimeSelect = useCallback( + (selectedSortBy: SortTrendingBy, timeOption: TimeOption) => { + const previousValue = selectedTimeOption; + setSortBy(selectedSortBy); + setSelectedTimeOption(timeOption); + + if (timeOption !== previousValue) { + sessionManager.trackFilterChange({ + filter_type: 'time', + previous_value: previousValue, + new_value: timeOption, + time_filter: timeOption, + sort_option: + selectedPriceChangeOption || PriceChangeOption.PriceChange, + network_filter: + selectedNetwork && selectedNetwork.length > 0 + ? selectedNetwork[0] + : 'all', + }); + } + }, + [ + selectedTimeOption, + selectedPriceChangeOption, + selectedNetwork, + setSelectedTimeOption, + sessionManager, + ], + ); + + const handle24hPress = useCallback(() => { + setShowTimeBottomSheet(true); + }, []); + + const { setRefreshing } = filters; + + const handleRefresh = useCallback(async () => { + setRefreshing(true); + try { + refetchTokensSection?.(); + } catch (error) { + console.warn('Failed to refresh trending tokens:', error); + } finally { + setRefreshing(false); + } + }, [refetchTokensSection, setRefreshing]); + + const timeFilterButton = ( + + + ); + + return ( ++ ++ {filters.selectedTimeOption} + ++ setShowTimeBottomSheet(false)} + onTimeSelect={handleTimeSelect} + selectedTime={filters.selectedTimeOption} + /> + } + /> + ); +}; + +TrendingTokensFullView.displayName = 'TrendingTokensFullView'; + +export default TrendingTokensFullView; diff --git a/app/components/UI/Trending/components/FilterBar/FilterBar.tsx b/app/components/UI/Trending/components/FilterBar/FilterBar.tsx new file mode 100644 index 00000000000..3ecb916d3a5 --- /dev/null +++ b/app/components/UI/Trending/components/FilterBar/FilterBar.tsx @@ -0,0 +1,117 @@ +import React from 'react'; +import { View, TouchableOpacity } from 'react-native'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import Icon, { + IconName, + IconColor, + IconSize, +} from '../../../../../component-library/components/Icons/Icon'; +import Text from '../../../../../component-library/components/Texts/Text'; + +interface FilterButtonProps { + testID: string; + label: string; + onPress: () => void; + disabled?: boolean; + numberOfLines?: number; + ellipsizeMode?: 'tail' | 'head' | 'middle' | 'clip'; + /** Extra horizontal padding (px-3) vs default (p-2) */ + wide?: boolean; +} + +const FilterButton: React.FC = ({ + testID, + label, + onPress, + disabled = false, + numberOfLines, + ellipsizeMode, + wide = false, +}) => { + const tw = useTailwind(); + + return ( + + + ); +}; + +export interface FilterBarProps { + priceChangeButtonText: string; + onPriceChangePress: () => void; + isPriceChangeDisabled?: boolean; + + networkName: string; + onNetworkPress: () => void; + + /** Optional extra filter buttons rendered after the network button */ + extraFilters?: React.ReactNode; +} + +/** + * Shared filter toolbar used in token list views. + * Renders price-change and network filter buttons with an optional slot + * for view-specific extras (e.g., time filter in TrendingTokensFullView). + */ +const FilterBar: React.FC+ ++ {label} + ++ = ({ + priceChangeButtonText, + onPriceChangePress, + isPriceChangeDisabled = false, + networkName, + onNetworkPress, + extraFilters, +}) => { + const tw = useTailwind(); + + return ( + + + ); +}; + +FilterBar.displayName = 'FilterBar'; + +export default FilterBar; diff --git a/app/components/UI/Trending/components/TokenListPageLayout/TokenListPageLayout.tsx b/app/components/UI/Trending/components/TokenListPageLayout/TokenListPageLayout.tsx new file mode 100644 index 00000000000..6dfcdab0905 --- /dev/null +++ b/app/components/UI/Trending/components/TokenListPageLayout/TokenListPageLayout.tsx @@ -0,0 +1,129 @@ +import React from 'react'; +import { Platform, View } from 'react-native'; +import { + SafeAreaView, + useSafeAreaInsets, +} from 'react-native-safe-area-context'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { useAppThemeFromContext } from '../../../../../util/theme'; +import { TrendingListHeader } from '../TrendingListHeader'; +import FilterBar from '../FilterBar/FilterBar'; +import { + TrendingTokenNetworkBottomSheet, + TrendingTokenPriceChangeBottomSheet, +} from '../TrendingTokensBottomSheet'; +import { TrendingTokensData } from '../../Views/TrendingTokensFullView/TrendingTokensFullView'; +import type { TrendingAsset } from '@metamask/assets-controllers'; +import type { ProcessedNetwork } from '../../../../hooks/useNetworksByNamespace/useNetworksByNamespace'; +import type { TokenListFilters } from '../../hooks/useTokenListFilters/useTokenListFilters'; + +export interface TokenListPageLayoutProps { + /** Page title displayed in the header */ + title: string; + /** Base testID used to derive sub-component testIDs */ + testID: string; + /** Filter state & handlers from useTokenListFilters */ + filters: TokenListFilters; + /** Token data to display */ + tokens: TrendingAsset[]; + /** Search results (may differ from tokens if client-side filtering is applied) */ + searchResults: TrendingAsset[]; + /** Whether data is currently loading */ + isLoading: boolean; + /** Callback to trigger data refetch */ + onRefresh: () => void; + /** Networks to show in the network filter bottom sheet */ + allowedNetworks: ProcessedNetwork[]; + /** Optional extra filter buttons (e.g., time filter) */ + extraFilters?: React.ReactNode; + /** Optional extra bottom sheets rendered at the end */ + extraBottomSheets?: React.ReactNode; +} + +/** + * Shared page layout for token list full-screen views. + * + * Renders SafeAreaView shell, TrendingListHeader with search, + * FilterBar with price-change / network buttons, + * TrendingTokensData (skeleton / empty states / token list), + * and Network & price-change bottom sheets. + */ +const TokenListPageLayout: React.FC+ ++ + ++ {extraFilters} + = ({ + title, + testID, + filters, + tokens, + searchResults, + isLoading, + onRefresh, + allowedNetworks, + extraFilters, + extraBottomSheets, +}) => { + const tw = useTailwind(); + const theme = useAppThemeFromContext(); + const insets = useSafeAreaInsets(); + + return ( + + + ); +}; + +TokenListPageLayout.displayName = 'TokenListPageLayout'; + +export default TokenListPageLayout; diff --git a/app/components/UI/Trending/components/TrendingTokenRowItem/TrendingTokenRowItem.tsx b/app/components/UI/Trending/components/TrendingTokenRowItem/TrendingTokenRowItem.tsx index d4682486296..8a6c17a3d7c 100644 --- a/app/components/UI/Trending/components/TrendingTokenRowItem/TrendingTokenRowItem.tsx +++ b/app/components/UI/Trending/components/TrendingTokenRowItem/TrendingTokenRowItem.tsx @@ -40,12 +40,9 @@ import { formatPriceWithSubscriptNotation } from '../../../Predict/utils/format' import { TimeOption, PriceChangeOption } from '../TrendingTokensBottomSheet'; import { selectNetworkConfigurationsByCaipChainId } from '../../../../../selectors/networkController'; import { getTrendingTokenImageUrl } from '../../utils/getTrendingTokenImageUrl'; -import { useRWAToken } from '../../../Bridge/hooks/useRWAToken'; -import StockBadge from '../../../shared/StockBadge'; import { useAddPopularNetwork } from '../../../../hooks/useAddPopularNetwork'; import TrendingFeedSessionManager from '../../services/TrendingFeedSessionManager'; import type { TrendingFilterContext } from '../TrendingTokensList/TrendingTokensList'; -import { BridgeToken } from '../../../Bridge/types'; import { TokenDetailsSource } from '../../../TokenDetails/constants/constants'; /** @@ -181,7 +178,6 @@ const TrendingTokenRowItem = ({ selectNetworkConfigurationsByCaipChainId, ); const { addPopularNetwork } = useAddPopularNetwork(); - const { isStockToken } = useRWAToken(); const sessionManager = TrendingFeedSessionManager.getInstance(); // Memoize derived values @@ -310,12 +306,6 @@ const TrendingTokenRowItem = ({ token.aggregatedUsdVolume ?? 0, )} - {isStockToken(token as unknown as BridgeToken) && ( -+ + + {!filters.isSearchVisible ? ( ++ + ) : null} + + + + filters.setShowNetworkBottomSheet(false)} + onNetworkSelect={filters.handleNetworkSelect} + selectedNetwork={filters.selectedNetwork} + networks={allowedNetworks} + /> + filters.setShowPriceChangeBottomSheet(false)} + onPriceChangeSelect={filters.handlePriceChangeSelect} + selectedOption={filters.selectedPriceChangeOption} + sortDirection={filters.priceChangeSortDirection} + /> + {extraBottomSheets} + - )} diff --git a/app/components/UI/Trending/components/TrendingTokensBottomSheet/TrendingTokenNetworkBottomSheet.test.tsx b/app/components/UI/Trending/components/TrendingTokensBottomSheet/TrendingTokenNetworkBottomSheet.test.tsx index c298f45f5f8..12564d914ef 100644 --- a/app/components/UI/Trending/components/TrendingTokensBottomSheet/TrendingTokenNetworkBottomSheet.test.tsx +++ b/app/components/UI/Trending/components/TrendingTokensBottomSheet/TrendingTokenNetworkBottomSheet.test.tsx @@ -15,33 +15,26 @@ jest.mock('../../../../../util/networks', () => ({ mockGetNetworkImageSource(params), })); -// Mock the TRENDING_NETWORKS_LIST constant -jest.mock('../../utils/trendingNetworksList', () => { - const mockNetworks: ProcessedNetwork[] = [ - { - id: 'eip155:1', - name: 'Ethereum Mainnet', - caipChainId: 'eip155:1' as CaipChainId, - imageSource: { - uri: 'https://example.com/ethereum.png', - } as ImageSourcePropType, - isSelected: false, - }, - { - id: 'eip155:137', - name: 'Polygon', - caipChainId: 'eip155:137' as CaipChainId, - imageSource: { - uri: 'https://example.com/polygon.png', - } as ImageSourcePropType, - isSelected: false, - }, - ]; - - return { - TRENDING_NETWORKS_LIST: mockNetworks, - }; -}); +const mockNetworks: ProcessedNetwork[] = [ + { + id: 'eip155:1', + name: 'Ethereum Mainnet', + caipChainId: 'eip155:1' as CaipChainId, + imageSource: { + uri: 'https://example.com/ethereum.png', + } as ImageSourcePropType, + isSelected: false, + }, + { + id: 'eip155:137', + name: 'Polygon', + caipChainId: 'eip155:137' as CaipChainId, + imageSource: { + uri: 'https://example.com/polygon.png', + } as ImageSourcePropType, + isSelected: false, + }, +]; let storedOnClose: (() => void) | undefined; @@ -278,7 +271,11 @@ describe('TrendingTokenNetworkBottomSheet', () => { it('renders with default "All networks" selected', () => { const { getByText, getByTestId } = renderWithProvider( - , + , { state: mockState }, false, ); @@ -290,7 +287,11 @@ describe('TrendingTokenNetworkBottomSheet', () => { it('renders all network options', () => { const { getByText } = renderWithProvider( - , + , { state: mockState }, false, ); @@ -308,6 +309,7 @@ describe('TrendingTokenNetworkBottomSheet', () => { isVisible onClose={mockOnClose} onNetworkSelect={mockOnNetworkSelect} + networks={mockNetworks} />, { state: mockState }, false, @@ -329,6 +331,7 @@ describe('TrendingTokenNetworkBottomSheet', () => { isVisible onClose={mockOnClose} onNetworkSelect={mockOnNetworkSelect} + networks={mockNetworks} />, { state: mockState }, false, @@ -350,6 +353,7 @@ describe('TrendingTokenNetworkBottomSheet', () => { isVisible onClose={mockOnClose} onNetworkSelect={mockOnNetworkSelect} + networks={mockNetworks} />, { state: mockState }, false, @@ -366,7 +370,11 @@ describe('TrendingTokenNetworkBottomSheet', () => { it('calls onClose when close button is pressed', () => { const { getByTestId } = renderWithProvider( - , + , { state: mockState }, false, ); @@ -380,7 +388,11 @@ describe('TrendingTokenNetworkBottomSheet', () => { it('calls onClose when sheet is closed via onClose callback', () => { renderWithProvider( - , + , { state: mockState }, false, ); @@ -398,6 +410,7 @@ describe('TrendingTokenNetworkBottomSheet', () => { isVisible onClose={mockOnClose} selectedNetwork={['eip155:1']} + networks={mockNetworks} />, { state: mockState }, false, @@ -409,7 +422,11 @@ describe('TrendingTokenNetworkBottomSheet', () => { it('displays selection indicator for "All networks" when selected', () => { const { getByText, getByTestId } = renderWithProvider( - , + , { state: mockState }, false, ); @@ -420,7 +437,11 @@ describe('TrendingTokenNetworkBottomSheet', () => { it('renders network avatars with correct props', () => { const { getByTestId } = renderWithProvider( - , + , { state: mockState }, false, ); @@ -440,7 +461,11 @@ describe('TrendingTokenNetworkBottomSheet', () => { it('renders global icon for "All networks" option', () => { const { getByTestId } = renderWithProvider( - , + , { state: mockState }, false, ); @@ -453,6 +478,7 @@ describe('TrendingTokenNetworkBottomSheet', () => { , { state: mockState }, false, @@ -466,6 +492,7 @@ describe('TrendingTokenNetworkBottomSheet', () => { , { state: mockState }, false, @@ -474,7 +501,11 @@ describe('TrendingTokenNetworkBottomSheet', () => { expect(mockOnOpenBottomSheet).not.toHaveBeenCalled(); rerender( - , + , ); expect(mockOnOpenBottomSheet).toHaveBeenCalled(); diff --git a/app/components/UI/Trending/components/TrendingTokensBottomSheet/TrendingTokenNetworkBottomSheet.tsx b/app/components/UI/Trending/components/TrendingTokensBottomSheet/TrendingTokenNetworkBottomSheet.tsx index e8e6c7a07b7..06eca9eaa0e 100644 --- a/app/components/UI/Trending/components/TrendingTokensBottomSheet/TrendingTokenNetworkBottomSheet.tsx +++ b/app/components/UI/Trending/components/TrendingTokensBottomSheet/TrendingTokenNetworkBottomSheet.tsx @@ -18,7 +18,6 @@ import Cell, { import { strings } from '../../../../../../locales/i18n'; import { ProcessedNetwork } from '../../../../hooks/useNetworksByNamespace/useNetworksByNamespace'; import { CaipChainId } from '@metamask/utils'; -import { TRENDING_NETWORKS_LIST } from '../../utils/trendingNetworksList'; export enum NetworkOption { AllNetworks = 'all', @@ -29,6 +28,8 @@ export interface TrendingTokenNetworkBottomSheetProps { onClose: () => void; onNetworkSelect?: (chainIds: CaipChainId[] | null) => void; selectedNetwork?: CaipChainId[] | null; + /** Networks to display in the bottom sheet */ + networks: ProcessedNetwork[]; } const TrendingTokenNetworkBottomSheet: React.FC< @@ -38,9 +39,9 @@ const TrendingTokenNetworkBottomSheet: React.FC< onClose, onNetworkSelect, selectedNetwork: initialSelectedNetwork, + networks, }) => { const sheetRef = useRef (null); - const networks = TRENDING_NETWORKS_LIST; // Default to "All networks" if no selection const [selectedNetwork, setSelectedNetwork] = useState< diff --git a/app/components/UI/Trending/hooks/useNetworkName/useNetworkName.test.ts b/app/components/UI/Trending/hooks/useNetworkName/useNetworkName.test.ts new file mode 100644 index 00000000000..ed5382d02d1 --- /dev/null +++ b/app/components/UI/Trending/hooks/useNetworkName/useNetworkName.test.ts @@ -0,0 +1,89 @@ +import { useNetworkName } from './useNetworkName'; +import { renderHookWithProvider } from '../../../../../util/test/renderWithProvider'; +import type { CaipChainId } from '@metamask/utils'; + +jest.mock('../../../../../selectors/networkController', () => ({ + ...jest.requireActual('../../../../../selectors/networkController'), + selectNetworkConfigurationsByCaipChainId: jest.fn(), +})); + +import { selectNetworkConfigurationsByCaipChainId } from '../../../../../selectors/networkController'; + +const mockSelector = jest.mocked(selectNetworkConfigurationsByCaipChainId); + +const renderUseNetworkName = (selectedNetwork: CaipChainId[] | null) => + renderHookWithProvider(() => useNetworkName(selectedNetwork)); + +describe('useNetworkName', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockSelector.mockImplementation(() => ({})); + }); + + it('returns "All networks" when selectedNetwork is null', () => { + const { result } = renderUseNetworkName(null); + + expect(result.current).toBe('All networks'); + }); + + it('returns "All networks" when selectedNetwork is empty', () => { + const { result } = renderUseNetworkName([]); + + expect(result.current).toBe('All networks'); + }); + + it('returns network name from user-configured networks', () => { + const caipId = 'eip155:42161' as CaipChainId; + mockSelector.mockImplementation( + () => + ({ + [caipId]: { name: 'My Custom Arbitrum' }, + }) as ReturnType , + ); + + const { result } = renderUseNetworkName([caipId]); + + expect(result.current).toBe('My Custom Arbitrum'); + }); + + it('falls back to PopularList nickname for eip155 chain not in user config', () => { + const avalancheCaipId = 'eip155:43114' as CaipChainId; + + const { result } = renderUseNetworkName([avalancheCaipId]); + + expect(result.current).toBe('Avalanche'); + }); + + it('returns "All networks" for eip155 chain not in user config or PopularList', () => { + const unknownCaipId = 'eip155:99999' as CaipChainId; + + const { result } = renderUseNetworkName([unknownCaipId]); + + expect(result.current).toBe('All networks'); + }); + + it('returns "All networks" for non-eip155 namespace not in user config', () => { + const solanaId = 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp' as CaipChainId; + + const { result } = renderUseNetworkName([solanaId]); + + expect(result.current).toBe('All networks'); + }); + + it('returns "All networks" when CAIP chain ID parsing fails', () => { + const malformedId = 'not-a-valid-caip-id' as CaipChainId; + + const { result } = renderUseNetworkName([malformedId]); + + expect(result.current).toBe('All networks'); + }); + + it('uses only the first element when multiple chain IDs are provided', () => { + const caipId = 'eip155:43114' as CaipChainId; + const secondId = 'eip155:42161' as CaipChainId; + + const { result } = renderUseNetworkName([caipId, secondId]); + + expect(result.current).toBe('Avalanche'); + }); +}); diff --git a/app/components/UI/Trending/hooks/useNetworkName/useNetworkName.ts b/app/components/UI/Trending/hooks/useNetworkName/useNetworkName.ts new file mode 100644 index 00000000000..98348691e23 --- /dev/null +++ b/app/components/UI/Trending/hooks/useNetworkName/useNetworkName.ts @@ -0,0 +1,50 @@ +import { useMemo } from 'react'; +import { useSelector } from 'react-redux'; +import { CaipChainId, Hex, parseCaipChainId } from '@metamask/utils'; +import { selectNetworkConfigurationsByCaipChainId } from '../../../../../selectors/networkController'; +import { PopularList } from '../../../../../util/networks/customNetworks'; +import { strings } from '../../../../../../locales/i18n'; + +/** + * Resolves a human-readable network name from selected CAIP chain IDs. + * + * Checks user-configured networks first, then falls back to PopularList, + * and finally defaults to "All networks". + */ +export const useNetworkName = ( + selectedNetwork: CaipChainId[] | null, +): string => { + const networkConfigurations = useSelector( + selectNetworkConfigurationsByCaipChainId, + ); + + return useMemo(() => { + if (!selectedNetwork || selectedNetwork.length === 0) { + return strings('trending.all_networks'); + } + + const selectedNetworkChainId = selectedNetwork[0]; + + const networkConfig = networkConfigurations[selectedNetworkChainId]; + if (networkConfig?.name) { + return networkConfig.name; + } + + try { + const { namespace, reference } = parseCaipChainId(selectedNetworkChainId); + if (namespace === 'eip155') { + const hexChainId = `0x${Number(reference).toString(16)}` as Hex; + const popularNetwork = PopularList.find( + (network) => network.chainId === hexChainId, + ); + if (popularNetwork?.nickname) { + return popularNetwork.nickname; + } + } + } catch { + // If parsing fails, fall through to default + } + + return strings('trending.all_networks'); + }, [selectedNetwork, networkConfigurations]); +}; diff --git a/app/components/UI/Trending/hooks/useRwaTokens/useRwaTokens.test.ts b/app/components/UI/Trending/hooks/useRwaTokens/useRwaTokens.test.ts new file mode 100644 index 00000000000..b4ef9c449be --- /dev/null +++ b/app/components/UI/Trending/hooks/useRwaTokens/useRwaTokens.test.ts @@ -0,0 +1,291 @@ +import { useRwaTokens } from './useRwaTokens'; +import { renderHookWithProvider } from '../../../../../util/test/renderWithProvider'; +import { useSearchRequest } from '../useSearchRequest/useSearchRequest'; +import { sortTrendingTokens } from '../../utils/sortTrendingTokens'; +import { + PriceChangeOption, + SortDirection, +} from '../../components/TrendingTokensBottomSheet'; +import { RWA_CHAIN_IDS } from '../../utils/trendingNetworksList'; +import type { CaipChainId } from '@metamask/utils'; + +jest.mock('../useSearchRequest/useSearchRequest'); +jest.mock('../../utils/sortTrendingTokens'); + +const mockUseSearchRequest = jest.mocked(useSearchRequest); +const mockSortTrendingTokens = jest.mocked(sortTrendingTokens); + +const mockRefetch = jest.fn(); + +const createSearchResult = (overrides: Record = {}) => ({ + assetId: 'eip155:1/erc20:0xaaa' as CaipChainId, + symbol: 'OUSG', + name: 'OUSG Token', + decimals: 18, + price: '100.50', + aggregatedUsdVolume: 500000, + marketCap: 1000000000, + pricePercentChange1d: '1.5', + rwaData: { underlyingAsset: 'US Treasury' }, + ...overrides, +}); + +const arrangeMocks = (options?: { + results?: ReturnType []; + isLoading?: boolean; +}) => { + mockUseSearchRequest.mockReturnValue({ + results: (options?.results ?? []) as ReturnType< + typeof useSearchRequest + >['results'], + isLoading: options?.isLoading ?? false, + error: null, + search: mockRefetch, + }); + mockSortTrendingTokens.mockImplementation((tokens) => tokens); +}; + +const NON_RESTRICTED_GEO_STATE = { + state: { fiatOrders: { detectedGeolocation: 'AR' } }, +}; + +const renderHookWithGeo = ( + geolocation: string | undefined, + hookOpts?: Parameters [0], +) => + renderHookWithProvider(() => useRwaTokens(hookOpts), { + state: { fiatOrders: { detectedGeolocation: geolocation } }, + }); + +describe('useRwaTokens', () => { + beforeEach(() => { + jest.clearAllMocks(); + arrangeMocks(); + }); + + it('calls useSearchRequest with correct defaults', () => { + renderHookWithProvider(() => useRwaTokens(), NON_RESTRICTED_GEO_STATE); + + expect(mockUseSearchRequest).toHaveBeenCalledWith({ + query: '(Ondo Tokenized)', + limit: 500, + chainIds: RWA_CHAIN_IDS, + includeMarketData: true, + }); + }); + + it('uses provided chainIds instead of defaults', () => { + const customChainIds: CaipChainId[] = ['eip155:137' as CaipChainId]; + + renderHookWithProvider( + () => useRwaTokens({ chainIds: customChainIds }), + NON_RESTRICTED_GEO_STATE, + ); + + expect(mockUseSearchRequest).toHaveBeenCalledWith( + expect.objectContaining({ chainIds: customChainIds }), + ); + }); + + it('defaults to RWA_CHAIN_IDS when chainIds is null', () => { + renderHookWithProvider( + () => useRwaTokens({ chainIds: null }), + NON_RESTRICTED_GEO_STATE, + ); + + expect(mockUseSearchRequest).toHaveBeenCalledWith( + expect.objectContaining({ chainIds: RWA_CHAIN_IDS }), + ); + }); + + it('filters out assets without rwaData', () => { + const rwaAsset = createSearchResult({ assetId: 'eip155:1/erc20:0x111' }); + const nonRwaAsset = createSearchResult({ + assetId: 'eip155:1/erc20:0x222', + symbol: 'ETH', + name: 'Ethereum', + rwaData: undefined, + }); + arrangeMocks({ results: [rwaAsset, nonRwaAsset] }); + + const { result } = renderHookWithProvider( + () => useRwaTokens(), + NON_RESTRICTED_GEO_STATE, + ); + + expect(result.current.data).toHaveLength(1); + expect(result.current.data[0].symbol).toBe('OUSG'); + }); + + it('normalizes pricePercentChange1d to priceChangePct.h24', () => { + arrangeMocks({ + results: [createSearchResult({ pricePercentChange1d: '3.14' })], + }); + + const { result } = renderHookWithProvider( + () => useRwaTokens(), + NON_RESTRICTED_GEO_STATE, + ); + + expect(result.current.data[0].priceChangePct).toEqual({ h24: '3.14' }); + }); + + it('sorts results with default options when no searchQuery', () => { + const results = [ + createSearchResult({ assetId: 'eip155:1/erc20:0x111', symbol: 'A' }), + createSearchResult({ assetId: 'eip155:1/erc20:0x222', symbol: 'B' }), + ]; + arrangeMocks({ results }); + + renderHookWithProvider(() => useRwaTokens(), NON_RESTRICTED_GEO_STATE); + + expect(mockSortTrendingTokens).toHaveBeenCalledWith( + expect.any(Array), + PriceChangeOption.PriceChange, + SortDirection.Descending, + ); + }); + + it('sorts results with custom sort options', () => { + arrangeMocks({ results: [createSearchResult()] }); + + renderHookWithProvider( + () => + useRwaTokens({ + sortTrendingTokensOptions: { + option: PriceChangeOption.Volume, + direction: SortDirection.Ascending, + }, + }), + NON_RESTRICTED_GEO_STATE, + ); + + expect(mockSortTrendingTokens).toHaveBeenCalledWith( + expect.any(Array), + PriceChangeOption.Volume, + SortDirection.Ascending, + ); + }); + + it('applies fuse search instead of sorting when searchQuery is provided', () => { + const results = [ + createSearchResult({ + assetId: 'eip155:1/erc20:0x111', + symbol: 'OUSG', + name: 'OUSG Token', + }), + createSearchResult({ + assetId: 'eip155:1/erc20:0x222', + symbol: 'USDY', + name: 'USDY Token', + }), + ]; + arrangeMocks({ results }); + + const { result } = renderHookWithProvider( + () => useRwaTokens({ searchQuery: 'OUSG' }), + NON_RESTRICTED_GEO_STATE, + ); + + expect(mockSortTrendingTokens).not.toHaveBeenCalled(); + expect(result.current.data).toHaveLength(1); + expect(result.current.data[0].symbol).toBe('OUSG'); + }); + + it('returns empty array when no results match search query', () => { + arrangeMocks({ results: [createSearchResult()] }); + + const { result } = renderHookWithProvider( + () => useRwaTokens({ searchQuery: 'nonexistent' }), + NON_RESTRICTED_GEO_STATE, + ); + + expect(result.current.data).toHaveLength(0); + }); + + it('passes through isLoading from useSearchRequest', () => { + arrangeMocks({ isLoading: true }); + + const { result } = renderHookWithProvider( + () => useRwaTokens(), + NON_RESTRICTED_GEO_STATE, + ); + + expect(result.current.isLoading).toBe(true); + }); + + it('exposes refetch from useSearchRequest', () => { + const { result } = renderHookWithProvider( + () => useRwaTokens(), + NON_RESTRICTED_GEO_STATE, + ); + + result.current.refetch(); + + expect(mockRefetch).toHaveBeenCalledTimes(1); + }); + + it('returns empty data when useSearchRequest returns no results', () => { + arrangeMocks({ results: [] }); + + const { result } = renderHookWithProvider( + () => useRwaTokens(), + NON_RESTRICTED_GEO_STATE, + ); + + expect(result.current.data).toEqual([]); + }); + + describe('geo-restriction (production mode)', () => { + const originalDev = (globalThis as Record ).__DEV__; + + beforeEach(() => { + (globalThis as Record ).__DEV__ = false; + }); + + afterEach(() => { + (globalThis as Record ).__DEV__ = originalDev; + }); + + it('returns empty data for a restricted country', () => { + arrangeMocks({ results: [createSearchResult()] }); + + const { result } = renderHookWithGeo('US'); + + expect(result.current.data).toEqual([]); + }); + + it('returns empty data when geolocation is unknown', () => { + arrangeMocks({ results: [createSearchResult()] }); + + const { result } = renderHookWithGeo(undefined); + + expect(result.current.data).toEqual([]); + }); + + it('returns data for a non-restricted country', () => { + arrangeMocks({ results: [createSearchResult()] }); + + const { result } = renderHookWithGeo('AR'); + + expect(result.current.data).toHaveLength(1); + expect(result.current.data[0].symbol).toBe('OUSG'); + }); + + it('handles region suffixes correctly', () => { + arrangeMocks({ results: [createSearchResult()] }); + + const { result } = renderHookWithGeo('GB-ENG'); + + expect(result.current.data).toEqual([]); + }); + + it('sends empty query to search API when restricted', () => { + renderHookWithGeo('US'); + + expect(mockUseSearchRequest).toHaveBeenCalledWith( + expect.objectContaining({ query: '' }), + ); + }); + }); +}); diff --git a/app/components/UI/Trending/hooks/useRwaTokens/useRwaTokens.ts b/app/components/UI/Trending/hooks/useRwaTokens/useRwaTokens.ts new file mode 100644 index 00000000000..baaeb61ad69 --- /dev/null +++ b/app/components/UI/Trending/hooks/useRwaTokens/useRwaTokens.ts @@ -0,0 +1,147 @@ +import { useMemo, useState, useEffect } from 'react'; +import Fuse, { type FuseOptions } from 'fuse.js'; +import type { CaipChainId } from '@metamask/utils'; +import { SortTrendingBy, TrendingAsset } from '@metamask/assets-controllers'; +import { useSelector } from 'react-redux'; +import { useSearchRequest } from '../useSearchRequest/useSearchRequest'; +import { sortTrendingTokens } from '../../utils/sortTrendingTokens'; +import { + PriceChangeOption, + SortDirection, +} from '../../components/TrendingTokensBottomSheet'; +import { RWA_CHAIN_IDS } from '../../utils/trendingNetworksList'; +import { isEqual } from 'lodash'; +import { getDetectedGeolocation } from '../../../../../reducers/fiatOrders'; + +// prettier-ignore +const ONDO_RESTRICTED_COUNTRIES = new Set([ + 'AF', 'DZ', 'BY', 'CA', 'CN', 'CU', 'KP', + 'ER', 'IR', 'LY', 'MM', 'MA', 'NP', 'RU', + 'SO', 'SS', 'SD', 'SY', 'US', 'VE', + 'BR', 'HK', 'MY', 'SG', 'CH', 'GB', + 'AT', 'BE', 'BG', 'HR', 'CY', 'CZ', 'DK', + 'EE', 'FI', 'FR', 'DE', 'GR', 'HU', 'IE', + 'IT', 'LV', 'LT', 'LU', 'MT', 'NL', 'PL', + 'PT', 'RO', 'SK', 'SI', 'ES', 'SE', 'IS', + 'LI', 'NO', 'UA', +]); + +const useStableReference = (value: T) => { + const [stableValue, setStableValue] = useState(value); + + useEffect(() => { + if (!isEqual(stableValue, value)) { + setStableValue(value); + } + }, [value, stableValue]); + + return stableValue; +}; + +const TOKEN_FUSE_OPTIONS: FuseOptions = { + shouldSort: true, + threshold: 0.2, + location: 0, + distance: 100, + maxPatternLength: 32, + minMatchCharLength: 1, + keys: ['symbol', 'name', 'assetId'], +}; + +const fuseSearch = ( + data: TrendingAsset[], + searchQuery: string | undefined, +): TrendingAsset[] => { + const trimmed = searchQuery?.trim(); + if (!trimmed) { + return data; + } + const fuse = new Fuse(data, TOKEN_FUSE_OPTIONS); + const results = fuse.search(trimmed); + // Penalize zero-marketCap tokens + return results.sort((a, b) => (b.marketCap ?? 0) - (a.marketCap ?? 0)); +}; + +/** + * Hook for RWA tokens search. + * Defaults to Ethereum + BNB when no chainIds are provided. + * + * @param opts.searchQuery - Client-side fuse.js query to filter results + * @param opts.chainIds - Chain IDs to filter by (defaults to RWA_CHAIN_IDS) + * @param opts.sortTrendingTokensOptions - Sorting options for price change / volume / market cap + * @returns Search results, loading state, and refetch function for rwa tokens + */ +export const useRwaTokens = (opts?: { + searchQuery?: string; + sortBy?: SortTrendingBy; + chainIds?: CaipChainId[] | null; + includeMarketData?: boolean; + sortTrendingTokensOptions?: { + option: PriceChangeOption; + direction: SortDirection; + }; +}) => { + const geolocation = useSelector(getDetectedGeolocation); + const isGeoRestricted = useMemo(() => { + if (__DEV__) return false; + const country = geolocation?.toUpperCase().split('-')[0]; + return !country || ONDO_RESTRICTED_COUNTRIES.has(country); + }, [geolocation]); + + const { + searchQuery, + chainIds, + includeMarketData = true, + sortTrendingTokensOptions = { + option: PriceChangeOption.PriceChange, + direction: SortDirection.Descending, + }, + } = useStableReference(opts ?? {}); + + const effectiveChainIds = chainIds ?? RWA_CHAIN_IDS; + + const { + results: searchResults, + isLoading: isSearchLoading, + search: refetch, + } = useSearchRequest({ + query: isGeoRestricted ? '' : '(Ondo Tokenized)', + limit: 500, + chainIds: effectiveChainIds, + includeMarketData, + }); + + const data = useMemo(() => { + if (isGeoRestricted) return []; + + const normalizedResults: TrendingAsset[] = searchResults + .filter((asset) => asset.rwaData) + .map((asset) => ({ + assetId: asset.assetId, + symbol: asset.symbol, + name: asset.name, + decimals: asset.decimals, + price: asset.price, + aggregatedUsdVolume: asset.aggregatedUsdVolume, + marketCap: asset.marketCap, + priceChangePct: { + h24: asset.pricePercentChange1d, + }, + rwaData: asset.rwaData as unknown as + | TrendingAsset['rwaData'] + | undefined, + })); + + if (searchQuery?.trim()) { + return fuseSearch(normalizedResults, searchQuery); + } + + return sortTrendingTokens( + normalizedResults, + sortTrendingTokensOptions.option, + sortTrendingTokensOptions.direction, + ); + }, [isGeoRestricted, searchResults, searchQuery, sortTrendingTokensOptions]); + + return { data, isLoading: isSearchLoading, refetch }; +}; diff --git a/app/components/UI/Trending/hooks/useTokenListFilters/useTokenListFilters.test.ts b/app/components/UI/Trending/hooks/useTokenListFilters/useTokenListFilters.test.ts new file mode 100644 index 00000000000..7b2294c5ca3 --- /dev/null +++ b/app/components/UI/Trending/hooks/useTokenListFilters/useTokenListFilters.test.ts @@ -0,0 +1,317 @@ +import { act } from '@testing-library/react-native'; +import { renderHookWithProvider } from '../../../../../util/test/renderWithProvider'; +import { useTokenListFilters } from './useTokenListFilters'; +import { + PriceChangeOption, + SortDirection, + TimeOption, +} from '../../components/TrendingTokensBottomSheet'; +import type { CaipChainId } from '@metamask/utils'; + +const mockGoBack = jest.fn(); +jest.mock('@react-navigation/native', () => ({ + ...jest.requireActual('@react-navigation/native'), + useNavigation: () => ({ goBack: mockGoBack }), +})); + +const mockTrackFilterChange = jest.fn(); +jest.mock('../../services/TrendingFeedSessionManager', () => ({ + __esModule: true, + default: { + getInstance: () => ({ + trackFilterChange: mockTrackFilterChange, + }), + }, +})); + +jest.mock('../useNetworkName/useNetworkName', () => ({ + useNetworkName: (network: CaipChainId[] | null) => + network && network.length > 0 ? 'Mock Network' : 'All networks', +})); + +const renderFilters = (options = {}) => + renderHookWithProvider(() => useTokenListFilters(options)); + +describe('useTokenListFilters', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('initial state', () => { + it('returns correct defaults', () => { + const { result } = renderFilters(); + + expect(result.current.selectedNetwork).toBeNull(); + expect(result.current.selectedPriceChangeOption).toBe( + PriceChangeOption.PriceChange, + ); + expect(result.current.priceChangeSortDirection).toBe( + SortDirection.Descending, + ); + expect(result.current.selectedTimeOption).toBe( + TimeOption.TwentyFourHours, + ); + expect(result.current.isSearchVisible).toBe(false); + expect(result.current.searchQuery).toBe(''); + expect(result.current.showNetworkBottomSheet).toBe(false); + expect(result.current.showPriceChangeBottomSheet).toBe(false); + expect(result.current.refreshing).toBe(false); + expect(result.current.selectedNetworkName).toBe('All networks'); + }); + + it('uses provided timeOption instead of default', () => { + const { result } = renderFilters({ timeOption: TimeOption.OneHour }); + + expect(result.current.selectedTimeOption).toBe(TimeOption.OneHour); + }); + }); + + describe('handleBackPress', () => { + it('calls navigation.goBack', () => { + const { result } = renderFilters(); + + act(() => result.current.handleBackPress()); + + expect(mockGoBack).toHaveBeenCalledTimes(1); + }); + }); + + describe('handleSearchToggle', () => { + it('opens search on first toggle', () => { + const { result } = renderFilters(); + + act(() => result.current.handleSearchToggle()); + + expect(result.current.isSearchVisible).toBe(true); + }); + + it('closes search and clears query on second toggle', () => { + const { result } = renderFilters(); + + act(() => result.current.handleSearchToggle()); + act(() => result.current.handleSearchQueryChange('test')); + act(() => result.current.handleSearchToggle()); + + expect(result.current.isSearchVisible).toBe(false); + expect(result.current.searchQuery).toBe(''); + }); + }); + + describe('handlePriceChangeSelect', () => { + it('updates price change option and sort direction', () => { + const { result } = renderFilters(); + + act(() => + result.current.handlePriceChangeSelect( + PriceChangeOption.Volume, + SortDirection.Ascending, + ), + ); + + expect(result.current.selectedPriceChangeOption).toBe( + PriceChangeOption.Volume, + ); + expect(result.current.priceChangeSortDirection).toBe( + SortDirection.Ascending, + ); + }); + + it('tracks analytics when option changes', () => { + const { result } = renderFilters(); + + act(() => + result.current.handlePriceChangeSelect( + PriceChangeOption.MarketCap, + SortDirection.Descending, + ), + ); + + expect(mockTrackFilterChange).toHaveBeenCalledWith( + expect.objectContaining({ + filter_type: 'sort', + previous_value: PriceChangeOption.PriceChange, + new_value: PriceChangeOption.MarketCap, + sort_option: PriceChangeOption.MarketCap, + network_filter: 'all', + }), + ); + }); + + it('includes selected network in analytics when a network is active', () => { + const { result } = renderFilters(); + + act(() => + result.current.handleNetworkSelect(['eip155:137' as CaipChainId]), + ); + mockTrackFilterChange.mockClear(); + + act(() => + result.current.handlePriceChangeSelect( + PriceChangeOption.Volume, + SortDirection.Ascending, + ), + ); + + expect(mockTrackFilterChange).toHaveBeenCalledWith( + expect.objectContaining({ + network_filter: 'eip155:137', + }), + ); + }); + + it('does not track analytics when re-selecting same option', () => { + const { result } = renderFilters(); + + act(() => + result.current.handlePriceChangeSelect( + PriceChangeOption.PriceChange, + SortDirection.Ascending, + ), + ); + + expect(mockTrackFilterChange).not.toHaveBeenCalled(); + }); + }); + + describe('handlePriceChangePress', () => { + it('opens price change bottom sheet', () => { + const { result } = renderFilters(); + + act(() => result.current.handlePriceChangePress()); + + expect(result.current.showPriceChangeBottomSheet).toBe(true); + }); + }); + + describe('handleNetworkSelect', () => { + it('updates selected network', () => { + const { result } = renderFilters(); + const chainIds = ['eip155:1' as CaipChainId]; + + act(() => result.current.handleNetworkSelect(chainIds)); + + expect(result.current.selectedNetwork).toEqual(chainIds); + }); + + it('tracks analytics when network changes', () => { + const { result } = renderFilters(); + const chainIds = ['eip155:137' as CaipChainId]; + + act(() => result.current.handleNetworkSelect(chainIds)); + + expect(mockTrackFilterChange).toHaveBeenCalledWith( + expect.objectContaining({ + filter_type: 'network', + previous_value: 'all', + new_value: 'eip155:137', + network_filter: 'eip155:137', + }), + ); + }); + + it('does not track analytics when selecting same network', () => { + const { result } = renderFilters(); + const chainIds = ['eip155:1' as CaipChainId]; + + act(() => result.current.handleNetworkSelect(chainIds)); + mockTrackFilterChange.mockClear(); + + act(() => result.current.handleNetworkSelect(chainIds)); + + expect(mockTrackFilterChange).not.toHaveBeenCalled(); + }); + + it('tracks analytics when clearing network back to all', () => { + const { result } = renderFilters(); + + act(() => + result.current.handleNetworkSelect(['eip155:1' as CaipChainId]), + ); + mockTrackFilterChange.mockClear(); + + act(() => result.current.handleNetworkSelect(null)); + + expect(mockTrackFilterChange).toHaveBeenCalledWith( + expect.objectContaining({ + previous_value: 'eip155:1', + new_value: 'all', + }), + ); + }); + }); + + describe('handleAllNetworksPress', () => { + it('opens network bottom sheet', () => { + const { result } = renderFilters(); + + act(() => result.current.handleAllNetworksPress()); + + expect(result.current.showNetworkBottomSheet).toBe(true); + }); + }); + + describe('priceChangeButtonText', () => { + it('returns "Price change" for PriceChange option', () => { + const { result } = renderFilters(); + + expect(result.current.priceChangeButtonText).toBe('Price change'); + }); + + it('returns "Volume" for Volume option', () => { + const { result } = renderFilters(); + + act(() => + result.current.handlePriceChangeSelect( + PriceChangeOption.Volume, + SortDirection.Descending, + ), + ); + + expect(result.current.priceChangeButtonText).toBe('Volume'); + }); + + it('returns "Market cap" for MarketCap option', () => { + const { result } = renderFilters(); + + act(() => + result.current.handlePriceChangeSelect( + PriceChangeOption.MarketCap, + SortDirection.Descending, + ), + ); + + expect(result.current.priceChangeButtonText).toBe('Market cap'); + }); + }); + + describe('filterContext', () => { + it('reflects current filter state', () => { + const { result } = renderFilters(); + + expect(result.current.filterContext).toEqual({ + timeFilter: TimeOption.TwentyFourHours, + sortOption: PriceChangeOption.PriceChange, + networkFilter: 'all', + isSearchResult: false, + }); + }); + + it('updates isSearchResult when search query has content', () => { + const { result } = renderFilters(); + + act(() => result.current.handleSearchQueryChange('eth')); + + expect(result.current.filterContext.isSearchResult).toBe(true); + }); + + it('updates networkFilter when a network is selected', () => { + const { result } = renderFilters(); + + act(() => + result.current.handleNetworkSelect(['eip155:42161' as CaipChainId]), + ); + + expect(result.current.filterContext.networkFilter).toBe('eip155:42161'); + }); + }); +}); diff --git a/app/components/UI/Trending/hooks/useTokenListFilters/useTokenListFilters.ts b/app/components/UI/Trending/hooks/useTokenListFilters/useTokenListFilters.ts new file mode 100644 index 00000000000..8dce1bcf7f9 --- /dev/null +++ b/app/components/UI/Trending/hooks/useTokenListFilters/useTokenListFilters.ts @@ -0,0 +1,235 @@ +import { useCallback, useMemo, useState } from 'react'; +import { useNavigation } from '@react-navigation/native'; +import { StackNavigationProp } from '@react-navigation/stack'; +import { CaipChainId } from '@metamask/utils'; +import { strings } from '../../../../../../locales/i18n'; +import { + PriceChangeOption, + SortDirection, + TimeOption, +} from '../../components/TrendingTokensBottomSheet'; +import type { TrendingFilterContext } from '../../components/TrendingTokensList/TrendingTokensList'; +import TrendingFeedSessionManager from '../../services/TrendingFeedSessionManager'; +import { useNetworkName } from '../useNetworkName/useNetworkName'; + +interface UseTokenListFiltersOptions { + /** + * Fixed time option when the view doesn't support time selection. + * When provided, the time filter is static and no time bottom sheet is shown. + */ + timeOption?: TimeOption; +} + +export interface TokenListFilters { + // Navigation + handleBackPress: () => void; + + // Search + isSearchVisible: boolean; + searchQuery: string; + handleSearchToggle: () => void; + handleSearchQueryChange: (query: string) => void; + + // Network + selectedNetwork: CaipChainId[] | null; + selectedNetworkName: string; + showNetworkBottomSheet: boolean; + setShowNetworkBottomSheet: (visible: boolean) => void; + handleNetworkSelect: (chainIds: CaipChainId[] | null) => void; + handleAllNetworksPress: () => void; + + // Price change / sort + selectedPriceChangeOption: PriceChangeOption | undefined; + priceChangeSortDirection: SortDirection; + showPriceChangeBottomSheet: boolean; + setShowPriceChangeBottomSheet: (visible: boolean) => void; + handlePriceChangeSelect: ( + option: PriceChangeOption, + sortDirection: SortDirection, + ) => void; + handlePriceChangePress: () => void; + priceChangeButtonText: string; + + // Time + selectedTimeOption: TimeOption; + setSelectedTimeOption: (timeOption: TimeOption) => void; + + // Refresh + refreshing: boolean; + setRefreshing: (refreshing: boolean) => void; + + // Analytics filter context + filterContext: TrendingFilterContext; +} + +/** + * Manages all filter-related state and handlers shared across token list views + * (TrendingTokensFullView, RWATokensFullView). + */ +export const useTokenListFilters = ( + options: UseTokenListFiltersOptions = {}, +): TokenListFilters => { + const { timeOption } = options; + + const navigation = + useNavigation >>(); + const sessionManager = TrendingFeedSessionManager.getInstance(); + + const [selectedNetwork, setSelectedNetwork] = useState ( + null, + ); + const [selectedPriceChangeOption, setSelectedPriceChangeOption] = useState< + PriceChangeOption | undefined + >(PriceChangeOption.PriceChange); + const [priceChangeSortDirection, setPriceChangeSortDirection] = + useState (SortDirection.Descending); + const [showNetworkBottomSheet, setShowNetworkBottomSheet] = useState(false); + const [showPriceChangeBottomSheet, setShowPriceChangeBottomSheet] = + useState(false); + const [isSearchVisible, setIsSearchVisible] = useState(false); + const [searchQuery, setSearchQuery] = useState(''); + const [refreshing, setRefreshing] = useState(false); + const [selectedTimeOption, setSelectedTimeOption] = useState ( + timeOption ?? TimeOption.TwentyFourHours, + ); + + const selectedNetworkName = useNetworkName(selectedNetwork); + + const handleBackPress = useCallback(() => { + navigation.goBack(); + }, [navigation]); + + const handleSearchToggle = useCallback(() => { + setIsSearchVisible((prev) => !prev); + if (isSearchVisible) { + setSearchQuery(''); + } + }, [isSearchVisible]); + + const handleSearchQueryChange = useCallback((query: string) => { + setSearchQuery(query); + }, []); + + const handlePriceChangeSelect = useCallback( + (option: PriceChangeOption, sortDirection: SortDirection) => { + const previousValue = + selectedPriceChangeOption || PriceChangeOption.PriceChange; + setSelectedPriceChangeOption(option); + setPriceChangeSortDirection(sortDirection); + + if (option !== previousValue) { + sessionManager.trackFilterChange({ + filter_type: 'sort', + previous_value: previousValue, + new_value: option, + time_filter: selectedTimeOption, + sort_option: option, + network_filter: + selectedNetwork && selectedNetwork.length > 0 + ? selectedNetwork[0] + : 'all', + }); + } + }, + [ + selectedPriceChangeOption, + selectedTimeOption, + selectedNetwork, + sessionManager, + ], + ); + + const handlePriceChangePress = useCallback(() => { + setShowPriceChangeBottomSheet(true); + }, []); + + const handleNetworkSelect = useCallback( + (chainIds: CaipChainId[] | null) => { + const previousValue = + selectedNetwork && selectedNetwork.length > 0 + ? selectedNetwork[0] + : 'all'; + const newValue = chainIds && chainIds.length > 0 ? chainIds[0] : 'all'; + + setSelectedNetwork(chainIds); + + if (newValue !== previousValue) { + sessionManager.trackFilterChange({ + filter_type: 'network', + previous_value: previousValue, + new_value: newValue, + time_filter: selectedTimeOption, + sort_option: + selectedPriceChangeOption || PriceChangeOption.PriceChange, + network_filter: newValue, + }); + } + }, + [ + selectedNetwork, + selectedTimeOption, + selectedPriceChangeOption, + sessionManager, + ], + ); + + const handleAllNetworksPress = useCallback(() => { + setShowNetworkBottomSheet(true); + }, []); + + const priceChangeButtonText = useMemo(() => { + switch (selectedPriceChangeOption) { + case PriceChangeOption.Volume: + return strings('trending.volume'); + case PriceChangeOption.MarketCap: + return strings('trending.market_cap'); + case PriceChangeOption.PriceChange: + default: + return strings('trending.price_change'); + } + }, [selectedPriceChangeOption]); + + const filterContext: TrendingFilterContext = useMemo( + () => ({ + timeFilter: selectedTimeOption, + sortOption: selectedPriceChangeOption, + networkFilter: + selectedNetwork && selectedNetwork.length > 0 + ? selectedNetwork[0] + : 'all', + isSearchResult: Boolean(searchQuery?.trim()), + }), + [ + selectedTimeOption, + selectedPriceChangeOption, + selectedNetwork, + searchQuery, + ], + ); + + return { + handleBackPress, + isSearchVisible, + searchQuery, + handleSearchToggle, + handleSearchQueryChange, + selectedNetwork, + selectedNetworkName, + showNetworkBottomSheet, + setShowNetworkBottomSheet, + handleNetworkSelect, + handleAllNetworksPress, + selectedPriceChangeOption, + priceChangeSortDirection, + showPriceChangeBottomSheet, + setShowPriceChangeBottomSheet, + handlePriceChangeSelect, + handlePriceChangePress, + priceChangeButtonText, + selectedTimeOption, + setSelectedTimeOption, + refreshing, + setRefreshing, + filterContext, + }; +}; diff --git a/app/components/UI/Trending/hooks/useTrendingSearch/useTrendingSearch.ts b/app/components/UI/Trending/hooks/useTrendingSearch/useTrendingSearch.ts index 74cb665ee54..b8baa6eb536 100644 --- a/app/components/UI/Trending/hooks/useTrendingSearch/useTrendingSearch.ts +++ b/app/components/UI/Trending/hooks/useTrendingSearch/useTrendingSearch.ts @@ -104,25 +104,27 @@ export const useTrendingSearch = (opts?: { filteredTrendingResults.map((result) => [result.assetId, result]), ); - searchResults.forEach((asset) => { - if (!resultMap.has(asset.assetId)) { - resultMap.set(asset.assetId, { - assetId: asset.assetId, - symbol: asset.symbol, - name: asset.name, - decimals: asset.decimals, - price: asset.price, - aggregatedUsdVolume: asset.aggregatedUsdVolume, - marketCap: asset.marketCap, - priceChangePct: { - h24: asset.pricePercentChange1d, - }, - rwaData: asset.rwaData as unknown as - | TrendingAsset['rwaData'] - | undefined, - }); - } - }); + searchResults + .filter((item) => !item.rwaData) + .forEach((asset) => { + if (!resultMap.has(asset.assetId)) { + resultMap.set(asset.assetId, { + assetId: asset.assetId, + symbol: asset.symbol, + name: asset.name, + decimals: asset.decimals, + price: asset.price, + aggregatedUsdVolume: asset.aggregatedUsdVolume, + marketCap: asset.marketCap, + priceChangePct: { + h24: asset.pricePercentChange1d, + }, + rwaData: asset.rwaData as unknown as + | TrendingAsset['rwaData'] + | undefined, + }); + } + }); return Array.from(resultMap.values()); }, [ diff --git a/app/components/UI/Trending/utils/sortTrendingTokens.test.ts b/app/components/UI/Trending/utils/sortTrendingTokens.test.ts index 7ccb39751be..296d00afa73 100644 --- a/app/components/UI/Trending/utils/sortTrendingTokens.test.ts +++ b/app/components/UI/Trending/utils/sortTrendingTokens.test.ts @@ -20,48 +20,50 @@ const createMockToken = ( }); describe('sortTrendingTokens', () => { + it('returns empty array for empty input', () => { + expect(sortTrendingTokens([])).toEqual([]); + }); + describe('PriceChange sorting', () => { it('sorts by price change descending (default)', () => { - const tokens: TrendingAsset[] = [ + const tokens = [ createMockToken({ - assetId: 'token1', - symbol: 'TOKEN1', + assetId: 'a', + symbol: 'A', priceChangePct: { h24: '5.0' }, }), createMockToken({ - assetId: 'token2', - symbol: 'TOKEN2', + assetId: 'b', + symbol: 'B', priceChangePct: { h24: '10.0' }, }), createMockToken({ - assetId: 'token3', - symbol: 'TOKEN3', + assetId: 'c', + symbol: 'C', priceChangePct: { h24: '2.0' }, }), ]; const result = sortTrendingTokens(tokens); - expect(result[0].symbol).toBe('TOKEN2'); // Highest price change - expect(result[1].symbol).toBe('TOKEN1'); - expect(result[2].symbol).toBe('TOKEN3'); // Lowest price change + expect(result.map((t) => t.symbol)).toEqual(['B', 'A', 'C']); }); it('sorts by price change ascending', () => { - const tokens: TrendingAsset[] = [ + const tokens = [ createMockToken({ - assetId: 'token1', - symbol: 'TOKEN1', + assetId: 'a', + symbol: 'A', priceChangePct: { h24: '5.0' }, }), createMockToken({ - assetId: 'token2', - symbol: 'TOKEN2', + assetId: 'b', + symbol: 'B', priceChangePct: { h24: '10.0' }, }), createMockToken({ - assetId: 'token3', - symbol: 'TOKEN3', + assetId: 'c', + symbol: 'C', priceChangePct: { h24: '2.0' }, }), ]; @@ -72,142 +74,112 @@ describe('sortTrendingTokens', () => { SortDirection.Ascending, ); - expect(result[0].symbol).toBe('TOKEN3'); // Lowest price change - expect(result[1].symbol).toBe('TOKEN1'); - expect(result[2].symbol).toBe('TOKEN2'); // Highest price change + expect(result.map((t) => t.symbol)).toEqual(['C', 'A', 'B']); }); - it('handles missing priceChangePct.h24 by treating as 0', () => { - const tokens: TrendingAsset[] = [ + it('handles missing priceChangePct by treating value as 0', () => { + const tokens = [ createMockToken({ - assetId: 'token1', - symbol: 'TOKEN1', + assetId: 'a', + symbol: 'A', priceChangePct: { h24: '5.0' }, }), createMockToken({ - assetId: 'token2', - symbol: 'TOKEN2', + assetId: 'b', + symbol: 'B', priceChangePct: undefined, }), createMockToken({ - assetId: 'token3', - symbol: 'TOKEN3', + assetId: 'c', + symbol: 'C', priceChangePct: { h24: '10.0' }, }), ]; - const result = sortTrendingTokens( - tokens, - PriceChangeOption.PriceChange, - SortDirection.Descending, - ); + const result = sortTrendingTokens(tokens); - expect(result[0].symbol).toBe('TOKEN3'); // Highest - expect(result[1].symbol).toBe('TOKEN1'); - expect(result[2].symbol).toBe('TOKEN2'); // Missing value treated as 0 + expect(result.map((t) => t.symbol)).toEqual(['C', 'A', 'B']); }); - it('handles invalid priceChangePct.h24 string by treating as 0', () => { - const tokens: TrendingAsset[] = [ + it('handles invalid priceChangePct string by treating value as 0', () => { + const tokens = [ createMockToken({ - assetId: 'token1', - symbol: 'TOKEN1', + assetId: 'a', + symbol: 'A', priceChangePct: { h24: 'invalid' }, }), createMockToken({ - assetId: 'token2', - symbol: 'TOKEN2', + assetId: 'b', + symbol: 'B', priceChangePct: { h24: '5.0' }, }), ]; - const result = sortTrendingTokens( - tokens, - PriceChangeOption.PriceChange, - SortDirection.Descending, - ); + const result = sortTrendingTokens(tokens); - expect(result[0].symbol).toBe('TOKEN2'); - expect(result[1].symbol).toBe('TOKEN1'); // Invalid value treated as 0 + expect(result.map((t) => t.symbol)).toEqual(['B', 'A']); }); - it('handles negative price changes correctly', () => { - const tokens: TrendingAsset[] = [ + it('sorts negative price changes correctly', () => { + const tokens = [ createMockToken({ - assetId: 'token1', - symbol: 'TOKEN1', + assetId: 'a', + symbol: 'A', priceChangePct: { h24: '-5.0' }, }), createMockToken({ - assetId: 'token2', - symbol: 'TOKEN2', + assetId: 'b', + symbol: 'B', priceChangePct: { h24: '10.0' }, }), createMockToken({ - assetId: 'token3', - symbol: 'TOKEN3', + assetId: 'c', + symbol: 'C', priceChangePct: { h24: '-2.0' }, }), ]; - const result = sortTrendingTokens( - tokens, - PriceChangeOption.PriceChange, - SortDirection.Descending, - ); + const result = sortTrendingTokens(tokens); - expect(result[0].symbol).toBe('TOKEN2'); // Highest (positive) - expect(result[1].symbol).toBe('TOKEN3'); // Less negative - expect(result[2].symbol).toBe('TOKEN1'); // Most negative + expect(result.map((t) => t.symbol)).toEqual(['B', 'C', 'A']); }); - }); - describe('Volume sorting', () => { - it('sorts by volume descending', () => { - const tokens: TrendingAsset[] = [ - createMockToken({ - assetId: 'token1', - symbol: 'TOKEN1', - aggregatedUsdVolume: 1000000, - }), + it('pushes tokens with no price data to end', () => { + const tokens = [ + createMockToken({ assetId: 'a', symbol: 'A', price: undefined }), createMockToken({ - assetId: 'token2', - symbol: 'TOKEN2', - aggregatedUsdVolume: 5000000, - }), - createMockToken({ - assetId: 'token3', - symbol: 'TOKEN3', - aggregatedUsdVolume: 2000000, + assetId: 'b', + symbol: 'B', + priceChangePct: { h24: '3.0' }, }), + createMockToken({ assetId: 'c', symbol: 'C', price: '0' }), ]; - const result = sortTrendingTokens( - tokens, - PriceChangeOption.Volume, - SortDirection.Descending, - ); + const result = sortTrendingTokens(tokens); - expect(result[0].symbol).toBe('TOKEN2'); // Highest volume - expect(result[1].symbol).toBe('TOKEN3'); - expect(result[2].symbol).toBe('TOKEN1'); // Lowest volume + expect(result[0].symbol).toBe('B'); + expect(result.slice(1).map((t) => t.symbol)).toEqual( + expect.arrayContaining(['A', 'C']), + ); }); + }); - it('sorts by volume ascending', () => { - const tokens: TrendingAsset[] = [ + describe('Volume sorting', () => { + it('sorts by volume descending', () => { + const tokens = [ createMockToken({ - assetId: 'token1', - symbol: 'TOKEN1', + assetId: 'a', + symbol: 'A', aggregatedUsdVolume: 1000000, }), createMockToken({ - assetId: 'token2', - symbol: 'TOKEN2', + assetId: 'b', + symbol: 'B', aggregatedUsdVolume: 5000000, }), createMockToken({ - assetId: 'token3', - symbol: 'TOKEN3', + assetId: 'c', + symbol: 'C', aggregatedUsdVolume: 2000000, }), ]; @@ -215,29 +187,27 @@ describe('sortTrendingTokens', () => { const result = sortTrendingTokens( tokens, PriceChangeOption.Volume, - SortDirection.Ascending, + SortDirection.Descending, ); - expect(result[0].symbol).toBe('TOKEN1'); // Lowest volume - expect(result[1].symbol).toBe('TOKEN3'); - expect(result[2].symbol).toBe('TOKEN2'); // Highest volume + expect(result.map((t) => t.symbol)).toEqual(['B', 'C', 'A']); }); - it('handles missing aggregatedUsdVolume by treating as 0', () => { - const tokens: TrendingAsset[] = [ + it('pushes tokens with no volume to end', () => { + const tokens = [ createMockToken({ - assetId: 'token1', - symbol: 'TOKEN1', + assetId: 'a', + symbol: 'A', aggregatedUsdVolume: 1000000, }), createMockToken({ - assetId: 'token2', - symbol: 'TOKEN2', + assetId: 'b', + symbol: 'B', aggregatedUsdVolume: undefined, }), createMockToken({ - assetId: 'token3', - symbol: 'TOKEN3', + assetId: 'c', + symbol: 'C', aggregatedUsdVolume: 5000000, }), ]; @@ -248,30 +218,16 @@ describe('sortTrendingTokens', () => { SortDirection.Descending, ); - expect(result[0].symbol).toBe('TOKEN3'); // Highest - expect(result[1].symbol).toBe('TOKEN1'); - expect(result[2].symbol).toBe('TOKEN2'); // Missing value treated as 0 + expect(result.map((t) => t.symbol)).toEqual(['C', 'A', 'B']); }); }); describe('MarketCap sorting', () => { it('sorts by market cap descending', () => { - const tokens: TrendingAsset[] = [ - createMockToken({ - assetId: 'token1', - symbol: 'TOKEN1', - marketCap: 10000000, - }), - createMockToken({ - assetId: 'token2', - symbol: 'TOKEN2', - marketCap: 50000000, - }), - createMockToken({ - assetId: 'token3', - symbol: 'TOKEN3', - marketCap: 20000000, - }), + const tokens = [ + createMockToken({ assetId: 'a', symbol: 'A', marketCap: 10000000 }), + createMockToken({ assetId: 'b', symbol: 'B', marketCap: 50000000 }), + createMockToken({ assetId: 'c', symbol: 'C', marketCap: 20000000 }), ]; const result = sortTrendingTokens( @@ -280,58 +236,14 @@ describe('sortTrendingTokens', () => { SortDirection.Descending, ); - expect(result[0].symbol).toBe('TOKEN2'); // Highest market cap - expect(result[1].symbol).toBe('TOKEN3'); - expect(result[2].symbol).toBe('TOKEN1'); // Lowest market cap + expect(result.map((t) => t.symbol)).toEqual(['B', 'C', 'A']); }); - it('sorts by market cap ascending', () => { - const tokens: TrendingAsset[] = [ - createMockToken({ - assetId: 'token1', - symbol: 'TOKEN1', - marketCap: 10000000, - }), - createMockToken({ - assetId: 'token2', - symbol: 'TOKEN2', - marketCap: 50000000, - }), - createMockToken({ - assetId: 'token3', - symbol: 'TOKEN3', - marketCap: 20000000, - }), - ]; - - const result = sortTrendingTokens( - tokens, - PriceChangeOption.MarketCap, - SortDirection.Ascending, - ); - - expect(result[0].symbol).toBe('TOKEN1'); // Lowest market cap - expect(result[1].symbol).toBe('TOKEN3'); - expect(result[2].symbol).toBe('TOKEN2'); // Highest market cap - }); - - it('handles missing marketCap by treating as 0', () => { - const tokens: TrendingAsset[] = [ - createMockToken({ - assetId: 'token1', - symbol: 'TOKEN1', - marketCap: 10000000, - }), - createMockToken({ - assetId: 'token2', - symbol: 'TOKEN2', - marketCap: undefined, - }), - createMockToken({ - assetId: 'token3', - symbol: 'TOKEN3', - marketCap: 50000000, - }), + it('pushes tokens with no market cap to end', () => { + const tokens = [ + createMockToken({ assetId: 'a', symbol: 'A', marketCap: 10000000 }), + createMockToken({ assetId: 'b', symbol: 'B', marketCap: undefined }), + createMockToken({ assetId: 'c', symbol: 'C', marketCap: 50000000 }), ]; const result = sortTrendingTokens( @@ -340,37 +252,7 @@ describe('sortTrendingTokens', () => { SortDirection.Descending, ); - expect(result[0].symbol).toBe('TOKEN3'); // Highest - expect(result[1].symbol).toBe('TOKEN1'); - expect(result[2].symbol).toBe('TOKEN2'); // Missing value treated as 0 - }); - }); - - describe('Default parameters', () => { - it('uses PriceChange and Descending as defaults', () => { - const tokens: TrendingAsset[] = [ - createMockToken({ - assetId: 'token1', - symbol: 'TOKEN1', - priceChangePct: { h24: '2.0' }, - }), - createMockToken({ - assetId: 'token2', - symbol: 'TOKEN2', - priceChangePct: { h24: '10.0' }, - }), - createMockToken({ - assetId: 'token3', - symbol: 'TOKEN3', - priceChangePct: { h24: '5.0' }, - }), - ]; - - const result = sortTrendingTokens(tokens); - - expect(result[0].symbol).toBe('TOKEN2'); // Highest price change - expect(result[1].symbol).toBe('TOKEN3'); - expect(result[2].symbol).toBe('TOKEN1'); // Lowest price change + expect(result.map((t) => t.symbol)).toEqual(['C', 'A', 'B']); }); }); }); diff --git a/app/components/UI/Trending/utils/sortTrendingTokens.ts b/app/components/UI/Trending/utils/sortTrendingTokens.ts index 6d975a3867b..dcb09e21788 100644 --- a/app/components/UI/Trending/utils/sortTrendingTokens.ts +++ b/app/components/UI/Trending/utils/sortTrendingTokens.ts @@ -6,6 +6,39 @@ import { } from '../components/TrendingTokensBottomSheet'; import { getPriceChangeFieldKey } from '../components/TrendingTokenRowItem/utils'; +const getDataPredicate = ( + option: PriceChangeOption, +): ((t: TrendingAsset) => boolean) => { + switch (option) { + case PriceChangeOption.PriceChange: + // Not using price change on purpose since price change can be 0% + return (t) => Boolean(t.price && t.price !== '0'); + case PriceChangeOption.Volume: + return (t) => Boolean(t.aggregatedUsdVolume); + case PriceChangeOption.MarketCap: + return (t) => Boolean(t.marketCap); + } +}; + +const getValueExtractor = ( + option: PriceChangeOption, + timeOption: TimeOption, +): ((t: TrendingAsset) => number) => { + switch (option) { + case PriceChangeOption.PriceChange: { + const key = getPriceChangeFieldKey(timeOption); + return (t) => { + const v = t.priceChangePct?.[key]; + return v ? parseFloat(v) || 0 : 0; + }; + } + case PriceChangeOption.Volume: + return (t) => t.aggregatedUsdVolume ?? 0; + case PriceChangeOption.MarketCap: + return (t) => t.marketCap ?? 0; + } +}; + /** * Sorts trending tokens based on the selected option and direction * @param tokens - Array of trending tokens to sort @@ -24,37 +57,17 @@ export const sortTrendingTokens = ( return []; } - // Create a new array and sort in-place for better performance - const sorted = [...tokens]; - sorted.sort((a, b) => { - let aValue: number; - let bValue: number; + const hasData = getDataPredicate(option); + const getValue = getValueExtractor(option, timeOption); + const dirMultiplier = direction === SortDirection.Ascending ? 1 : -1; - switch (option) { - case PriceChangeOption.PriceChange: { - // For price change, use the priceChangePct field corresponding to the selected time option - const priceChangeFieldKey = getPriceChangeFieldKey(timeOption); - const aPriceChange = a.priceChangePct?.[priceChangeFieldKey]; - aValue = aPriceChange ? parseFloat(aPriceChange) || 0 : 0; - const bPriceChange = b.priceChangePct?.[priceChangeFieldKey]; - bValue = bPriceChange ? parseFloat(bPriceChange) || 0 : 0; - break; - } - case PriceChangeOption.Volume: - aValue = a.aggregatedUsdVolume ?? 0; - bValue = b.aggregatedUsdVolume ?? 0; - break; - case PriceChangeOption.MarketCap: - aValue = a.marketCap ?? 0; - bValue = b.marketCap ?? 0; - break; - default: - return 0; - } + const sortable: TrendingAsset[] = []; + const nulled: TrendingAsset[] = []; + for (const token of tokens) { + (hasData(token) ? sortable : nulled).push(token); + } - const comparison = aValue - bValue; - return direction === SortDirection.Ascending ? comparison : -comparison; - }); + sortable.sort((a, b) => (getValue(a) - getValue(b)) * dirMultiplier); - return sorted; + return [...sortable, ...nulled]; }; diff --git a/app/components/UI/Trending/utils/trendingNetworksList.ts b/app/components/UI/Trending/utils/trendingNetworksList.ts index 50207e763ae..8bd1a98ba19 100644 --- a/app/components/UI/Trending/utils/trendingNetworksList.ts +++ b/app/components/UI/Trending/utils/trendingNetworksList.ts @@ -3,6 +3,7 @@ import { TrxScope, ///: END:ONLY_INCLUDE_IF } from '@metamask/keyring-api'; +import type { CaipChainId } from '@metamask/utils'; import { ProcessedNetwork } from '../../../hooks/useNetworksByNamespace/useNetworksByNamespace'; import { getNetworkImageSource } from '../../../../util/networks'; import { NetworkToCaipChainId } from '../../NetworkMultiSelector/NetworkMultiSelector.constants'; @@ -123,3 +124,17 @@ export const TRENDING_NETWORKS_LIST: ProcessedNetwork[] = [ }), }, ]; + +/** + * Networks supported for RWA (Real World Asset) tokens. + */ +export const RWA_NETWORKS_LIST: ProcessedNetwork[] = + TRENDING_NETWORKS_LIST.filter((n) => + [NetworkToCaipChainId.ETHEREUM, NetworkToCaipChainId.BNB].includes( + n.caipChainId as NetworkToCaipChainId, + ), + ); + +export const RWA_CHAIN_IDS: CaipChainId[] = RWA_NETWORKS_LIST.map( + (n) => n.caipChainId, +); diff --git a/app/components/Views/TrendingTokens/TrendingTokensFullView/TrendingTokensFullView.tsx b/app/components/Views/TrendingTokens/TrendingTokensFullView/TrendingTokensFullView.tsx deleted file mode 100644 index 443bf3c1078..00000000000 --- a/app/components/Views/TrendingTokens/TrendingTokensFullView/TrendingTokensFullView.tsx +++ /dev/null @@ -1,617 +0,0 @@ -import React, { useCallback, useMemo, useState } from 'react'; -import { useNavigation } from '@react-navigation/native'; -import { - SafeAreaView, - useSafeAreaInsets, -} from 'react-native-safe-area-context'; -import { - Platform, - StyleSheet, - View, - TouchableOpacity, - RefreshControl, -} from 'react-native'; -import { useSelector } from 'react-redux'; -import { useAppThemeFromContext } from '../../../../util/theme'; -import { Theme } from '../../../../util/theme/models'; -import { selectNetworkConfigurationsByCaipChainId } from '../../../../selectors/networkController'; -import Icon, { - IconName, - IconColor, - IconSize, -} from '../../../../component-library/components/Icons/Icon'; -import { strings } from '../../../../../locales/i18n'; -import { TrendingListHeader } from '../../../UI/Trending/components/TrendingListHeader'; -import TrendingTokensList, { - TrendingFilterContext, -} from '../../../UI/Trending/components/TrendingTokensList/TrendingTokensList'; -import TrendingTokensSkeleton from '../../../UI/Trending/components/TrendingTokenSkeleton/TrendingTokensSkeleton'; -import { - SortTrendingBy, - type TrendingAsset, -} from '@metamask/assets-controllers'; -import { CaipChainId, Hex, parseCaipChainId } from '@metamask/utils'; -import { PopularList } from '../../../../util/networks/customNetworks'; -import Text from '../../../../component-library/components/Texts/Text'; -import { - TrendingTokenTimeBottomSheet, - TrendingTokenNetworkBottomSheet, - TrendingTokenPriceChangeBottomSheet, - PriceChangeOption, - SortDirection, - TimeOption, -} from '../../../UI/Trending/components/TrendingTokensBottomSheet'; -import { sortTrendingTokens } from '../../../UI/Trending/utils/sortTrendingTokens'; -import { useTrendingSearch } from '../../../UI/Trending/hooks/useTrendingSearch/useTrendingSearch'; -import EmptyErrorTrendingState from '../../TrendingView/components/EmptyErrorState/EmptyErrorTrendingState'; -import EmptySearchResultState from '../../TrendingView/components/EmptyErrorState/EmptySearchResultState'; -import { useTailwind } from '@metamask/design-system-twrnc-preset'; -import TrendingFeedSessionManager from '../../../UI/Trending/services/TrendingFeedSessionManager'; -import { useSearchTracking } from '../../../UI/Trending/hooks/useSearchTracking/useSearchTracking'; - -const createStyles = (theme: Theme) => - StyleSheet.create({ - safeArea: { - flex: 1, - backgroundColor: theme.colors.background.default, - }, - headerContainer: { - backgroundColor: theme.colors.background.default, - }, - cardContainer: { - margin: 16, - borderRadius: 16, - backgroundColor: theme.colors.background.muted, - padding: 16, - }, - controlBarWrapper: { - paddingVertical: 16, - paddingHorizontal: 16, - flexGrow: 0, - }, - controlButtonOuterWrapper: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - }, - controlButtonInnerWrapper: { - flexDirection: 'row', - gap: 8, - alignItems: 'center', - flexShrink: 1, - marginLeft: 8, - minWidth: 0, - }, - controlButton: { - paddingVertical: 8, - paddingHorizontal: 12, - alignItems: 'center', - borderRadius: 8, - backgroundColor: theme.colors.background.muted, - }, - controlButtonRight: { - padding: 8, - alignItems: 'center', - borderRadius: 8, - backgroundColor: theme.colors.background.muted, - flexShrink: 1, - minWidth: 0, - }, - controlButtonRightFixed: { - padding: 8, - alignItems: 'center', - borderRadius: 8, - backgroundColor: theme.colors.background.muted, - flexShrink: 0, - }, - controlButtonContent: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - gap: 4, - }, - controlButtonText: { - color: theme.colors.text.default, - fontSize: 14, - fontWeight: '600', - lineHeight: 19.6, // 140% of 14px - fontStyle: 'normal', - flexShrink: 1, - minWidth: 0, - }, - controlButtonDisabled: { - opacity: 0.5, - }, - }); - -export interface TrendingTokensDataProps { - isLoading: boolean; - refreshing: boolean; - trendingTokens: TrendingAsset[]; - handleRefresh: () => void; - selectedTimeOption: TimeOption; - filterContext: TrendingFilterContext; - theme: Theme; - - search: { - searchResults: TrendingAsset[]; - searchQuery: string; - }; -} - -export const TrendingTokensData = (props: TrendingTokensDataProps) => { - const { - isLoading, - refreshing, - trendingTokens, - search, - handleRefresh, - selectedTimeOption, - filterContext, - theme, - } = props; - - const tw = useTailwind(); - - const isSearching = search.searchQuery.trim().length > 0; - const hasSearchResults = search.searchResults.length > 0; - - // Loading - show skeleton - if (isLoading) { - return ( - - {Array.from({ length: 12 }).map((_, index) => ( - - ); - } - - // Show empty trending search results - if (isSearching && !hasSearchResults) { - return- ))} - ; - } - - // Show error if no results found - if (!isSearching && !hasSearchResults) { - return ; - } - - // Show trending tokens list - return ( - - - ); -}; - -const TrendingTokensFullView = () => { - const navigation = useNavigation(); - const theme = useAppThemeFromContext(); - const styles = useMemo(() => createStyles(theme), [theme]); - const insets = useSafeAreaInsets(); - const sessionManager = TrendingFeedSessionManager.getInstance(); - const [sortBy, setSortBy] = useState- } - /> - (undefined); - const [selectedTimeOption, setSelectedTimeOption] = useState ( - TimeOption.TwentyFourHours, - ); - const [selectedNetwork, setSelectedNetwork] = useState ( - null, - ); - const [selectedPriceChangeOption, setSelectedPriceChangeOption] = useState< - PriceChangeOption | undefined - >(PriceChangeOption.PriceChange); - const [priceChangeSortDirection, setPriceChangeSortDirection] = - useState (SortDirection.Descending); - const [showTimeBottomSheet, setShowTimeBottomSheet] = useState(false); - const [showNetworkBottomSheet, setShowNetworkBottomSheet] = useState(false); - const [showPriceChangeBottomSheet, setShowPriceChangeBottomSheet] = - useState(false); - const [isSearchVisible, setIsSearchVisible] = useState(false); - const [searchQuery, setSearchQuery] = useState(''); - const [refreshing, setRefreshing] = useState(false); - - const handleBackPress = useCallback(() => { - navigation.goBack(); - }, [navigation]); - - const handleSearchToggle = useCallback(() => { - setIsSearchVisible((prev) => !prev); - if (isSearchVisible) { - setSearchQuery(''); - } - }, [isSearchVisible]); - - const handleSearchQueryChange = useCallback((query: string) => { - setSearchQuery(query); - }, []); - - const networkConfigurations = useSelector( - selectNetworkConfigurationsByCaipChainId, - ); - - // Derive network name from selectedNetwork chain IDs - const selectedNetworkName = useMemo(() => { - if (!selectedNetwork || selectedNetwork.length === 0) { - return strings('trending.all_networks'); - } - const selectedNetworkChainId = selectedNetwork[0]; - - // First check if network is in user's configurations - const networkConfig = networkConfigurations[selectedNetworkChainId]; - if (networkConfig?.name) { - return networkConfig.name; - } - - // If not found, check PopularList - try { - const { namespace, reference } = parseCaipChainId(selectedNetworkChainId); - if (namespace === 'eip155') { - const hexChainId = `0x${Number(reference).toString(16)}` as Hex; - const popularNetwork = PopularList.find( - (network) => network.chainId === hexChainId, - ); - if (popularNetwork?.nickname) { - return popularNetwork.nickname; - } - } - } catch { - // If parsing fails, fall through to default - } - - return strings('trending.all_networks'); - }, [selectedNetwork, networkConfigurations]); - - // Use tokens section data as the single source of truth: - // - When no search query: returns trending results from useTrendingRequest - // - When search query exists: returns merged trending + search results - const { - data: searchResults, - isLoading, - refetch: refetchTokensSection, - } = useTrendingSearch({ - searchQuery: searchQuery || undefined, - sortBy, - chainIds: selectedNetwork, - }); - - // Sort and display tokens based on selected option and direction - const trendingTokens = useMemo(() => { - // Early return if no results - if (searchResults.length === 0) { - return []; - } - - // When searching, return results in relevance order (no sorting) - if (searchQuery?.trim()) { - return searchResults; - } - - // When browsing (no search), apply sorting if option is selected - if (!selectedPriceChangeOption) { - return searchResults; - } - - // Sort using the shared utility function - const sorted = sortTrendingTokens( - searchResults, - selectedPriceChangeOption, - priceChangeSortDirection, - selectedTimeOption, - ); - - return sorted; - }, [ - searchResults, - searchQuery, - selectedPriceChangeOption, - priceChangeSortDirection, - selectedTimeOption, - ]); - - // Compute filter context for analytics tracking - const filterContext: TrendingFilterContext = useMemo( - () => ({ - timeFilter: selectedTimeOption, - sortOption: selectedPriceChangeOption, - networkFilter: - selectedNetwork && selectedNetwork.length > 0 - ? selectedNetwork[0] - : 'all', - isSearchResult: Boolean(searchQuery?.trim()), - }), - [ - selectedTimeOption, - selectedPriceChangeOption, - selectedNetwork, - searchQuery, - ], - ); - - // Track search events with debounce - useSearchTracking({ - searchQuery, - resultsCount: trendingTokens.length, - isLoading, - timeFilter: selectedTimeOption, - sortOption: selectedPriceChangeOption || PriceChangeOption.PriceChange, - networkFilter: - selectedNetwork && selectedNetwork.length > 0 - ? selectedNetwork[0] - : 'all', - }); - - const handlePriceChangeSelect = useCallback( - (option: PriceChangeOption, sortDirection: SortDirection) => { - const previousValue = - selectedPriceChangeOption || PriceChangeOption.PriceChange; - setSelectedPriceChangeOption(option); - setPriceChangeSortDirection(sortDirection); - - // Track filter change if value actually changed - if (option !== previousValue) { - sessionManager.trackFilterChange({ - filter_type: 'sort', - previous_value: previousValue, - new_value: option, - time_filter: selectedTimeOption, - sort_option: option, - network_filter: - selectedNetwork && selectedNetwork.length > 0 - ? selectedNetwork[0] - : 'all', - }); - } - }, - [ - selectedPriceChangeOption, - selectedTimeOption, - selectedNetwork, - sessionManager, - ], - ); - - const handlePriceChangePress = useCallback(() => { - setShowPriceChangeBottomSheet(true); - }, []); - - const handleNetworkSelect = useCallback( - (chainIds: CaipChainId[] | null) => { - const previousValue = - selectedNetwork && selectedNetwork.length > 0 - ? selectedNetwork[0] - : 'all'; - const newValue = chainIds && chainIds.length > 0 ? chainIds[0] : 'all'; - - setSelectedNetwork(chainIds); - - // Track filter change if value actually changed - if (newValue !== previousValue) { - sessionManager.trackFilterChange({ - filter_type: 'network', - previous_value: previousValue, - new_value: newValue, - time_filter: selectedTimeOption, - sort_option: - selectedPriceChangeOption || PriceChangeOption.PriceChange, - network_filter: newValue, - }); - } - }, - [ - selectedNetwork, - selectedTimeOption, - selectedPriceChangeOption, - sessionManager, - ], - ); - - const handleAllNetworksPress = useCallback(() => { - setShowNetworkBottomSheet(true); - }, []); - - const handleTimeSelect = useCallback( - (selectedSortBy: SortTrendingBy, timeOption: TimeOption) => { - const previousValue = selectedTimeOption; - setSortBy(selectedSortBy); - setSelectedTimeOption(timeOption); - - // Track filter change if value actually changed - if (timeOption !== previousValue) { - sessionManager.trackFilterChange({ - filter_type: 'time', - previous_value: previousValue, - new_value: timeOption, - time_filter: timeOption, - sort_option: - selectedPriceChangeOption || PriceChangeOption.PriceChange, - network_filter: - selectedNetwork && selectedNetwork.length > 0 - ? selectedNetwork[0] - : 'all', - }); - } - }, - [ - selectedTimeOption, - selectedPriceChangeOption, - selectedNetwork, - sessionManager, - ], - ); - - const handle24hPress = useCallback(() => { - setShowTimeBottomSheet(true); - }, []); - - // Handle pull-to-refresh - const handleRefresh = useCallback(async () => { - setRefreshing(true); - try { - refetchTokensSection?.(); - } catch (error) { - console.warn('Failed to refresh trending tokens:', error); - } finally { - setRefreshing(false); - } - }, [refetchTokensSection]); - - // Get the button text based on selected price change option - const priceChangeButtonText = useMemo(() => { - switch (selectedPriceChangeOption) { - case PriceChangeOption.Volume: - return strings('trending.volume'); - case PriceChangeOption.MarketCap: - return strings('trending.market_cap'); - case PriceChangeOption.PriceChange: - default: - return strings('trending.price_change'); - } - }, [selectedPriceChangeOption]); - - return ( - - - ); -}; - -TrendingTokensFullView.displayName = 'TrendingTokensFullView'; - -export default TrendingTokensFullView; diff --git a/app/components/Views/TrendingView/components/ExploreSearchResults/ExploreSearchResults.test.tsx b/app/components/Views/TrendingView/components/ExploreSearchResults/ExploreSearchResults.test.tsx index fc8ceafa8f8..7ccc8b84a2c 100644 --- a/app/components/Views/TrendingView/components/ExploreSearchResults/ExploreSearchResults.test.tsx +++ b/app/components/Views/TrendingView/components/ExploreSearchResults/ExploreSearchResults.test.tsx @@ -93,15 +93,17 @@ describe('ExploreSearchResults', () => { ], }, ], + stocks: [], sites: [], }, isLoading: { tokens: false, perps: false, predictions: false, + stocks: false, sites: false, }, - sectionsOrder: ['tokens', 'perps', 'predictions', 'sites'], + sectionsOrder: ['tokens', 'stocks', 'perps', 'predictions', 'sites'], }); const { getByText, getByTestId } = render( @@ -120,15 +122,17 @@ describe('ExploreSearchResults', () => { tokens: [{ assetId: '1', symbol: 'BTC', name: 'Bitcoin' }], perps: [], predictions: [], + stocks: [], sites: [], }, isLoading: { tokens: false, perps: false, predictions: false, + stocks: false, sites: false, }, - sectionsOrder: ['tokens', 'perps', 'predictions', 'sites'], + sectionsOrder: ['tokens', 'stocks', 'perps', 'predictions', 'sites'], }); const { getByText, queryByText } = render( @@ -146,15 +150,17 @@ describe('ExploreSearchResults', () => { tokens: [], perps: [], predictions: [], + stocks: [], sites: [], }, isLoading: { tokens: false, perps: false, predictions: false, + stocks: false, sites: false, }, - sectionsOrder: ['tokens', 'perps', 'predictions', 'sites'], + sectionsOrder: ['tokens', 'stocks', 'perps', 'predictions', 'sites'], }); render(- - {!isSearchVisible ? ( -- - - ) : null} - -- -- -- -- {priceChangeButtonText} - -- - -- -- -- {selectedNetworkName} - -- - -- -- {selectedTimeOption} - -- - - setShowTimeBottomSheet(false)} - onTimeSelect={handleTimeSelect} - selectedTime={selectedTimeOption} - /> - setShowNetworkBottomSheet(false)} - onNetworkSelect={handleNetworkSelect} - selectedNetwork={selectedNetwork} - /> - setShowPriceChangeBottomSheet(false)} - onPriceChangeSelect={handlePriceChangeSelect} - selectedOption={selectedPriceChangeOption} - sortDirection={priceChangeSortDirection} - /> - ); @@ -174,15 +180,17 @@ describe('ExploreSearchResults', () => { tokens: [{ assetId: '1', symbol: 'BTC', name: 'Bitcoin' }], perps: [], predictions: [], + stocks: [], sites: [], }, isLoading: { tokens: false, perps: false, predictions: false, + stocks: false, sites: false, }, - sectionsOrder: ['tokens', 'perps', 'predictions', 'sites'], + sectionsOrder: ['tokens', 'stocks', 'perps', 'predictions', 'sites'], }); // Act @@ -203,15 +211,17 @@ describe('ExploreSearchResults', () => { tokens: [{ assetId: '1', symbol: 'BTC', name: 'Bitcoin' }], perps: [], predictions: [], + stocks: [], sites: [], }, isLoading: { tokens: false, perps: false, predictions: false, + stocks: false, sites: false, }, - sectionsOrder: ['tokens', 'perps', 'predictions', 'sites'], + sectionsOrder: ['tokens', 'stocks', 'perps', 'predictions', 'sites'], }); // Act @@ -231,15 +241,17 @@ describe('ExploreSearchResults', () => { tokens: [], perps: [], predictions: [], + stocks: [], sites: [], }, isLoading: { tokens: true, perps: false, predictions: false, + stocks: false, sites: false, }, - sectionsOrder: ['tokens', 'perps', 'predictions', 'sites'], + sectionsOrder: ['tokens', 'stocks', 'perps', 'predictions', 'sites'], }); // Act @@ -258,15 +270,17 @@ describe('ExploreSearchResults', () => { tokens: [], perps: [], predictions: [], + stocks: [], sites: [], }, isLoading: { tokens: false, perps: false, predictions: false, + stocks: false, sites: false, }, - sectionsOrder: ['tokens', 'perps', 'predictions', 'sites'], + sectionsOrder: ['tokens', 'stocks', 'perps', 'predictions', 'sites'], }); // Act @@ -295,15 +309,17 @@ describe('ExploreSearchResults', () => { tokens: [{ assetId: '1', symbol: 'BTC', name: 'Bitcoin' }], perps: [{ symbol: 'BTC-USD', name: 'Bitcoin' }], predictions: [], + stocks: [], sites: [], }, isLoading: { tokens: false, perps: false, predictions: false, + stocks: false, sites: false, }, - sectionsOrder: ['tokens', 'perps', 'predictions', 'sites'], + sectionsOrder: ['tokens', 'stocks', 'perps', 'predictions', 'sites'], }); // Act @@ -325,17 +341,20 @@ describe('ExploreSearchResults', () => { tokens: [{ assetId: '1', symbol: 'BTC', name: 'Bitcoin' }], perps: [], predictions: [], + stocks: [], sites: [], }, isLoading: { tokens: false, perps: false, predictions: false, + stocks: false, sites: false, }, sectionsOrder: [ 'tokens', 'unknown' as 'tokens', // Intentionally invalid ID to test graceful handling + 'stocks', 'perps', 'predictions', 'sites', diff --git a/app/components/Views/TrendingView/components/QuickActions/QuickActions.tsx b/app/components/Views/TrendingView/components/QuickActions/QuickActions.tsx index f1b5630fcef..841fa2576be 100644 --- a/app/components/Views/TrendingView/components/QuickActions/QuickActions.tsx +++ b/app/components/Views/TrendingView/components/QuickActions/QuickActions.tsx @@ -3,16 +3,48 @@ import { ScrollView, TouchableOpacity } from 'react-native'; import { useNavigation } from '@react-navigation/native'; import { Box, - Icon, - IconColor, - IconSize, + Icon as DSIcon, + IconColor as DSIconColor, + IconSize as DSIconSize, Text, TextVariant, } from '@metamask/design-system-react-native'; +import LocalIcon, { + IconColor as LocalIconColor, + IconSize as LocalIconSize, +} from '../../../../../component-library/components/Icons/Icon'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; -import { useSectionsArray, SectionId } from '../../sections.config'; +import { + useSectionsArray, + SectionId, + type SectionIcon, +} from '../../sections.config'; import { TrendingViewSelectorsIDs } from '../../TrendingView.testIds'; +const SectionIconRenderer: React.FC<{ + icon: SectionIcon; + style?: object; +}> = ({ icon, style }) => { + if (icon.source === 'design-system') { + return ( + + ); + } + return ( + + ); +}; + interface QuickActionsProps { /** Set of section IDs that have empty data and should be hidden */ emptySections: Set ; @@ -47,10 +79,8 @@ const QuickActions: React.FC = ({ emptySections }) => { 'flex-row items-center justify-center gap-1 rounded-xl bg-background-section px-3 py-2', )} > - {section.title} diff --git a/app/components/Views/TrendingView/hooks/useExploreSearch.test.ts b/app/components/Views/TrendingView/hooks/useExploreSearch.test.ts index a4fe12bffb6..39ca30fd05e 100644 --- a/app/components/Views/TrendingView/hooks/useExploreSearch.test.ts +++ b/app/components/Views/TrendingView/hooks/useExploreSearch.test.ts @@ -91,9 +91,18 @@ jest.mock('../../../UI/Sites/hooks/useSiteData/useSitesData', () => ({ }), })); +jest.mock('../../../UI/Trending/hooks/useRwaTokens/useRwaTokens', () => ({ + useRwaTokens: () => ({ + data: [], + isLoading: false, + refetch: jest.fn(), + }), +})); + // Mock useSectionsArray to return all sections for testing const mockSectionsArray: { id: SectionId }[] = [ { id: 'tokens' }, + { id: 'stocks' }, { id: 'perps' }, { id: 'predictions' }, { id: 'sites' }, @@ -248,7 +257,13 @@ describe('useExploreSearch', () => { }); it('returns custom sectionsOrder when provided in options', () => { - const customOrder = ['sites', 'tokens', 'perps', 'predictions'] as const; + const customOrder = [ + 'sites', + 'tokens', + 'stocks', + 'perps', + 'predictions', + ] as const; const { result } = renderHook(() => useExploreSearch('', { sectionsOrder: [...customOrder] }), ); diff --git a/app/components/Views/TrendingView/sections.config.tsx b/app/components/Views/TrendingView/sections.config.tsx index a2ff5e6e1b4..0c0eeae9294 100644 --- a/app/components/Views/TrendingView/sections.config.tsx +++ b/app/components/Views/TrendingView/sections.config.tsx @@ -12,10 +12,8 @@ import { filterMarketsByQuery, type PerpsMarketData, } from '@metamask/perps-controller'; -import PredictMarket from '../../UI/Predict/components/PredictMarket'; import type { PredictMarket as PredictMarketType } from '../../UI/Predict/types'; import type { PerpsNavigationParamList } from '../../UI/Perps/types/navigation'; -import PredictMarketSkeleton from '../../UI/Predict/components/PredictMarketSkeleton'; import { usePredictMarketData } from '../../UI/Predict/hooks/usePredictMarketData'; import { selectPerpsEnabledFlag } from '../../UI/Perps'; import { usePerpsMarkets } from '../../UI/Perps/hooks'; @@ -24,7 +22,8 @@ import { PerpsConnectionContext, } from '../../UI/Perps/providers/PerpsConnectionProvider'; import { PerpsStreamProvider } from '../../UI/Perps/providers/PerpsStreamManager'; -import { Box, IconName } from '@metamask/design-system-react-native'; +import { IconName as DSIconName } from '@metamask/design-system-react-native'; +import { IconName as LocalIconName } from '../../../component-library/components/Icons/Icon/Icon.types'; import type { SiteData } from '../../UI/Sites/components/SiteRowItem/SiteRowItem'; import SiteRowItemWrapper from '../../UI/Sites/components/SiteRowItemWrapper/SiteRowItemWrapper'; import SiteSkeleton from '../../UI/Sites/components/SiteSkeleton/SiteSkeleton'; @@ -37,9 +36,13 @@ import { import type { TrendingFilterContext } from '../../UI/Trending/components/TrendingTokensList/TrendingTokensList'; import PredictMarketRowItem from '../../UI/Predict/components/PredictMarketRowItem'; import SectionCard from './components/Sections/SectionTypes/SectionCard'; -import SectionCarrousel from './components/Sections/SectionTypes/SectionCarrousel'; +import { useRwaTokens } from '../../UI/Trending/hooks/useRwaTokens/useRwaTokens'; -export type SectionId = 'predictions' | 'tokens' | 'perps' | 'sites'; +export type SectionId = 'predictions' | 'tokens' | 'perps' | 'stocks' | 'sites'; + +export type SectionIcon = + | { source: 'local'; name: LocalIconName } + | { source: 'design-system'; name: DSIconName }; interface SectionData { data: unknown[]; @@ -49,7 +52,7 @@ interface SectionData { interface SectionConfig { id: SectionId; title: string; - icon: IconName; + icon: SectionIcon; viewAllAction: (navigation: NavigationProp) => void; RowItem: React.ComponentType<{ item: unknown; @@ -163,7 +166,7 @@ export const SECTIONS_CONFIG: Record = { tokens: { id: 'tokens', title: strings('trending.trending_tokens'), - icon: IconName.Ethereum, + icon: { source: 'design-system', name: DSIconName.Ethereum }, viewAllAction: (navigation) => { navigation.navigate(Routes.WALLET.TRENDING_TOKENS_FULL_VIEW); }, @@ -205,7 +208,7 @@ export const SECTIONS_CONFIG: Record = { perps: { id: 'perps', title: strings('trending.perps'), - icon: IconName.Candlestick, + icon: { source: 'design-system', name: DSIconName.Candlestick }, viewAllAction: (navigation) => { navigation.navigate(Routes.PERPS.ROOT, { screen: Routes.PERPS.MARKET_LIST, @@ -258,33 +261,53 @@ export const SECTIONS_CONFIG: Record = { }; }, }, + stocks: { + id: 'stocks', + title: strings('trending.stocks'), + icon: { source: 'local', name: LocalIconName.CorporateFare }, + viewAllAction: (navigation) => { + navigation.navigate(Routes.WALLET.RWA_TOKENS_FULL_VIEW); + }, + RowItem: ({ item, index }) => ( + + ), + OverrideRowItemSearch: ({ item, index }) => ( + + ), + Skeleton: TrendingTokensSkeleton, + Section: SectionCard, + useSectionData: (searchQuery) => { + const { data, isLoading, refetch } = useRwaTokens({ searchQuery }); + return { data, isLoading, refetch }; + }, + }, predictions: { id: 'predictions', title: strings('wallet.predict'), - icon: IconName.Speedometer, + icon: { source: 'design-system', name: DSIconName.Speedometer }, viewAllAction: (navigation) => { navigation.navigate(Routes.PREDICT.ROOT, { screen: Routes.PREDICT.MARKET_LIST, }); }, RowItem: ({ item, index: _index }) => ( - - +- ), OverrideRowItemSearch: ({ item }) => ( ), - Skeleton: () => , + Skeleton: SiteSkeleton, // Using sites skeleton cause PredictMarketSkeleton has too much spacing OverrideSkeletonSearch: SiteSkeleton, - Section: SectionCarrousel, + Section: SectionCard, useSectionData: (searchQuery) => { const { marketData, isFetching, refetch } = usePredictMarketData({ category: 'trending', @@ -303,7 +326,7 @@ export const SECTIONS_CONFIG: Record = { sites: { id: 'sites', title: strings('trending.sites'), - icon: IconName.Global, + icon: { source: 'design-system', name: DSIconName.Global }, viewAllAction: (navigation) => { navigation.navigate(Routes.SITES_FULL_VIEW); }, @@ -321,15 +344,17 @@ export const SECTIONS_CONFIG: Record = { // Sorted by order on the main screen const HOME_SECTIONS_ARRAY: (SectionConfig & { id: SectionId })[] = [ - SECTIONS_CONFIG.predictions, SECTIONS_CONFIG.tokens, + SECTIONS_CONFIG.stocks, SECTIONS_CONFIG.perps, + SECTIONS_CONFIG.predictions, SECTIONS_CONFIG.sites, ]; // Sorted by order on the QuickAction buttons and SearchResults const SECTIONS_ARRAY: (SectionConfig & { id: SectionId })[] = [ SECTIONS_CONFIG.tokens, + SECTIONS_CONFIG.stocks, SECTIONS_CONFIG.perps, SECTIONS_CONFIG.predictions, SECTIONS_CONFIG.sites, @@ -376,6 +401,9 @@ export const useSectionsData = ( const { data: perpsMarkets, isLoading: isPerpsLoading } = SECTIONS_CONFIG.perps.useSectionData(searchQuery); + const { data: stocks, isLoading: isStocksLoading } = + SECTIONS_CONFIG.stocks.useSectionData(searchQuery); + const { data: predictionMarkets, isLoading: isPredictionsLoading } = SECTIONS_CONFIG.predictions.useSectionData(searchQuery); @@ -391,6 +419,11 @@ export const useSectionsData = ( data: perpsMarkets, isLoading: isPerpsLoading, }, + stocks: { + // Avoids making 2 API calls to the search endpoint when searching on the main search + data: stocks, + isLoading: isStocksLoading, + }, predictions: { data: predictionMarkets, isLoading: isPredictionsLoading, diff --git a/app/constants/navigation/Routes.ts b/app/constants/navigation/Routes.ts index 84d69e111c1..f223a0b15ef 100644 --- a/app/constants/navigation/Routes.ts +++ b/app/constants/navigation/Routes.ts @@ -258,6 +258,7 @@ const Routes = { NFTS_FULL_VIEW: 'NftFullView', TOKENS_FULL_VIEW: 'TokensFullView', TRENDING_TOKENS_FULL_VIEW: 'TrendingTokensFullView', + RWA_TOKENS_FULL_VIEW: 'RWATokensFullView', DEFI_FULL_VIEW: 'DeFiFullView', }, VAULT_RECOVERY: { diff --git a/app/core/NavigationService/types.ts b/app/core/NavigationService/types.ts index 9ddf3c55890..8769ce5d452 100644 --- a/app/core/NavigationService/types.ts +++ b/app/core/NavigationService/types.ts @@ -443,6 +443,7 @@ export interface RootStackParamList extends ParamListBase { NftFullView: undefined; TokensFullView: undefined; TrendingTokensFullView: undefined; + RWATokensFullView: undefined; // Vault recovery routes RestoreWallet: undefined; diff --git a/locales/languages/en.json b/locales/languages/en.json index 832ea7949a7..b0e88a5c617 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -7811,6 +7811,7 @@ "trending": { "title": "Explore", "trending_tokens": "Trending tokens", + "stocks": "Stocks", "price_change": "Price change", "all_networks": "All networks", "24h": "24h", diff --git a/tests/api-mocking/mock-responses/trending-api-mocks.ts b/tests/api-mocking/mock-responses/trending-api-mocks.ts index 2bcb46a8eb0..7071d9b7320 100644 --- a/tests/api-mocking/mock-responses/trending-api-mocks.ts +++ b/tests/api-mocking/mock-responses/trending-api-mocks.ts @@ -15,6 +15,26 @@ const TRENDING_TOKENS_RESPONSE = [ }, ]; +export const RWA_STOCK_ASSET_ID = + 'eip155:1/erc20:0x96f6ef951840721adbf46ac996b59e0235cb985c'; + +const RWA_TOKENS_SEARCH_RESPONSE = { + count: 1, + data: [ + { + assetId: RWA_STOCK_ASSET_ID, + symbol: 'USDY', + name: 'Ondo US Dollar Yield (Ondo Tokenized)', + decimals: 18, + price: '1.05', + aggregatedUsdVolume: 500000, + marketCap: 200000000, + pricePercentChange1d: '0.12', + rwaData: { type: 'rwa' }, + }, + ], +}; + export const TRENDING_API_MOCKS: MockEventsObject = { GET: [ { @@ -122,6 +142,12 @@ export const TRENDING_API_MOCKS: MockEventsObject = { ], priority: 1000, }, + { + urlEndpoint: /\/tokens\/search.*Ondo/, + responseCode: 200, + response: RWA_TOKENS_SEARCH_RESPONSE, + priority: 1001, + }, { urlEndpoint: /\/tokens\/search.*/, responseCode: 200, diff --git a/tests/component-view/renderers/trending.ts b/tests/component-view/renderers/trending.ts index eb8f8b5cbfe..98f10a7ee70 100644 --- a/tests/component-view/renderers/trending.ts +++ b/tests/component-view/renderers/trending.ts @@ -7,7 +7,8 @@ import Routes from '../../../app/constants/navigation/Routes'; import { ExploreFeed } from '../../../app/components/Views/TrendingView/TrendingView'; import ExploreSearchScreen from '../../../app/components/Views/TrendingView/Views/ExploreSearchScreen/ExploreSearchScreen'; import AssetDetails from '../../../app/components/Views/AssetDetails'; -import TrendingTokensFullView from '../../../app/components/Views/TrendingTokens/TrendingTokensFullView/TrendingTokensFullView'; +import TrendingTokensFullView from '../../../app/components/UI/Trending/Views/TrendingTokensFullView/TrendingTokensFullView'; +import RWATokensFullView from '../../../app/components/UI/Trending/Views/RWATokensFullView/RWATokensFullView'; import { initialStateTrending } from '../presets/trending'; interface RenderTrendingViewOptions { @@ -44,6 +45,10 @@ export function renderTrendingViewWithRoutes( Component: TrendingTokensFullView as unknown as React.ComponentType , }, + { + name: Routes.WALLET.RWA_TOKENS_FULL_VIEW, + Component: RWATokensFullView as unknown as React.ComponentType , + }, ], { state }, ); diff --git a/tests/locators/Trending/TrendingView.selectors.ts b/tests/locators/Trending/TrendingView.selectors.ts index d4be3f30435..eea617ae9ae 100644 --- a/tests/locators/Trending/TrendingView.selectors.ts +++ b/tests/locators/Trending/TrendingView.selectors.ts @@ -8,7 +8,7 @@ export const TrendingViewSelectorsIDs = { SEARCH_CANCEL_BUTTON: 'explore-search-cancel-button', TOKEN_ROW_ITEM_PREFIX: 'trending-token-row-item-', PERPS_ROW_ITEM_PREFIX: 'perps-market-row-item-', - PREDICTIONS_ROW_ITEM_PREFIX: 'predict-market-list-trending-card-', + PREDICTIONS_ROW_ITEM_PREFIX: 'predict-market-row-item-', SITE_ROW_ITEM_PREFIX: 'site-row-item-', SEARCH_FOOTER_GOOGLE_LINK: 'trending-search-footer-google-link', SCROLL_VIEW: AppTrendingViewSelectorsIDs.TRENDING_FEED_SCROLL_VIEW, @@ -22,6 +22,7 @@ export const TrendingViewSelectorsText = { // Section titles - must match the actual localized strings from sections.config.tsx SECTION_PREDICTIONS: 'Predictions', SECTION_TOKENS: 'Trending tokens', + SECTION_STOCKS: 'Stocks', SECTION_PERPS: 'Perps', SECTION_SITES: 'Sites', } as const; @@ -30,6 +31,7 @@ export const TrendingViewSelectorsText = { export const SECTION_BACK_BUTTONS: Record