From 4cc5309b006a9a8754879f05162ec9604f137d23 Mon Sep 17 00:00:00 2001 From: Gerhard Steenkamp Date: Fri, 19 Sep 2025 17:00:09 +0200 Subject: [PATCH 01/39] initial port Signed-off-by: Gerhard Steenkamp --- src/Routes.tsx | 5 +- src/assets/chain-logos/all-swap-chain.png | Bin 0 -> 10258 bytes src/assets/icons/arrows-cross.svg | 17 + src/assets/icons/product.svg | 6 + src/assets/icons/search.svg | 7 + src/assets/mask/token-mask-corner.svg | 3 + src/assets/mask/token-mask.svg | 3 + src/hooks/useAvailableCrosschainRoutes.ts | 38 + src/hooks/useEnrichedCrosschainBalances.ts | 60 ++ src/hooks/useTokenBalancesOnChain.ts | 68 ++ .../components/BalanceSelector.tsx | 122 +++ .../components/ChainTokenSelector/Modal.tsx | 477 +++++++++++ .../ChainTokenSelector/Searchbar.tsx | 79 ++ .../ChainTokenSelector/SelectorButton.tsx | 210 +++++ .../components/ConfirmationButton.tsx | 799 ++++++++++++++++++ .../SwapAndBridge/components/InputForm.tsx | 310 +++++++ src/views/SwapAndBridge/hooks/useSwapQuote.ts | 101 +++ src/views/SwapAndBridge/index.tsx | 109 +++ 18 files changed, 2413 insertions(+), 1 deletion(-) create mode 100644 src/assets/chain-logos/all-swap-chain.png create mode 100644 src/assets/icons/arrows-cross.svg create mode 100644 src/assets/icons/product.svg create mode 100644 src/assets/icons/search.svg create mode 100644 src/assets/mask/token-mask-corner.svg create mode 100644 src/assets/mask/token-mask.svg create mode 100644 src/hooks/useAvailableCrosschainRoutes.ts create mode 100644 src/hooks/useEnrichedCrosschainBalances.ts create mode 100644 src/hooks/useTokenBalancesOnChain.ts create mode 100644 src/views/SwapAndBridge/components/BalanceSelector.tsx create mode 100644 src/views/SwapAndBridge/components/ChainTokenSelector/Modal.tsx create mode 100644 src/views/SwapAndBridge/components/ChainTokenSelector/Searchbar.tsx create mode 100644 src/views/SwapAndBridge/components/ChainTokenSelector/SelectorButton.tsx create mode 100644 src/views/SwapAndBridge/components/ConfirmationButton.tsx create mode 100644 src/views/SwapAndBridge/components/InputForm.tsx create mode 100644 src/views/SwapAndBridge/hooks/useSwapQuote.ts create mode 100644 src/views/SwapAndBridge/index.tsx diff --git a/src/Routes.tsx b/src/Routes.tsx index 911bfadb3..a7943530a 100644 --- a/src/Routes.tsx +++ b/src/Routes.tsx @@ -50,6 +50,9 @@ const Transactions = lazyWithRetry( const Staking = lazyWithRetry( () => import(/* webpackChunkName: "RewardStaking" */ "./views/Staking") ); +const SwapAndBridge = lazyWithRetry( + () => import(/* webpackChunkName: "RewardStaking" */ "./views/SwapAndBridge") +); const DepositStatus = lazyWithRetry(() => import("./views/DepositStatus")); function useRoutes() { @@ -137,7 +140,7 @@ const Routes: React.FC = () => { } }} /> - + am0;H6zVGe#`%2wHJk$cC1%!n#_OM3#@DPJ7n+;EF?3m?mHz3v%9>+4{VKURgM3266Ng^uA(lB!h&62*<^*B1%$QL&4Oa39>oEu^ z5yKz`gUs+k8LOJaSfhqfA^2>V70T!gA?9)7Ime&ad;c2w1O|hALIO58HSB$D+P))W zr}qM7WrB@M;j-Z3&y44#CL<;_APMf4Nr(sWrol5Az!c2O@E_(kO;MNuBMfqExxV`P zaBtt6|K+xQ74iuPA1ec7FW*m?aUZnY9>0OLJiPs6gaM7r`5^OIkmym5sA^zdk!Hwe zh?B)lBK~2z34ew~4g-^3vTgf~kCPc63j>}w_|rYaVvhj&J)&`-H#3jOW`fRa@P_Tl zTvY>9%aA?b+3KJ=1hud#PC~tklcD zzc#dh3qT7oDjqYR`Sxf2^-JU<3Lgmr{_DY49)jWiDD>u&x8huM|-@*-p z8G7;=7jadgS4^%@$Z1k+fBne3Tw-*x|o&WI0{ z0goSid5>Y(2cU9b!4R$>5us%m?)LtX$}J=1nT(why3K`COsdyK?m+)T8 z8!WCC3Z4eXFQsWQ1xul9i$%kYi?#Bz{eN!h0P%oBHgcq8INVZg)x_kWI+ z`-dL~6LBBDGuaSn%W@}shbo^M*)o%|Ojj2trYRZ+-&MSdQ}H~XiipV{B+2rTe9;h1 zKe;eBdSYRA%JJG6l#PlJaJ@(k_UwVrJaEmG^)+X}AHMd3FBa_V<6s_^n8&z`hhF4P z^$k~cj*ib{%#@o%2-{>FiHYx)ClL->Rnnr;^SpbxGI3_{@)n1=8MMr}^~Fd9Y+uk1 zU%aNH;hHf3m8|1>kC>D^grbHrQJt-Q{gqu~ljpNsq3UuL1J$16Lm-`LC`Od*UUoC^ zqj?ZA!mT>>?9sWI$+`8lQ5*?MqEVAPzizKS`}E#FxF!?3Yr+7K^=0bO=Ya4sz9X?2 z+iu=IHg$5mr)QOCQuU-^ai8=Ym!=42F%?KuAxZf(y{oH>XujF?wcfX8&hBV=UPfdB zX25LQw8+EPLL_})27K$_kEe-C{})&iB?WOyZ-3>M;VtJgX4s4rR!MXeghRXq6Y0rJ zDAXh?M~_o#B&-AUvJy7H#96f-r}8*3uXuOi;^f8E<+4wL!73K@zW(idzjBOxfbfAB z@U36GxF6)>lSnMu@wN%p&A`aH>7j|4P)60F(U271CFlm@RfVg-slv&HOB2TzX0GRS z7#61l42{PR{PX=UlB*0?&453C?WKpH6aI;M&)QaV_xQH=26Dw3_tD_&miuB<-xa9* zG=7h`9Z0yZhx@&NXPUaO=oCCO1SgzF;noFOm>zMD2A!^|<79tx@zgHI0|iZl&k4-x zr=Glb|MTQ3!c{TgkAL~nLm&(PgfLM#m_Ri@J$BvEVm8|(*D#6U%6r?TrmS4`}vFeO`AQ* zqzAH?&eU!j-SS>3m1{=DdeCE4gz{Qy>52q1CQrCK^ZY5>wo2*3h;5}yY186*$7No< z)~e4}o3%wVS~f&b5Er5?%BKjX1TFCn}oL``U>dKgBqc=wv&Bx_WZ84E9MF~Yi_n04?cbW#*?*8*<+1N{t<(+ zvg#7I4VRC?%(m%)j~$7}3!gFYIG2e_rs1=m7cC8p&;XW-Q?&3d;RX5px@k1}^kIHQGYjff zv@^9WrT%jw4gR3b{_rP%^~H_ax9gaD9OS-3$#d{n#_ly*?5*tRA3d$o8QG)^i-e8K zg(W;bZ6DgYyGxdiaAx7co1{xXapQ}g1p9wOBr)N{@}=$bwW^pgGg6(cF7N+~2mixt zA|2g;wV3q?kWfZ4QZUT6^$wm#a(GXad6*O=a&`f0tWNT zFxkT$w_WA=)WyP0qo;vFtn2TdgstOjqnMHKAwQ=J)*LKFi?~>9SwCH)TcvL+4V+II zwj*^@X`*NFPrJ2oVn=-J=YIpDhdxPUWv2&5kM!sA+_9#X5IeXTiC6EonX(27y(7g; zeiSQ;X)ahR)yDeVYGZxTrEM>c1|8l=X>hzJSDFYBFMamWu(-Z_cDcT~sLIFDi*c|* z2Xyt16tnq3c+V)hn(bC~xxO;vw4D}72&S}%;@~ekjouTBmv;GdQu6TkzVOw0%}e+#cSMF*Es4{UWr*!N)v(sqE@twopNs9@1!#14->1%K%ioP@ys^ z8X%~-CEs(NQkPHjJ1;lW`J47olp4CO23k#K#dO3cPfKC-Z-Z!=bacerA!=w_PWg_jv*TWK} z6{W4)MP&WA6noBto^iQlYq0Wv{^|dIXwwV;k@%<~>{&9DFILdx3}w0B`9 zVvoSMfnxszHoj1H;-aMUIb~V-fnwhXhr_K4Pnw&-9WSTVZ2flj)H`#lm(R9cr!K0? zFuuC9zC3$o?%c5^h>f6}q>&2RluxHOUmC;)CTjDRAHuZRwwWF%^o)e+tpBNa5?D^E zJz#{pLof=6G>zf^qyGu!Q_;4zqY;n&Ets7|*!(HOYK~5vVG`uz!#_N%>Y;k6>Q7c0SfOa-YSz?1^APdnH3Z9CS43eV+^MdEV z?Mvq?OU?Bu4$~lCi!vZ2g8t}df3*+#2sb+|%V-XP@+xTJm%m*>MIQqcx@q!Q-wUN}#*oJNLeZ}ZRyu$cKJgY%D(oMEqSb_ZJAZZ%3uQTcB(#Jd8+n(!m!U%6o*(fCCkH*)XW}}$PXB0hAkD+efx}ew{x8;#53`z_E zR<}2mtt>h9Db7dBL>I;3AfF76zx;!;;n9D=M?PFbnf&>vhUdyP=Gk@K*!Y#|>Y~td zLAOWdddi=p{6g@&2r^xj_f0$&n0wtU50%a>teAMk1a&R0F3fXUBz{F>;8T)x!L_uu zG#^*DZf(P@Hm~i|K452O49R5}Bztsa>@%Qkxt~`3G;NrxDPlDB#!v|KXT5>cv!3VD z^89?;Z994eLt|X_x<;c>U0hpQOlmNmO*RaNt>N`?%Es5}QSs_sU8{|1wbrOtr7tg_ zO9u7sxvq10bzxEAjKT}i0Oj;}AC_ojH)R;B$OP^zB*y*@2Ee%6&mJBO*J0pw z>eAU$4ZzKa7zrUA0PoF~Q%Bmt1>*6#<0y~7BU-x<>5fo%Q7X9pJcN*fon62Yh2qUN z%=`Ri$A8}d@nc)w--%7ajBL&Kz5%14NwsOdDV^>ZXKEX-qUq6~@(=e84fYrN22*y5 z+uzMrs{y@heyzS%C7TQir^2t})Ze3N-C&LO4-fVi`UY*=PGb*kyRFt*y}E*Os{ziDo@MV_GXMkM?3iyDmldx?lh^*fhg= z2FRc^F={TTYTv`>d123=ngJRth{JTMDIOK40+Z7he_J{6O+N*b_bx-vPl%~aOZ^$A zR!+2uz#Zq6%xd z5Tw*zd@>&U(T_^ZuDz~o^NDosS8Rjc5e@QyNyoc>S0OInMlhrBlHxXkdnI%v)uCJ4 zMCps>2PJIJyUn{fV_@Vo2;FNFbA2Z&ALMSzGHYsx#VMP_lPfggohGD{4dRKE7Tw^; zXX?c$$1BCxtxg-&Aqh@QexT8X)u?qKwTPo4M~!J4NS@{!G;o1}8cjR`sud8jRXMwu zU@PhizNJITemyQOMAQ88F>rj`UT4{8KJ|`vH{NyY!`BHWNu(=8V-$7_-nhFS;_^g; zMU5H=t)d{6PQwVWCb-jz(`v2rMQIYqSE?%`-I5?uF4OL84?L1lk17qpF;$O_;+C<# ztfqv7ys{F1M|_ARnm|)H&ZBMujUr9q3%zUQRonTr&`vKh3{n^YbX1%*Fv`FHI)MrY zok}({YSf_qVl^eolnWNHgL(x@H=RmZ<)QLODOW5NvU$E;&;ft_rNz1VnZ-+sab==$ zw8QKTzk96Bjf6NDarxsG+STE}&C;>idimv)hnRSwc)k4$lX}W|Z8K}a24kj2ja=~N zxowTHY6R(~Etg2z$jW8%>6@pfcckrfTELLRTbcaS=;XGsfw77A&K^HeYt~zF+yC@; z9cNrVY{0m3(XE4cXECr`wQYJwO4du&En2TetKJIH#6)?0)M5Mi z^cqEDaCt|Gqc17S88+Iifh$19^EkL}p2Wsar3382g?gL-*VmNKm_ zDkvr({dzwzGMc zYJ_U}1k6AhX3~X{f**xt=*LCjS*OEqI9aJ^e!o$0Ca=ZwWu*lARnTGRWz*@LUoQ+6 zSLmviK|S%UkS&ySy7b16;-@|oF~demY&EpRda5$%%;cV;)rZALhem>@#dW~+f_vcl z5EZofwBOkhHd?#d}((!bWNn7Q2H@6=`#KL>NZw^x981 zefYu|CufktsYc``)i5l%pInv~{kjUSyM4Y~5`cr@%(j60^P;2~_RS215K>C2|-v_FqB0&CBKdhxz3@%nBH z*@lK@S@HT2(H?5jZbNz%fbn5OVXeUA#)q~Jz=7h>((G9|td^^*)hsYT9}0YeAvZ=c zi45--=V#_UZ7aAApC7DH;)y1^Ji@0|gvb!@k@<>WHi$>~DxKe57T%!C^Dw_&xtw5t z?+@g|TDBYw8t}q=WnPtw%-BD%atsy?xFx807IhIC5vSzr z%<1`o-r>P?I+YQfC0LA5lOX)xf@R4IvuFK@i|)0gF(Lt0^JAy+-DpXQ)8muw(dTne zum0Y_!Av@niL9;o-;Y4Xx7%2+bBknyptq%AnYJ2p0BX%-hPftYZvZ}9iJqh374f*9eR1Y2)XQt-WW5mlI_QT5sMj&5SJQ!|6T{Hx5p)nmH&yjhX(0A8 zRxzE)4s*|~L5`ym=BL{E2R2N@rgAfq;VEqv7RCZ-=P2K9==nH~$5UpQ zzAZ>tKRRAtAyX1)T`w>O#dm#_gG~J6`8|zZ|5+`C1wvYC3m<)?Y5jA((I!s{9tl@H zQkKJ`(uCo|B3xYILxCz4cqom;b%8jGN;lc$Qkdx_l-Hk?(Jto(arF3YqTx}VRV!5# z#rPTN3M%2_3O2ZFims-OLd$gocS9;uFETQy$Q=+DcEKiX>`g2~!*G4qw~tRPP{cu0 za0KjR+!_sP_D=&(slJ(4?Q+xMQ~#Op^8A^`&=|pGSVqP_3QJG~^%swkwFZv>IadHt z1eOr^D{vwthq%`PsIx-cyXOg0k8jwf!I4!jHoqo967ncq)DZ4+JuZ|Z78i|$YjlA{ z4dSQi<*xKuhZ{b+ORhgb@`X z+ycT!&H!M}n=pxb5G|o}Cfx@G&r4j|;&^llOMUA@qcsRDDx)=>5bD!J#nGqL)5lNsZ%~MWuzr)x7IMA*$sEUfQ>4rAt)Kk$bD$XZV8>luT0FHpKgZ_> zy4yhCaHK+9y2^v^xm+ndeaoFUiCLKMEhB$gv zfCU%pmRmn_eX-Oh+%Q^WponQUo2~bb{N_ZhzS>H{nbbB>+VQz(aAF~?wzMSa0G+p6& ze9QJh3o62ua`Yl{Pd_~8feAf*!=)s@Q^Nc-5-^&`BT>xJmh;U)R6)McmbY&KuudqNS1MXb&fGTaZ?L%X$acw|DH z^l&**s?oTgo(V$zc=@g-JV+GA507sh75yA5#QcgeDIr}x8bsRI*6T(T%;?4OdUEkR zA&(mB0bN91+B*@n4~~`xz37^)MkDh*xr_`9jSWf60=5^x)8Q)GR6ajHT!UldlT<2X zXwp{?JP>?K$PUX)_AJZ<_F(3o{{HgfrP&$QZo4STLM|31woDRQxY51AsdUD&Gnup? zO2}7FR*HqRwUEA`)(Gc>j*{nG_o+v(bDc_dL>-nejslP2n zr%T1_X^m#RB`Cl@(J6!~4s-`M72sY6y3pmWm)BwH&rzDt!Pz%DGNopcAda4mws+@2 z?PB^xtL8omJ)i{MIeV}?F;O{l`n=DvjZUvJ z=NH2>YEWLb?c-8Tuv9v2ZQZ_Oq}V@D0?o}$$Y#A-pS^Hye&+0{#MLRC?xn>`EBPK! z!jcxI(M9A;oG<3XLv>|oUC|STWBNa#5+6plZY`%WS#=z?@@K!>`9;N!5rE2=GUFLe z1qiVIkr5aLw(Soq*X=lxne=}4{bTbjr|uw;F5eU-%slKN`0n!F=HcYWxp?}wi#iMp z3K#d^K;CSjn7(!I=WZU^IyI3m;yxqM+d#(VCvV!h?T+8scPr+J6A{ACBc4wmduP7B zzQz~RgV7;GrOVpBiQH_~ozo|d&gjQ*bvSyws<*m8&t&_D$I5Eo6o}tv>jWcsc;^(`Nj?;9Gz4gYadbQfpR|G?K*V;g}iyFie+qO^Iu&9hyC0LiSy&r=`62d)y@NZ85 zd-fwixniNzsIRZqYc+>kF^u`=ehq16s=weousCzyZr0mNm*-Z%ny_IcS>WZcu}i>u zvU9m|{_=^p|KV(-(Qv6=N%gG$;2A13I5;vsh_Z`sGZ84nvVcB~H7!_QGU?L7+&YV) z3lDVQU#sPK^Of_9FzIr0`9cPb4z6^;1#{`bnYoj1zjbzfeXXS*;O6yZ!9jDMR*g>l z>m3^E9i7~Ey_#M_{_h@q;Rna#+PUiz)?+_=@oA7k`>;K|cDwn`;X`k`?N-MRFl^>n zs0t{<@g8T^#T|N30mRErn9SrmQ-W1B;Yl^Xy|G$7^7p?ysUK^N(ix>WiLRvMut{|W zpO3xs&OLX4=+6+b6#UZn{?(TgKU-#!E}?fI_as!Qij{&{VRp;)H%_UZ;7_v|lO~$y z$Lr~S9(h4;_wdrw)H9c_5g6*8$>s7wM8LRm+roTA(ZS`gItedCNxp#lHMR8#312m! zqFKj*J0bAW>DFBkdR-M`qjkXhmK$!IN@X$`F)EEp+iE}4t&O`f;PE@ZFl(ml0}N*q za1D%)PmFE9zD%`gHl83?y;eO(tt5>1q{8ZOYq)O6&7ffTcRAG;TfSZGvjDEx?jiL` zps-!}2{BE!Q@Q;_(h|OSDdm1h8}%I;|7iZzvCpjZX*z237_Q^6d9F_=0X% z24MKky}$QkX42<5&w&YJliMcqy?q6j3E8i%Hg6c|c98pV_=UZM1@UUnkjl%SsRoM$ zcRg)&b-9YpGNp7~O=obpJioBfYqe^fMKYQ;^P;wbgbg)h-ZbT^HcFS=N zzxK0t$R@&OKa&uheCg>hq08>N`<`8?6im2GPEG}y=i%?~d*IrAyjz8D{^G~e;4A&1 zT2`sosL?;qDatJ#iH?sxv2r@hkQPtzGcrnH=&XXVuMAVHfYgz-}ueR zT6L}EAD>OYzfn;1>2yYEwo@tl_RoEO7r3?yRQgE3KKja^fBuWdH;dQgC9K_(mItIlpy-@QN9L z;g5d#%0tjM`0vM{Ol*TV`ltW*i=)T{9-(%c`4tf%RJ}-^fE-bt@2635rci>sn5ple zi^5R5>>|MocB=`vNjvjc2u?7zg8Ud)?CZ*&(k16-`$iP#iJaIv_vq z$Q$gttoQ_m~iE4E3%n!??r1K3IL93+cqC!}s%Uj zJEkX1JH?l4k+~+NS1fk)@PQbB;hP8lb{hOEPZRvjcfOuvkl8b*XHLF(_#Eg&H&HBH z5sCWVWVzjh5MPwYsZ%6Q!>Rj||zzSnk8$|0MjBp$d=v`qw3gtl~GPA5vgZM$TWnFbAXIe06E1 zsT63i#eI{%Ig=3dt@2?B7KR&D;DJ;L;;E+0@jaEx+tYX6IaR*l#tH7|<7KetdoOsl z@yyeE@Bifdn5Q1T{;QY2Obq)Gs6<(PdA8MPG%uVuIdk$K-q3nXSSKjute-&iDO5^Z z?i~&C3m75AuJXhgH(hax(U!><>>F;oy6B z1{6uKCIq9w+^@H0v)*W)Ir>lMPaXNkxoDYg;v-fmh6`^lE{YEyWFD0BH}2kD-g3i^ z30StUabtoZnFkJsSEt9P4m^6@EtkkO4j&2wRQUSApTKA^9sxAvV55df9AL!CBm*#lmHAO9Z6Rar^5xYFzkPph{@mHs>f%!DZ&AkW$;8lY zk7&2OqbO#!@49tlbZT;_XLz_bG*Q4CV^sj~%+q&Woo#<5;Ui&y3Y-x(c?6hHR>D_j z$jbcu>g9767MADdYP0X3TI~i)nY=vcrhH7u7V>ug_;_Jx%htZZ@v+|C5omN7{5dYt zfgWGxzOclsm;Uk^DcOy}N6G*dzJB;8`@qG!pW$++#}5aRjuwR1_0`pyQ>!)C78lo7 z=H_Z`r{%6MEjf)w-EB9T{*Miyj|N3a4&=C<&su|H<3(7Yw|WN$ip9afVzIxM`)l|Q zkXn{tIPQFqyX;_b`7kW1Jo`UBL{XCzJ{AV3@E<{0!$SQYVv#RF58UJ1{)X(4!Ad(( z42sSVeCeTCJHq`SPdpm(JyHhNa}NWK*H%69k@fbt@Ub#L56F!ELSgq>+u6$uV+SrlgKzEx$`s~=~h>){i_fN1!4hhFI^S>7}> z`ZO@4>>1vKW$@n*F2jtwhsY02`O7hkR6(Asfp}R1=FNB>n=uVq!K0v6&pQ4|Y4B+H Y|9n+SEyE|LPXGV_07*qoM6N<$f) + + + + + + + + + + + + \ No newline at end of file diff --git a/src/assets/icons/product.svg b/src/assets/icons/product.svg new file mode 100644 index 000000000..a370c74fb --- /dev/null +++ b/src/assets/icons/product.svg @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/src/assets/icons/search.svg b/src/assets/icons/search.svg new file mode 100644 index 000000000..908f1c03d --- /dev/null +++ b/src/assets/icons/search.svg @@ -0,0 +1,7 @@ + + + + + \ No newline at end of file diff --git a/src/assets/mask/token-mask-corner.svg b/src/assets/mask/token-mask-corner.svg new file mode 100644 index 000000000..1b89f0b41 --- /dev/null +++ b/src/assets/mask/token-mask-corner.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/mask/token-mask.svg b/src/assets/mask/token-mask.svg new file mode 100644 index 000000000..b968415b0 --- /dev/null +++ b/src/assets/mask/token-mask.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/hooks/useAvailableCrosschainRoutes.ts b/src/hooks/useAvailableCrosschainRoutes.ts new file mode 100644 index 000000000..ca6eddbc8 --- /dev/null +++ b/src/hooks/useAvailableCrosschainRoutes.ts @@ -0,0 +1,38 @@ +import { MAINNET_CHAIN_IDs } from "@across-protocol/constants"; +import { useQuery } from "@tanstack/react-query"; + +export type LifiToken = { + chainId: number; + address: string; + symbol: string; + name: string; + decimals: number; + priceUSD: string; + coinKey: string; + logoURI: string; +}; + +// TODO: Currently stubbed and will need to be added to the swap API +export default function useAvailableCrosschainRoutes() { + return useQuery({ + queryKey: ["availableCrosschainRoutes"], + queryFn: async () => { + const result = await fetch( + "https://li.quest/v1/tokens?chainTypes=EVM&minPriceUSD=0.001" + ); + const data = (await result.json()) as { + tokens: Record>; + }; + + return Object.entries(data.tokens).reduce( + (acc, [chainId, tokens]) => { + if (Object.values(MAINNET_CHAIN_IDs).includes(Number(chainId))) { + acc[Number(chainId)] = tokens; + } + return acc; + }, + {} as Record> + ); + }, + }); +} diff --git a/src/hooks/useEnrichedCrosschainBalances.ts b/src/hooks/useEnrichedCrosschainBalances.ts new file mode 100644 index 000000000..4b07e0c29 --- /dev/null +++ b/src/hooks/useEnrichedCrosschainBalances.ts @@ -0,0 +1,60 @@ +import { useMemo } from "react"; +import useAvailableCrosschainRoutes, { + LifiToken, +} from "./useAvailableCrosschainRoutes"; +import useTokenBalancesOnChain from "./useTokenBalancesOnChain"; +import { compareAddressesSimple } from "utils"; +import { BigNumber, utils } from "ethers"; + +export default function useEnrichedCrosschainBalances() { + const tokenBalances = useTokenBalancesOnChain(); + const availableCrosschainRoutes = useAvailableCrosschainRoutes(); + + return useMemo(() => { + if (availableCrosschainRoutes.isLoading) { + return {}; + } + const chains = Object.keys(availableCrosschainRoutes.data || {}); + + return chains.reduce( + (acc, chainId) => { + const balancesForChain = tokenBalances.find( + (t) => t.isSuccess && t.data.chainId === Number(chainId) + ); + + const tokens = availableCrosschainRoutes.data![Number(chainId)]; + const enrichedTokens = tokens + .map((t) => { + const balance = balancesForChain?.data?.balances.find((b) => + compareAddressesSimple(b.address, t.address) + ); + return { + ...t, + balance: balance?.balance ?? BigNumber.from(0), + balanceUsd: + balance?.balance && t + ? Number(utils.formatUnits(balance.balance, t.decimals)) * + Number(t.priceUSD) + : 0, + }; + }) + // Filter out tokens that don't have a logoURI + .filter((t) => t.logoURI !== undefined); + + // Sort high to low balanceUsd + const orderedEnrichedTokens = enrichedTokens.sort( + (a, b) => b.balanceUsd - a.balanceUsd + ); + + return { + ...acc, + [Number(chainId)]: orderedEnrichedTokens, + }; + }, + {} as Record< + number, + Array + > + ); + }, [availableCrosschainRoutes, tokenBalances]); +} diff --git a/src/hooks/useTokenBalancesOnChain.ts b/src/hooks/useTokenBalancesOnChain.ts new file mode 100644 index 000000000..09ad766e8 --- /dev/null +++ b/src/hooks/useTokenBalancesOnChain.ts @@ -0,0 +1,68 @@ +import { useConnection } from "./useConnection"; +import { CHAIN_IDs, MAINNET_CHAIN_IDs } from "@across-protocol/constants"; +import { useQueries } from "@tanstack/react-query"; +import { BigNumber } from "ethers"; + +const CHAIN_TO_ALCHEMY = { + [CHAIN_IDs.MAINNET]: "eth-mainnet", + [CHAIN_IDs.OPTIMISM]: "opt-mainnet", + [CHAIN_IDs.POLYGON]: "polygon-mainnet", + [CHAIN_IDs.BASE]: "base-mainnet", + [CHAIN_IDs.LINEA]: "linea-mainnet", + [CHAIN_IDs.ARBITRUM]: "arb-mainnet", +}; + +// TODO: delete this, move to serverless /batch-account-balance +const getAlchemyRpcUrl = (chainId: number) => { + const chain = CHAIN_TO_ALCHEMY[chainId]; + return `https://${chain}.g.alchemy.com/v2/${process.env.REACT_APP_ALCHEMY_KEY}`; +}; + +export default function useTokenBalancesOnChain() { + const { account } = useConnection(); + const chainIdsAvailable = Object.values(MAINNET_CHAIN_IDs) + .sort((a, b) => a - b) + .filter((chainId) => !!CHAIN_TO_ALCHEMY[chainId]); + + return useQueries({ + queries: chainIdsAvailable.map((chainId) => ({ + queryKey: ["tokenBalancesOnChain", chainId], + enabled: account !== undefined, + queryFn: async () => { + const rpcUrl = getAlchemyRpcUrl(chainId); + + const balances = await fetch(rpcUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "alchemy_getTokenBalances", + params: [account], + }), + }); + + const data = await balances.json(); + + return { + chainId, + balances: ( + data.result.tokenBalances as { + contractAddress: string; + tokenBalance: string; + }[] + ) + .filter( + (t) => !!t.tokenBalance && BigNumber.from(t.tokenBalance).gt(0) + ) + .map((t) => ({ + address: t.contractAddress, + balance: BigNumber.from(t.tokenBalance), + })), + }; + }, + })), + }); +} diff --git a/src/views/SwapAndBridge/components/BalanceSelector.tsx b/src/views/SwapAndBridge/components/BalanceSelector.tsx new file mode 100644 index 000000000..89ebfc280 --- /dev/null +++ b/src/views/SwapAndBridge/components/BalanceSelector.tsx @@ -0,0 +1,122 @@ +import { motion, AnimatePresence } from "framer-motion"; +import { useState } from "react"; +import { BigNumber } from "ethers"; +import styled from "@emotion/styled"; +import { COLORS, formatUnitsWithMaxFractions } from "utils"; + +type BalanceSelectorProps = { + balance: BigNumber; + decimals: number; + setAmount: (amount: BigNumber | null) => void; + disableHover?: boolean; +}; + +export default function BalanceSelector({ + balance, + decimals, + setAmount, + disableHover, +}: BalanceSelectorProps) { + const [isHovered, setIsHovered] = useState(false); + if (!balance || balance.lte(0)) return null; + const percentages = ["25%", "50%", "75%", "MAX"]; + + const handlePillClick = (percentage: string) => { + if (percentage === "MAX") { + setAmount(balance); + } else { + const percent = parseInt(percentage) / 100; + const amount = balance.mul(Math.floor(percent * 10000)).div(10000); + setAmount(amount); + } + }; + + const formattedBalance = formatUnitsWithMaxFractions(balance, decimals); + + return ( + !disableHover && setIsHovered(true)} + onMouseLeave={() => !disableHover && setIsHovered(false)} + > + + + {isHovered && + percentages.map((percentage, index) => ( + handlePillClick(percentage)} + > + {percentage} + + ))} + + + Balance: {formattedBalance} + + ); +} + +const BalanceWrapper = styled.div` + display: flex; + align-items: center; + gap: 12px; + margin-top: 8px; +`; + +const BalanceText = styled.span` + color: ${() => COLORS.aqua}; + font-size: 14px; + font-weight: 400; + line-height: 130%; +`; + +const PillsContainer = styled.div` + display: flex; + align-items: center; + gap: 4px; + + .pill { + display: flex; + height: 20px; + padding: 0 8px; + border-radius: 10px; + align-items: center; + justify-content: center; + font-size: 12px; + font-weight: 600; + background-color: rgba(224, 243, 255, 0.05); + color: rgba(224, 243, 255, 0.5); + cursor: pointer; + user-select: none; + } +`; diff --git a/src/views/SwapAndBridge/components/ChainTokenSelector/Modal.tsx b/src/views/SwapAndBridge/components/ChainTokenSelector/Modal.tsx new file mode 100644 index 000000000..f884119dd --- /dev/null +++ b/src/views/SwapAndBridge/components/ChainTokenSelector/Modal.tsx @@ -0,0 +1,477 @@ +import Modal from "components/Modal"; +import { EnrichedTokenSelect } from "./SelectorButton"; +import styled from "@emotion/styled"; +import Searchbar from "./Searchbar"; +import TokenMask from "assets/mask/token-mask-corner.svg"; +import useAvailableCrosschainRoutes, { + LifiToken, +} from "hooks/useAvailableCrosschainRoutes"; +import { + COLORS, + formatUnitsWithMaxFractions, + formatUSD, + getChainInfo, + parseUnits, +} from "utils"; +import { useMemo, useState } from "react"; +import { ReactComponent as CheckmarkCircle } from "assets/icons/checkmark-circle.svg"; +import AllChainsIcon from "assets/chain-logos/all-swap-chain.png"; +import useEnrichedCrosschainBalances from "hooks/useEnrichedCrosschainBalances"; +import { BigNumber } from "ethers"; + +type Props = { + onSelect: (token: EnrichedTokenSelect) => void; + isOriginToken: boolean; + + displayModal: boolean; + setDisplayModal: (displayModal: boolean) => void; +}; + +export default function ChainTokenSelectorModal({ + isOriginToken, + displayModal, + setDisplayModal, + onSelect, +}: Props) { + const balances = useEnrichedCrosschainBalances(); + + const crossChainRoutes = useAvailableCrosschainRoutes(); + + const [selectedChain, setSelectedChain] = useState(null); + + const [tokenSearch, setTokenSearch] = useState(""); + const [chainSearch, setChainSearch] = useState(""); + + const displayedTokens = useMemo(() => { + let tokens = selectedChain ? (balances[selectedChain] ?? []) : []; + + if (tokens.length === 0 && selectedChain === null) { + tokens = Object.values(balances).flatMap((t) => t); + } + // Return ordering top 100 tokens ordering highest balanceUsd to lowest (fallback alphabetical) + const sortedTokens = tokens.slice(0, 100).sort((a, b) => { + if (Math.abs(b.balanceUsd - a.balanceUsd) < 0.0001) { + return a.symbol.toLocaleLowerCase().localeCompare(b.symbol); + } + return b.balanceUsd - a.balanceUsd; + }); + + return sortedTokens.filter((t) => { + if (tokenSearch === "") { + return true; + } + const keywords = [ + t.symbol.toLowerCase().replaceAll(" ", ""), + t.name.toLowerCase().replaceAll(" ", ""), + t.address.toLowerCase().replaceAll(" ", ""), + ]; + return keywords.some((keyword) => + keyword.includes(tokenSearch.toLowerCase().replaceAll(" ", "")) + ); + }); + }, [selectedChain, balances, tokenSearch]); + + const displayedChains = useMemo(() => { + return Object.fromEntries( + Object.entries(crossChainRoutes.data || {}).filter(([chainId]) => { + if ([288].includes(Number(chainId))) { + return false; + } + + const keywords = [ + String(chainId), + getChainInfo(Number(chainId)).name.toLowerCase().replace(" ", ""), + ]; + return keywords.some((keyword) => + keyword.toLowerCase().includes(chainSearch.toLowerCase()) + ); + }) + ); + }, [chainSearch, crossChainRoutes.data]); + + return ( + Select {isOriginToken ? "Origin" : "Destination"} Token + } + isOpen={displayModal} + padding="thin" + exitModalHandler={() => setDisplayModal(false)} + exitOnOutsideClick + width={720} + height={800} + > + + + + + + + setSelectedChain(null)} + /> + {Object.entries(displayedChains).map(([chainId]) => ( + setSelectedChain(Number(chainId))} + /> + ))} + + + + + + + + + {displayedTokens.map((token) => ( + { + onSelect({ + chainId: token.chainId, + symbolUri: token.logoURI, + symbol: token.symbol, + address: token.address, + balance: token.balance, + priceUsd: parseUnits(token.priceUSD, 18), + decimals: token.decimals, + }); + setDisplayModal(false); + }} + /> + ))} + + + + + ); +} + +const ChainEntry = ({ + chainId, + isSelected, + onClick, +}: { + chainId: number | null; + isSelected: boolean; + onClick: () => void; +}) => { + const chainInfo = chainId + ? getChainInfo(chainId) + : { + logoURI: AllChainsIcon, + name: "All", + }; + return ( + + + {chainInfo.name} + {isSelected && } + + ); +}; + +const TokenEntry = ({ + token, + isSelected, + onClick, +}: { + token: LifiToken & { balanceUsd: number; balance: BigNumber }; + isSelected: boolean; + onClick: () => void; +}) => { + const hasBalance = token.balance.gt(0) && token.balanceUsd > 0.01; + return ( + + + + {token.name} + {token.symbol} + + {hasBalance && ( + + + {formatUnitsWithMaxFractions( + token.balance.toBigInt(), + token.decimals + )} + + + ${formatUSD(parseUnits(token.balanceUsd.toString(), 18))} + + + )} + + ); +}; + +const TokenItemImage = ({ token }: { token: LifiToken }) => { + return ( + + + + + ); +}; + +const TokenItemImageWrapper = styled.div` + width: 32px; + height: 32px; + + flex-shrink: 0; + + position: relative; +`; + +const TokenItemTokenImage = styled.img` + width: 100%; + height: 100%; + + top: 0; + left: 0; + + position: absolute; + + mask-image: url(${TokenMask}); + mask-size: 100% 100%; + mask-repeat: no-repeat; + mask-position: center; +`; + +const TokenItemChainImage = styled.img` + width: 12px; + height: 12px; + + position: absolute; + + bottom: 0; + right: 0; +`; + +const InnerWrapper = styled.div` + width: 100%; + height: 100%; + + display: flex; + flex-direction: row; + gap: 12px; +`; + +const VerticalDivider = styled.div` + width: 1px; + + height: 400px; + + margin: -16px 0; + + background-color: #3f4247; + + flex-shrink: 0; +`; + +const Title = styled.div` + overflow: hidden; + color: var(--Base-bright-gray, #e0f3ff); + + font-family: Barlow; + font-size: 20px; + font-style: normal; + font-weight: 400; + line-height: 130%; /* 26px */ +`; + +const ChainWrapper = styled.div` + width: calc(33% - 0.5px); + height: 100%; + + display: flex; + flex-direction: column; + gap: 16px; +`; + +const TokenWrapper = styled.div` + width: calc(67% - 0.5px); + height: 100%; + + display: flex; + flex-direction: column; + gap: 8px; +`; + +const SearchWrapper = styled.div` + padding: 0px 8px; +`; + +const ListWrapper = styled.div` + display: flex; + flex-direction: column; + gap: 4px; + + overflow-y: scroll; + max-height: 300px; + + &::-webkit-scrollbar { + width: 8px; + } + + &::-webkit-scrollbar-track { + background: transparent; + } + + &::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.1); + border-radius: 4px; + } + + &::-webkit-scrollbar-thumb:hover { + background: rgba(255, 255, 255, 0.2); + } + + scrollbar-width: thin; + scrollbar-color: rgba(255, 255, 255, 0.1) transparent; +`; + +const EntryItem = styled.div<{ isSelected: boolean }>` + display: flex; + flex-direction: row; + justify-content: space-between; + + width: 100%; + flex-shrink: 0; + + align-items: center; + + padding: 8px; + height: 48px; + gap: 8px; + + border-radius: 8px; + background: ${({ isSelected }) => + isSelected ? COLORS["aqua-5"] : "transparent"}; + + cursor: pointer; + + transition: background 0.2s ease-in-out; + + &:hover { + background: ${({ isSelected }) => + isSelected ? COLORS["aqua-15"] : COLORS["grey-400-15"]}; + } +`; + +const ChainItemImage = styled.img` + width: 32px; + height: 32px; + + flex-shrink: 0; +`; + +const ChainItemName = styled.div` + overflow: hidden; + color: var(--Base-bright-gray, #e0f3ff); + text-overflow: ellipsis; + /* Body/Medium */ + font-family: Barlow; + font-size: 16px; + font-style: normal; + font-weight: 400; + line-height: 130%; /* 20.8px */ + + width: 100%; +`; + +const ChainItemCheckmark = styled(CheckmarkCircle)` + width: 20px; + height: 20px; +`; + +const TokenNameSymbolWrapper = styled.div` + display: flex; + flex-direction: row; + gap: 4px; + + width: 100%; + + align-items: center; + justify-content: start; +`; + +const TokenName = styled.div` + overflow: hidden; + color: var(--Base-bright-gray, #e0f3ff); + /* Body/Medium */ + font-family: Barlow; + font-size: 16px; + font-style: normal; + font-weight: 400; + line-height: 130%; /* 20.8px */ + + max-width: 20ch; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +`; + +const TokenSymbol = styled.div` + overflow: hidden; + color: var(--Base-bright-gray, #e0f3ff); + text-overflow: ellipsis; + /* Body/X Small */ + font-family: Barlow; + font-size: 12px; + font-style: normal; + font-weight: 400; + line-height: 130%; /* 15.6px */ + + opacity: 0.5; + + text-transform: uppercase; +`; + +const TokenBalanceStack = styled.div` + display: flex; + flex-direction: column; + + align-items: flex-end; + + gap: 4px; +`; + +const TokenBalance = styled.div` + color: var(--Base-bright-gray, #e0f3ff); + /* Body/Small */ + font-family: Barlow; + font-size: 14px; + font-style: normal; + font-weight: 400; + line-height: 130%; /* 18.2px */ +`; + +const TokenBalanceUsd = styled.div` + color: var(--Base-bright-gray, #e0f3ff); + /* Body/X Small */ + font-family: Barlow; + font-size: 12px; + font-style: normal; + font-weight: 400; + line-height: 130%; /* 15.6px */ + opacity: 0.5; +`; diff --git a/src/views/SwapAndBridge/components/ChainTokenSelector/Searchbar.tsx b/src/views/SwapAndBridge/components/ChainTokenSelector/Searchbar.tsx new file mode 100644 index 000000000..9b2603699 --- /dev/null +++ b/src/views/SwapAndBridge/components/ChainTokenSelector/Searchbar.tsx @@ -0,0 +1,79 @@ +import styled from "@emotion/styled"; +import { ReactComponent as SearchIcon } from "assets/icons/search.svg"; +import { ReactComponent as ProductIcon } from "assets/icons/product.svg"; + +type Props = { + searchTopic: string; + search: string; + setSearch: (search: string) => void; +}; + +export default function Searchbar({ searchTopic, search, setSearch }: Props) { + return ( + + + setSearch(e.target.value)} + /> + {search ? setSearch("")} /> :
} + + ); +} + +const Wrapper = styled.div` + display: flex; + height: 44px; + padding: 0px 12px; + align-items: center; + gap: 8px; + + flex-direction: row; + justify-content: space-between; + + border-radius: 8px; + background: rgba(224, 243, 255, 0.05); + + width: 100%; +`; + +const StyledSearchIcon = styled(SearchIcon)` + width: 20px; + height: 20px; + + flex-grow: 0; +`; + +const StyledProductIcon = styled(ProductIcon)` + width: 16px; + height: 16px; + + cursor: pointer; + + flex-grow: 0; +`; + +const Input = styled.input` + overflow: hidden; + color: var(--Base-bright-gray, #e0f3ff); + text-overflow: ellipsis; + + &::placeholder { + color: #e0f3ff4d; + } + + background: transparent; + + border: none; + outline: none; + + width: 100%; + + /* Body/Medium */ + font-family: Barlow; + font-size: 16px; + font-style: normal; + font-weight: 400; + line-height: 130%; /* 20.8px */ +`; diff --git a/src/views/SwapAndBridge/components/ChainTokenSelector/SelectorButton.tsx b/src/views/SwapAndBridge/components/ChainTokenSelector/SelectorButton.tsx new file mode 100644 index 000000000..1909ce16f --- /dev/null +++ b/src/views/SwapAndBridge/components/ChainTokenSelector/SelectorButton.tsx @@ -0,0 +1,210 @@ +import styled from "@emotion/styled"; +import { BigNumber } from "ethers"; +import { useCallback, useEffect, useState } from "react"; +import { COLORS, getChainInfo } from "utils"; +import TokenMask from "assets/mask/token-mask.svg"; +import { ReactComponent as ChevronDownIcon } from "assets/icons/chevron-down.svg"; +import ChainTokenSelectorModal from "./Modal"; + +export type TokenSelect = { + chainId: number; + symbolUri: string; + symbol: string; + address: string; +}; + +export type EnrichedTokenSelect = TokenSelect & { + priceUsd: BigNumber; + balance: BigNumber; + decimals: number; +}; + +type Props = { + selectedToken: EnrichedTokenSelect | null; + onSelect?: (token: EnrichedTokenSelect) => void; + isOriginToken: boolean; + marginBottom?: string; +}; + +export default function SelectorButton({ + onSelect, + selectedToken, + isOriginToken, + marginBottom, +}: Props) { + const [displayModal, setDisplayModal] = useState(false); + + useEffect(() => { + if (selectedToken) { + onSelect?.(selectedToken); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedToken]); + + const setSelectedToken = useCallback( + (token: EnrichedTokenSelect) => { + onSelect?.(token); + setDisplayModal(false); + }, + [onSelect] + ); + + if (!selectedToken) { + return ( + <> + setDisplayModal(true)} + marginBottom={marginBottom} + > + + Select a token + + + + + + + + + ); + } + + const chain = getChainInfo(selectedToken.chainId); + + return ( + <> + setDisplayModal(true)} + marginBottom={marginBottom} + > + + + + + + {selectedToken.symbol} + {chain.name} + + + + + + + + + ); +} + +const Wrapper = styled.div<{ marginBottom?: string }>` + display: flex; + flex-direction: row; + justify-content: space-between; + margin-bottom: ${({ marginBottom }) => marginBottom || "0"}; + + border-radius: 8px; + border: 1px solid #3f4247; + background: #e0f3ff0d; + padding: 8px 12px; + + height: 64px; + + gap: 12px; + width: 184px; + + cursor: pointer; +`; + +const SelectWrapper = styled(Wrapper)` + height: 48px; +`; + +const VerticalDivider = styled.div` + width: 1px; + height: calc(100% + 16px); + + margin-top: -8px; + + background: #3f4247; +`; + +const ChevronStack = styled.div` + display: flex; + align-items: center; + justify-content: center; + height: 100%; +`; + +const NamesStack = styled.div` + display: flex; + flex-direction: column; + gap: 6px; + + height: 100%; + + flex-grow: 1; + + justify-content: center; + align-items: flex-start; +`; + +const TokenName = styled.div` + font-size: 16px; + line-height: 16px; + + font-weight: 600; + color: #e0f3ff; +`; + +const SelectTokenName = styled(TokenName)` + color: ${COLORS["aqua"]}; +`; + +const ChainName = styled.div` + font-size: 12px; + line-height: 12px; + font-weight: 400; + color: #e0f3ff; +`; + +const TokenStack = styled.div` + width: 32px; + height: 48px; + position: relative; + + flex-grow: 0; +`; + +const TokenImg = styled.img` + position: absolute; + top: 0; + left: 0; + width: 32px; + height: 32px; + z-index: 1; + + mask: url(${TokenMask}) no-repeat center center; +`; + +const ChainImg = styled.img` + position: absolute; + bottom: 0; + left: 4.5px; + width: 24px; + height: 24px; + z-index: 2; +`; + +const ChevronDown = styled(ChevronDownIcon)` + height: 16px; + width: 16px; +`; diff --git a/src/views/SwapAndBridge/components/ConfirmationButton.tsx b/src/views/SwapAndBridge/components/ConfirmationButton.tsx new file mode 100644 index 000000000..2e5a4e69d --- /dev/null +++ b/src/views/SwapAndBridge/components/ConfirmationButton.tsx @@ -0,0 +1,799 @@ +"use client"; +import { ButtonHTMLAttributes } from "react"; +import { ReactComponent as ChevronDownIcon } from "assets/icons/chevron-down.svg"; +import { ReactComponent as LoadingIcon } from "assets/icons/loading.svg"; +import React from "react"; +import { motion, AnimatePresence } from "framer-motion"; +import { BigNumber } from "ethers"; +import { COLORS } from "utils"; +import { useConnection, useIsWrongNetwork } from "hooks"; +import { EnrichedTokenSelect } from "./ChainTokenSelector/SelectorButton"; +import styled from "@emotion/styled"; + +type SwapQuoteResponse = { + checks: object; + steps: object; + refundToken: object; + inputAmount: string; + expectedOutputAmount: string; + minOutputAmount: string; + expectedFillTime: number; + swapTx: object; +}; + +export type BridgeButtonState = + | "notConnected" + | "awaitingTokenSelection" + | "awaitingAmountInput" + | "readyToConfirm" + | "submitting" + | "wrongNetwork"; + +interface ConfirmationButtonProps + extends ButtonHTMLAttributes { + inputToken: EnrichedTokenSelect | null; + outputToken: EnrichedTokenSelect | null; + amount: BigNumber | null; + swapQuote: SwapQuoteResponse | null; + isQuoteLoading: boolean; + onConfirm?: () => void; +} + +const stateLabels: Record = { + notConnected: "Connect Wallet", + awaitingTokenSelection: "Select Token", + awaitingAmountInput: "Input Amount", + readyToConfirm: "Confirm Swap", + submitting: "Submitting...", + wrongNetwork: "Switch Network", +}; + +// Expandable label section component +const ExpandableLabelSection: React.FC< + React.PropsWithChildren<{ + fee: string; + time: string; + expanded: boolean; + onToggle: () => void; + visible: boolean; + }> +> = ({ fee, time, expanded, onToggle, visible, children }) => { + return ( + + {visible && ( + + + + + + + Fast & Secure + + + + + + + + + {fee} + + + + + + + + + {time} + + + + + + {expanded && children} + + + )} + + ); +}; + +// Core button component, used by all states +const ButtonCore: React.FC< + ConfirmationButtonProps & { + label: string; + loading?: boolean; + aqua?: boolean; + state: BridgeButtonState; + fullHeight?: boolean; + } +> = ({ + label, + loading, + disabled, + aqua, + state, + onConfirm, + onClick, + fullHeight, +}) => ( + + + + + {loading && } + {!loading && label} + + + + +); + +export const ConfirmationButton: React.FC = ({ + inputToken, + outputToken, + amount, + swapQuote, + isQuoteLoading, + onConfirm, + ...props +}) => { + const { account, connect } = useConnection(); + const [expanded, setExpanded] = React.useState(false); + const [isSubmitting, setIsSubmitting] = React.useState(false); + + const { isWrongNetworkHandler, isWrongNetwork } = useIsWrongNetwork( + inputToken?.chainId + ); + + // Determine the current state + const getButtonState = (): BridgeButtonState => { + if (isSubmitting) return "submitting"; + if (!account) return "notConnected"; + if (!inputToken || !outputToken) return "awaitingTokenSelection"; + if (!amount || amount.lte(0)) return "awaitingAmountInput"; + if (isWrongNetwork) return "wrongNetwork"; + return "readyToConfirm"; + }; + + const state = getButtonState(); + + // Calculate display values from swapQuote + const displayValues = React.useMemo(() => { + if (!swapQuote || !inputToken || !outputToken) { + return { + fee: "$0.05", + time: "~2 min", + bridgeFee: "$0.01", + destinationGasFee: "$0", + extraFee: "$0.04", + route: "Across V4", + estimatedTime: "~2 secs", + netFee: "$0.05", + }; + } + + // Calculate fees based on swapQuote data + // This is a placeholder - you'd calculate actual fees from the quote + const bridgeFee = "$0.01"; + const destinationGasFee = "$0"; + const extraFee = "$0.04"; + const netFee = "$0.05"; + + // Format time from expectedFillTime (in seconds) + const timeInMinutes = Math.ceil(swapQuote.expectedFillTime / 60); + const time = timeInMinutes < 1 ? "~30 sec" : `~${timeInMinutes} min`; + + return { + fee: netFee, + time, + bridgeFee, + destinationGasFee, + extraFee, + route: "Across V4", + estimatedTime: timeInMinutes < 1 ? "~30 secs" : `~${timeInMinutes} mins`, + netFee, + }; + }, [swapQuote, inputToken, outputToken]); + + // Handle confirmation + const handleConfirm = async () => { + if (!onConfirm) return; + + setIsSubmitting(true); + try { + onConfirm(); + } catch (error) { + console.error("Confirmation failed:", error); + } finally { + setIsSubmitting(false); + } + }; + + // Compute target height based on state and expansion + let targetHeight = 88; + if (state === "readyToConfirm") { + targetHeight = expanded ? 300 : 128; + } + + // Render state-specific content + let content: React.ReactNode = null; + switch (state) { + case "readyToConfirm": + content = ( + <> + + setExpanded((e) => !e)} + visible={true} + > + {expanded ? ( + + + + + + + + + Route + + + + {displayValues.route} + + + + + + + + + + + + Est. Time + + {displayValues.estimatedTime} + + + + + + + + + + + Net Fee + + + + + + + + + {displayValues.netFee} + + + + Bridge Fee + + {displayValues.bridgeFee} + + + + Destination Gas Fee + + {displayValues.destinationGasFee} + + + + Extra Fee + + {displayValues.extraFee} + + + + + ) : null} + + + + + + + ); + break; + case "notConnected": + content = ( + connect()} + /> + ); + break; + case "wrongNetwork": + content = ( + isWrongNetworkHandler()} + /> + ); + break; + case "awaitingTokenSelection": + content = ( + + ); + break; + case "awaitingAmountInput": + content = ( + + ); + break; + case "submitting": + content = ( + + ); + break; + default: + content = null; + } + + return ( + + {content} + + ); +}; + +// Styled components +const Container = styled(motion.div)<{ state: BridgeButtonState }>` + background: rgba(108, 249, 216, 0.1); + border-radius: 24px; + display: flex; + flex-direction: column; + padding: ${({ state }) => + state === "readyToConfirm" || state === "submitting" + ? "4px 12px 12px 12px" + : "0"}; + width: 100%; + overflow: hidden; + gap: ${({ state }) => (state === "readyToConfirm" ? "8px" : "0")}; +`; + +const ExpandableLabelButton = styled.button` + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + padding: 8px; + background: transparent; + border: none; + cursor: pointer; + user-select: none; +`; + +const ExpandableLabelLeft = styled.span` + color: ${COLORS.aqua}; + font-weight: 600; + font-size: 14px; + flex: 1; + text-align: left; + display: flex; + align-items: center; + gap: 8px; +`; + +const ShieldIcon = styled.svg` + width: 16px; + height: 16px; +`; + +const FastSecureText = styled.span` + color: ${COLORS.aqua}; +`; + +const ExpandableLabelRight = styled.div` + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; + color: #e0f3ff; +`; + +const FeeTimeItem = styled.span` + display: flex; + align-items: center; + gap: 4px; +`; + +const GasIcon = styled.svg` + width: 16px; + height: 16px; +`; + +const TimeIcon = styled.svg` + width: 16px; + height: 16px; +`; + +const Divider = styled.span` + margin: 0 8px; + height: 16px; + width: 1px; + background: rgba(224, 243, 255, 0.5); +`; + +const StyledChevronDown = styled(ChevronDownIcon)<{ expanded: boolean }>` + width: 20px; + height: 20px; + margin-left: 12px; + transition: transform 0.3s ease; + cursor: pointer; + color: #e0f3ff; + transform: ${({ expanded }) => + expanded ? "rotate(180deg)" : "rotate(0deg)"}; +`; + +const ExpandableContent = styled.div<{ expanded: boolean }>` + overflow: hidden; + transition: all 0.3s ease; + max-height: ${({ expanded }) => (expanded ? "160px" : "0")}; + margin-top: ${({ expanded }) => (expanded ? "8px" : "0")}; +`; + +const StyledButton = styled.button<{ + aqua?: boolean; + loading?: boolean; + fullHeight?: boolean; +}>` + width: 100%; + height: ${({ fullHeight }) => (fullHeight ? "100%" : "64px")}; + border-radius: 12px; + font-weight: 600; + font-size: 16px; + transition: all 0.3s ease; + border: none; + cursor: pointer; + + background: ${({ aqua }) => (aqua ? "transparent" : COLORS.aqua)}; + color: ${({ aqua }) => (aqua ? COLORS.aqua : "#000000")}; + + &:hover { + ${({ aqua }) => + aqua + ? `background: rgba(108, 249, 216, 0.1);` + : ` + box-shadow: 0 0 16px 0 ${COLORS.aqua}; + background: ${COLORS.aqua}; + `} + } + + &:focus { + ${({ aqua }) => !aqua && `box-shadow: 0 0 16px 0 ${COLORS.aqua};`} + } + + &:disabled { + ${({ loading }) => loading && "opacity: 0.6; cursor: wait;"} + } +`; + +const ButtonContent = styled.span` + display: flex; + align-items: center; + justify-content: center; + gap: 8px; +`; + +const StyledLoadingIcon = styled(LoadingIcon)<{ aqua?: boolean }>` + width: 16px; + height: 16px; + animation: spin 1s linear infinite; + color: ${({ aqua }) => (aqua ? COLORS.aqua : "#000000")}; + + @keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } + } +`; + +const ButtonContainer = styled.div<{ expanded: boolean }>` + margin-top: ${({ expanded }) => (expanded ? "24px" : "0")}; +`; + +const ExpandedDetails = styled.div` + color: #e0f3ff; + font-size: 14px; + width: 100%; + padding: 8px 16px 24px; +`; + +const DetailRow = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 8px; +`; + +const DetailLeft = styled.div` + display: flex; + align-items: center; + gap: 8px; +`; + +const DetailRight = styled.div` + display: flex; + align-items: center; + gap: 8px; +`; + +const RouteIcon = styled.svg` + width: 20px; + height: 20px; +`; + +const InfoIcon = styled.svg` + width: 20px; + height: 20px; +`; + +const RouteDot = styled.span` + display: inline-block; + width: 20px; + height: 20px; + background: ${COLORS.aqua}; + border-radius: 50%; + opacity: 0.8; +`; + +const FeeBreakdown = styled.div` + padding-left: 24px; + border-left: 1px solid rgba(224, 243, 255, 0.1); + margin-left: 8px; +`; + +const FeeBreakdownRow = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 4px; +`; + +const FeeBreakdownLabel = styled.span` + color: rgba(224, 243, 255, 0.7); +`; + +const FeeBreakdownValue = styled.span` + color: #e0f3ff; +`; + +export default ConfirmationButton; diff --git a/src/views/SwapAndBridge/components/InputForm.tsx b/src/views/SwapAndBridge/components/InputForm.tsx new file mode 100644 index 000000000..18e9b501b --- /dev/null +++ b/src/views/SwapAndBridge/components/InputForm.tsx @@ -0,0 +1,310 @@ +import { COLORS, formatUnitsWithMaxFractions, formatUSD } from "utils"; +import SelectorButton, { + EnrichedTokenSelect, +} from "./ChainTokenSelector/SelectorButton"; +import BalanceSelector from "./BalanceSelector"; +import styled from "@emotion/styled"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { BigNumber, utils } from "ethers"; +import { ReactComponent as ArrowsCross } from "assets/icons/arrows-cross.svg"; + +export const InputForm = ({ + inputToken, + outputToken, + setInputToken, + setOutputToken, + setAmount, + isAmountOrigin, + setIsAmountOrigin, + isQuoteLoading, + expectedOutputAmount, + expectedInputAmount, +}: { + inputToken: EnrichedTokenSelect | null; + setInputToken: (token: EnrichedTokenSelect | null) => void; + + outputToken: EnrichedTokenSelect | null; + setOutputToken: (token: EnrichedTokenSelect | null) => void; + + isQuoteLoading: boolean; + expectedOutputAmount: string | undefined; + expectedInputAmount: string | undefined; + + setAmount: (amount: BigNumber | null) => void; + + isAmountOrigin: boolean; + setIsAmountOrigin: (isAmountOrigin: boolean) => void; +}) => { + const quickSwap = useCallback(() => { + const origin = inputToken; + const destination = outputToken; + + setOutputToken(origin); + setInputToken(destination); + + setAmount(null); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [inputToken, outputToken]); + + return ( + + { + setAmount(amount); + setIsAmountOrigin(true); + }} + isOrigin={true} + expectedAmount={expectedInputAmount} + shouldUpdate={!isAmountOrigin} + isUpdateLoading={isQuoteLoading} + /> + + + + + + { + setAmount(amount); + setIsAmountOrigin(false); + }} + isOrigin={false} + expectedAmount={expectedOutputAmount} + shouldUpdate={isAmountOrigin} + isUpdateLoading={isQuoteLoading} + /> + + ); +}; + +const TokenInput = ({ + setToken, + token, + setAmount, + isOrigin, + expectedAmount, + shouldUpdate, + isUpdateLoading, +}: { + setToken: (token: EnrichedTokenSelect) => void; + token: EnrichedTokenSelect | null; + setAmount: (amount: BigNumber | null) => void; + isOrigin: boolean; + expectedAmount: string | undefined; + shouldUpdate: boolean; + isUpdateLoading: boolean; +}) => { + const [amountString, setAmountString] = useState(""); + const [justTyped, setJustTyped] = useState(false); + + // Handle user input changes + useEffect(() => { + if (!justTyped) { + return; + } + setJustTyped(false); + try { + setAmount(utils.parseUnits(amountString, token!.decimals)); + } catch (e) { + setAmount(null); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [amountString]); + + // Reset amount when token changes + useEffect(() => { + if (token) { + setAmountString(""); + } + }, [token]); + + // Handle quote updates - only update the field that should receive the quote + useEffect(() => { + if (shouldUpdate && isUpdateLoading) { + setAmountString(""); + } + + if (expectedAmount && token && shouldUpdate) { + setAmountString( + formatUnitsWithMaxFractions(expectedAmount, token.decimals) + ); + } + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [expectedAmount, isUpdateLoading]); + + const estimatedUsdAmount = useMemo(() => { + try { + const amount = utils.parseUnits(amountString, token!.decimals); + if (!token) { + return null; + } + const priceAsNumeric = Number(utils.formatUnits(token.priceUsd, 18)); + const amountAsNumeric = Number(utils.formatUnits(amount, token.decimals)); + const estimatedUsdAmountNumeric = amountAsNumeric * priceAsNumeric; + const estimatedUsdAmount = utils.parseUnits( + estimatedUsdAmountNumeric.toString(), + 18 + ); + return formatUSD(estimatedUsdAmount); + } catch (e) { + return null; + } + }, [amountString, token]); + + return ( + + + + {isOrigin ? "Sell" : "Buy"} + + { + setJustTyped(true); + setAmountString(e.target.value); + }} + disabled={shouldUpdate && isUpdateLoading} + /> + + {estimatedUsdAmount && <>Value: ~${estimatedUsdAmount}} + + + + {token && ( + + { + if (amount) { + setAmount(amount); + setAmountString( + formatUnitsWithMaxFractions(amount, token.decimals) + ); + } + }} + /> + + )} + + ); +}; + +const TokenAmountStack = styled.div` + display: flex; + flex-direction: column; + justify-content: space-between; + align-items: flex-start; + align-self: stretch; + + height: 100%; +`; + +const TokenAmountInputTitle = styled.div` + color: ${() => COLORS.aqua}; + font-size: 16px; + font-weight: 400; + line-height: 130%; +`; + +const TokenAmountInput = styled.input` + color: #e0f3ff; + font-family: Barlow; + font-size: 48px; + font-weight: 300; + line-height: 120%; + letter-spacing: -1.92px; + + outline: none; + border: none; + background: transparent; + + flex-shrink: 0; + + &:focus { + font-size: 48px; + } +`; + +const TokenAmountInputEstimatedUsd = styled.div` + color: #e0f3ff; + font-family: Barlow; + font-size: 14px; + font-weight: 400; + line-height: 130%; +`; + +const TokenInputWrapper = styled.div` + display: flex; + height: 132px; + padding: 16px; + justify-content: space-between; + align-items: center; + align-self: stretch; + border-radius: 12px; + border: 1px solid rgba(224, 243, 255, 0.05); + background: #2d2e32; + position: relative; +`; + +const BalanceSelectorWrapper = styled.div` + position: absolute; + bottom: 16px; + right: 16px; +`; + +const Wrapper = styled.div` + position: relative; + + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: center; + gap: 12px; + align-self: stretch; + padding: 12px; + border-radius: 24px; + border: 1px solid rgba(224, 243, 255, 0.05); + background: #34353b; + box-shadow: 0px 2px 4px 0px rgba(0, 0, 0, 0.08); +`; + +const QuickSwapButton = styled.button` + display: flex; + width: 48px; + height: 32px; + + padding: 0px 16px; + justify-content: center; + align-items: center; + gap: 8px; + border-radius: 32px; + border: 1px solid #4c4e57; + background: #34353b; + box-shadow: 0px 2px 4px 0px rgba(0, 0, 0, 0.08); + cursor: pointer; + + & * { + flex-shrink: 0; + } +`; + +const QuickSwapButtonWrapper = styled.div` + position: absolute; + left: calc(50% - 24px); + top: calc(50% - 16px); + + z-index: 4; +`; diff --git a/src/views/SwapAndBridge/hooks/useSwapQuote.ts b/src/views/SwapAndBridge/hooks/useSwapQuote.ts new file mode 100644 index 000000000..c4ea54b84 --- /dev/null +++ b/src/views/SwapAndBridge/hooks/useSwapQuote.ts @@ -0,0 +1,101 @@ +import { useQuery } from "@tanstack/react-query"; +import axios from "axios"; +import { BigNumber } from "ethers"; +import { useConnection } from "hooks"; +import { vercelApiBaseUrl } from "utils"; + +type TokenParam = { + address: string; + chainId: number; +}; + +type SwapQuoteParams = { + origin: TokenParam | null; + destination: TokenParam | null; + amount: BigNumber | null; + isInputAmount: boolean; + recipient?: string; + integratorId?: string; + refundAddress?: string; + refundOnOrigin?: boolean; + slippageTolerance?: number; +}; + +type SwapTransaction = { + simulationSuccess: boolean; + chainId: number; + to: string; + data: string; + maxFeePerGas: string; + maxPriorityFeePerGas: string; +}; + +type SwapQuoteResponse = { + checks: object; + steps: object; + refundToken: object; + inputAmount: string; + expectedOutputAmount: string; + minOutputAmount: string; + expectedFillTime: number; + swapTx: SwapTransaction; + approvalTxns: { + chainId: number; + data: string; + to: string; + }[]; +}; + +const useSwapQuote = ({ + origin, + destination, + amount, + isInputAmount, + recipient, + integratorId, + refundAddress, + refundOnOrigin = true, + slippageTolerance = 1, +}: SwapQuoteParams) => { + const { account: depositor } = useConnection(); + const { data, isLoading, error } = useQuery({ + queryKey: [ + "swap-quote", + origin, + destination, + amount, + isInputAmount, + depositor, + recipient, + ], + queryFn: async (): Promise => { + const { data } = await axios.get( + `${vercelApiBaseUrl}/api/swap/approval`, + { + params: { + tradeType: isInputAmount ? "exactInput" : "exactOutput", + inputToken: origin?.address, + outputToken: destination?.address, + originChainId: origin?.chainId, + destinationChainId: destination?.chainId, + depositor, + recipient: recipient || depositor, + ...(integratorId && { integratorId }), + ...(refundAddress && { refundAddress }), + amount: amount?.toString(), + refundOnOrigin, + slippageTolerance, + }, + } + ); + return data; + }, + enabled: + !!origin?.address && !!destination?.address && !!amount && !!depositor, + refetchInterval: 2_000, + }); + + return { data, isLoading, error }; +}; + +export default useSwapQuote; diff --git a/src/views/SwapAndBridge/index.tsx b/src/views/SwapAndBridge/index.tsx new file mode 100644 index 000000000..cb7b05434 --- /dev/null +++ b/src/views/SwapAndBridge/index.tsx @@ -0,0 +1,109 @@ +import { LayoutV2 } from "components"; +import { EnrichedTokenSelect } from "./components/ChainTokenSelector/SelectorButton"; +import styled from "@emotion/styled"; +import { useCallback, useState } from "react"; +import { InputForm } from "./components/InputForm"; +import { BigNumber } from "ethers"; +import ConfirmationButton from "./components/ConfirmationButton"; +import useSwapQuote from "./hooks/useSwapQuote"; +import { useConnection } from "hooks"; +import { useHistory } from "react-router-dom"; + +export default function SwapAndBridge() { + const [inputToken, setInputToken] = useState( + null + ); + const [outputToken, setOutputToken] = useState( + null + ); + const [amount, setAmount] = useState(null); + const [isAmountOrigin, setIsAmountOrigin] = useState(true); + const { signer } = useConnection(); + const history = useHistory(); + + const { data: swapData, isLoading: isUpdateLoading } = useSwapQuote({ + origin: inputToken + ? { + address: inputToken.address, + chainId: inputToken.chainId, + } + : null, + destination: outputToken + ? { + address: outputToken.address, + chainId: outputToken.chainId, + } + : null, + amount: amount, + isInputAmount: isAmountOrigin, + }); + + // Handle confirmation (placeholder for now) + const handleConfirm = useCallback(async () => { + if (!swapData || !signer) { + return; + } + + if (swapData.approvalTxns?.length > 0) { + for (const approvalTxn of swapData.approvalTxns) { + await signer.sendTransaction({ + data: approvalTxn.data, + to: approvalTxn.to, + chainId: approvalTxn.chainId, + }); + } + } + + const tx = await signer.sendTransaction({ + data: swapData.swapTx.data, + to: swapData.swapTx.to, + chainId: swapData.swapTx.chainId, + }); + + history.push( + `/bridge/${tx.hash}?originChainId=${inputToken?.chainId}&destinationChainId=${outputToken?.chainId}&inputTokenSymbol=${inputToken?.symbol}&outputTokenSymbol=${outputToken?.symbol}&referrer=` + ); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [swapData, signer]); + + return ( + + + + + + + ); +} + +const Wrapper = styled.div` + display: flex; + flex-direction: column; + + gap: 16px; + + align-items: center; + justify-content: center; + + width: 100%; + + padding-top: 64px; +`; From 11d6044d3d4a4304a88bbee3fb1336d93a8adc72 Mon Sep 17 00:00:00 2001 From: Gerhard Steenkamp Date: Mon, 22 Sep 2025 13:56:36 +0200 Subject: [PATCH 02/39] add chains and tokens fetchers Signed-off-by: Gerhard Steenkamp --- src/hooks/useAvailableCrosschainRoutes.ts | 46 +++++++++++++++----- src/utils/serverless-api/mocked/index.ts | 4 ++ src/utils/serverless-api/prod/index.ts | 4 ++ src/utils/serverless-api/prod/swap-chains.ts | 12 +++++ src/utils/serverless-api/prod/swap-tokens.ts | 21 +++++++++ src/utils/serverless-api/types.ts | 22 ++++++++++ 6 files changed, 99 insertions(+), 10 deletions(-) create mode 100644 src/utils/serverless-api/prod/swap-chains.ts create mode 100644 src/utils/serverless-api/prod/swap-tokens.ts diff --git a/src/hooks/useAvailableCrosschainRoutes.ts b/src/hooks/useAvailableCrosschainRoutes.ts index ca6eddbc8..1ced8551d 100644 --- a/src/hooks/useAvailableCrosschainRoutes.ts +++ b/src/hooks/useAvailableCrosschainRoutes.ts @@ -1,5 +1,7 @@ import { MAINNET_CHAIN_IDs } from "@across-protocol/constants"; import { useQuery } from "@tanstack/react-query"; +import getApiEndpoint from "utils/serverless-api"; +import { SwapChain, SwapToken } from "utils/serverless-api/types"; export type LifiToken = { chainId: number; @@ -17,22 +19,46 @@ export default function useAvailableCrosschainRoutes() { return useQuery({ queryKey: ["availableCrosschainRoutes"], queryFn: async () => { - const result = await fetch( - "https://li.quest/v1/tokens?chainTypes=EVM&minPriceUSD=0.001" - ); - const data = (await result.json()) as { - tokens: Record>; - }; + const api = getApiEndpoint(); + const [chains, tokens] = await Promise.all([ + api.swapChains(), + api.swapTokens(), + ]); + + const allowedChainIds = new Set(Object.values(MAINNET_CHAIN_IDs)); - return Object.entries(data.tokens).reduce( - (acc, [chainId, tokens]) => { - if (Object.values(MAINNET_CHAIN_IDs).includes(Number(chainId))) { - acc[Number(chainId)] = tokens; + const tokenByChain = (tokens as SwapToken[]).reduce( + (acc, token) => { + if (!allowedChainIds.has(token.chainId)) { + return acc; + } + const mapped: LifiToken = { + chainId: token.chainId, + address: token.address, + name: token.name, + symbol: token.symbol, + decimals: token.decimals, + logoURI: token.logoUrl || "", + priceUSD: token.priceUsd || "0", + coinKey: token.symbol, + }; + if (!acc[token.chainId]) { + acc[token.chainId] = []; } + acc[token.chainId].push(mapped); return acc; }, {} as Record> ); + + // Ensure chains with no tokens are present as empty arrays + (chains as SwapChain[]).forEach((c) => { + if (allowedChainIds.has(c.chainId) && !tokenByChain[c.chainId]) { + tokenByChain[c.chainId] = []; + } + }); + + return tokenByChain; }, }); } diff --git a/src/utils/serverless-api/mocked/index.ts b/src/utils/serverless-api/mocked/index.ts index e7aec8384..6f7ef7496 100644 --- a/src/utils/serverless-api/mocked/index.ts +++ b/src/utils/serverless-api/mocked/index.ts @@ -11,6 +11,8 @@ import { poolsApiCall } from "./pools.mocked"; import { swapQuoteApiCall } from "./swap-quote"; import { poolsUserApiCall } from "./pools-user.mocked"; import { swapApprovalApiCall } from "../prod/swap-approval"; +import { swapChainsApiCall } from "../prod/swap-chains"; +import { swapTokensApiCall } from "../prod/swap-tokens"; export const mockedEndpoints: ServerlessAPIEndpoints = { coingecko: coingeckoMockedApiCall, @@ -29,4 +31,6 @@ export const mockedEndpoints: ServerlessAPIEndpoints = { poolsUser: poolsUserApiCall, swapQuote: swapQuoteApiCall, swapApproval: swapApprovalApiCall, + swapChains: swapChainsApiCall, + swapTokens: swapTokensApiCall, }; diff --git a/src/utils/serverless-api/prod/index.ts b/src/utils/serverless-api/prod/index.ts index c885b478e..58926c9aa 100644 --- a/src/utils/serverless-api/prod/index.ts +++ b/src/utils/serverless-api/prod/index.ts @@ -11,6 +11,8 @@ import { poolsApiCall } from "./pools"; import { swapQuoteApiCall } from "./swap-quote"; import { poolsUserApiCall } from "./pools-user"; import { swapApprovalApiCall } from "./swap-approval"; +import { swapChainsApiCall } from "./swap-chains"; +import { swapTokensApiCall } from "./swap-tokens"; export const prodEndpoints: ServerlessAPIEndpoints = { coingecko: coingeckoApiCall, suggestedFees: suggestedFeesApiCall, @@ -28,4 +30,6 @@ export const prodEndpoints: ServerlessAPIEndpoints = { poolsUser: poolsUserApiCall, swapQuote: swapQuoteApiCall, swapApproval: swapApprovalApiCall, + swapChains: swapChainsApiCall, + swapTokens: swapTokensApiCall, }; diff --git a/src/utils/serverless-api/prod/swap-chains.ts b/src/utils/serverless-api/prod/swap-chains.ts new file mode 100644 index 000000000..daccf8ea6 --- /dev/null +++ b/src/utils/serverless-api/prod/swap-chains.ts @@ -0,0 +1,12 @@ +import axios from "axios"; +import { vercelApiBaseUrl } from "utils"; +import { SwapChain } from "../types"; + +export type SwapChainsApiCall = typeof swapChainsApiCall; + +export async function swapChainsApiCall(): Promise { + const response = await axios.get( + `${vercelApiBaseUrl}/api/swap/chains` + ); + return response.data; +} diff --git a/src/utils/serverless-api/prod/swap-tokens.ts b/src/utils/serverless-api/prod/swap-tokens.ts new file mode 100644 index 000000000..e323b1604 --- /dev/null +++ b/src/utils/serverless-api/prod/swap-tokens.ts @@ -0,0 +1,21 @@ +import axios from "axios"; +import { vercelApiBaseUrl } from "utils"; +import { SwapToken } from "../types"; + +export type SwapTokensApiCall = typeof swapTokensApiCall; + +export type SwapTokensQuery = { + chainId?: number | number[]; +}; + +export async function swapTokensApiCall( + query?: SwapTokensQuery +): Promise { + const response = await axios.get( + `${vercelApiBaseUrl}/api/swap/tokens`, + { + params: query, + } + ); + return response.data; +} diff --git a/src/utils/serverless-api/types.ts b/src/utils/serverless-api/types.ts index ec196ea61..706376018 100644 --- a/src/utils/serverless-api/types.ts +++ b/src/utils/serverless-api/types.ts @@ -6,6 +6,8 @@ import { PoolsApiCall } from "./prod/pools"; import { SwapQuoteApiCall } from "./prod/swap-quote"; import { PoolsUserApiCall } from "./prod/pools-user"; import { SwapApprovalApiCall } from "./prod/swap-approval"; +import { SwapChainsApiCall } from "./prod/swap-chains"; +import { SwapTokensApiCall } from "./prod/swap-tokens"; export type ServerlessAPIEndpoints = { coingecko: CoingeckoApiCall; @@ -24,6 +26,8 @@ export type ServerlessAPIEndpoints = { poolsUser: PoolsUserApiCall; swapQuote: SwapQuoteApiCall; swapApproval: SwapApprovalApiCall; + swapChains: SwapChainsApiCall; + swapTokens: SwapTokensApiCall; }; export type RewardsApiFunction = @@ -111,3 +115,21 @@ export type BridgeLimitFunction = ( fromChainId: string | ChainId, toChainId: string | ChainId ) => Promise; + +export type SwapChain = { + chainId: number; + name: string; + publicRpcUrl: string; + explorerUrl: string; + logoUrl: string; +}; + +export type SwapToken = { + chainId: number; + address: string; + name: string; + symbol: string; + decimals: number; + logoUrl?: string; + priceUsd: string | null; +}; From 0aa37c72835e98a90aade7c769c6acb121e90ac8 Mon Sep 17 00:00:00 2001 From: Gerhard Steenkamp Date: Mon, 22 Sep 2025 16:10:30 +0200 Subject: [PATCH 03/39] update frontend swap api client Signed-off-by: Gerhard Steenkamp --- src/utils/serverless-api/prod/swap-approval.ts | 17 +++++++++++++++-- .../components/ChainTokenSelector/Modal.tsx | 1 + src/views/SwapAndBridge/hooks/useSwapQuote.ts | 4 +++- 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/src/utils/serverless-api/prod/swap-approval.ts b/src/utils/serverless-api/prod/swap-approval.ts index f23332e66..17714424f 100644 --- a/src/utils/serverless-api/prod/swap-approval.ts +++ b/src/utils/serverless-api/prod/swap-approval.ts @@ -30,6 +30,7 @@ export type SwapApprovalApiResponse = { }; }; approvalTxns: { + chainId: number; to: string; data: string; }[]; @@ -41,6 +42,10 @@ export type SwapApprovalApiResponse = { outputAmount: string; minOutputAmount: string; maxInputAmount: string; + swapProvider: { + name: string; + sources: string[]; + }; }; bridge: { inputAmount: string; @@ -73,6 +78,10 @@ export type SwapApprovalApiResponse = { maxInputAmount: string; outputAmount: string; minOutputAmount: string; + swapProvider: { + name: string; + sources: string[]; + }; }; }; refundToken: SwapApiToken; @@ -85,7 +94,7 @@ export type SwapApprovalApiResponse = { chainId: number; to: string; data: string; - value: string; + value?: string; gas?: string; maxFeePerGas?: string; maxPriorityFeePerGas?: string; @@ -146,6 +155,7 @@ export async function swapApprovalApiCall(params: SwapApprovalApiQueryParams) { maxInputAmount: BigNumber.from( result.steps.originSwap.maxInputAmount ), + swapProvider: result.steps.originSwap.swapProvider, } : undefined, bridge: { @@ -190,6 +200,7 @@ export async function swapApprovalApiCall(params: SwapApprovalApiQueryParams) { minOutputAmount: BigNumber.from( result.steps.destinationSwap.minOutputAmount ), + swapProvider: result.steps.destinationSwap.swapProvider, } : undefined, }, @@ -203,7 +214,9 @@ export async function swapApprovalApiCall(params: SwapApprovalApiQueryParams) { chainId: result.swapTx.chainId, to: result.swapTx.to, data: result.swapTx.data, - value: BigNumber.from(result.swapTx.value || "0"), + value: result.swapTx.value + ? BigNumber.from(result.swapTx.value) + : undefined, gas: result.swapTx.gas ? BigNumber.from(result.swapTx.gas) : undefined, maxFeePerGas: result.swapTx.maxFeePerGas ? BigNumber.from(result.swapTx.maxFeePerGas) diff --git a/src/views/SwapAndBridge/components/ChainTokenSelector/Modal.tsx b/src/views/SwapAndBridge/components/ChainTokenSelector/Modal.tsx index f884119dd..e0a900a12 100644 --- a/src/views/SwapAndBridge/components/ChainTokenSelector/Modal.tsx +++ b/src/views/SwapAndBridge/components/ChainTokenSelector/Modal.tsx @@ -100,6 +100,7 @@ export default function ChainTokenSelectorModal({ exitOnOutsideClick width={720} height={800} + titleBorder > diff --git a/src/views/SwapAndBridge/hooks/useSwapQuote.ts b/src/views/SwapAndBridge/hooks/useSwapQuote.ts index c4ea54b84..deb6bc1fc 100644 --- a/src/views/SwapAndBridge/hooks/useSwapQuote.ts +++ b/src/views/SwapAndBridge/hooks/useSwapQuote.ts @@ -26,6 +26,8 @@ type SwapTransaction = { chainId: number; to: string; data: string; + value?: string; + gas?: string; maxFeePerGas: string; maxPriorityFeePerGas: string; }; @@ -73,7 +75,7 @@ const useSwapQuote = ({ `${vercelApiBaseUrl}/api/swap/approval`, { params: { - tradeType: isInputAmount ? "exactInput" : "exactOutput", + tradeType: isInputAmount ? "exactInput" : "minOutput", inputToken: origin?.address, outputToken: destination?.address, originChainId: origin?.chainId, From 185ada7f2452eee83a6b54016d2d13e91b9a2dd8 Mon Sep 17 00:00:00 2001 From: Gerhard Steenkamp Date: Fri, 26 Sep 2025 18:23:40 +0200 Subject: [PATCH 04/39] add base strategies fro swap and bridge Signed-off-by: Gerhard Steenkamp --- .../SwapAndBridge/hooks/useSwapAndBridge.ts | 157 ++++++++++++++++++ .../hooks/useSwapApprovalAction/factory.ts | 35 ++++ .../hooks/useSwapApprovalAction/index.ts | 28 ++++ .../strategies/abstract.ts | 20 +++ .../useSwapApprovalAction/strategies/evm.ts | 59 +++++++ .../useSwapApprovalAction/strategies/svm.ts | 37 +++++ .../useSwapApprovalAction/strategies/types.ts | 32 ++++ 7 files changed, 368 insertions(+) create mode 100644 src/views/SwapAndBridge/hooks/useSwapAndBridge.ts create mode 100644 src/views/SwapAndBridge/hooks/useSwapApprovalAction/factory.ts create mode 100644 src/views/SwapAndBridge/hooks/useSwapApprovalAction/index.ts create mode 100644 src/views/SwapAndBridge/hooks/useSwapApprovalAction/strategies/abstract.ts create mode 100644 src/views/SwapAndBridge/hooks/useSwapApprovalAction/strategies/evm.ts create mode 100644 src/views/SwapAndBridge/hooks/useSwapApprovalAction/strategies/svm.ts create mode 100644 src/views/SwapAndBridge/hooks/useSwapApprovalAction/strategies/types.ts diff --git a/src/views/SwapAndBridge/hooks/useSwapAndBridge.ts b/src/views/SwapAndBridge/hooks/useSwapAndBridge.ts new file mode 100644 index 000000000..d64a0edc9 --- /dev/null +++ b/src/views/SwapAndBridge/hooks/useSwapAndBridge.ts @@ -0,0 +1,157 @@ +import { useCallback, useMemo, useState } from "react"; +import { BigNumber } from "ethers"; +import axios from "axios"; + +import { AmountInputError } from "../../Bridge/utils"; +import useSwapQuote from "./useSwapQuote"; +import { EnrichedTokenSelect } from "../components/ChainTokenSelector/SelectorButton"; +import { + useSwapApprovalAction, + SwapApprovalData, +} from "./useSwapApprovalAction"; + +export type UseSwapAndBridgeReturn = { + inputToken: EnrichedTokenSelect | null; + outputToken: EnrichedTokenSelect | null; + setInputToken: (t: EnrichedTokenSelect | null) => void; + setOutputToken: (t: EnrichedTokenSelect | null) => void; + quickSwap: () => void; + + amount: BigNumber | null; + setAmount: (a: BigNumber | null) => void; + isAmountOrigin: boolean; + setIsAmountOrigin: (v: boolean) => void; + + swapQuote: ReturnType["data"]; + isQuoteLoading: boolean; + expectedInputAmount?: string; + expectedOutputAmount?: string; + + validationError?: AmountInputError; + validationWarning?: AmountInputError; + + isConnected: boolean; + isWrongNetwork: boolean; + isSubmitting: boolean; + buttonDisabled: boolean; + onConfirm: () => Promise; +}; + +export function useSwapAndBridge(): UseSwapAndBridgeReturn { + const [inputToken, setInputToken] = useState( + null + ); + const [outputToken, setOutputToken] = useState( + null + ); + const [amount, setAmount] = useState(null); + const [isAmountOrigin, setIsAmountOrigin] = useState(true); + + const quickSwap = useCallback(() => { + setInputToken((prevInput) => { + const prevOut = outputToken; + setOutputToken(prevInput || null); + return prevOut || null; + }); + setAmount(null); + }, [outputToken]); + + const { + data: swapQuote, + isLoading: isQuoteLoading, + error, + } = useSwapQuote({ + origin: inputToken ? inputToken : null, + destination: outputToken ? outputToken : null, + amount: amount, + isInputAmount: isAmountOrigin, + }); + + const approvalData: SwapApprovalData | undefined = useMemo(() => { + if (!swapQuote) return undefined; + return { + approvalTxns: swapQuote.approvalTxns, + swapTx: swapQuote.swapTx as any, + }; + }, [swapQuote]); + + const approvalAction = useSwapApprovalAction( + inputToken?.chainId || 0, + approvalData + ); + + const validation = useMemo(() => { + let errorType: AmountInputError | undefined = undefined; + // invalid or empty amount + if (!amount || amount.lte(0)) { + errorType = AmountInputError.INVALID; + } + // balance check for origin-side inputs + if (!errorType && isAmountOrigin && inputToken?.balance) { + if (amount && amount.gt(inputToken.balance)) { + errorType = AmountInputError.INSUFFICIENT_BALANCE; + } + } + // backend availability + if (!errorType && error && axios.isAxiosError(error)) { + const code = (error.response?.data as any)?.code as string | undefined; + if (code === "AMOUNT_TOO_LOW") { + errorType = AmountInputError.AMOUNT_TOO_LOW; + } else if (code === "SWAP_QUOTE_UNAVAILABLE") { + errorType = AmountInputError.SWAP_QUOTE_UNAVAILABLE; + } + } + return { + error: errorType, + warn: undefined as AmountInputError | undefined, + }; + }, [amount, isAmountOrigin, inputToken, error]); + + const expectedInputAmount = useMemo(() => { + return swapQuote?.inputAmount?.toString(); + }, [swapQuote]); + + const expectedOutputAmount = useMemo(() => { + return swapQuote?.expectedOutputAmount?.toString(); + }, [swapQuote]); + + const onConfirm = useCallback(async () => { + const txHash = await approvalAction.buttonActionHandler(); + return txHash as string; + }, [approvalAction]); + + return { + inputToken, + outputToken, + setInputToken, + setOutputToken, + quickSwap, + + amount, + setAmount, + isAmountOrigin, + setIsAmountOrigin, + + swapQuote, + isQuoteLoading, + expectedInputAmount, + expectedOutputAmount, + + validationError: validation.error, + validationWarning: validation.warn, + + isConnected: approvalAction.isConnected, + isWrongNetwork: approvalAction.isWrongNetwork, + isSubmitting: approvalAction.isButtonActionLoading, + buttonDisabled: + approvalAction.buttonDisabled || + !!validation.error || + !inputToken || + !outputToken || + !amount || + amount.lte(0), + onConfirm, + }; +} + +export default useSwapAndBridge; diff --git a/src/views/SwapAndBridge/hooks/useSwapApprovalAction/factory.ts b/src/views/SwapAndBridge/hooks/useSwapApprovalAction/factory.ts new file mode 100644 index 000000000..5bfdb296d --- /dev/null +++ b/src/views/SwapAndBridge/hooks/useSwapApprovalAction/factory.ts @@ -0,0 +1,35 @@ +import { useMutation } from "@tanstack/react-query"; +import { + SwapApprovalActionStrategy, + SwapApprovalData, +} from "./strategies/types"; + +export function createSwapApprovalActionHook( + strategy: SwapApprovalActionStrategy +) { + return function useSwapApprovalAction(approvalData?: SwapApprovalData) { + const isConnected = strategy.isConnected(); + const isWrongNetwork = approvalData + ? strategy.isWrongNetwork(approvalData.swapTx.chainId) + : false; + + const action = useMutation({ + mutationFn: async () => { + if (!approvalData) throw new Error("Missing approval data"); + const txHash = await strategy.swap(approvalData); + return txHash; + }, + }); + + const buttonDisabled = !approvalData || (isConnected && action.isPending); + + return { + isConnected, + isWrongNetwork, + buttonActionHandler: action.mutateAsync, + isButtonActionLoading: action.isPending, + didActionError: action.isError, + buttonDisabled, + }; + }; +} diff --git a/src/views/SwapAndBridge/hooks/useSwapApprovalAction/index.ts b/src/views/SwapAndBridge/hooks/useSwapApprovalAction/index.ts new file mode 100644 index 000000000..f861b783e --- /dev/null +++ b/src/views/SwapAndBridge/hooks/useSwapApprovalAction/index.ts @@ -0,0 +1,28 @@ +import { createSwapApprovalActionHook } from "./factory"; +import { useConnectionSVM } from "hooks/useConnectionSVM"; +import { useConnectionEVM } from "hooks/useConnectionEVM"; +import { EVMSwapApprovalActionStrategy } from "./strategies/evm"; +import { SVMSwapApprovalActionStrategy } from "./strategies/svm"; +import { getEcosystem } from "utils"; +import { SwapApprovalData } from "./strategies/types"; + +export function useSwapApprovalAction( + originChainId: number, + approvalData?: SwapApprovalData +) { + const connectionEVM = useConnectionEVM(); + const connectionSVM = useConnectionSVM(); + + const evmHook = createSwapApprovalActionHook( + new EVMSwapApprovalActionStrategy(connectionEVM) + ); + const svmHook = createSwapApprovalActionHook( + new SVMSwapApprovalActionStrategy(connectionSVM, connectionEVM) + ); + + return getEcosystem(originChainId) === "evm" + ? evmHook(approvalData) + : svmHook(approvalData); +} + +export type { SwapApprovalData } from "./strategies/types"; diff --git a/src/views/SwapAndBridge/hooks/useSwapApprovalAction/strategies/abstract.ts b/src/views/SwapAndBridge/hooks/useSwapApprovalAction/strategies/abstract.ts new file mode 100644 index 000000000..31b1346e8 --- /dev/null +++ b/src/views/SwapAndBridge/hooks/useSwapApprovalAction/strategies/abstract.ts @@ -0,0 +1,20 @@ +import { useConnectionEVM } from "hooks/useConnectionEVM"; +import { SwapApprovalActionStrategy } from "./types"; + +export abstract class AbstractSwapApprovalActionStrategy + implements SwapApprovalActionStrategy +{ + constructor(readonly evmConnection: ReturnType) {} + + abstract isConnected(): boolean; + abstract isWrongNetwork(requiredChainId: number): boolean; + abstract switchNetwork(requiredChainId: number): Promise; + abstract swap(approvalData: any): Promise; + + async assertCorrectNetwork(requiredChainId: number) { + const currentChainId = this.evmConnection.chainId; + if (currentChainId !== requiredChainId) { + await this.evmConnection.setChain(requiredChainId); + } + } +} diff --git a/src/views/SwapAndBridge/hooks/useSwapApprovalAction/strategies/evm.ts b/src/views/SwapAndBridge/hooks/useSwapApprovalAction/strategies/evm.ts new file mode 100644 index 000000000..cce0602e4 --- /dev/null +++ b/src/views/SwapAndBridge/hooks/useSwapApprovalAction/strategies/evm.ts @@ -0,0 +1,59 @@ +import { AbstractSwapApprovalActionStrategy } from "./abstract"; +import { useConnectionEVM } from "hooks/useConnectionEVM"; +import { ApprovalTxn, SwapApprovalData, SwapTx } from "./types"; + +export class EVMSwapApprovalActionStrategy extends AbstractSwapApprovalActionStrategy { + constructor(evmConnection: ReturnType) { + super(evmConnection); + } + + private get signer() { + const { signer } = this.evmConnection; + if (!signer) { + throw new Error("No signer available"); + } + return signer; + } + + isConnected(): boolean { + return this.evmConnection.isConnected; + } + + isWrongNetwork(requiredChainId: number): boolean { + const connectedChainId = this.evmConnection.chainId; + return connectedChainId !== requiredChainId; + } + + async switchNetwork(requiredChainId: number): Promise { + await this.evmConnection.setChain(requiredChainId); + } + + async swap(approvalData: SwapApprovalData): Promise { + const signer = this.signer; + // approvals first + const approvals: ApprovalTxn[] = approvalData.approvalTxns || []; + for (const approval of approvals) { + await this.switchNetwork(approval.chainId); + await signer.sendTransaction({ + to: approval.to, + data: approval.data, + chainId: approval.chainId, + }); + } + // then final swap + const swapTx: SwapTx = approvalData.swapTx; + await this.switchNetwork(swapTx.chainId); + await this.assertCorrectNetwork(swapTx.chainId); + const tx = await signer.sendTransaction({ + to: swapTx.to, + data: swapTx.data, + value: swapTx.value, + chainId: swapTx.chainId, + gasPrice: undefined, + maxFeePerGas: swapTx.maxFeePerGas as any, + maxPriorityFeePerGas: swapTx.maxPriorityFeePerGas as any, + gasLimit: swapTx.gas as any, + }); + return tx.hash; + } +} diff --git a/src/views/SwapAndBridge/hooks/useSwapApprovalAction/strategies/svm.ts b/src/views/SwapAndBridge/hooks/useSwapApprovalAction/strategies/svm.ts new file mode 100644 index 000000000..d65578aef --- /dev/null +++ b/src/views/SwapAndBridge/hooks/useSwapApprovalAction/strategies/svm.ts @@ -0,0 +1,37 @@ +import { AbstractSwapApprovalActionStrategy } from "./abstract"; +import { useConnectionSVM } from "hooks/useConnectionSVM"; +import { useConnectionEVM } from "hooks/useConnectionEVM"; +import { SwapApprovalData, SwapTx } from "./types"; + +export class SVMSwapApprovalActionStrategy extends AbstractSwapApprovalActionStrategy { + constructor( + private readonly svmConnection: ReturnType, + evmConnection: ReturnType + ) { + super(evmConnection); + } + + isConnected(): boolean { + return this.svmConnection.isConnected; + } + + isWrongNetwork(_: number): boolean { + return !this.svmConnection.isConnected; + } + + async switchNetwork(_: number): Promise { + await this.svmConnection.connect(); + } + + async swap(approvalData: SwapApprovalData): Promise { + if (!this.svmConnection.wallet?.adapter) { + throw new Error("Wallet needs to be connected"); + } + + const swapTx: SwapTx = approvalData.swapTx; + const sig = await this.svmConnection.provider.sendRawTransaction( + Buffer.from(swapTx.data, "base64") + ); + return sig; + } +} diff --git a/src/views/SwapAndBridge/hooks/useSwapApprovalAction/strategies/types.ts b/src/views/SwapAndBridge/hooks/useSwapApprovalAction/strategies/types.ts new file mode 100644 index 000000000..a0e95a60e --- /dev/null +++ b/src/views/SwapAndBridge/hooks/useSwapApprovalAction/strategies/types.ts @@ -0,0 +1,32 @@ +export type ApprovalTxn = { + chainId: number; + to: string; + data: string; +}; + +export type SwapTx = { + simulationSuccess: boolean; + chainId: number; + to: string; + data: string; + value?: string; + gas?: string; + maxFeePerGas?: string; + maxPriorityFeePerGas?: string; +}; + +export type SwapApprovalData = { + approvalTxns?: ApprovalTxn[]; + swapTx: SwapTx; +}; + +export type ApproveAndExecuteParams = { + approvalData: SwapApprovalData; +}; + +export type SwapApprovalActionStrategy = { + isConnected(): boolean; + isWrongNetwork(requiredChainId: number): boolean; + switchNetwork(requiredChainId: number): Promise; + swap(approvalData: SwapApprovalData): Promise; +}; From 1ccac280ce8a8d6d7b23336a99c7f22284b3e23f Mon Sep 17 00:00:00 2001 From: Gerhard Steenkamp Date: Fri, 26 Sep 2025 18:24:03 +0200 Subject: [PATCH 05/39] get bridge and swap routes. refactor Signed-off-by: Gerhard Steenkamp --- src/hooks/useAvailableCrosschainRoutes.ts | 78 +++++++++++++-- src/views/Bridge/components/AmountInput.tsx | 2 +- .../components/ChainTokenSelector/Modal.tsx | 1 + .../SwapAndBridge/components/InputForm.tsx | 96 ++++++++++++++++++- src/views/SwapAndBridge/hooks/useSwapQuote.ts | 79 ++++++--------- src/views/SwapAndBridge/index.tsx | 83 +++++----------- 6 files changed, 218 insertions(+), 121 deletions(-) diff --git a/src/hooks/useAvailableCrosschainRoutes.ts b/src/hooks/useAvailableCrosschainRoutes.ts index 1ced8551d..3f199733d 100644 --- a/src/hooks/useAvailableCrosschainRoutes.ts +++ b/src/hooks/useAvailableCrosschainRoutes.ts @@ -2,6 +2,7 @@ import { MAINNET_CHAIN_IDs } from "@across-protocol/constants"; import { useQuery } from "@tanstack/react-query"; import getApiEndpoint from "utils/serverless-api"; import { SwapChain, SwapToken } from "utils/serverless-api/types"; +import { getConfig } from "utils/config"; export type LifiToken = { chainId: number; @@ -12,9 +13,9 @@ export type LifiToken = { priceUSD: string; coinKey: string; logoURI: string; + routeSource: "bridge" | "swap" | "both"; }; -// TODO: Currently stubbed and will need to be added to the swap API export default function useAvailableCrosschainRoutes() { return useQuery({ queryKey: ["availableCrosschainRoutes"], @@ -27,7 +28,8 @@ export default function useAvailableCrosschainRoutes() { const allowedChainIds = new Set(Object.values(MAINNET_CHAIN_IDs)); - const tokenByChain = (tokens as SwapToken[]).reduce( + // 1) Build swap token map by chain + const swapTokensByChain = (tokens as SwapToken[]).reduce( (acc, token) => { if (!allowedChainIds.has(token.chainId)) { return acc; @@ -41,6 +43,7 @@ export default function useAvailableCrosschainRoutes() { logoURI: token.logoUrl || "", priceUSD: token.priceUsd || "0", coinKey: token.symbol, + routeSource: "swap", }; if (!acc[token.chainId]) { acc[token.chainId] = []; @@ -51,14 +54,71 @@ export default function useAvailableCrosschainRoutes() { {} as Record> ); - // Ensure chains with no tokens are present as empty arrays - (chains as SwapChain[]).forEach((c) => { - if (allowedChainIds.has(c.chainId) && !tokenByChain[c.chainId]) { - tokenByChain[c.chainId] = []; - } - }); + // 2) Build bridge token map by origin chain from generated routes + const config = getConfig(); + const enabledRoutes = config.getEnabledRoutes(); + const bridgeOriginChains = Array.from( + new Set(enabledRoutes.map((r) => r.fromChain)) + ); + + const bridgeTokensByChain = bridgeOriginChains.reduce( + (acc, fromChainId) => { + if (!allowedChainIds.has(fromChainId)) { + return acc; + } + const reachable = config.filterReachableTokens(fromChainId); + const lifiTokens: LifiToken[] = reachable.map((t) => ({ + chainId: fromChainId, + address: t.address, + name: t.name, + symbol: t.displaySymbol || t.symbol, + decimals: t.decimals, + logoURI: t.logoURI || "", + // We do not have price data from the routes; default to 0 + priceUSD: "0", + coinKey: t.symbol, + routeSource: "bridge", + })); + acc[fromChainId] = lifiTokens; + return acc; + }, + {} as Record> + ); + + // 3) Merge swap and bridge tokens, de-duplicating by address (case-insensitive) + const chainIdsInSwap = new Set( + (chains as SwapChain[]).map((c) => c.chainId) + ); + const chainIdsInBridge = new Set( + Object.keys(bridgeTokensByChain).map(Number) + ); + const chainIds = Array.from( + new Set([...chainIdsInSwap, ...chainIdsInBridge]) + ).filter((id) => allowedChainIds.has(id)); + + const blendedByChain: Record> = {}; + for (const chainId of chainIds) { + const mapByAddr = new Map(); + // Prefer swap tokens first (they include price) + (swapTokensByChain[chainId] || []).forEach((t) => { + mapByAddr.set(t.address.toLowerCase(), t); + }); + // Add bridge tokens, merging routeSource when duplicate + (bridgeTokensByChain[chainId] || []).forEach((t) => { + const key = t.address.toLowerCase(); + const existing = mapByAddr.get(key); + if (!existing) { + mapByAddr.set(key, t); + } else { + // Merge: if token exists from swap, mark as both + mapByAddr.set(key, { ...existing, routeSource: "both" }); + } + }); + + blendedByChain[chainId] = Array.from(mapByAddr.values()); + } - return tokenByChain; + return blendedByChain; }, }); } diff --git a/src/views/Bridge/components/AmountInput.tsx b/src/views/Bridge/components/AmountInput.tsx index b3eb30c28..a9c5cb4cc 100644 --- a/src/views/Bridge/components/AmountInput.tsx +++ b/src/views/Bridge/components/AmountInput.tsx @@ -6,7 +6,7 @@ import { AmountInputError, SelectedRoute } from "../utils"; import { formatUnitsWithMaxFractions, getToken } from "utils"; import { BridgeLimits } from "hooks"; -const validationErrorTextMap: Record = { +export const validationErrorTextMap: Record = { [AmountInputError.INSUFFICIENT_BALANCE]: "Insufficient balance to process this transfer.", [AmountInputError.PAUSED_DEPOSITS]: diff --git a/src/views/SwapAndBridge/components/ChainTokenSelector/Modal.tsx b/src/views/SwapAndBridge/components/ChainTokenSelector/Modal.tsx index e0a900a12..589fc9533 100644 --- a/src/views/SwapAndBridge/components/ChainTokenSelector/Modal.tsx +++ b/src/views/SwapAndBridge/components/ChainTokenSelector/Modal.tsx @@ -74,6 +74,7 @@ export default function ChainTokenSelectorModal({ const displayedChains = useMemo(() => { return Object.fromEntries( Object.entries(crossChainRoutes.data || {}).filter(([chainId]) => { + // why ar we filtering out Boba? if ([288].includes(Number(chainId))) { return false; } diff --git a/src/views/SwapAndBridge/components/InputForm.tsx b/src/views/SwapAndBridge/components/InputForm.tsx index 18e9b501b..efec1fe2a 100644 --- a/src/views/SwapAndBridge/components/InputForm.tsx +++ b/src/views/SwapAndBridge/components/InputForm.tsx @@ -7,6 +7,7 @@ import styled from "@emotion/styled"; import { useCallback, useEffect, useMemo, useState } from "react"; import { BigNumber, utils } from "ethers"; import { ReactComponent as ArrowsCross } from "assets/icons/arrows-cross.svg"; +import { AmountInputError } from "../../Bridge/utils"; export const InputForm = ({ inputToken, @@ -100,6 +101,36 @@ const TokenInput = ({ }) => { const [amountString, setAmountString] = useState(""); const [justTyped, setJustTyped] = useState(false); + const [validationError, setValidationError] = useState( + undefined + ); + + const getValidationErrorText = useCallback( + (error?: AmountInputError) => { + if (!error || !token) return undefined; + const validationErrorTextMap: Record = { + [AmountInputError.INSUFFICIENT_BALANCE]: + "Insufficient balance to process this transfer.", + [AmountInputError.PAUSED_DEPOSITS]: + "[INPUT_TOKEN] deposits are temporarily paused.", + [AmountInputError.INSUFFICIENT_LIQUIDITY]: + "Input amount exceeds limits set to maintain optimal service for all users. Decrease amount to [MAX_DEPOSIT] or lower.", + [AmountInputError.INVALID]: + "Only positive numbers are allowed as an input.", + [AmountInputError.AMOUNT_TOO_LOW]: + "The amount you are trying to bridge is too low.", + [AmountInputError.PRICE_IMPACT_TOO_HIGH]: + "Price impact is too high. Check back later when liquidity is restored.", + [AmountInputError.SWAP_QUOTE_UNAVAILABLE]: + "Swap quote temporarily unavailable. Please try again later.", + }; + + return validationErrorTextMap[error] + .replace("[INPUT_TOKEN]", token.symbol) + .replace("[MAX_DEPOSIT]", ""); + }, + [token] + ); // Handle user input changes useEffect(() => { @@ -108,9 +139,41 @@ const TokenInput = ({ } setJustTyped(false); try { - setAmount(utils.parseUnits(amountString, token!.decimals)); + if (!token) { + setAmount(null); + setValidationError(undefined); + return; + } + const parsed = utils.parseUnits(amountString, token.decimals); + if (isOrigin) { + if (parsed.lt(0)) { + setValidationError(getValidationErrorText(AmountInputError.INVALID)); + setAmount(null); + return; + } + if (token.balance && parsed.gt(token.balance)) { + setValidationError( + getValidationErrorText(AmountInputError.INSUFFICIENT_BALANCE) + ); + } else { + setValidationError(undefined); + } + } else { + if (parsed.lt(0)) { + setValidationError(getValidationErrorText(AmountInputError.INVALID)); + setAmount(null); + return; + } + setValidationError(undefined); + } + setAmount(parsed); } catch (e) { setAmount(null); + if (amountString !== "") { + setValidationError(getValidationErrorText(AmountInputError.INVALID)); + } else { + setValidationError(undefined); + } } // eslint-disable-next-line react-hooks/exhaustive-deps }, [amountString]); @@ -132,6 +195,7 @@ const TokenInput = ({ setAmountString( formatUnitsWithMaxFractions(expectedAmount, token.decimals) ); + setValidationError(undefined); } // eslint-disable-next-line react-hooks/exhaustive-deps @@ -166,14 +230,22 @@ const TokenInput = ({ placeholder="0.00" value={amountString} onChange={(e) => { - setJustTyped(true); - setAmountString(e.target.value); + const value = e.target.value; + if (value === "" || /^\d*\.?\d*$/.test(value)) { + setJustTyped(true); + setAmountString(value); + } }} disabled={shouldUpdate && isUpdateLoading} /> {estimatedUsdAmount && <>Value: ~${estimatedUsdAmount}} + {validationError && ( + + {validationError} + + )} @@ -246,6 +327,15 @@ const TokenAmountInputEstimatedUsd = styled.div` line-height: 130%; `; +const TokenAmountInputValidationError = styled.div` + color: #f96c6c; + font-family: Barlow; + font-size: 12px; + font-weight: 400; + line-height: 130%; + margin-top: 4px; +`; + const TokenInputWrapper = styled.div` display: flex; height: 132px; diff --git a/src/views/SwapAndBridge/hooks/useSwapQuote.ts b/src/views/SwapAndBridge/hooks/useSwapQuote.ts index deb6bc1fc..e84854424 100644 --- a/src/views/SwapAndBridge/hooks/useSwapQuote.ts +++ b/src/views/SwapAndBridge/hooks/useSwapQuote.ts @@ -3,15 +3,15 @@ import axios from "axios"; import { BigNumber } from "ethers"; import { useConnection } from "hooks"; import { vercelApiBaseUrl } from "utils"; - -type TokenParam = { - address: string; - chainId: number; -}; +import { + SwapApiToken, + SwapApprovalApiQueryParams, + SwapApprovalApiResponse, +} from "utils/serverless-api/prod/swap-approval"; type SwapQuoteParams = { - origin: TokenParam | null; - destination: TokenParam | null; + origin: SwapApiToken | null; + destination: SwapApiToken | null; amount: BigNumber | null; isInputAmount: boolean; recipient?: string; @@ -21,33 +21,6 @@ type SwapQuoteParams = { slippageTolerance?: number; }; -type SwapTransaction = { - simulationSuccess: boolean; - chainId: number; - to: string; - data: string; - value?: string; - gas?: string; - maxFeePerGas: string; - maxPriorityFeePerGas: string; -}; - -type SwapQuoteResponse = { - checks: object; - steps: object; - refundToken: object; - inputAmount: string; - expectedOutputAmount: string; - minOutputAmount: string; - expectedFillTime: number; - swapTx: SwapTransaction; - approvalTxns: { - chainId: number; - data: string; - to: string; - }[]; -}; - const useSwapQuote = ({ origin, destination, @@ -70,31 +43,37 @@ const useSwapQuote = ({ depositor, recipient, ], - queryFn: async (): Promise => { + queryFn: async (): Promise => { + if (!origin || !destination || !amount || !depositor) { + throw new Error("Missing required swap quote parameters"); + } + + const params: SwapApprovalApiQueryParams = { + tradeType: isInputAmount ? "exactInput" : "minOutput", + inputToken: origin.address, + outputToken: destination.address, + originChainId: origin.chainId, + destinationChainId: destination.chainId, + depositor, + recipient: recipient || depositor, + amount: amount.toString(), + refundOnOrigin, + slippageTolerance, + ...(integratorId ? { integratorId } : {}), + ...(refundAddress ? { refundAddress } : {}), + }; + const { data } = await axios.get( `${vercelApiBaseUrl}/api/swap/approval`, { - params: { - tradeType: isInputAmount ? "exactInput" : "minOutput", - inputToken: origin?.address, - outputToken: destination?.address, - originChainId: origin?.chainId, - destinationChainId: destination?.chainId, - depositor, - recipient: recipient || depositor, - ...(integratorId && { integratorId }), - ...(refundAddress && { refundAddress }), - amount: amount?.toString(), - refundOnOrigin, - slippageTolerance, - }, + params, } ); return data; }, enabled: !!origin?.address && !!destination?.address && !!amount && !!depositor, - refetchInterval: 2_000, + refetchInterval: 5_000, }); return { data, isLoading, error }; diff --git a/src/views/SwapAndBridge/index.tsx b/src/views/SwapAndBridge/index.tsx index cb7b05434..8b81a4e94 100644 --- a/src/views/SwapAndBridge/index.tsx +++ b/src/views/SwapAndBridge/index.tsx @@ -1,70 +1,37 @@ import { LayoutV2 } from "components"; -import { EnrichedTokenSelect } from "./components/ChainTokenSelector/SelectorButton"; import styled from "@emotion/styled"; -import { useCallback, useState } from "react"; +import { useCallback } from "react"; import { InputForm } from "./components/InputForm"; -import { BigNumber } from "ethers"; import ConfirmationButton from "./components/ConfirmationButton"; -import useSwapQuote from "./hooks/useSwapQuote"; -import { useConnection } from "hooks"; import { useHistory } from "react-router-dom"; +import useSwapAndBridge from "./hooks/useSwapAndBridge"; export default function SwapAndBridge() { - const [inputToken, setInputToken] = useState( - null - ); - const [outputToken, setOutputToken] = useState( - null - ); - const [amount, setAmount] = useState(null); - const [isAmountOrigin, setIsAmountOrigin] = useState(true); - const { signer } = useConnection(); + const { + inputToken, + outputToken, + setInputToken, + setOutputToken, + amount, + setAmount, + isAmountOrigin, + setIsAmountOrigin, + swapQuote, + isQuoteLoading, + expectedInputAmount, + expectedOutputAmount, + onConfirm, + } = useSwapAndBridge(); const history = useHistory(); - const { data: swapData, isLoading: isUpdateLoading } = useSwapQuote({ - origin: inputToken - ? { - address: inputToken.address, - chainId: inputToken.chainId, - } - : null, - destination: outputToken - ? { - address: outputToken.address, - chainId: outputToken.chainId, - } - : null, - amount: amount, - isInputAmount: isAmountOrigin, - }); - // Handle confirmation (placeholder for now) const handleConfirm = useCallback(async () => { - if (!swapData || !signer) { - return; - } - - if (swapData.approvalTxns?.length > 0) { - for (const approvalTxn of swapData.approvalTxns) { - await signer.sendTransaction({ - data: approvalTxn.data, - to: approvalTxn.to, - chainId: approvalTxn.chainId, - }); - } - } - - const tx = await signer.sendTransaction({ - data: swapData.swapTx.data, - to: swapData.swapTx.to, - chainId: swapData.swapTx.chainId, - }); - + const txHash = await onConfirm(); history.push( - `/bridge/${tx.hash}?originChainId=${inputToken?.chainId}&destinationChainId=${outputToken?.chainId}&inputTokenSymbol=${inputToken?.symbol}&outputTokenSymbol=${outputToken?.symbol}&referrer=` + `/bridge/${txHash}?originChainId=${inputToken?.chainId}&destinationChainId=${outputToken?.chainId}&inputTokenSymbol=${inputToken?.symbol}&outputTokenSymbol=${outputToken?.symbol}&referrer=` ); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [swapData, signer]); + }, [onConfirm, inputToken, outputToken]); return ( @@ -77,16 +44,16 @@ export default function SwapAndBridge() { setAmount={setAmount} isAmountOrigin={isAmountOrigin} setIsAmountOrigin={setIsAmountOrigin} - isQuoteLoading={isUpdateLoading} - expectedOutputAmount={swapData?.expectedOutputAmount} - expectedInputAmount={swapData?.inputAmount} + isQuoteLoading={isQuoteLoading} + expectedOutputAmount={expectedOutputAmount} + expectedInputAmount={expectedInputAmount} /> From 32401c39e4a073ee0bb436e2f6447d9546e9d9f4 Mon Sep 17 00:00:00 2001 From: Gerhard Steenkamp Date: Sat, 27 Sep 2025 09:58:49 +0200 Subject: [PATCH 06/39] centralize navigation links Signed-off-by: Gerhard Steenkamp --- src/Routes.tsx | 22 +++++---- src/components/Header/Header.tsx | 11 +---- .../Sidebar/components/NavigationContent.tsx | 45 ++++++++----------- 3 files changed, 34 insertions(+), 44 deletions(-) diff --git a/src/Routes.tsx b/src/Routes.tsx index a7943530a..99ef012b7 100644 --- a/src/Routes.tsx +++ b/src/Routes.tsx @@ -8,14 +8,9 @@ import { } from "react-router-dom"; import { Header, Sidebar } from "components"; import { useConnection, useError } from "hooks"; -import { - enableMigration, - stringValueInArray, - getConfig, - chainEndpointToId, -} from "utils"; +import { stringValueInArray, getConfig, chainEndpointToId } from "utils"; import lazyWithRetry from "utils/lazy-with-retry"; - +import { enableMigration } from "utils"; import Toast from "components/Toast"; import BouncingDotsLoader from "components/BouncingDotsLoader"; import NotFound from "./views/NotFound"; @@ -23,6 +18,15 @@ import ScrollToTop from "components/ScrollToTop"; import { AmpliTrace } from "components/AmpliTrace"; import Banners from "components/Banners"; +export const NAVIGATION_LINKS = !enableMigration + ? [ + { href: "/bridge-and-swap", name: "Bridge & Swap" }, + { href: "/pool", name: "Pool" }, + { href: "/rewards", name: "Rewards" }, + { href: "/transactions", name: "Transactions" }, + ] + : []; + const LiquidityPool = lazyWithRetry( () => import(/* webpackChunkName: "LiquidityPools" */ "./views/LiquidityPool") ); @@ -140,13 +144,13 @@ const Routes: React.FC = () => { } }} /> - + diff --git a/src/components/Header/Header.tsx b/src/components/Header/Header.tsx index 3ab13f5ef..051bfb9f9 100644 --- a/src/components/Header/Header.tsx +++ b/src/components/Header/Header.tsx @@ -13,19 +13,12 @@ import { StyledLogo, } from "./Header.styles"; import MenuToggle from "./MenuToggle"; -import { enableMigration } from "utils"; import useScrollPosition from "hooks/useScrollPosition"; import { isChildPath } from "./utils"; import { useSidebarContext } from "hooks/useSidebarContext"; +import { NAVIGATION_LINKS } from "Routes"; -const LINKS = !enableMigration - ? [ - { href: "/bridge", name: "Bridge" }, - { href: "/pool", name: "Pool" }, - { href: "/rewards", name: "Rewards" }, - { href: "/transactions", name: "Transactions" }, - ] - : []; +export const LINKS = NAVIGATION_LINKS; interface Props { transparentHeader?: boolean; diff --git a/src/components/Sidebar/components/NavigationContent.tsx b/src/components/Sidebar/components/NavigationContent.tsx index a39d13000..ece53394f 100644 --- a/src/components/Sidebar/components/NavigationContent.tsx +++ b/src/components/Sidebar/components/NavigationContent.tsx @@ -5,27 +5,20 @@ import { useSidebarContext } from "hooks/useSidebarContext"; import { AccountContent } from "./AccountContent"; import { SidebarItem } from "./SidebarItem"; import { TermsOfServiceDisclaimer } from "./TermsOfServiceDisclaimer"; +import { NAVIGATION_LINKS } from "Routes"; -const sidebarNavigationLinks = [ - { - pathName: "/bridge", - title: "Bridge", - }, - { - pathName: "/pool", - title: "Pool", - }, - { - pathName: "/rewards", - title: "Rewards", - }, - { - pathName: "/transactions", - title: "Transactions", - }, +type NavigationLInk = { + href: string; + name: string; + isExternalLink?: boolean; + rightIcon?: React.ReactNode; +}; + +const sidebarNavigationLinks: NavigationLInk[] = [ + ...NAVIGATION_LINKS, { - pathName: "https://docs.across.to/", - title: "Docs", + href: "https://docs.across.to/", + name: "Docs", isExternalLink: true, rightIcon: , }, @@ -75,20 +68,20 @@ export function NavigationContent() { <> {sidebarNavigationLinks.map((item) => - item.isExternalLink ? ( + item?.isExternalLink ? ( ) : ( ) )} From 37398788ef32cb10d4a967968fd404c56946aaf3 Mon Sep 17 00:00:00 2001 From: Gerhard Steenkamp Date: Sat, 27 Sep 2025 12:09:22 +0200 Subject: [PATCH 07/39] style selectorButton Signed-off-by: Gerhard Steenkamp --- .../ChainTokenSelector/SelectorButton.tsx | 84 +++++++++---------- .../SwapAndBridge/components/InputForm.tsx | 22 +++-- 2 files changed, 48 insertions(+), 58 deletions(-) diff --git a/src/views/SwapAndBridge/components/ChainTokenSelector/SelectorButton.tsx b/src/views/SwapAndBridge/components/ChainTokenSelector/SelectorButton.tsx index 1909ce16f..c806cb3db 100644 --- a/src/views/SwapAndBridge/components/ChainTokenSelector/SelectorButton.tsx +++ b/src/views/SwapAndBridge/components/ChainTokenSelector/SelectorButton.tsx @@ -2,7 +2,6 @@ import styled from "@emotion/styled"; import { BigNumber } from "ethers"; import { useCallback, useEffect, useState } from "react"; import { COLORS, getChainInfo } from "utils"; -import TokenMask from "assets/mask/token-mask.svg"; import { ReactComponent as ChevronDownIcon } from "assets/icons/chevron-down.svg"; import ChainTokenSelectorModal from "./Modal"; @@ -24,13 +23,14 @@ type Props = { onSelect?: (token: EnrichedTokenSelect) => void; isOriginToken: boolean; marginBottom?: string; + className?: string; }; export default function SelectorButton({ onSelect, selectedToken, isOriginToken, - marginBottom, + className, }: Props) { const [displayModal, setDisplayModal] = useState(false); @@ -52,18 +52,15 @@ export default function SelectorButton({ if (!selectedToken) { return ( <> - setDisplayModal(true)} - marginBottom={marginBottom} - > + setDisplayModal(true)}> - Select a token + Select token - + - setDisplayModal(true)} - marginBottom={marginBottom} - > + setDisplayModal(true)}> + {selectedToken.symbol} {chain.name} @@ -105,36 +100,28 @@ export default function SelectorButton({ ); } -const Wrapper = styled.div<{ marginBottom?: string }>` +const Wrapper = styled.div` + --height: 48px; + --padding: 8px; + height: var(--height); + position: relative; display: flex; flex-direction: row; justify-content: space-between; - margin-bottom: ${({ marginBottom }) => marginBottom || "0"}; - - border-radius: 8px; - border: 1px solid #3f4247; - background: #e0f3ff0d; - padding: 8px 12px; - height: 64px; - - gap: 12px; - width: 184px; + border-radius: 12px; + border: 1px solid rgba(224, 243, 255, 0.05); + background: rgba(224, 243, 255, 0.05); cursor: pointer; `; -const SelectWrapper = styled(Wrapper)` - height: 48px; -`; - const VerticalDivider = styled.div` width: 1px; - height: calc(100% + 16px); + height: calc(100% - (var(--padding) * 2)); + margin-top: var(--padding); - margin-top: -8px; - - background: #3f4247; + background: rgba(224, 243, 255, 0.05); `; const ChevronStack = styled.div` @@ -142,13 +129,15 @@ const ChevronStack = styled.div` align-items: center; justify-content: center; height: 100%; + width: var(--height); `; const NamesStack = styled.div` display: flex; flex-direction: column; - gap: 6px; - + gap: 2px; + padding-inline: var(--padding); + white-space: nowrap; height: 100%; flex-grow: 1; @@ -174,33 +163,36 @@ const ChainName = styled.div` line-height: 12px; font-weight: 400; color: #e0f3ff; + opacity: 0.5; `; const TokenStack = styled.div` - width: 32px; - height: 48px; + height: 100%; + width: var(--height); + padding-inline: var(--padding); position: relative; - flex-grow: 0; `; const TokenImg = styled.img` + border-radius: 50%; position: absolute; - top: 0; - left: 0; - width: 32px; - height: 32px; + top: var(--padding); + left: var(--padding); + width: calc(var(--height) * 0.66); + height: calc(var(--height) * 0.66); z-index: 1; - - mask: url(${TokenMask}) no-repeat center center; `; const ChainImg = styled.img` + border-radius: 50%; + border: 1px solid transparent; + background: ${COLORS["grey-600"]}; position: absolute; - bottom: 0; - left: 4.5px; - width: 24px; - height: 24px; + bottom: calc(var(--padding) / 2); + right: calc(var(--padding) / 2); + width: 30%; + height: 30%; z-index: 2; `; diff --git a/src/views/SwapAndBridge/components/InputForm.tsx b/src/views/SwapAndBridge/components/InputForm.tsx index efec1fe2a..cb5ecbae0 100644 --- a/src/views/SwapAndBridge/components/InputForm.tsx +++ b/src/views/SwapAndBridge/components/InputForm.tsx @@ -224,7 +224,7 @@ const TokenInput = ({ - {isOrigin ? "Sell" : "Buy"} + {isOrigin ? "From" : "To"} COLORS.aqua}; + color: ${COLORS.aqua}; font-size: 16px; - font-weight: 400; + font-weight: 500; line-height: 130%; `; -const TokenAmountInput = styled.input` - color: #e0f3ff; +const TokenAmountInput = styled.input<{ value: string }>` font-family: Barlow; font-size: 48px; font-weight: 300; line-height: 120%; letter-spacing: -1.92px; + width: 100%; + color: ${(value) => (value ? COLORS.aqua : COLORS["light-200"])}; outline: none; border: none; @@ -320,7 +321,7 @@ const TokenAmountInput = styled.input` `; const TokenAmountInputEstimatedUsd = styled.div` - color: #e0f3ff; + color: ${COLORS["light-200"]}; font-family: Barlow; font-size: 14px; font-weight: 400; @@ -344,8 +345,7 @@ const TokenInputWrapper = styled.div` align-items: center; align-self: stretch; border-radius: 12px; - border: 1px solid rgba(224, 243, 255, 0.05); - background: #2d2e32; + background: transparent; position: relative; `; @@ -357,7 +357,6 @@ const BalanceSelectorWrapper = styled.div` const Wrapper = styled.div` position: relative; - display: flex; flex-direction: column; align-items: flex-start; @@ -366,8 +365,7 @@ const Wrapper = styled.div` align-self: stretch; padding: 12px; border-radius: 24px; - border: 1px solid rgba(224, 243, 255, 0.05); - background: #34353b; + background: ${COLORS["black-700"]}; box-shadow: 0px 2px 4px 0px rgba(0, 0, 0, 0.08); `; @@ -382,7 +380,7 @@ const QuickSwapButton = styled.button` gap: 8px; border-radius: 32px; border: 1px solid #4c4e57; - background: #34353b; + background: ${COLORS["black-700"]}; box-shadow: 0px 2px 4px 0px rgba(0, 0, 0, 0.08); cursor: pointer; From d66a0daa4767c56ad36087578b65ef838a55b879 Mon Sep 17 00:00:00 2001 From: Gerhard Steenkamp Date: Sat, 27 Sep 2025 12:29:33 +0200 Subject: [PATCH 08/39] style input form Signed-off-by: Gerhard Steenkamp --- .../SwapAndBridge/components/InputForm.tsx | 34 +++++++++++++------ 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/src/views/SwapAndBridge/components/InputForm.tsx b/src/views/SwapAndBridge/components/InputForm.tsx index cb5ecbae0..592e2d8cc 100644 --- a/src/views/SwapAndBridge/components/InputForm.tsx +++ b/src/views/SwapAndBridge/components/InputForm.tsx @@ -239,7 +239,10 @@ const TokenInput = ({ disabled={shouldUpdate && isUpdateLoading} /> - {estimatedUsdAmount && <>Value: ~${estimatedUsdAmount}} + + {" "} + Value: ${estimatedUsdAmount ?? "0.00"} + {validationError && ( @@ -283,6 +286,18 @@ const TokenInput = ({ ); }; +const ValueRow = styled.div` + font-size: 16px; + span { + margin-left: 4px; + } + span, + svg { + display: inline-block; + vertical-align: middle; + } +`; + const TokenAmountStack = styled.div` display: flex; flex-direction: column; @@ -326,27 +341,30 @@ const TokenAmountInputEstimatedUsd = styled.div` font-size: 14px; font-weight: 400; line-height: 130%; + opacity: 0.5; `; const TokenAmountInputValidationError = styled.div` color: #f96c6c; font-family: Barlow; font-size: 12px; - font-weight: 400; + font-weight: 600; line-height: 130%; margin-top: 4px; `; const TokenInputWrapper = styled.div` display: flex; - height: 132px; - padding: 16px; + min-height: 148px; justify-content: space-between; align-items: center; align-self: stretch; - border-radius: 12px; background: transparent; position: relative; + padding: 24px; + border-radius: 24px; + background: ${COLORS["black-700"]}; + box-shadow: 0px 2px 4px 0px rgba(0, 0, 0, 0.08); `; const BalanceSelectorWrapper = styled.div` @@ -361,12 +379,8 @@ const Wrapper = styled.div` flex-direction: column; align-items: flex-start; justify-content: center; - gap: 12px; + gap: 8px; align-self: stretch; - padding: 12px; - border-radius: 24px; - background: ${COLORS["black-700"]}; - box-shadow: 0px 2px 4px 0px rgba(0, 0, 0, 0.08); `; const QuickSwapButton = styled.button` From 62d8fb4e69c127752c4f77b068bf098f85ed1792 Mon Sep 17 00:00:00 2001 From: Gerhard Steenkamp Date: Sat, 27 Sep 2025 13:44:11 +0200 Subject: [PATCH 09/39] style balance selector Signed-off-by: Gerhard Steenkamp --- .../components/BalanceSelector.tsx | 22 +++++++++--- .../SwapAndBridge/components/InputForm.tsx | 34 ++++++++++--------- 2 files changed, 36 insertions(+), 20 deletions(-) diff --git a/src/views/SwapAndBridge/components/BalanceSelector.tsx b/src/views/SwapAndBridge/components/BalanceSelector.tsx index 89ebfc280..11e24228a 100644 --- a/src/views/SwapAndBridge/components/BalanceSelector.tsx +++ b/src/views/SwapAndBridge/components/BalanceSelector.tsx @@ -81,7 +81,9 @@ export default function BalanceSelector({ ))} - Balance: {formattedBalance} + + Balance: {formattedBalance} + ); } @@ -91,19 +93,30 @@ const BalanceWrapper = styled.div` align-items: center; gap: 12px; margin-top: 8px; + position: relative; + justify-content: flex-end; + margin-left: auto; `; -const BalanceText = styled.span` - color: ${() => COLORS.aqua}; +const BalanceText = styled.div` + color: ${COLORS.white}; + opacity: 1; font-size: 14px; font-weight: 400; line-height: 130%; + + span { + opacity: 0.5; + } `; const PillsContainer = styled.div` + --spacing: 4px; display: flex; align-items: center; - gap: 4px; + gap: var(--spacing); + position: absolute; + right: calc(100% + (var(--spacing) * 2)); .pill { display: flex; @@ -114,6 +127,7 @@ const PillsContainer = styled.div` justify-content: center; font-size: 12px; font-weight: 600; + border: 1px solid rgba(224, 243, 255, 0.5); background-color: rgba(224, 243, 255, 0.05); color: rgba(224, 243, 255, 0.5); cursor: pointer; diff --git a/src/views/SwapAndBridge/components/InputForm.tsx b/src/views/SwapAndBridge/components/InputForm.tsx index 592e2d8cc..f6e2b0db6 100644 --- a/src/views/SwapAndBridge/components/InputForm.tsx +++ b/src/views/SwapAndBridge/components/InputForm.tsx @@ -250,14 +250,15 @@ const TokenInput = ({ )} - - {token && ( - + + + + {token && ( - - )} + )} + ); }; +const TokenSelectorColumn = styled.div` + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 12px; +`; + const ValueRow = styled.div` font-size: 16px; span { @@ -367,12 +375,6 @@ const TokenInputWrapper = styled.div` box-shadow: 0px 2px 4px 0px rgba(0, 0, 0, 0.08); `; -const BalanceSelectorWrapper = styled.div` - position: absolute; - bottom: 16px; - right: 16px; -`; - const Wrapper = styled.div` position: relative; display: flex; From 23181f334392b123f45e29e58d9e6676b656527a Mon Sep 17 00:00:00 2001 From: Gerhard Steenkamp Date: Sat, 27 Sep 2025 17:04:45 +0200 Subject: [PATCH 10/39] use swap quote fees Signed-off-by: Gerhard Steenkamp --- .../ChainTokenSelector/SelectorButton.tsx | 5 + .../components/ConfirmationButton.tsx | 134 ++++++++++++------ 2 files changed, 95 insertions(+), 44 deletions(-) diff --git a/src/views/SwapAndBridge/components/ChainTokenSelector/SelectorButton.tsx b/src/views/SwapAndBridge/components/ChainTokenSelector/SelectorButton.tsx index c806cb3db..0f05ab9d5 100644 --- a/src/views/SwapAndBridge/components/ChainTokenSelector/SelectorButton.tsx +++ b/src/views/SwapAndBridge/components/ChainTokenSelector/SelectorButton.tsx @@ -114,6 +114,10 @@ const Wrapper = styled.div` background: rgba(224, 243, 255, 0.05); cursor: pointer; + + &:hover { + background: rgba(224, 243, 255, 0.1); + } `; const VerticalDivider = styled.div` @@ -156,6 +160,7 @@ const TokenName = styled.div` const SelectTokenName = styled(TokenName)` color: ${COLORS["aqua"]}; + padding-inline: 8px; `; const ChainName = styled.div` diff --git a/src/views/SwapAndBridge/components/ConfirmationButton.tsx b/src/views/SwapAndBridge/components/ConfirmationButton.tsx index 2e5a4e69d..4f49a326a 100644 --- a/src/views/SwapAndBridge/components/ConfirmationButton.tsx +++ b/src/views/SwapAndBridge/components/ConfirmationButton.tsx @@ -5,7 +5,8 @@ import { ReactComponent as LoadingIcon } from "assets/icons/loading.svg"; import React from "react"; import { motion, AnimatePresence } from "framer-motion"; import { BigNumber } from "ethers"; -import { COLORS } from "utils"; +import { COLORS, formatUSD, getConfig } from "utils"; +import { useTokenConversion } from "hooks/useTokenConversion"; import { useConnection, useIsWrongNetwork } from "hooks"; import { EnrichedTokenSelect } from "./ChainTokenSelector/SelectorButton"; import styled from "@emotion/styled"; @@ -215,42 +216,102 @@ export const ConfirmationButton: React.FC = ({ const state = getButtonState(); // Calculate display values from swapQuote + // Resolve conversion helpers outside memo to respect hooks rules + const bridgeTokenSymbol = + (swapQuote as any)?.steps?.bridge?.tokenOut?.symbol || + outputToken?.symbol || + "ETH"; + const destinationNativeSymbol = getConfig().getNativeTokenInfo( + outputToken?.chainId || 1 + ).symbol; + const { convertTokenToBaseCurrency: convertInputTokenToUsd } = + useTokenConversion(inputToken?.symbol || "ETH", "usd"); + const { convertTokenToBaseCurrency: convertBridgeTokenToUsd } = + useTokenConversion(bridgeTokenSymbol, "usd"); + const { convertTokenToBaseCurrency: convertDestinationNativeToUsd } = + useTokenConversion(destinationNativeSymbol, "usd"); + const displayValues = React.useMemo(() => { + const toBN = (v: any) => { + try { + return BigNumber.from(v ?? 0); + } catch { + return BigNumber.from(0); + } + }; + + const formatUsdString = (v?: BigNumber) => { + if (!v) return "-"; + try { + return `$${formatUSD(v)}`; + } catch { + return "-"; + } + }; + if (!swapQuote || !inputToken || !outputToken) { return { - fee: "$0.05", - time: "~2 min", - bridgeFee: "$0.01", - destinationGasFee: "$0", - extraFee: "$0.04", + fee: "-", + time: "-", + bridgeFee: "-", + destinationGasFee: "-", + extraFee: "-", route: "Across V4", - estimatedTime: "~2 secs", - netFee: "$0.05", + estimatedTime: "-", + netFee: "-", }; } - // Calculate fees based on swapQuote data - // This is a placeholder - you'd calculate actual fees from the quote - const bridgeFee = "$0.01"; - const destinationGasFee = "$0"; - const extraFee = "$0.04"; - const netFee = "$0.05"; + const fees = (swapQuote as any)?.steps?.bridge?.fees || {}; + const relayerCapitalTotal = toBN(fees?.relayerCapital?.total); + const lpTotal = toBN(fees?.lp?.total); + const relayerGasTotal = toBN(fees?.relayerGas?.total); + + // Convert components to USD + const bridgeFeeTokenAmount = relayerCapitalTotal.add(lpTotal); + const bridgeFeeUsd = convertBridgeTokenToUsd(bridgeFeeTokenAmount); + const gasFeeUsd = convertDestinationNativeToUsd(relayerGasTotal); + + // Approximate swap fee in USD if we have user input and bridge input + const bridgeInputAmount = toBN( + (swapQuote as any)?.steps?.bridge?.inputAmount + ); + const inputAmountUsd = convertInputTokenToUsd(amount ?? BigNumber.from(0)); + const bridgeInputUsd = convertBridgeTokenToUsd(bridgeInputAmount); + const swapFeeUsd = + inputAmountUsd && bridgeInputUsd && inputAmountUsd.gt(bridgeInputUsd) + ? inputAmountUsd.sub(bridgeInputUsd) + : BigNumber.from(0); + + const netFeeUsd = (bridgeFeeUsd || BigNumber.from(0)) + .add(gasFeeUsd || BigNumber.from(0)) + .add(swapFeeUsd || BigNumber.from(0)); // Format time from expectedFillTime (in seconds) - const timeInMinutes = Math.ceil(swapQuote.expectedFillTime / 60); + const timeInMinutes = Math.ceil( + ((swapQuote as any).expectedFillTime || 0) / 60 + ); const time = timeInMinutes < 1 ? "~30 sec" : `~${timeInMinutes} min`; return { - fee: netFee, + fee: formatUsdString(netFeeUsd), time, - bridgeFee, - destinationGasFee, - extraFee, + bridgeFee: formatUsdString(bridgeFeeUsd), + destinationGasFee: formatUsdString(gasFeeUsd), + extraFee: formatUsdString(swapFeeUsd), route: "Across V4", estimatedTime: timeInMinutes < 1 ? "~30 secs" : `~${timeInMinutes} mins`, - netFee, + netFee: formatUsdString(netFeeUsd), }; - }, [swapQuote, inputToken, outputToken]); + }, [ + swapQuote, + inputToken, + outputToken, + amount, + convertInputTokenToUsd, + convertBridgeTokenToUsd, + convertDestinationNativeToUsd, + ]); // Handle confirmation const handleConfirm = async () => { @@ -266,12 +327,6 @@ export const ConfirmationButton: React.FC = ({ } }; - // Compute target height based on state and expansion - let targetHeight = 88; - if (state === "readyToConfirm") { - targetHeight = expanded ? 300 : 128; - } - // Render state-specific content let content: React.ReactNode = null; switch (state) { @@ -281,14 +336,10 @@ export const ConfirmationButton: React.FC = ({ = ({ return ( {content} @@ -663,7 +709,7 @@ const StyledChevronDown = styled(ChevronDownIcon)<{ expanded: boolean }>` const ExpandableContent = styled.div<{ expanded: boolean }>` overflow: hidden; transition: all 0.3s ease; - max-height: ${({ expanded }) => (expanded ? "160px" : "0")}; + max-height: ${({ expanded }) => (expanded ? "500px" : "0")}; margin-top: ${({ expanded }) => (expanded ? "8px" : "0")}; `; @@ -673,7 +719,7 @@ const StyledButton = styled.button<{ fullHeight?: boolean; }>` width: 100%; - height: ${({ fullHeight }) => (fullHeight ? "100%" : "64px")}; + height: 64px; border-radius: 12px; font-weight: 600; font-size: 16px; From 22cacbfce958580ba64f2ed2628798956105fc66 Mon Sep 17 00:00:00 2001 From: Gerhard Steenkamp Date: Sat, 27 Sep 2025 18:00:07 +0200 Subject: [PATCH 11/39] better button states Signed-off-by: Gerhard Steenkamp --- src/assets/icons/loading-2.svg | 6 + .../components/ConfirmationButton.tsx | 183 +++++++----------- 2 files changed, 75 insertions(+), 114 deletions(-) create mode 100644 src/assets/icons/loading-2.svg diff --git a/src/assets/icons/loading-2.svg b/src/assets/icons/loading-2.svg new file mode 100644 index 000000000..547982fb3 --- /dev/null +++ b/src/assets/icons/loading-2.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/views/SwapAndBridge/components/ConfirmationButton.tsx b/src/views/SwapAndBridge/components/ConfirmationButton.tsx index 4f49a326a..a9d1b80bb 100644 --- a/src/views/SwapAndBridge/components/ConfirmationButton.tsx +++ b/src/views/SwapAndBridge/components/ConfirmationButton.tsx @@ -1,7 +1,9 @@ "use client"; import { ButtonHTMLAttributes } from "react"; import { ReactComponent as ChevronDownIcon } from "assets/icons/chevron-down.svg"; -import { ReactComponent as LoadingIcon } from "assets/icons/loading.svg"; +import { ReactComponent as LoadingIcon } from "assets/icons/loading-2.svg"; +import { ReactComponent as Info } from "assets/icons/info.svg"; +import { ReactComponent as Wallet } from "assets/icons/wallet.svg"; import React from "react"; import { motion, AnimatePresence } from "framer-motion"; import { BigNumber } from "ethers"; @@ -28,7 +30,8 @@ export type BridgeButtonState = | "awaitingAmountInput" | "readyToConfirm" | "submitting" - | "wrongNetwork"; + | "wrongNetwork" + | "loadingQuote"; interface ConfirmationButtonProps extends ButtonHTMLAttributes { @@ -40,15 +43,6 @@ interface ConfirmationButtonProps onConfirm?: () => void; } -const stateLabels: Record = { - notConnected: "Connect Wallet", - awaitingTokenSelection: "Select Token", - awaitingAmountInput: "Input Amount", - readyToConfirm: "Confirm Swap", - submitting: "Submitting...", - wrongNetwork: "Switch Network", -}; - // Expandable label section component const ExpandableLabelSection: React.FC< React.PropsWithChildren<{ @@ -146,7 +140,7 @@ const ExpandableLabelSection: React.FC< // Core button component, used by all states const ButtonCore: React.FC< ConfirmationButtonProps & { - label: string; + label: React.ReactNode; loading?: boolean; aqua?: boolean; state: BridgeButtonState; @@ -171,16 +165,13 @@ const ButtonCore: React.FC< > - - {loading && } - {!loading && label} - + {label} @@ -210,6 +201,7 @@ export const ConfirmationButton: React.FC = ({ if (!inputToken || !outputToken) return "awaitingTokenSelection"; if (!amount || amount.lte(0)) return "awaitingAmountInput"; if (isWrongNetwork) return "wrongNetwork"; + if (isQuoteLoading) return "loadingQuote"; return "readyToConfirm"; }; @@ -287,11 +279,11 @@ export const ConfirmationButton: React.FC = ({ .add(gasFeeUsd || BigNumber.from(0)) .add(swapFeeUsd || BigNumber.from(0)); - // Format time from expectedFillTime (in seconds) - const timeInMinutes = Math.ceil( - ((swapQuote as any).expectedFillTime || 0) / 60 - ); - const time = timeInMinutes < 1 ? "~30 sec" : `~${timeInMinutes} min`; + const totalSeconds = Math.max(0, Number(swapQuote.expectedFillTime || 0)); + const underOneMinute = totalSeconds < 60; + const time = underOneMinute + ? `~${Math.max(1, Math.round(totalSeconds))} secs` + : `~${Math.ceil(totalSeconds / 60)} min`; return { fee: formatUsdString(netFeeUsd), @@ -300,7 +292,7 @@ export const ConfirmationButton: React.FC = ({ destinationGasFee: formatUsdString(gasFeeUsd), extraFee: formatUsdString(swapFeeUsd), route: "Across V4", - estimatedTime: timeInMinutes < 1 ? "~30 secs" : `~${timeInMinutes} mins`, + estimatedTime: time, netFee: formatUsdString(netFeeUsd), }; }, [ @@ -327,6 +319,26 @@ export const ConfirmationButton: React.FC = ({ } }; + const stateLabels: Record = { + notConnected: ( + <> + + Connect Wallet + + ), + awaitingTokenSelection: "Select Token", + awaitingAmountInput: "Input Amount", + readyToConfirm: "Confirm Swap", + submitting: "Submitting...", + wrongNetwork: "Switch Network", + loadingQuote: ( + <> + + Finalizing Quote + + ), + }; + // Render state-specific content let content: React.ReactNode = null; switch (state) { @@ -377,97 +389,16 @@ export const ConfirmationButton: React.FC = ({ - - - - - - - + Est. Time {displayValues.estimatedTime} - - - - - - - + Net Fee - - - - - - - + {displayValues.netFee} @@ -588,7 +519,25 @@ export const ConfirmationButton: React.FC = ({ + ); + break; + case "loadingQuote": + content = ( + = ({ return ( @@ -708,7 +656,9 @@ const StyledChevronDown = styled(ChevronDownIcon)<{ expanded: boolean }>` const ExpandableContent = styled.div<{ expanded: boolean }>` overflow: hidden; - transition: all 0.3s ease; + transition: + max-height 0.3s ease, + margin-top 0.3s ease; max-height: ${({ expanded }) => (expanded ? "500px" : "0")}; margin-top: ${({ expanded }) => (expanded ? "8px" : "0")}; `; @@ -756,11 +706,11 @@ const ButtonContent = styled.span` gap: 8px; `; -const StyledLoadingIcon = styled(LoadingIcon)<{ aqua?: boolean }>` +const StyledLoadingIcon = styled(LoadingIcon)` width: 16px; height: 16px; animation: spin 1s linear infinite; - color: ${({ aqua }) => (aqua ? COLORS.aqua : "#000000")}; + color: inherit; @keyframes spin { from { @@ -773,7 +723,7 @@ const StyledLoadingIcon = styled(LoadingIcon)<{ aqua?: boolean }>` `; const ButtonContainer = styled.div<{ expanded: boolean }>` - margin-top: ${({ expanded }) => (expanded ? "24px" : "0")}; + flex: 0 0 auto; `; const ExpandedDetails = styled.div` @@ -807,7 +757,7 @@ const RouteIcon = styled.svg` height: 20px; `; -const InfoIcon = styled.svg` +const InfoIconSvg = styled.svg` width: 20px; height: 20px; `; @@ -842,4 +792,9 @@ const FeeBreakdownValue = styled.span` color: #e0f3ff; `; +const SmallInfoIcon = styled(Info)` + width: 16px; + height: 16px; +`; + export default ConfirmationButton; From 8911b7baaf9e04d4e417a07def116cb1db8fc366 Mon Sep 17 00:00:00 2001 From: Gerhard Steenkamp Date: Sun, 28 Sep 2025 13:40:45 +0200 Subject: [PATCH 12/39] refactor Signed-off-by: Gerhard Steenkamp --- .../components/ConfirmationButton.tsx | 410 +++++++++--------- 1 file changed, 199 insertions(+), 211 deletions(-) diff --git a/src/views/SwapAndBridge/components/ConfirmationButton.tsx b/src/views/SwapAndBridge/components/ConfirmationButton.tsx index a9d1b80bb..492fb46e1 100644 --- a/src/views/SwapAndBridge/components/ConfirmationButton.tsx +++ b/src/views/SwapAndBridge/components/ConfirmationButton.tsx @@ -12,6 +12,7 @@ import { useTokenConversion } from "hooks/useTokenConversion"; import { useConnection, useIsWrongNetwork } from "hooks"; import { EnrichedTokenSelect } from "./ChainTokenSelector/SelectorButton"; import styled from "@emotion/styled"; +import { AmountInputError } from "../../Bridge/utils"; type SwapQuoteResponse = { checks: object; @@ -41,6 +42,8 @@ interface ConfirmationButtonProps swapQuote: SwapQuoteResponse | null; isQuoteLoading: boolean; onConfirm?: () => void; + validationError?: AmountInputError; + validationWarning?: AmountInputError; } // Expandable label section component @@ -51,88 +54,168 @@ const ExpandableLabelSection: React.FC< expanded: boolean; onToggle: () => void; visible: boolean; + state: BridgeButtonState; + validationError?: AmountInputError; + validationWarning?: AmountInputError; }> -> = ({ fee, time, expanded, onToggle, visible, children }) => { - return ( - - {visible && ( - - - - = ({ fee, time, expanded, onToggle, state, children }) => { + // Render state-specific content + let content: React.ReactNode = null; + switch (state) { + case "notConnected": + content = ( + <> + + + + + Fast & Secure + + + + - - - Fast & Secure - - - - - - - - - {fee} - - - - - - - - - {time} - - - - - - {expanded && children} - - - )} + + + + + {fee} + + + + + + + + + {time} + + + + ); + break; + + case "readyToConfirm": + content = ( + <> + + + + + Fast & Secure + + + + + + + + + {fee} + + + + + + + + + {time} + + + + + ); + break; + default: + break; + } + return ( + + + + {content} + + + {expanded && children} + + ); }; @@ -142,24 +225,14 @@ const ButtonCore: React.FC< ConfirmationButtonProps & { label: React.ReactNode; loading?: boolean; - aqua?: boolean; state: BridgeButtonState; fullHeight?: boolean; } -> = ({ - label, - loading, - disabled, - aqua, - state, - onConfirm, - onClick, - fullHeight, -}) => ( +> = ({ label, loading, disabled, state, onConfirm, onClick, fullHeight }) => ( @@ -184,6 +257,8 @@ export const ConfirmationButton: React.FC = ({ swapQuote, isQuoteLoading, onConfirm, + validationError, + validationWarning, ...props }) => { const { account, connect } = useConnection(); @@ -339,12 +414,28 @@ export const ConfirmationButton: React.FC = ({ ), }; - // Render state-specific content - let content: React.ReactNode = null; - switch (state) { - case "readyToConfirm": - content = ( - <> + // Map visual and behavior from state + const isExpandable = state === "readyToConfirm"; + const buttonLabel = stateLabels[state]; + const buttonLoading = state === "loadingQuote" || state === "submitting"; + const buttonDisabled = + state === "awaitingTokenSelection" || + state === "awaitingAmountInput" || + state === "loadingQuote" || + state === "submitting"; + + const clickHandler = + state === "notConnected" + ? () => connect() + : state === "wrongNetwork" + ? () => isWrongNetworkHandler() + : undefined; + + // Render unified group driven by state + const content = ( + <> + + {isExpandable && ( = ({ expanded={expanded} onToggle={() => setExpanded((e) => !e)} visible={true} + state={state} + validationError={validationError} + validationWarning={validationWarning} > {expanded ? ( @@ -426,133 +520,27 @@ export const ConfirmationButton: React.FC = ({ ) : null} - - - - - ); - break; - case "notConnected": - content = ( + )} + + connect()} + fullHeight={state !== "readyToConfirm"} + onClick={clickHandler} /> - ); - break; - case "wrongNetwork": - content = ( - isWrongNetworkHandler()} - /> - ); - break; - case "awaitingTokenSelection": - content = ( - - ); - break; - case "awaitingAmountInput": - content = ( - - ); - break; - case "submitting": - content = ( - - ); - break; - case "loadingQuote": - content = ( - - ); - break; - default: - content = null; - } + + + ); return ( Date: Sun, 28 Sep 2025 16:42:49 +0200 Subject: [PATCH 13/39] show validation Signed-off-by: Gerhard Steenkamp --- .../components/ConfirmationButton.tsx | 453 +++++++++--------- .../SwapAndBridge/components/InputForm.tsx | 82 +--- .../SwapAndBridge/hooks/useSwapAndBridge.ts | 4 +- src/views/SwapAndBridge/index.tsx | 4 + 4 files changed, 234 insertions(+), 309 deletions(-) diff --git a/src/views/SwapAndBridge/components/ConfirmationButton.tsx b/src/views/SwapAndBridge/components/ConfirmationButton.tsx index 492fb46e1..4dd6d35ff 100644 --- a/src/views/SwapAndBridge/components/ConfirmationButton.tsx +++ b/src/views/SwapAndBridge/components/ConfirmationButton.tsx @@ -4,6 +4,7 @@ import { ReactComponent as ChevronDownIcon } from "assets/icons/chevron-down.svg import { ReactComponent as LoadingIcon } from "assets/icons/loading-2.svg"; import { ReactComponent as Info } from "assets/icons/info.svg"; import { ReactComponent as Wallet } from "assets/icons/wallet.svg"; +import { ReactComponent as Across } from "assets/token-logos/acx.svg"; import React from "react"; import { motion, AnimatePresence } from "framer-motion"; import { BigNumber } from "ethers"; @@ -13,6 +14,7 @@ import { useConnection, useIsWrongNetwork } from "hooks"; import { EnrichedTokenSelect } from "./ChainTokenSelector/SelectorButton"; import styled from "@emotion/styled"; import { AmountInputError } from "../../Bridge/utils"; +import { validationErrorTextMap } from "views/Bridge/components/AmountInput"; type SwapQuoteResponse = { checks: object; @@ -32,7 +34,8 @@ export type BridgeButtonState = | "readyToConfirm" | "submitting" | "wrongNetwork" - | "loadingQuote"; + | "loadingQuote" + | "validationError"; interface ConfirmationButtonProps extends ButtonHTMLAttributes { @@ -55,146 +58,122 @@ const ExpandableLabelSection: React.FC< onToggle: () => void; visible: boolean; state: BridgeButtonState; + hasQuote: boolean; validationError?: AmountInputError; validationWarning?: AmountInputError; }> -> = ({ fee, time, expanded, onToggle, state, children }) => { +> = ({ + fee, + time, + expanded, + onToggle, + state, + children, + hasQuote, + validationError, +}) => { // Render state-specific content let content: React.ReactNode = null; - switch (state) { - case "notConnected": - content = ( - <> - - + + + {validationErrorTextMap[validationError]} + + + ); + } else if (hasQuote) { + content = ( + <> + + + + + Fast & Secure + + + + - - - Fast & Secure - - - - - - - - - {fee} - - - - - - - - - {time} - - - - ); - break; - - case "readyToConfirm": - content = ( - <> - - + + + + {fee} + + + + - - - Fast & Secure - - - - - - - - - {fee} - - - - - - - - - {time} - - - - - ); - break; - default: - break; + + + + + {time} + + + + + ); + } else { + content = ( + <> + + + + + Fast & Secure + + + Across V4. More Chains Faster. + + + ); } + return ( = ({ label, loading, disabled, state, onConfirm, onClick, fullHeight }) => ( +> = ({ label, loading, disabled, state, onClick, fullHeight }) => ( @@ -277,6 +256,7 @@ export const ConfirmationButton: React.FC = ({ if (!amount || amount.lte(0)) return "awaitingAmountInput"; if (isWrongNetwork) return "wrongNetwork"; if (isQuoteLoading) return "loadingQuote"; + if (validationError) return "validationError"; return "readyToConfirm"; }; @@ -412,115 +392,117 @@ export const ConfirmationButton: React.FC = ({ Finalizing Quote ), + validationError: "Confirm Swap", }; // Map visual and behavior from state - const isExpandable = state === "readyToConfirm"; const buttonLabel = stateLabels[state]; const buttonLoading = state === "loadingQuote" || state === "submitting"; const buttonDisabled = state === "awaitingTokenSelection" || state === "awaitingAmountInput" || state === "loadingQuote" || - state === "submitting"; + state === "submitting" || + state === "validationError"; const clickHandler = state === "notConnected" ? () => connect() : state === "wrongNetwork" ? () => isWrongNetworkHandler() - : undefined; + : state === "readyToConfirm" + ? () => handleConfirm() + : undefined; // Render unified group driven by state const content = ( <> - - {isExpandable && ( - + + setExpanded((e) => !e)} + visible={true} + state={state} + validationError={validationError} + validationWarning={validationWarning} + hasQuote={!!swapQuote} > - setExpanded((e) => !e)} - visible={true} - state={state} - validationError={validationError} - validationWarning={validationWarning} - > - {expanded ? ( - - - - - - - - - Route - - - - {displayValues.route} - - - - - - Est. Time - - {displayValues.estimatedTime} - - - - - Net Fee - - - {displayValues.netFee} - - - - Bridge Fee - - {displayValues.bridgeFee} - - - - Destination Gas Fee - - {displayValues.destinationGasFee} - - - - Extra Fee - - {displayValues.extraFee} - - - - - ) : null} - - - )} + {expanded && state === "readyToConfirm" ? ( + + + + + + + + + Route + + + + {displayValues.route} + + + + + + Est. Time + + {displayValues.estimatedTime} + + + + + Net Fee + + + {displayValues.netFee} + + + + Bridge Fee + + {displayValues.bridgeFee} + + + + Destination Gas Fee + + {displayValues.destinationGasFee} + + + + Extra Fee + + {displayValues.extraFee} + + + + + ) : null} + + = ({ label={buttonLabel} loading={buttonLoading} disabled={buttonDisabled} - onConfirm={handleConfirm} inputToken={inputToken} outputToken={outputToken} amount={amount} @@ -553,16 +534,26 @@ export const ConfirmationButton: React.FC = ({ ); }; +const ValidationText = styled.div` + color: ${COLORS.white}; + font-size: 14px; + font-weight: 400; + margin-inline: auto; + display: flex; + align-items: center; + gap: 4px; +`; + // Styled components const Container = styled(motion.div)<{ state: BridgeButtonState }>` - background: rgba(108, 249, 216, 0.1); + background: ${({ state }) => + state === "validationError" + ? COLORS["grey-400-5"] + : "rgba(108, 249, 216, 0.1)"}; border-radius: 24px; display: flex; flex-direction: column; - padding: ${({ state }) => - state === "readyToConfirm" || state === "submitting" - ? "4px 12px 12px 12px" - : "0"}; + padding: 8px 12px 12px 12px; width: 100%; overflow: hidden; gap: ${({ state }) => (state === "readyToConfirm" ? "8px" : "0")}; @@ -589,6 +580,7 @@ const ExpandableLabelLeft = styled.span` display: flex; align-items: center; gap: 8px; + justify-content: flex-start; `; const ShieldIcon = styled.svg` @@ -606,6 +598,12 @@ const ExpandableLabelRight = styled.div` gap: 8px; font-size: 12px; color: #e0f3ff; + justify-content: flex-end; +`; + +const ExpandableLabelRightAccent = styled(ExpandableLabelLeft)` + text-align: right; + justify-content: flex-end; `; const FeeTimeItem = styled.span` @@ -665,10 +663,11 @@ const StyledButton = styled.button<{ border: none; cursor: pointer; - background: ${({ aqua }) => (aqua ? "transparent" : COLORS.aqua)}; - color: ${({ aqua }) => (aqua ? COLORS.aqua : "#000000")}; + background: ${({ aqua }) => + aqua ? COLORS.aqua : "rgba(224, 243, 255, 0.05)"}; + color: ${({ aqua }) => (aqua ? "#2D2E33" : "#E0F3FF")}; - &:hover { + &:not(:disabled):hover { ${({ aqua }) => aqua ? `background: rgba(108, 249, 216, 0.1);` @@ -678,12 +677,14 @@ const StyledButton = styled.button<{ `} } - &:focus { + &:not(:disabled):focus { ${({ aqua }) => !aqua && `box-shadow: 0 0 16px 0 ${COLORS.aqua};`} } &:disabled { - ${({ loading }) => loading && "opacity: 0.6; cursor: wait;"} + cursor: ${({ loading }) => (loading ? "wait" : "not-allowed")}; + box-shadow: none; + opacity: ${({ loading }) => (loading ? 0.9 : 0.6)}; } `; diff --git a/src/views/SwapAndBridge/components/InputForm.tsx b/src/views/SwapAndBridge/components/InputForm.tsx index f6e2b0db6..4ed1f4a49 100644 --- a/src/views/SwapAndBridge/components/InputForm.tsx +++ b/src/views/SwapAndBridge/components/InputForm.tsx @@ -101,36 +101,7 @@ const TokenInput = ({ }) => { const [amountString, setAmountString] = useState(""); const [justTyped, setJustTyped] = useState(false); - const [validationError, setValidationError] = useState( - undefined - ); - - const getValidationErrorText = useCallback( - (error?: AmountInputError) => { - if (!error || !token) return undefined; - const validationErrorTextMap: Record = { - [AmountInputError.INSUFFICIENT_BALANCE]: - "Insufficient balance to process this transfer.", - [AmountInputError.PAUSED_DEPOSITS]: - "[INPUT_TOKEN] deposits are temporarily paused.", - [AmountInputError.INSUFFICIENT_LIQUIDITY]: - "Input amount exceeds limits set to maintain optimal service for all users. Decrease amount to [MAX_DEPOSIT] or lower.", - [AmountInputError.INVALID]: - "Only positive numbers are allowed as an input.", - [AmountInputError.AMOUNT_TOO_LOW]: - "The amount you are trying to bridge is too low.", - [AmountInputError.PRICE_IMPACT_TOO_HIGH]: - "Price impact is too high. Check back later when liquidity is restored.", - [AmountInputError.SWAP_QUOTE_UNAVAILABLE]: - "Swap quote temporarily unavailable. Please try again later.", - }; - - return validationErrorTextMap[error] - .replace("[INPUT_TOKEN]", token.symbol) - .replace("[MAX_DEPOSIT]", ""); - }, - [token] - ); + const [validationError] = useState(undefined); // Handle user input changes useEffect(() => { @@ -141,39 +112,12 @@ const TokenInput = ({ try { if (!token) { setAmount(null); - setValidationError(undefined); return; } const parsed = utils.parseUnits(amountString, token.decimals); - if (isOrigin) { - if (parsed.lt(0)) { - setValidationError(getValidationErrorText(AmountInputError.INVALID)); - setAmount(null); - return; - } - if (token.balance && parsed.gt(token.balance)) { - setValidationError( - getValidationErrorText(AmountInputError.INSUFFICIENT_BALANCE) - ); - } else { - setValidationError(undefined); - } - } else { - if (parsed.lt(0)) { - setValidationError(getValidationErrorText(AmountInputError.INVALID)); - setAmount(null); - return; - } - setValidationError(undefined); - } setAmount(parsed); } catch (e) { setAmount(null); - if (amountString !== "") { - setValidationError(getValidationErrorText(AmountInputError.INVALID)); - } else { - setValidationError(undefined); - } } // eslint-disable-next-line react-hooks/exhaustive-deps }, [amountString]); @@ -195,7 +139,6 @@ const TokenInput = ({ setAmountString( formatUnitsWithMaxFractions(expectedAmount, token.decimals) ); - setValidationError(undefined); } // eslint-disable-next-line react-hooks/exhaustive-deps @@ -244,11 +187,6 @@ const TokenInput = ({ Value: ${estimatedUsdAmount ?? "0.00"} - {validationError && ( - - {validationError} - - )} @@ -352,15 +281,6 @@ const TokenAmountInputEstimatedUsd = styled.div` opacity: 0.5; `; -const TokenAmountInputValidationError = styled.div` - color: #f96c6c; - font-family: Barlow; - font-size: 12px; - font-weight: 600; - line-height: 130%; - margin-top: 4px; -`; - const TokenInputWrapper = styled.div` display: flex; min-height: 148px; diff --git a/src/views/SwapAndBridge/hooks/useSwapAndBridge.ts b/src/views/SwapAndBridge/hooks/useSwapAndBridge.ts index d64a0edc9..ee74a7023 100644 --- a/src/views/SwapAndBridge/hooks/useSwapAndBridge.ts +++ b/src/views/SwapAndBridge/hooks/useSwapAndBridge.ts @@ -82,8 +82,8 @@ export function useSwapAndBridge(): UseSwapAndBridgeReturn { const validation = useMemo(() => { let errorType: AmountInputError | undefined = undefined; - // invalid or empty amount - if (!amount || amount.lte(0)) { + // invalid amount (allow empty/no amount without error) + if (amount && amount.lte(0)) { errorType = AmountInputError.INVALID; } // balance check for origin-side inputs diff --git a/src/views/SwapAndBridge/index.tsx b/src/views/SwapAndBridge/index.tsx index 8b81a4e94..6a533654f 100644 --- a/src/views/SwapAndBridge/index.tsx +++ b/src/views/SwapAndBridge/index.tsx @@ -21,6 +21,8 @@ export default function SwapAndBridge() { expectedInputAmount, expectedOutputAmount, onConfirm, + validationError, + validationWarning, } = useSwapAndBridge(); const history = useHistory(); @@ -55,6 +57,8 @@ export default function SwapAndBridge() { swapQuote={swapQuote || null} isQuoteLoading={isQuoteLoading} onConfirm={handleConfirm} + validationError={validationError} + validationWarning={validationWarning} /> From 28e9560635d2ba14ea09afe90ab79289a902dffc Mon Sep 17 00:00:00 2001 From: Gerhard Steenkamp Date: Tue, 30 Sep 2025 13:14:40 +0200 Subject: [PATCH 14/39] refactor Signed-off-by: Gerhard Steenkamp --- src/assets/icons/gas.svg | 5 + src/assets/icons/route.svg | 5 + src/assets/icons/shield.svg | 3 + src/assets/icons/time.svg | 5 + src/assets/icons/wallet.svg | 8 +- .../components/ConfirmationButton.tsx | 201 ++++-------------- .../SwapAndBridge/hooks/useSwapAndBridge.ts | 91 +++++--- .../hooks/useSwapApprovalAction/factory.ts | 2 +- .../strategies/abstract.ts | 2 +- .../useSwapApprovalAction/strategies/evm.ts | 24 ++- .../useSwapApprovalAction/strategies/svm.ts | 10 + .../useSwapApprovalAction/strategies/types.ts | 2 +- .../hooks/useValidateSwapAndBridge.ts | 47 ++++ src/views/SwapAndBridge/index.tsx | 10 +- 14 files changed, 206 insertions(+), 209 deletions(-) create mode 100644 src/assets/icons/gas.svg create mode 100644 src/assets/icons/route.svg create mode 100644 src/assets/icons/shield.svg create mode 100644 src/assets/icons/time.svg create mode 100644 src/views/SwapAndBridge/hooks/useValidateSwapAndBridge.ts diff --git a/src/assets/icons/gas.svg b/src/assets/icons/gas.svg new file mode 100644 index 000000000..bba1d5579 --- /dev/null +++ b/src/assets/icons/gas.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/assets/icons/route.svg b/src/assets/icons/route.svg new file mode 100644 index 000000000..8671c3c4b --- /dev/null +++ b/src/assets/icons/route.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/src/assets/icons/shield.svg b/src/assets/icons/shield.svg new file mode 100644 index 000000000..503c3a2e5 --- /dev/null +++ b/src/assets/icons/shield.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/time.svg b/src/assets/icons/time.svg new file mode 100644 index 000000000..055c9a2b4 --- /dev/null +++ b/src/assets/icons/time.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/assets/icons/wallet.svg b/src/assets/icons/wallet.svg index 37c3b159d..9bc251d8a 100644 --- a/src/assets/icons/wallet.svg +++ b/src/assets/icons/wallet.svg @@ -1,5 +1,5 @@ - - - - + + + + diff --git a/src/views/SwapAndBridge/components/ConfirmationButton.tsx b/src/views/SwapAndBridge/components/ConfirmationButton.tsx index 4dd6d35ff..fce19d789 100644 --- a/src/views/SwapAndBridge/components/ConfirmationButton.tsx +++ b/src/views/SwapAndBridge/components/ConfirmationButton.tsx @@ -5,12 +5,16 @@ import { ReactComponent as LoadingIcon } from "assets/icons/loading-2.svg"; import { ReactComponent as Info } from "assets/icons/info.svg"; import { ReactComponent as Wallet } from "assets/icons/wallet.svg"; import { ReactComponent as Across } from "assets/token-logos/acx.svg"; +import { ReactComponent as Route } from "assets/icons/route.svg"; +import { ReactComponent as Shield } from "assets/icons/shield.svg"; +import { ReactComponent as Gas } from "assets/icons/gas.svg"; +import { ReactComponent as Time } from "assets/icons/time.svg"; + import React from "react"; import { motion, AnimatePresence } from "framer-motion"; import { BigNumber } from "ethers"; import { COLORS, formatUSD, getConfig } from "utils"; import { useTokenConversion } from "hooks/useTokenConversion"; -import { useConnection, useIsWrongNetwork } from "hooks"; import { EnrichedTokenSelect } from "./ChainTokenSelector/SelectorButton"; import styled from "@emotion/styled"; import { AmountInputError } from "../../Bridge/utils"; @@ -47,6 +51,11 @@ interface ConfirmationButtonProps onConfirm?: () => void; validationError?: AmountInputError; validationWarning?: AmountInputError; + // External state props + buttonState: BridgeButtonState; + buttonDisabled: boolean; + buttonLoading: boolean; + buttonLabel?: string; } // Expandable label section component @@ -87,60 +96,17 @@ const ExpandableLabelSection: React.FC< content = ( <> - - - + Fast & Secure - - - - - + {fee} - - - - - + {time} @@ -151,20 +117,7 @@ const ExpandableLabelSection: React.FC< content = ( <> - - - + Fast & Secure @@ -200,14 +153,14 @@ const ExpandableLabelSection: React.FC< }; // Core button component, used by all states -const ButtonCore: React.FC< - ConfirmationButtonProps & { - label: React.ReactNode; - loading?: boolean; - state: BridgeButtonState; - fullHeight?: boolean; - } -> = ({ label, loading, disabled, state, onClick, fullHeight }) => ( +const ButtonCore: React.FC<{ + label: React.ReactNode; + loading?: boolean; + disabled?: boolean; + state: BridgeButtonState; + fullHeight?: boolean; + onClick?: () => void; +}> = ({ label, loading, disabled, state, onClick, fullHeight }) => ( - {label} + + {loading && } + {state === "notConnected" && ( + + )} + {label} + @@ -238,29 +197,14 @@ export const ConfirmationButton: React.FC = ({ onConfirm, validationError, validationWarning, - ...props + buttonState, + buttonDisabled, + buttonLoading, + buttonLabel, }) => { - const { account, connect } = useConnection(); const [expanded, setExpanded] = React.useState(false); - const [isSubmitting, setIsSubmitting] = React.useState(false); - - const { isWrongNetworkHandler, isWrongNetwork } = useIsWrongNetwork( - inputToken?.chainId - ); - - // Determine the current state - const getButtonState = (): BridgeButtonState => { - if (isSubmitting) return "submitting"; - if (!account) return "notConnected"; - if (!inputToken || !outputToken) return "awaitingTokenSelection"; - if (!amount || amount.lte(0)) return "awaitingAmountInput"; - if (isWrongNetwork) return "wrongNetwork"; - if (isQuoteLoading) return "loadingQuote"; - if (validationError) return "validationError"; - return "readyToConfirm"; - }; - const state = getButtonState(); + const state = buttonState; // Calculate display values from swapQuote // Resolve conversion helpers outside memo to respect hooks rules @@ -360,59 +304,7 @@ export const ConfirmationButton: React.FC = ({ convertDestinationNativeToUsd, ]); - // Handle confirmation - const handleConfirm = async () => { - if (!onConfirm) return; - - setIsSubmitting(true); - try { - onConfirm(); - } catch (error) { - console.error("Confirmation failed:", error); - } finally { - setIsSubmitting(false); - } - }; - - const stateLabels: Record = { - notConnected: ( - <> - - Connect Wallet - - ), - awaitingTokenSelection: "Select Token", - awaitingAmountInput: "Input Amount", - readyToConfirm: "Confirm Swap", - submitting: "Submitting...", - wrongNetwork: "Switch Network", - loadingQuote: ( - <> - - Finalizing Quote - - ), - validationError: "Confirm Swap", - }; - - // Map visual and behavior from state - const buttonLabel = stateLabels[state]; - const buttonLoading = state === "loadingQuote" || state === "submitting"; - const buttonDisabled = - state === "awaitingTokenSelection" || - state === "awaitingAmountInput" || - state === "loadingQuote" || - state === "submitting" || - state === "validationError"; - - const clickHandler = - state === "notConnected" - ? () => connect() - : state === "wrongNetwork" - ? () => isWrongNetworkHandler() - : state === "readyToConfirm" - ? () => handleConfirm() - : undefined; + const clickHandler = onConfirm; // Render unified group driven by state const content = ( @@ -441,22 +333,7 @@ export const ConfirmationButton: React.FC = ({ - - - - - + Route @@ -506,16 +383,10 @@ export const ConfirmationButton: React.FC = ({ diff --git a/src/views/SwapAndBridge/hooks/useSwapAndBridge.ts b/src/views/SwapAndBridge/hooks/useSwapAndBridge.ts index ee74a7023..1cf013140 100644 --- a/src/views/SwapAndBridge/hooks/useSwapAndBridge.ts +++ b/src/views/SwapAndBridge/hooks/useSwapAndBridge.ts @@ -1,6 +1,5 @@ import { useCallback, useMemo, useState } from "react"; import { BigNumber } from "ethers"; -import axios from "axios"; import { AmountInputError } from "../../Bridge/utils"; import useSwapQuote from "./useSwapQuote"; @@ -9,6 +8,8 @@ import { useSwapApprovalAction, SwapApprovalData, } from "./useSwapApprovalAction"; +import { useValidateSwapAndBridge } from "./useValidateSwapAndBridge"; +import { BridgeButtonState } from "../components/ConfirmationButton"; export type UseSwapAndBridgeReturn = { inputToken: EnrichedTokenSelect | null; @@ -30,10 +31,16 @@ export type UseSwapAndBridgeReturn = { validationError?: AmountInputError; validationWarning?: AmountInputError; + // Button state information + buttonState: BridgeButtonState; + buttonDisabled: boolean; + buttonLoading: boolean; + buttonLabel: string; + + // Legacy properties isConnected: boolean; isWrongNetwork: boolean; isSubmitting: boolean; - buttonDisabled: boolean; onConfirm: () => Promise; }; @@ -80,32 +87,12 @@ export function useSwapAndBridge(): UseSwapAndBridgeReturn { approvalData ); - const validation = useMemo(() => { - let errorType: AmountInputError | undefined = undefined; - // invalid amount (allow empty/no amount without error) - if (amount && amount.lte(0)) { - errorType = AmountInputError.INVALID; - } - // balance check for origin-side inputs - if (!errorType && isAmountOrigin && inputToken?.balance) { - if (amount && amount.gt(inputToken.balance)) { - errorType = AmountInputError.INSUFFICIENT_BALANCE; - } - } - // backend availability - if (!errorType && error && axios.isAxiosError(error)) { - const code = (error.response?.data as any)?.code as string | undefined; - if (code === "AMOUNT_TOO_LOW") { - errorType = AmountInputError.AMOUNT_TOO_LOW; - } else if (code === "SWAP_QUOTE_UNAVAILABLE") { - errorType = AmountInputError.SWAP_QUOTE_UNAVAILABLE; - } - } - return { - error: errorType, - warn: undefined as AmountInputError | undefined, - }; - }, [amount, isAmountOrigin, inputToken, error]); + const validation = useValidateSwapAndBridge( + amount, + isAmountOrigin, + inputToken, + error + ); const expectedInputAmount = useMemo(() => { return swapQuote?.inputAmount?.toString(); @@ -120,6 +107,31 @@ export function useSwapAndBridge(): UseSwapAndBridgeReturn { return txHash as string; }, [approvalAction]); + // Button state logic + const buttonState: BridgeButtonState = useMemo(() => { + if (isQuoteLoading) return "loadingQuote"; + if (!approvalAction.isConnected) return "notConnected"; + if (approvalAction.isButtonActionLoading) return "submitting"; + if (!inputToken || !outputToken) return "awaitingTokenSelection"; + if (!amount || amount.lte(0)) return "awaitingAmountInput"; + if (validation.error) return "validationError"; + return "readyToConfirm"; + }, [ + approvalAction.isButtonActionLoading, + approvalAction.isConnected, + inputToken, + outputToken, + amount, + isQuoteLoading, + validation.error, + ]); + + const buttonLoading = useMemo(() => { + return buttonState === "loadingQuote" || buttonState === "submitting"; + }, [buttonState]); + + const buttonLabel = buttonLabels[buttonState]; + return { inputToken, outputToken, @@ -140,9 +152,8 @@ export function useSwapAndBridge(): UseSwapAndBridgeReturn { validationError: validation.error, validationWarning: validation.warn, - isConnected: approvalAction.isConnected, - isWrongNetwork: approvalAction.isWrongNetwork, - isSubmitting: approvalAction.isButtonActionLoading, + // Button state information + buttonState, buttonDisabled: approvalAction.buttonDisabled || !!validation.error || @@ -150,8 +161,24 @@ export function useSwapAndBridge(): UseSwapAndBridgeReturn { !outputToken || !amount || amount.lte(0), + buttonLoading, + buttonLabel, + + // Legacy properties + isConnected: approvalAction.isConnected, + isWrongNetwork: approvalAction.isWrongNetwork, + isSubmitting: approvalAction.isButtonActionLoading, onConfirm, }; } -export default useSwapAndBridge; +const buttonLabels: Record = { + notConnected: "Connect Wallet", + awaitingTokenSelection: "Select a token", + awaitingAmountInput: "Enter an amount", + readyToConfirm: "Confirm Swap", + submitting: "Confirming...", + wrongNetwork: "Switch network and confirm transaction", + loadingQuote: "Finalizing quote", + validationError: "Confirm Swap", +}; diff --git a/src/views/SwapAndBridge/hooks/useSwapApprovalAction/factory.ts b/src/views/SwapAndBridge/hooks/useSwapApprovalAction/factory.ts index 5bfdb296d..314f1328b 100644 --- a/src/views/SwapAndBridge/hooks/useSwapApprovalAction/factory.ts +++ b/src/views/SwapAndBridge/hooks/useSwapApprovalAction/factory.ts @@ -16,7 +16,7 @@ export function createSwapApprovalActionHook( const action = useMutation({ mutationFn: async () => { if (!approvalData) throw new Error("Missing approval data"); - const txHash = await strategy.swap(approvalData); + const txHash = await strategy.execute(approvalData); return txHash; }, }); diff --git a/src/views/SwapAndBridge/hooks/useSwapApprovalAction/strategies/abstract.ts b/src/views/SwapAndBridge/hooks/useSwapApprovalAction/strategies/abstract.ts index 31b1346e8..ba311c19c 100644 --- a/src/views/SwapAndBridge/hooks/useSwapApprovalAction/strategies/abstract.ts +++ b/src/views/SwapAndBridge/hooks/useSwapApprovalAction/strategies/abstract.ts @@ -9,7 +9,7 @@ export abstract class AbstractSwapApprovalActionStrategy abstract isConnected(): boolean; abstract isWrongNetwork(requiredChainId: number): boolean; abstract switchNetwork(requiredChainId: number): Promise; - abstract swap(approvalData: any): Promise; + abstract execute(approvalData: any): Promise; async assertCorrectNetwork(requiredChainId: number) { const currentChainId = this.evmConnection.chainId; diff --git a/src/views/SwapAndBridge/hooks/useSwapApprovalAction/strategies/evm.ts b/src/views/SwapAndBridge/hooks/useSwapApprovalAction/strategies/evm.ts index cce0602e4..53ecaa6e7 100644 --- a/src/views/SwapAndBridge/hooks/useSwapApprovalAction/strategies/evm.ts +++ b/src/views/SwapAndBridge/hooks/useSwapApprovalAction/strategies/evm.ts @@ -7,7 +7,7 @@ export class EVMSwapApprovalActionStrategy extends AbstractSwapApprovalActionStr super(evmConnection); } - private get signer() { + private getSigner() { const { signer } = this.evmConnection; if (!signer) { throw new Error("No signer available"); @@ -28,19 +28,25 @@ export class EVMSwapApprovalActionStrategy extends AbstractSwapApprovalActionStr await this.evmConnection.setChain(requiredChainId); } - async swap(approvalData: SwapApprovalData): Promise { - const signer = this.signer; + async approve(approvalData: SwapApprovalData): Promise { + const signer = this.getSigner(); // approvals first const approvals: ApprovalTxn[] = approvalData.approvalTxns || []; for (const approval of approvals) { await this.switchNetwork(approval.chainId); + await this.assertCorrectNetwork(approval.chainId); await signer.sendTransaction({ to: approval.to, data: approval.data, chainId: approval.chainId, }); } - // then final swap + return true; + } + + async swap(approvalData: SwapApprovalData): Promise { + const signer = this.getSigner(); + const swapTx: SwapTx = approvalData.swapTx; await this.switchNetwork(swapTx.chainId); await this.assertCorrectNetwork(swapTx.chainId); @@ -56,4 +62,14 @@ export class EVMSwapApprovalActionStrategy extends AbstractSwapApprovalActionStr }); return tx.hash; } + + async execute(approvalData: SwapApprovalData): Promise { + try { + await this.approve(approvalData); + return await this.swap(approvalData); + } catch (e) { + console.error(e); + throw e; + } + } } diff --git a/src/views/SwapAndBridge/hooks/useSwapApprovalAction/strategies/svm.ts b/src/views/SwapAndBridge/hooks/useSwapApprovalAction/strategies/svm.ts index d65578aef..ac72768b2 100644 --- a/src/views/SwapAndBridge/hooks/useSwapApprovalAction/strategies/svm.ts +++ b/src/views/SwapAndBridge/hooks/useSwapApprovalAction/strategies/svm.ts @@ -23,6 +23,11 @@ export class SVMSwapApprovalActionStrategy extends AbstractSwapApprovalActionStr await this.svmConnection.connect(); } + // stubbed for now + approve(approvalData: SwapApprovalData): boolean { + return true; + } + async swap(approvalData: SwapApprovalData): Promise { if (!this.svmConnection.wallet?.adapter) { throw new Error("Wallet needs to be connected"); @@ -34,4 +39,9 @@ export class SVMSwapApprovalActionStrategy extends AbstractSwapApprovalActionStr ); return sig; } + + async execute(approvalData: SwapApprovalData): Promise { + this.approve(approvalData); + return this.swap(approvalData); + } } diff --git a/src/views/SwapAndBridge/hooks/useSwapApprovalAction/strategies/types.ts b/src/views/SwapAndBridge/hooks/useSwapApprovalAction/strategies/types.ts index a0e95a60e..d2b77ec07 100644 --- a/src/views/SwapAndBridge/hooks/useSwapApprovalAction/strategies/types.ts +++ b/src/views/SwapAndBridge/hooks/useSwapApprovalAction/strategies/types.ts @@ -28,5 +28,5 @@ export type SwapApprovalActionStrategy = { isConnected(): boolean; isWrongNetwork(requiredChainId: number): boolean; switchNetwork(requiredChainId: number): Promise; - swap(approvalData: SwapApprovalData): Promise; + execute(approvalData: SwapApprovalData): Promise; }; diff --git a/src/views/SwapAndBridge/hooks/useValidateSwapAndBridge.ts b/src/views/SwapAndBridge/hooks/useValidateSwapAndBridge.ts new file mode 100644 index 000000000..3e26e9f16 --- /dev/null +++ b/src/views/SwapAndBridge/hooks/useValidateSwapAndBridge.ts @@ -0,0 +1,47 @@ +import { useMemo } from "react"; +import { BigNumber } from "ethers"; +import axios from "axios"; + +import { AmountInputError } from "../../Bridge/utils"; +import { EnrichedTokenSelect } from "../components/ChainTokenSelector/SelectorButton"; + +export type ValidationResult = { + error?: AmountInputError; + warn?: AmountInputError; +}; + +export function useValidateSwapAndBridge( + amount: BigNumber | null, + isAmountOrigin: boolean, + inputToken: EnrichedTokenSelect | null, + error: any +): ValidationResult { + const validation = useMemo(() => { + let errorType: AmountInputError | undefined = undefined; + // invalid amount (allow empty/no amount without error) + if (amount && amount.lte(0)) { + errorType = AmountInputError.INVALID; + } + // balance check for origin-side inputs + if (!errorType && isAmountOrigin && inputToken?.balance) { + if (amount && amount.gt(inputToken.balance)) { + errorType = AmountInputError.INSUFFICIENT_BALANCE; + } + } + // backend availability + if (!errorType && error && axios.isAxiosError(error)) { + const code = (error.response?.data as any)?.code as string | undefined; + if (code === "AMOUNT_TOO_LOW") { + errorType = AmountInputError.AMOUNT_TOO_LOW; + } else if (code === "SWAP_QUOTE_UNAVAILABLE") { + errorType = AmountInputError.SWAP_QUOTE_UNAVAILABLE; + } + } + return { + error: errorType, + warn: undefined as AmountInputError | undefined, + }; + }, [amount, isAmountOrigin, inputToken, error]); + + return validation; +} diff --git a/src/views/SwapAndBridge/index.tsx b/src/views/SwapAndBridge/index.tsx index 6a533654f..12d33ae72 100644 --- a/src/views/SwapAndBridge/index.tsx +++ b/src/views/SwapAndBridge/index.tsx @@ -4,7 +4,7 @@ import { useCallback } from "react"; import { InputForm } from "./components/InputForm"; import ConfirmationButton from "./components/ConfirmationButton"; import { useHistory } from "react-router-dom"; -import useSwapAndBridge from "./hooks/useSwapAndBridge"; +import { useSwapAndBridge } from "./hooks/useSwapAndBridge"; export default function SwapAndBridge() { const { @@ -23,6 +23,10 @@ export default function SwapAndBridge() { onConfirm, validationError, validationWarning, + buttonState, + buttonDisabled, + buttonLoading, + buttonLabel, } = useSwapAndBridge(); const history = useHistory(); @@ -59,6 +63,10 @@ export default function SwapAndBridge() { onConfirm={handleConfirm} validationError={validationError} validationWarning={validationWarning} + buttonState={buttonState} + buttonDisabled={buttonDisabled} + buttonLoading={buttonLoading} + buttonLabel={buttonLabel} /> From 2d3a9f9cd12cb307a139a776710eaffcaee822eb Mon Sep 17 00:00:00 2001 From: Gerhard Steenkamp Date: Tue, 30 Sep 2025 13:17:15 +0200 Subject: [PATCH 15/39] clean up Signed-off-by: Gerhard Steenkamp --- .../components/ConfirmationButton.tsx | 30 ++----------------- .../SwapAndBridge/components/InputForm.tsx | 2 -- .../useSwapApprovalAction/strategies/svm.ts | 2 +- 3 files changed, 3 insertions(+), 31 deletions(-) diff --git a/src/views/SwapAndBridge/components/ConfirmationButton.tsx b/src/views/SwapAndBridge/components/ConfirmationButton.tsx index fce19d789..84c18b2ad 100644 --- a/src/views/SwapAndBridge/components/ConfirmationButton.tsx +++ b/src/views/SwapAndBridge/components/ConfirmationButton.tsx @@ -87,7 +87,7 @@ const ExpandableLabelSection: React.FC< content = ( <> - + {validationErrorTextMap[validationError]} @@ -193,7 +193,6 @@ export const ConfirmationButton: React.FC = ({ outputToken, amount, swapQuote, - isQuoteLoading, onConfirm, validationError, validationWarning, @@ -333,7 +332,7 @@ export const ConfirmationButton: React.FC = ({ - + Route @@ -454,11 +453,6 @@ const ExpandableLabelLeft = styled.span` justify-content: flex-start; `; -const ShieldIcon = styled.svg` - width: 16px; - height: 16px; -`; - const FastSecureText = styled.span` color: ${COLORS.aqua}; `; @@ -483,16 +477,6 @@ const FeeTimeItem = styled.span` gap: 4px; `; -const GasIcon = styled.svg` - width: 16px; - height: 16px; -`; - -const TimeIcon = styled.svg` - width: 16px; - height: 16px; -`; - const Divider = styled.span` margin: 0 8px; height: 16px; @@ -612,16 +596,6 @@ const DetailRight = styled.div` gap: 8px; `; -const RouteIcon = styled.svg` - width: 20px; - height: 20px; -`; - -const InfoIconSvg = styled.svg` - width: 20px; - height: 20px; -`; - const RouteDot = styled.span` display: inline-block; width: 20px; diff --git a/src/views/SwapAndBridge/components/InputForm.tsx b/src/views/SwapAndBridge/components/InputForm.tsx index 4ed1f4a49..6771b4a4f 100644 --- a/src/views/SwapAndBridge/components/InputForm.tsx +++ b/src/views/SwapAndBridge/components/InputForm.tsx @@ -7,7 +7,6 @@ import styled from "@emotion/styled"; import { useCallback, useEffect, useMemo, useState } from "react"; import { BigNumber, utils } from "ethers"; import { ReactComponent as ArrowsCross } from "assets/icons/arrows-cross.svg"; -import { AmountInputError } from "../../Bridge/utils"; export const InputForm = ({ inputToken, @@ -101,7 +100,6 @@ const TokenInput = ({ }) => { const [amountString, setAmountString] = useState(""); const [justTyped, setJustTyped] = useState(false); - const [validationError] = useState(undefined); // Handle user input changes useEffect(() => { diff --git a/src/views/SwapAndBridge/hooks/useSwapApprovalAction/strategies/svm.ts b/src/views/SwapAndBridge/hooks/useSwapApprovalAction/strategies/svm.ts index ac72768b2..9dca85820 100644 --- a/src/views/SwapAndBridge/hooks/useSwapApprovalAction/strategies/svm.ts +++ b/src/views/SwapAndBridge/hooks/useSwapApprovalAction/strategies/svm.ts @@ -24,7 +24,7 @@ export class SVMSwapApprovalActionStrategy extends AbstractSwapApprovalActionStr } // stubbed for now - approve(approvalData: SwapApprovalData): boolean { + approve(_approvalData: SwapApprovalData): boolean { return true; } From 7fd9e839c8bf08313004c8cd524403625c76b2a4 Mon Sep 17 00:00:00 2001 From: Gerhard Steenkamp Date: Tue, 30 Sep 2025 15:14:35 +0200 Subject: [PATCH 16/39] update validation warning Signed-off-by: Gerhard Steenkamp --- src/assets/icons/warning_triangle.svg | 3 +++ .../components/BalanceSelector.tsx | 12 ++++++++---- .../components/ConfirmationButton.tsx | 3 ++- .../SwapAndBridge/components/InputForm.tsx | 18 ++++++++++++++++-- src/views/SwapAndBridge/index.tsx | 1 + 5 files changed, 30 insertions(+), 7 deletions(-) create mode 100644 src/assets/icons/warning_triangle.svg diff --git a/src/assets/icons/warning_triangle.svg b/src/assets/icons/warning_triangle.svg new file mode 100644 index 000000000..762ee642c --- /dev/null +++ b/src/assets/icons/warning_triangle.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/views/SwapAndBridge/components/BalanceSelector.tsx b/src/views/SwapAndBridge/components/BalanceSelector.tsx index 11e24228a..093625c4b 100644 --- a/src/views/SwapAndBridge/components/BalanceSelector.tsx +++ b/src/views/SwapAndBridge/components/BalanceSelector.tsx @@ -9,6 +9,7 @@ type BalanceSelectorProps = { decimals: number; setAmount: (amount: BigNumber | null) => void; disableHover?: boolean; + error?: boolean; }; export default function BalanceSelector({ @@ -16,6 +17,7 @@ export default function BalanceSelector({ decimals, setAmount, disableHover, + error = false, }: BalanceSelectorProps) { const [isHovered, setIsHovered] = useState(false); if (!balance || balance.lte(0)) return null; @@ -81,7 +83,7 @@ export default function BalanceSelector({ ))} - + Balance: {formattedBalance} @@ -98,15 +100,17 @@ const BalanceWrapper = styled.div` margin-left: auto; `; -const BalanceText = styled.div` - color: ${COLORS.white}; +const BalanceText = styled.div<{ error?: boolean }>` + color: ${({ error }) => (error ? COLORS.error : COLORS.white)}; opacity: 1; font-size: 14px; - font-weight: 400; + font-weight: 600; line-height: 130%; span { opacity: 0.5; + color: ${COLORS.white}; + font-weight: 400; } `; diff --git a/src/views/SwapAndBridge/components/ConfirmationButton.tsx b/src/views/SwapAndBridge/components/ConfirmationButton.tsx index 84c18b2ad..904df0aba 100644 --- a/src/views/SwapAndBridge/components/ConfirmationButton.tsx +++ b/src/views/SwapAndBridge/components/ConfirmationButton.tsx @@ -9,6 +9,7 @@ import { ReactComponent as Route } from "assets/icons/route.svg"; import { ReactComponent as Shield } from "assets/icons/shield.svg"; import { ReactComponent as Gas } from "assets/icons/gas.svg"; import { ReactComponent as Time } from "assets/icons/time.svg"; +import { ReactComponent as Warning } from "assets/icons/warning_triangle.svg"; import React from "react"; import { motion, AnimatePresence } from "framer-motion"; @@ -87,7 +88,7 @@ const ExpandableLabelSection: React.FC< content = ( <> - + {validationErrorTextMap[validationError]} diff --git a/src/views/SwapAndBridge/components/InputForm.tsx b/src/views/SwapAndBridge/components/InputForm.tsx index 6771b4a4f..169fc02be 100644 --- a/src/views/SwapAndBridge/components/InputForm.tsx +++ b/src/views/SwapAndBridge/components/InputForm.tsx @@ -7,6 +7,7 @@ import styled from "@emotion/styled"; import { useCallback, useEffect, useMemo, useState } from "react"; import { BigNumber, utils } from "ethers"; import { ReactComponent as ArrowsCross } from "assets/icons/arrows-cross.svg"; +import { AmountInputError } from "views/Bridge/utils"; export const InputForm = ({ inputToken, @@ -19,6 +20,7 @@ export const InputForm = ({ isQuoteLoading, expectedOutputAmount, expectedInputAmount, + validationError, }: { inputToken: EnrichedTokenSelect | null; setInputToken: (token: EnrichedTokenSelect | null) => void; @@ -34,6 +36,7 @@ export const InputForm = ({ isAmountOrigin: boolean; setIsAmountOrigin: (isAmountOrigin: boolean) => void; + validationError: AmountInputError | undefined; }) => { const quickSwap = useCallback(() => { const origin = inputToken; @@ -59,6 +62,9 @@ export const InputForm = ({ expectedAmount={expectedInputAmount} shouldUpdate={!isAmountOrigin} isUpdateLoading={isQuoteLoading} + insufficientInputBalance={ + validationError === AmountInputError.INSUFFICIENT_BALANCE + } /> @@ -89,6 +95,7 @@ const TokenInput = ({ expectedAmount, shouldUpdate, isUpdateLoading, + insufficientInputBalance = false, }: { setToken: (token: EnrichedTokenSelect) => void; token: EnrichedTokenSelect | null; @@ -97,6 +104,7 @@ const TokenInput = ({ expectedAmount: string | undefined; shouldUpdate: boolean; isUpdateLoading: boolean; + insufficientInputBalance?: boolean; }) => { const [amountString, setAmountString] = useState(""); const [justTyped, setJustTyped] = useState(false); @@ -178,6 +186,7 @@ const TokenInput = ({ } }} disabled={shouldUpdate && isUpdateLoading} + error={insufficientInputBalance} /> @@ -199,6 +208,7 @@ const TokenInput = ({ balance={token.balance} disableHover={!isOrigin} decimals={token.decimals} + error={insufficientInputBalance} setAmount={(amount) => { if (amount) { setAmount(amount); @@ -250,14 +260,18 @@ const TokenAmountInputTitle = styled.div` line-height: 130%; `; -const TokenAmountInput = styled.input<{ value: string }>` +const TokenAmountInput = styled.input<{ + value: string; + error: boolean; +}>` font-family: Barlow; font-size: 48px; font-weight: 300; line-height: 120%; letter-spacing: -1.92px; width: 100%; - color: ${(value) => (value ? COLORS.aqua : COLORS["light-200"])}; + color: ${({ value, error }) => + error ? COLORS.error : value ? COLORS.aqua : COLORS["light-200"]}; outline: none; border: none; diff --git a/src/views/SwapAndBridge/index.tsx b/src/views/SwapAndBridge/index.tsx index 12d33ae72..54ba56c2c 100644 --- a/src/views/SwapAndBridge/index.tsx +++ b/src/views/SwapAndBridge/index.tsx @@ -53,6 +53,7 @@ export default function SwapAndBridge() { isQuoteLoading={isQuoteLoading} expectedOutputAmount={expectedOutputAmount} expectedInputAmount={expectedInputAmount} + validationError={validationError} /> Date: Tue, 30 Sep 2025 15:39:54 +0200 Subject: [PATCH 17/39] update icons and validation logic Signed-off-by: Gerhard Steenkamp --- src/assets/icons/dollar.svg | 5 +++++ src/views/Bridge/components/AmountInput.tsx | 2 +- .../components/ConfirmationButton.tsx | 18 +++++++++++------- .../SwapAndBridge/hooks/useSwapAndBridge.ts | 3 ++- .../hooks/useValidateSwapAndBridge.ts | 19 +++++++++++++++++++ src/views/SwapAndBridge/index.tsx | 2 ++ 6 files changed, 40 insertions(+), 9 deletions(-) create mode 100644 src/assets/icons/dollar.svg diff --git a/src/assets/icons/dollar.svg b/src/assets/icons/dollar.svg new file mode 100644 index 000000000..fa7dcf539 --- /dev/null +++ b/src/assets/icons/dollar.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/views/Bridge/components/AmountInput.tsx b/src/views/Bridge/components/AmountInput.tsx index a9c5cb4cc..7a2b55677 100644 --- a/src/views/Bridge/components/AmountInput.tsx +++ b/src/views/Bridge/components/AmountInput.tsx @@ -8,7 +8,7 @@ import { BridgeLimits } from "hooks"; export const validationErrorTextMap: Record = { [AmountInputError.INSUFFICIENT_BALANCE]: - "Insufficient balance to process this transfer.", + "Not enough [INPUT_TOKEN] to process this transfer.", [AmountInputError.PAUSED_DEPOSITS]: "[INPUT_TOKEN] deposits are temporarily paused.", [AmountInputError.INSUFFICIENT_LIQUIDITY]: diff --git a/src/views/SwapAndBridge/components/ConfirmationButton.tsx b/src/views/SwapAndBridge/components/ConfirmationButton.tsx index 904df0aba..520cd2db2 100644 --- a/src/views/SwapAndBridge/components/ConfirmationButton.tsx +++ b/src/views/SwapAndBridge/components/ConfirmationButton.tsx @@ -7,7 +7,7 @@ import { ReactComponent as Wallet } from "assets/icons/wallet.svg"; import { ReactComponent as Across } from "assets/token-logos/acx.svg"; import { ReactComponent as Route } from "assets/icons/route.svg"; import { ReactComponent as Shield } from "assets/icons/shield.svg"; -import { ReactComponent as Gas } from "assets/icons/gas.svg"; +import { ReactComponent as Dollar } from "assets/icons/dollar.svg"; import { ReactComponent as Time } from "assets/icons/time.svg"; import { ReactComponent as Warning } from "assets/icons/warning_triangle.svg"; @@ -19,7 +19,6 @@ import { useTokenConversion } from "hooks/useTokenConversion"; import { EnrichedTokenSelect } from "./ChainTokenSelector/SelectorButton"; import styled from "@emotion/styled"; import { AmountInputError } from "../../Bridge/utils"; -import { validationErrorTextMap } from "views/Bridge/components/AmountInput"; type SwapQuoteResponse = { checks: object; @@ -52,6 +51,7 @@ interface ConfirmationButtonProps onConfirm?: () => void; validationError?: AmountInputError; validationWarning?: AmountInputError; + validationErrorFormatted?: string; // External state props buttonState: BridgeButtonState; buttonDisabled: boolean; @@ -71,6 +71,7 @@ const ExpandableLabelSection: React.FC< hasQuote: boolean; validationError?: AmountInputError; validationWarning?: AmountInputError; + validationErrorFormatted?: string; }> > = ({ fee, @@ -81,15 +82,16 @@ const ExpandableLabelSection: React.FC< children, hasQuote, validationError, + validationErrorFormatted, }) => { // Render state-specific content let content: React.ReactNode = null; - if (validationError) { + if (validationError && validationErrorFormatted) { content = ( <> - {validationErrorTextMap[validationError]} + {validationErrorFormatted} ); @@ -102,7 +104,7 @@ const ExpandableLabelSection: React.FC< - + {fee} @@ -197,6 +199,7 @@ export const ConfirmationButton: React.FC = ({ onConfirm, validationError, validationWarning, + validationErrorFormatted, buttonState, buttonDisabled, buttonLoading, @@ -327,6 +330,7 @@ export const ConfirmationButton: React.FC = ({ state={state} validationError={validationError} validationWarning={validationWarning} + validationErrorFormatted={validationErrorFormatted} hasQuote={!!swapQuote} > {expanded && state === "readyToConfirm" ? ( @@ -343,14 +347,14 @@ export const ConfirmationButton: React.FC = ({ - + {displayValues.estimatedTime} - + Net Fee diff --git a/src/views/SwapAndBridge/hooks/useSwapAndBridge.ts b/src/views/SwapAndBridge/hooks/useSwapAndBridge.ts index 1cf013140..436824a53 100644 --- a/src/views/SwapAndBridge/hooks/useSwapAndBridge.ts +++ b/src/views/SwapAndBridge/hooks/useSwapAndBridge.ts @@ -30,6 +30,7 @@ export type UseSwapAndBridgeReturn = { validationError?: AmountInputError; validationWarning?: AmountInputError; + validationErrorFormatted?: string | undefined; // Button state information buttonState: BridgeButtonState; @@ -148,7 +149,7 @@ export function useSwapAndBridge(): UseSwapAndBridgeReturn { isQuoteLoading, expectedInputAmount, expectedOutputAmount, - + validationErrorFormatted: validation.errorFormatted, validationError: validation.error, validationWarning: validation.warn, diff --git a/src/views/SwapAndBridge/hooks/useValidateSwapAndBridge.ts b/src/views/SwapAndBridge/hooks/useValidateSwapAndBridge.ts index 3e26e9f16..71ad0cbec 100644 --- a/src/views/SwapAndBridge/hooks/useValidateSwapAndBridge.ts +++ b/src/views/SwapAndBridge/hooks/useValidateSwapAndBridge.ts @@ -4,10 +4,12 @@ import axios from "axios"; import { AmountInputError } from "../../Bridge/utils"; import { EnrichedTokenSelect } from "../components/ChainTokenSelector/SelectorButton"; +import { validationErrorTextMap } from "views/Bridge/components/AmountInput"; export type ValidationResult = { error?: AmountInputError; warn?: AmountInputError; + errorFormatted?: string; }; export function useValidateSwapAndBridge( @@ -40,8 +42,25 @@ export function useValidateSwapAndBridge( return { error: errorType, warn: undefined as AmountInputError | undefined, + errorFormatted: getValidationErrorText({ + validationError: errorType, + inputToken, + }), }; }, [amount, isAmountOrigin, inputToken, error]); return validation; } + +function getValidationErrorText(props: { + validationError?: AmountInputError; + inputToken: EnrichedTokenSelect | null; +}): string | undefined { + if (!props.validationError) { + return; + } + return validationErrorTextMap[props.validationError]?.replace( + "[INPUT_TOKEN]", + props.inputToken!.symbol + ); +} diff --git a/src/views/SwapAndBridge/index.tsx b/src/views/SwapAndBridge/index.tsx index 54ba56c2c..cc1d35d78 100644 --- a/src/views/SwapAndBridge/index.tsx +++ b/src/views/SwapAndBridge/index.tsx @@ -23,6 +23,7 @@ export default function SwapAndBridge() { onConfirm, validationError, validationWarning, + validationErrorFormatted, buttonState, buttonDisabled, buttonLoading, @@ -64,6 +65,7 @@ export default function SwapAndBridge() { onConfirm={handleConfirm} validationError={validationError} validationWarning={validationWarning} + validationErrorFormatted={validationErrorFormatted} buttonState={buttonState} buttonDisabled={buttonDisabled} buttonLoading={buttonLoading} From 0b0b0c25158be11be2be1bc89c8597fcc06bbb3a Mon Sep 17 00:00:00 2001 From: Gerhard Steenkamp Date: Tue, 30 Sep 2025 15:50:27 +0200 Subject: [PATCH 18/39] add tooltip Signed-off-by: Gerhard Steenkamp --- src/views/SwapAndBridge/components/ConfirmationButton.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/views/SwapAndBridge/components/ConfirmationButton.tsx b/src/views/SwapAndBridge/components/ConfirmationButton.tsx index 520cd2db2..e60c763d7 100644 --- a/src/views/SwapAndBridge/components/ConfirmationButton.tsx +++ b/src/views/SwapAndBridge/components/ConfirmationButton.tsx @@ -19,6 +19,7 @@ import { useTokenConversion } from "hooks/useTokenConversion"; import { EnrichedTokenSelect } from "./ChainTokenSelector/SelectorButton"; import styled from "@emotion/styled"; import { AmountInputError } from "../../Bridge/utils"; +import { Tooltip } from "components/Tooltip"; type SwapQuoteResponse = { checks: object; @@ -356,7 +357,12 @@ export const ConfirmationButton: React.FC = ({ Net Fee - + + + {displayValues.netFee} From 51396253680e3224dbb99e9358d72e6e4d9d77b5 Mon Sep 17 00:00:00 2001 From: Gerhard Steenkamp Date: Tue, 30 Sep 2025 21:59:54 +0200 Subject: [PATCH 19/39] rsolve swap token info Signed-off-by: Gerhard Steenkamp --- src/hooks/useAvailableCrosschainRoutes.ts | 66 ++++++++----------- src/hooks/useSwapChains.ts | 12 ++++ src/hooks/useSwapTokens.ts | 13 ++++ src/hooks/useTokenConversion.ts | 38 ++++++++++- .../components/ConfirmationButton.tsx | 16 +---- 5 files changed, 90 insertions(+), 55 deletions(-) create mode 100644 src/hooks/useSwapChains.ts create mode 100644 src/hooks/useSwapTokens.ts diff --git a/src/hooks/useAvailableCrosschainRoutes.ts b/src/hooks/useAvailableCrosschainRoutes.ts index 3f199733d..2209a2708 100644 --- a/src/hooks/useAvailableCrosschainRoutes.ts +++ b/src/hooks/useAvailableCrosschainRoutes.ts @@ -1,8 +1,7 @@ -import { MAINNET_CHAIN_IDs } from "@across-protocol/constants"; import { useQuery } from "@tanstack/react-query"; -import getApiEndpoint from "utils/serverless-api"; -import { SwapChain, SwapToken } from "utils/serverless-api/types"; import { getConfig } from "utils/config"; +import { useSwapChains } from "./useSwapChains"; +import { useSwapTokens } from "./useSwapTokens"; export type LifiToken = { chainId: number; @@ -13,27 +12,19 @@ export type LifiToken = { priceUSD: string; coinKey: string; logoURI: string; - routeSource: "bridge" | "swap" | "both"; + routeSource: "bridge" | "swap"; }; export default function useAvailableCrosschainRoutes() { + const swapChainsQuery = useSwapChains(); + const swapTokensQuery = useSwapTokens(); + return useQuery({ queryKey: ["availableCrosschainRoutes"], queryFn: async () => { - const api = getApiEndpoint(); - const [chains, tokens] = await Promise.all([ - api.swapChains(), - api.swapTokens(), - ]); - - const allowedChainIds = new Set(Object.values(MAINNET_CHAIN_IDs)); - // 1) Build swap token map by chain - const swapTokensByChain = (tokens as SwapToken[]).reduce( + const swapTokensByChain = (swapTokensQuery.data || []).reduce( (acc, token) => { - if (!allowedChainIds.has(token.chainId)) { - return acc; - } const mapped: LifiToken = { chainId: token.chainId, address: token.address, @@ -63,9 +54,6 @@ export default function useAvailableCrosschainRoutes() { const bridgeTokensByChain = bridgeOriginChains.reduce( (acc, fromChainId) => { - if (!allowedChainIds.has(fromChainId)) { - return acc; - } const reachable = config.filterReachableTokens(fromChainId); const lifiTokens: LifiToken[] = reachable.map((t) => ({ chainId: fromChainId, @@ -85,40 +73,40 @@ export default function useAvailableCrosschainRoutes() { {} as Record> ); - // 3) Merge swap and bridge tokens, de-duplicating by address (case-insensitive) + // 3) Combine swap and bridge tokens, deduplicating by address const chainIdsInSwap = new Set( - (chains as SwapChain[]).map((c) => c.chainId) + (swapChainsQuery.data || []).map((c) => c.chainId) ); const chainIdsInBridge = new Set( Object.keys(bridgeTokensByChain).map(Number) ); const chainIds = Array.from( new Set([...chainIdsInSwap, ...chainIdsInBridge]) - ).filter((id) => allowedChainIds.has(id)); + ); - const blendedByChain: Record> = {}; + const combinedByChain: Record> = {}; for (const chainId of chainIds) { - const mapByAddr = new Map(); - // Prefer swap tokens first (they include price) - (swapTokensByChain[chainId] || []).forEach((t) => { - mapByAddr.set(t.address.toLowerCase(), t); + const swapTokens = swapTokensByChain[chainId] || []; + const bridgeTokens = bridgeTokensByChain[chainId] || []; + + // Deduplicate by address (case-insensitive), preferring swap tokens for price data + const tokenMap = new Map(); + + // Add bridge tokens first + bridgeTokens.forEach((token) => { + tokenMap.set(token.address.toLowerCase(), token); }); - // Add bridge tokens, merging routeSource when duplicate - (bridgeTokensByChain[chainId] || []).forEach((t) => { - const key = t.address.toLowerCase(); - const existing = mapByAddr.get(key); - if (!existing) { - mapByAddr.set(key, t); - } else { - // Merge: if token exists from swap, mark as both - mapByAddr.set(key, { ...existing, routeSource: "both" }); - } + + // Add swap tokens, overriding bridge tokens if same address (swap has price data) + swapTokens.forEach((token) => { + tokenMap.set(token.address.toLowerCase(), token); }); - blendedByChain[chainId] = Array.from(mapByAddr.values()); + combinedByChain[chainId] = Array.from(tokenMap.values()); } - return blendedByChain; + return combinedByChain; }, + enabled: swapChainsQuery.isSuccess && swapTokensQuery.isSuccess, }); } diff --git a/src/hooks/useSwapChains.ts b/src/hooks/useSwapChains.ts new file mode 100644 index 000000000..96356deb0 --- /dev/null +++ b/src/hooks/useSwapChains.ts @@ -0,0 +1,12 @@ +import { useQuery } from "@tanstack/react-query"; +import getApiEndpoint from "utils/serverless-api"; + +export function useSwapChains() { + return useQuery({ + queryKey: ["swapChains"], + queryFn: async () => { + const api = getApiEndpoint(); + return await api.swapChains(); + }, + }); +} diff --git a/src/hooks/useSwapTokens.ts b/src/hooks/useSwapTokens.ts new file mode 100644 index 000000000..fe9fecb3d --- /dev/null +++ b/src/hooks/useSwapTokens.ts @@ -0,0 +1,13 @@ +import { useQuery } from "@tanstack/react-query"; +import getApiEndpoint from "utils/serverless-api"; +import { SwapTokensQuery } from "utils/serverless-api/prod/swap-tokens"; + +export function useSwapTokens(query?: SwapTokensQuery) { + return useQuery({ + queryKey: ["swapTokens", query], + queryFn: async () => { + const api = getApiEndpoint(); + return await api.swapTokens(query); + }, + }); +} diff --git a/src/hooks/useTokenConversion.ts b/src/hooks/useTokenConversion.ts index dc283c24b..a1f769599 100644 --- a/src/hooks/useTokenConversion.ts +++ b/src/hooks/useTokenConversion.ts @@ -10,6 +10,7 @@ import { hubPoolChainId, } from "utils"; import { ConvertDecimals } from "utils/convertdecimals"; +import useAvailableCrosschainRoutes from "./useAvailableCrosschainRoutes"; const config = getConfig(); @@ -18,7 +19,42 @@ export function useTokenConversion( baseCurrency: string, historicalDateISO?: string ) { - const token = getToken(symbol); + const availableCrosschainRoutes = useAvailableCrosschainRoutes(); + + // Try to get token from constants first, fallback to swap API data + let token; + try { + token = getToken(symbol); + } catch (error) { + // If token not found in constants, try to find it in swap API data + const swapTokens = availableCrosschainRoutes.data; + if (swapTokens) { + // Search across all chains for a token with matching symbol + for (const chainId of Object.keys(swapTokens)) { + const tokensOnChain = swapTokens[Number(chainId)]; + const foundToken = tokensOnChain.find( + (t) => t.symbol.toUpperCase() === symbol.toUpperCase() + ); + if (foundToken) { + // Convert LifiToken to TokenInfo format + token = { + symbol: foundToken.symbol, + name: foundToken.name, + decimals: foundToken.decimals, + addresses: { [foundToken.chainId]: foundToken.address }, + mainnetAddress: foundToken.address, // Use the found address as mainnet address + logoURI: foundToken.logoURI, + }; + break; + } + } + } + + // If still not found, re-throw the original error + if (!token) { + throw error; + } + } // If the token is OP, we need to use the address of the token on Optimism const l1Token = diff --git a/src/views/SwapAndBridge/components/ConfirmationButton.tsx b/src/views/SwapAndBridge/components/ConfirmationButton.tsx index e60c763d7..95e44b76c 100644 --- a/src/views/SwapAndBridge/components/ConfirmationButton.tsx +++ b/src/views/SwapAndBridge/components/ConfirmationButton.tsx @@ -342,7 +342,7 @@ export const ConfirmationButton: React.FC = ({ Route - + {displayValues.route} @@ -607,15 +607,6 @@ const DetailRight = styled.div` gap: 8px; `; -const RouteDot = styled.span` - display: inline-block; - width: 20px; - height: 20px; - background: ${COLORS.aqua}; - border-radius: 50%; - opacity: 0.8; -`; - const FeeBreakdown = styled.div` padding-left: 24px; border-left: 1px solid rgba(224, 243, 255, 0.1); @@ -637,9 +628,4 @@ const FeeBreakdownValue = styled.span` color: #e0f3ff; `; -const SmallInfoIcon = styled(Info)` - width: 16px; - height: 16px; -`; - export default ConfirmationButton; From 79c617de3234fdda8686ccee8b6e45cc8354b5ae Mon Sep 17 00:00:00 2001 From: Gerhard Steenkamp Date: Tue, 30 Sep 2025 22:27:26 +0200 Subject: [PATCH 20/39] disable unreachable tokens Signed-off-by: Gerhard Steenkamp --- src/hooks/useAvailableCrosschainRoutes.ts | 66 +++++++++++++- .../components/ChainTokenSelector/Modal.tsx | 91 ++++++++++++++++--- .../ChainTokenSelector/SelectorButton.tsx | 4 + .../SwapAndBridge/components/InputForm.tsx | 5 + 4 files changed, 149 insertions(+), 17 deletions(-) diff --git a/src/hooks/useAvailableCrosschainRoutes.ts b/src/hooks/useAvailableCrosschainRoutes.ts index 2209a2708..ff0e45923 100644 --- a/src/hooks/useAvailableCrosschainRoutes.ts +++ b/src/hooks/useAvailableCrosschainRoutes.ts @@ -13,14 +13,28 @@ export type LifiToken = { coinKey: string; logoURI: string; routeSource: "bridge" | "swap"; + isReachable?: boolean; // Added to mark if token is reachable from the other token }; -export default function useAvailableCrosschainRoutes() { +export type TokenInfo = { + chainId: number; + address: string; + symbol: string; +}; + +export type RouteFilterParams = { + inputToken?: TokenInfo | null; + outputToken?: TokenInfo | null; +}; + +export default function useAvailableCrosschainRoutes( + filterParams?: RouteFilterParams +) { const swapChainsQuery = useSwapChains(); const swapTokensQuery = useSwapTokens(); return useQuery({ - queryKey: ["availableCrosschainRoutes"], + queryKey: ["availableCrosschainRoutes", filterParams], queryFn: async () => { // 1) Build swap token map by chain const swapTokensByChain = (swapTokensQuery.data || []).reduce( @@ -105,6 +119,54 @@ export default function useAvailableCrosschainRoutes() { combinedByChain[chainId] = Array.from(tokenMap.values()); } + // 4) Apply route filtering if filterParams are provided + if (filterParams?.inputToken || filterParams?.outputToken) { + const config = getConfig(); + const otherToken = filterParams.inputToken || filterParams.outputToken; + const isFilteringForInput = !!filterParams.inputToken; + + // Mark tokens as reachable/unreachable based on route validation + for (const chainId of Object.keys(combinedByChain)) { + combinedByChain[Number(chainId)] = combinedByChain[ + Number(chainId) + ].map((token) => { + const fromChain = isFilteringForInput + ? Number(chainId) + : otherToken!.chainId; + const toChain = isFilteringForInput + ? otherToken!.chainId + : Number(chainId); + const fromTokenSymbol = isFilteringForInput + ? token.symbol + : otherToken!.symbol; + const toTokenSymbol = isFilteringForInput + ? otherToken!.symbol + : token.symbol; + + let isReachable = true; + + // For same chain (swap), always reachable + if (fromChain === toChain) { + isReachable = true; + } else { + // For bridge, check if there's an explicit bridge route + const bridgeRoutes = config.filterRoutes({ + fromChain, + toChain, + fromTokenSymbol, + toTokenSymbol, + }); + isReachable = bridgeRoutes.length > 0; + } + + return { + ...token, + isReachable, + }; + }); + } + } + return combinedByChain; }, enabled: swapChainsQuery.isSuccess && swapTokensQuery.isSuccess, diff --git a/src/views/SwapAndBridge/components/ChainTokenSelector/Modal.tsx b/src/views/SwapAndBridge/components/ChainTokenSelector/Modal.tsx index 589fc9533..bd3e0ef4e 100644 --- a/src/views/SwapAndBridge/components/ChainTokenSelector/Modal.tsx +++ b/src/views/SwapAndBridge/components/ChainTokenSelector/Modal.tsx @@ -22,6 +22,7 @@ import { BigNumber } from "ethers"; type Props = { onSelect: (token: EnrichedTokenSelect) => void; isOriginToken: boolean; + otherToken?: EnrichedTokenSelect | null; // The currently selected token on the other side displayModal: boolean; setDisplayModal: (displayModal: boolean) => void; @@ -32,10 +33,21 @@ export default function ChainTokenSelectorModal({ displayModal, setDisplayModal, onSelect, + otherToken, }: Props) { const balances = useEnrichedCrosschainBalances(); - const crossChainRoutes = useAvailableCrosschainRoutes(); + const crossChainRoutes = useAvailableCrosschainRoutes( + otherToken + ? { + [isOriginToken ? "outputToken" : "inputToken"]: { + chainId: otherToken.chainId, + address: otherToken.address, + symbol: otherToken.symbol, + }, + } + : undefined + ); const [selectedChain, setSelectedChain] = useState(null); @@ -48,8 +60,22 @@ export default function ChainTokenSelectorModal({ if (tokens.length === 0 && selectedChain === null) { tokens = Object.values(balances).flatMap((t) => t); } + + // Enrich tokens with reachability information from the hook + const enrichedTokens = tokens.map((token) => { + // Find the corresponding token in crossChainRoutes to check isReachable + const routeToken = crossChainRoutes.data?.[token.chainId]?.find( + (rt) => rt.address.toLowerCase() === token.address.toLowerCase() + ); + + return { + ...token, + isReachable: routeToken?.isReachable, + }; + }); + // Return ordering top 100 tokens ordering highest balanceUsd to lowest (fallback alphabetical) - const sortedTokens = tokens.slice(0, 100).sort((a, b) => { + const sortedTokens = enrichedTokens.slice(0, 100).sort((a, b) => { if (Math.abs(b.balanceUsd - a.balanceUsd) < 0.0001) { return a.symbol.toLocaleLowerCase().localeCompare(b.symbol); } @@ -69,11 +95,11 @@ export default function ChainTokenSelectorModal({ keyword.includes(tokenSearch.toLowerCase().replaceAll(" ", "")) ); }); - }, [selectedChain, balances, tokenSearch]); + }, [selectedChain, balances, tokenSearch, otherToken, crossChainRoutes.data]); const displayedChains = useMemo(() => { - return Object.fromEntries( - Object.entries(crossChainRoutes.data || {}).filter(([chainId]) => { + const chainsWithDisabledState = Object.entries(crossChainRoutes.data || {}) + .filter(([chainId]) => { // why ar we filtering out Boba? if ([288].includes(Number(chainId))) { return false; @@ -87,8 +113,23 @@ export default function ChainTokenSelectorModal({ keyword.toLowerCase().includes(chainSearch.toLowerCase()) ); }) - ); - }, [chainSearch, crossChainRoutes.data]); + .map(([chainId, tokens]) => { + let isDisabled = false; + + // If there's an other token selected, check if this chain has any reachable tokens + if (otherToken) { + const tokensOnChain = tokens || []; + const hasReachableTokens = tokensOnChain.some( + (token) => token.isReachable !== false + ); + isDisabled = !hasReachableTokens; + } + + return [chainId, { tokens, isDisabled }]; + }); + + return Object.fromEntries(chainsWithDisabledState); + }, [chainSearch, crossChainRoutes.data, otherToken]); return ( setSelectedChain(null)} /> - {Object.entries(displayedChains).map(([chainId]) => ( + {Object.entries(displayedChains).map(([chainId, chainData]) => ( setSelectedChain(Number(chainId))} /> ))} @@ -168,10 +212,12 @@ const ChainEntry = ({ chainId, isSelected, onClick, + isDisabled = false, }: { chainId: number | null; isSelected: boolean; onClick: () => void; + isDisabled?: boolean; }) => { const chainInfo = chainId ? getChainInfo(chainId) @@ -180,7 +226,11 @@ const ChainEntry = ({ name: "All", }; return ( - + {chainInfo.name} {isSelected && } @@ -198,8 +248,14 @@ const TokenEntry = ({ onClick: () => void; }) => { const hasBalance = token.balance.gt(0) && token.balanceUsd > 0.01; + const isDisabled = token.isReachable === false; + return ( - + {token.name} @@ -351,7 +407,7 @@ const ListWrapper = styled.div` scrollbar-color: rgba(255, 255, 255, 0.1) transparent; `; -const EntryItem = styled.div<{ isSelected: boolean }>` +const EntryItem = styled.div<{ isSelected: boolean; isDisabled?: boolean }>` display: flex; flex-direction: row; justify-content: space-between; @@ -369,13 +425,18 @@ const EntryItem = styled.div<{ isSelected: boolean }>` background: ${({ isSelected }) => isSelected ? COLORS["aqua-5"] : "transparent"}; - cursor: pointer; + cursor: ${({ isDisabled }) => (isDisabled ? "not-allowed" : "pointer")}; + opacity: ${({ isDisabled }) => (isDisabled ? 0.5 : 1)}; - transition: background 0.2s ease-in-out; + transition: + background 0.2s ease-in-out, + opacity 0.2s ease-in-out; &:hover { - background: ${({ isSelected }) => - isSelected ? COLORS["aqua-15"] : COLORS["grey-400-15"]}; + background: ${({ isSelected, isDisabled }) => { + if (isDisabled) return "transparent"; + return isSelected ? COLORS["aqua-15"] : COLORS["grey-400-15"]; + }}; } `; diff --git a/src/views/SwapAndBridge/components/ChainTokenSelector/SelectorButton.tsx b/src/views/SwapAndBridge/components/ChainTokenSelector/SelectorButton.tsx index 0f05ab9d5..8c0aeeb8f 100644 --- a/src/views/SwapAndBridge/components/ChainTokenSelector/SelectorButton.tsx +++ b/src/views/SwapAndBridge/components/ChainTokenSelector/SelectorButton.tsx @@ -22,6 +22,7 @@ type Props = { selectedToken: EnrichedTokenSelect | null; onSelect?: (token: EnrichedTokenSelect) => void; isOriginToken: boolean; + otherToken?: EnrichedTokenSelect | null; // The currently selected token on the other side marginBottom?: string; className?: string; }; @@ -30,6 +31,7 @@ export default function SelectorButton({ onSelect, selectedToken, isOriginToken, + otherToken, className, }: Props) { const [displayModal, setDisplayModal] = useState(false); @@ -66,6 +68,7 @@ export default function SelectorButton({ displayModal={displayModal} setDisplayModal={setDisplayModal} isOriginToken={isOriginToken} + otherToken={otherToken} /> ); @@ -95,6 +98,7 @@ export default function SelectorButton({ displayModal={displayModal} setDisplayModal={setDisplayModal} isOriginToken={isOriginToken} + otherToken={otherToken} /> ); diff --git a/src/views/SwapAndBridge/components/InputForm.tsx b/src/views/SwapAndBridge/components/InputForm.tsx index 169fc02be..66c3d8862 100644 --- a/src/views/SwapAndBridge/components/InputForm.tsx +++ b/src/views/SwapAndBridge/components/InputForm.tsx @@ -65,6 +65,7 @@ export const InputForm = ({ insufficientInputBalance={ validationError === AmountInputError.INSUFFICIENT_BALANCE } + otherToken={outputToken} /> @@ -82,6 +83,7 @@ export const InputForm = ({ expectedAmount={expectedOutputAmount} shouldUpdate={isAmountOrigin} isUpdateLoading={isQuoteLoading} + otherToken={inputToken} /> ); @@ -96,6 +98,7 @@ const TokenInput = ({ shouldUpdate, isUpdateLoading, insufficientInputBalance = false, + otherToken, }: { setToken: (token: EnrichedTokenSelect) => void; token: EnrichedTokenSelect | null; @@ -105,6 +108,7 @@ const TokenInput = ({ shouldUpdate: boolean; isUpdateLoading: boolean; insufficientInputBalance?: boolean; + otherToken?: EnrichedTokenSelect | null; }) => { const [amountString, setAmountString] = useState(""); const [justTyped, setJustTyped] = useState(false); @@ -201,6 +205,7 @@ const TokenInput = ({ isOriginToken={isOrigin} marginBottom={token ? "24px" : "0px"} selectedToken={token} + otherToken={otherToken} /> {token && ( From 526ca7692b8370bab19ea7f627656c86b1bd4b24 Mon Sep 17 00:00:00 2001 From: Gerhard Steenkamp Date: Tue, 30 Sep 2025 22:37:18 +0200 Subject: [PATCH 21/39] filter and sort Signed-off-by: Gerhard Steenkamp --- .../components/ChainTokenSelector/Modal.tsx | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/views/SwapAndBridge/components/ChainTokenSelector/Modal.tsx b/src/views/SwapAndBridge/components/ChainTokenSelector/Modal.tsx index bd3e0ef4e..146b41d73 100644 --- a/src/views/SwapAndBridge/components/ChainTokenSelector/Modal.tsx +++ b/src/views/SwapAndBridge/components/ChainTokenSelector/Modal.tsx @@ -75,7 +75,17 @@ export default function ChainTokenSelectorModal({ }); // Return ordering top 100 tokens ordering highest balanceUsd to lowest (fallback alphabetical) + // Push disabled tokens to the bottom const sortedTokens = enrichedTokens.slice(0, 100).sort((a, b) => { + // First, sort by disabled status - disabled tokens go to bottom + const aDisabled = a.isReachable === false; + const bDisabled = b.isReachable === false; + + if (aDisabled !== bDisabled) { + return aDisabled ? 1 : -1; + } + + // Then sort by balance (for enabled tokens) or alphabetically (for disabled tokens) if (Math.abs(b.balanceUsd - a.balanceUsd) < 0.0001) { return a.symbol.toLocaleLowerCase().localeCompare(b.symbol); } @@ -105,6 +115,11 @@ export default function ChainTokenSelectorModal({ return false; } + // Filter out the chain of the other token (same chain can't be both input and output) + if (otherToken && Number(chainId) === otherToken.chainId) { + return false; + } + const keywords = [ String(chainId), getChainInfo(Number(chainId)).name.toLowerCase().replace(" ", ""), @@ -126,6 +141,23 @@ export default function ChainTokenSelectorModal({ } return [chainId, { tokens, isDisabled }]; + }) + // Sort chains to push disabled ones to the bottom + .sort(([chainIdA, chainDataA], [chainIdB, chainDataB]) => { + const aDisabled = (chainDataA as { tokens: any; isDisabled: boolean }) + .isDisabled; + const bDisabled = (chainDataB as { tokens: any; isDisabled: boolean }) + .isDisabled; + + // First, sort by disabled status - disabled chains go to bottom + if (aDisabled !== bDisabled) { + return aDisabled ? 1 : -1; + } + + // Then sort alphabetically by chain name + const chainInfoA = getChainInfo(Number(chainIdA)); + const chainInfoB = getChainInfo(Number(chainIdB)); + return chainInfoA.name.localeCompare(chainInfoB.name); }); return Object.fromEntries(chainsWithDisabledState); From dd6cb80282d14eee3e42af9c96564937752cbb50 Mon Sep 17 00:00:00 2001 From: Gerhard Steenkamp Date: Tue, 30 Sep 2025 22:43:36 +0200 Subject: [PATCH 22/39] fixup Signed-off-by: Gerhard Steenkamp --- .../components/ChainTokenSelector/Modal.tsx | 46 +++++++++---------- 1 file changed, 22 insertions(+), 24 deletions(-) diff --git a/src/views/SwapAndBridge/components/ChainTokenSelector/Modal.tsx b/src/views/SwapAndBridge/components/ChainTokenSelector/Modal.tsx index 146b41d73..f7654c624 100644 --- a/src/views/SwapAndBridge/components/ChainTokenSelector/Modal.tsx +++ b/src/views/SwapAndBridge/components/ChainTokenSelector/Modal.tsx @@ -68,9 +68,26 @@ export default function ChainTokenSelectorModal({ (rt) => rt.address.toLowerCase() === token.address.toLowerCase() ); + // Determine if token should be disabled based on new requirements: + // Only disable tokens if the token is NOT a swap token AND it is not reachable via a bridge route + let shouldDisable = false; + if (routeToken) { + // If it's a swap token, never disable it + if (routeToken.routeSource === "swap") { + shouldDisable = false; + } else { + // If it's not a swap token, disable it only if it's not reachable via bridge + shouldDisable = routeToken.isReachable === false; + } + } else { + // If no route token found, disable it (not available for any routes) + shouldDisable = true; + } + return { ...token, - isReachable: routeToken?.isReachable, + isReachable: !shouldDisable, + routeSource: routeToken?.routeSource || "bridge", // Default to bridge if not found }; }); @@ -129,32 +146,13 @@ export default function ChainTokenSelectorModal({ ); }) .map(([chainId, tokens]) => { - let isDisabled = false; - - // If there's an other token selected, check if this chain has any reachable tokens - if (otherToken) { - const tokensOnChain = tokens || []; - const hasReachableTokens = tokensOnChain.some( - (token) => token.isReachable !== false - ); - isDisabled = !hasReachableTokens; - } + // Never disable chains - requirement 1 + const isDisabled = false; return [chainId, { tokens, isDisabled }]; }) - // Sort chains to push disabled ones to the bottom - .sort(([chainIdA, chainDataA], [chainIdB, chainDataB]) => { - const aDisabled = (chainDataA as { tokens: any; isDisabled: boolean }) - .isDisabled; - const bDisabled = (chainDataB as { tokens: any; isDisabled: boolean }) - .isDisabled; - - // First, sort by disabled status - disabled chains go to bottom - if (aDisabled !== bDisabled) { - return aDisabled ? 1 : -1; - } - - // Then sort alphabetically by chain name + // Sort chains alphabetically by name (no need to sort by disabled status since none are disabled) + .sort(([chainIdA], [chainIdB]) => { const chainInfoA = getChainInfo(Number(chainIdA)); const chainInfoB = getChainInfo(Number(chainIdB)); return chainInfoA.name.localeCompare(chainInfoB.name); From efd6b76e4c4ea6170573317ae1bffb1878b1845b Mon Sep 17 00:00:00 2001 From: Gerhard Steenkamp Date: Wed, 1 Oct 2025 13:46:45 +0200 Subject: [PATCH 23/39] mobile token selector Signed-off-by: Gerhard Steenkamp --- .../components/ChainTokenSelector/Modal.tsx | 455 +++++++++++++++--- .../ChainTokenSelector/Searchbar.tsx | 10 +- 2 files changed, 402 insertions(+), 63 deletions(-) diff --git a/src/views/SwapAndBridge/components/ChainTokenSelector/Modal.tsx b/src/views/SwapAndBridge/components/ChainTokenSelector/Modal.tsx index f7654c624..1d874389b 100644 --- a/src/views/SwapAndBridge/components/ChainTokenSelector/Modal.tsx +++ b/src/views/SwapAndBridge/components/ChainTokenSelector/Modal.tsx @@ -13,10 +13,12 @@ import { getChainInfo, parseUnits, } from "utils"; -import { useMemo, useState } from "react"; +import { useMemo, useState, useEffect } from "react"; import { ReactComponent as CheckmarkCircle } from "assets/icons/checkmark-circle.svg"; +import { ReactComponent as ChevronRight } from "assets/icons/chevron-right.svg"; import AllChainsIcon from "assets/chain-logos/all-swap-chain.png"; import useEnrichedCrosschainBalances from "hooks/useEnrichedCrosschainBalances"; +import useCurrentBreakpoint from "hooks/useCurrentBreakpoint"; import { BigNumber } from "ethers"; type Props = { @@ -36,6 +38,7 @@ export default function ChainTokenSelectorModal({ otherToken, }: Props) { const balances = useEnrichedCrosschainBalances(); + const { isMobile } = useCurrentBreakpoint(); const crossChainRoutes = useAvailableCrosschainRoutes( otherToken @@ -50,10 +53,19 @@ export default function ChainTokenSelectorModal({ ); const [selectedChain, setSelectedChain] = useState(null); + const [mobileStep, setMobileStep] = useState<"chain" | "token">("chain"); const [tokenSearch, setTokenSearch] = useState(""); const [chainSearch, setChainSearch] = useState(""); + // Reset mobile step when modal opens/closes + useEffect(() => { + if (displayModal) { + setMobileStep("chain"); + setSelectedChain(null); + } + }, [displayModal]); + const displayedTokens = useMemo(() => { let tokens = selectedChain ? (balances[selectedChain] ?? []) : []; @@ -161,33 +173,220 @@ export default function ChainTokenSelectorModal({ return Object.fromEntries(chainsWithDisabledState); }, [chainSearch, crossChainRoutes.data, otherToken]); + return isMobile ? ( + { + setSelectedChain(chainId); + setMobileStep("token"); + }} + onTokenSelect={onSelect} + /> + ) : ( + + ); +} + +// Mobile Modal Component +const MobileModal = ({ + isOriginToken, + displayModal, + setDisplayModal, + mobileStep, + setMobileStep, + selectedChain, + chainSearch, + setChainSearch, + tokenSearch, + setTokenSearch, + displayedChains, + displayedTokens, + onChainSelect, + onTokenSelect, +}: { + isOriginToken: boolean; + displayModal: boolean; + setDisplayModal: (display: boolean) => void; + mobileStep: "chain" | "token"; + setMobileStep: (step: "chain" | "token") => void; + selectedChain: number | null; + chainSearch: string; + setChainSearch: (search: string) => void; + tokenSearch: string; + setTokenSearch: (search: string) => void; + displayedChains: any; + displayedTokens: any[]; + onChainSelect: (chainId: number | null) => void; + onTokenSelect: (token: EnrichedTokenSelect) => void; +}) => { return ( Select {isOriginToken ? "Origin" : "Destination"} Token + + {mobileStep === "token" && ( + { + setMobileStep("chain"); + setTokenSearch(""); // Clear token search when going back + }} + > + + + )} + + {mobileStep === "chain" + ? `Select ${isOriginToken ? "Origin" : "Destination"} Chain` + : `Select ${isOriginToken ? "Origin" : "Destination"} Token`} + + } isOpen={displayModal} padding="thin" exitModalHandler={() => setDisplayModal(false)} exitOnOutsideClick - width={720} + width={400} + height={600} + titleBorder + > + setDisplayModal(false)} + /> + + ); +}; + +// Desktop Modal Component +const DesktopModal = ({ + isOriginToken, + displayModal, + setDisplayModal, + selectedChain, + chainSearch, + setChainSearch, + tokenSearch, + setTokenSearch, + displayedChains, + displayedTokens, + onChainSelect, + onTokenSelect, +}: { + isOriginToken: boolean; + displayModal: boolean; + setDisplayModal: (display: boolean) => void; + selectedChain: number | null; + chainSearch: string; + setChainSearch: (search: string) => void; + tokenSearch: string; + setTokenSearch: (search: string) => void; + displayedChains: any; + displayedTokens: any[]; + onChainSelect: (chainId: number | null) => void; + onTokenSelect: (token: EnrichedTokenSelect) => void; +}) => { + return ( + setDisplayModal(false)} + exitOnOutsideClick + width={1100} height={800} titleBorder > - - - - - + setDisplayModal(false)} + /> + + ); +}; + +// Mobile Layout Component - 2-step process +const MobileLayout = ({ + mobileStep, + selectedChain, + chainSearch, + setChainSearch, + tokenSearch, + setTokenSearch, + displayedChains, + displayedTokens, + onChainSelect, + onTokenSelect, + onModalClose, +}: { + mobileStep: "chain" | "token"; + selectedChain: number | null; + chainSearch: string; + setChainSearch: (search: string) => void; + tokenSearch: string; + setTokenSearch: (search: string) => void; + displayedChains: any; + displayedTokens: any[]; + onChainSelect: (chainId: number | null) => void; + onTokenSelect: (token: EnrichedTokenSelect) => void; + onModalClose: () => void; +}) => { + return ( + + {mobileStep === "chain" ? ( + // Step 1: Chain Selection + + setSelectedChain(null)} + onClick={() => onChainSelect(null)} /> {Object.entries(displayedChains).map(([chainId, chainData]) => ( setSelectedChain(Number(chainId))} + onClick={() => onChainSelect(Number(chainId))} /> ))} - - - - - - + + ) : ( + // Step 2: Token Selection + + {displayedTokens.map((token) => ( { - onSelect({ + onTokenSelect({ chainId: token.chainId, symbolUri: token.logoURI, symbol: token.symbol, @@ -227,16 +425,100 @@ export default function ChainTokenSelectorModal({ priceUsd: parseUnits(token.priceUSD, 18), decimals: token.decimals, }); - setDisplayModal(false); + onModalClose(); }} /> ))} - - - + + )} + ); -} +}; + +// Desktop Layout Component - Side-by-side columns +const DesktopLayout = ({ + selectedChain, + chainSearch, + setChainSearch, + tokenSearch, + setTokenSearch, + displayedChains, + displayedTokens, + onChainSelect, + onTokenSelect, + onModalClose, +}: { + selectedChain: number | null; + chainSearch: string; + setChainSearch: (search: string) => void; + tokenSearch: string; + setTokenSearch: (search: string) => void; + displayedChains: any; + displayedTokens: any[]; + onChainSelect: (chainId: number | null) => void; + onTokenSelect: (token: EnrichedTokenSelect) => void; + onModalClose: () => void; +}) => { + return ( + + + + + onChainSelect(null)} + /> + {Object.entries(displayedChains).map(([chainId, chainData]) => ( + onChainSelect(Number(chainId))} + /> + ))} + + + + + + + {displayedTokens.map((token) => ( + { + onTokenSelect({ + chainId: token.chainId, + symbolUri: token.logoURI, + symbol: token.symbol, + address: token.address, + balance: token.balance, + priceUsd: parseUnits(token.priceUSD, 18), + decimals: token.decimals, + }); + onModalClose(); + }} + /> + ))} + + + + ); +}; const ChainEntry = ({ chainId, @@ -320,6 +602,10 @@ const TokenItemImage = ({ token }: { token: LifiToken }) => { ); }; +const SearchBarStyled = styled(Searchbar)` + flex-shrink: 0; +`; + const TokenItemImageWrapper = styled.div` width: 32px; height: 32px; @@ -354,25 +640,94 @@ const TokenItemChainImage = styled.img` right: 0; `; -const InnerWrapper = styled.div` +// Mobile Layout Styled Components +const MobileInnerWrapper = styled.div` width: 100%; - height: 100%; + height: 600px; /* Constrain height to enable scrolling */ + display: flex; + flex-direction: column; +`; + +const MobileChainWrapper = styled.div` + width: 100%; + display: flex; + flex-direction: column; + gap: 8px; + flex: 1; + min-height: 0; +`; + +const MobileTokenWrapper = styled.div` + width: 100%; + display: flex; + flex-direction: column; + gap: 8px; + flex: 1; + min-height: 0; +`; +// Desktop Layout Styled Components +const DesktopInnerWrapper = styled.div` + width: 100%; + height: 800px; display: flex; flex-direction: row; gap: 12px; `; +const DesktopChainWrapper = styled.div` + flex: 1; + display: flex; + flex-direction: column; + gap: 8px; + min-height: 0; + min-width: 230px; +`; + +const DesktopTokenWrapper = styled.div` + flex: 2; + display: flex; + flex-direction: column; + gap: 8px; + min-height: 0; +`; + const VerticalDivider = styled.div` width: 1px; + margin: -16px 0; + background-color: #3f4247; + flex-shrink: 0; +`; - height: 400px; +const TitleWrapper = styled.div` + display: flex; + align-items: center; + gap: 12px; + width: 100%; +`; - margin: -16px 0; +const BackButton = styled.button` + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border: none; + background: transparent; + color: var(--Base-bright-gray, #e0f3ff); + cursor: pointer; + border-radius: 6px; + transition: background 0.2s ease-in-out; - background-color: #3f4247; + &:hover { + background: rgba(255, 255, 255, 0.1); + } - flex-shrink: 0; + svg { + width: 16px; + height: 16px; + transform: rotate(180deg); /* Rotate chevron-right to make it point left */ + } `; const Title = styled.div` @@ -386,35 +741,13 @@ const Title = styled.div` line-height: 130%; /* 26px */ `; -const ChainWrapper = styled.div` - width: calc(33% - 0.5px); - height: 100%; - - display: flex; - flex-direction: column; - gap: 16px; -`; - -const TokenWrapper = styled.div` - width: calc(67% - 0.5px); - height: 100%; - - display: flex; - flex-direction: column; - gap: 8px; -`; - -const SearchWrapper = styled.div` - padding: 0px 8px; -`; - const ListWrapper = styled.div` display: flex; flex-direction: column; gap: 4px; - - overflow-y: scroll; - max-height: 300px; + overflow-y: auto; + flex: 1; /* Take up remaining space in parent */ + min-height: 0; /* Allow flex child to shrink below content size */ &::-webkit-scrollbar { width: 8px; diff --git a/src/views/SwapAndBridge/components/ChainTokenSelector/Searchbar.tsx b/src/views/SwapAndBridge/components/ChainTokenSelector/Searchbar.tsx index 9b2603699..dc4a4ab51 100644 --- a/src/views/SwapAndBridge/components/ChainTokenSelector/Searchbar.tsx +++ b/src/views/SwapAndBridge/components/ChainTokenSelector/Searchbar.tsx @@ -6,11 +6,17 @@ type Props = { searchTopic: string; search: string; setSearch: (search: string) => void; + className?: string; }; -export default function Searchbar({ searchTopic, search, setSearch }: Props) { +export default function Searchbar({ + searchTopic, + search, + setSearch, + className, +}: Props) { return ( - + Date: Wed, 1 Oct 2025 14:12:42 +0200 Subject: [PATCH 24/39] add sections Signed-off-by: Gerhard Steenkamp --- src/utils/constants.ts | 4 + .../components/ChainTokenSelector/Modal.tsx | 242 ++++++++++++++++-- 2 files changed, 218 insertions(+), 28 deletions(-) diff --git a/src/utils/constants.ts b/src/utils/constants.ts index dcff81ea4..af7b62b10 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -1,10 +1,14 @@ import assert from "assert"; import { BigNumber, ethers, providers } from "ethers"; + import { CHAIN_IDs, PUBLIC_NETWORKS, TOKEN_SYMBOLS_MAP, } from "@across-protocol/constants"; + +export { CHAIN_IDs } from "@across-protocol/constants"; + import * as superstruct from "superstruct"; import { parseEtherLike } from "./format"; diff --git a/src/views/SwapAndBridge/components/ChainTokenSelector/Modal.tsx b/src/views/SwapAndBridge/components/ChainTokenSelector/Modal.tsx index 1d874389b..784eb7336 100644 --- a/src/views/SwapAndBridge/components/ChainTokenSelector/Modal.tsx +++ b/src/views/SwapAndBridge/components/ChainTokenSelector/Modal.tsx @@ -7,6 +7,7 @@ import useAvailableCrosschainRoutes, { LifiToken, } from "hooks/useAvailableCrosschainRoutes"; import { + CHAIN_IDs, COLORS, formatUnitsWithMaxFractions, formatUSD, @@ -21,6 +22,14 @@ import useEnrichedCrosschainBalances from "hooks/useEnrichedCrosschainBalances"; import useCurrentBreakpoint from "hooks/useCurrentBreakpoint"; import { BigNumber } from "ethers"; +const popularChains = [ + CHAIN_IDs.MAINNET, + CHAIN_IDs.BASE, + CHAIN_IDs.OPTIMISM, + CHAIN_IDs.ARBITRUM, + CHAIN_IDs.POLYGON, +]; + type Props = { onSelect: (token: EnrichedTokenSelect) => void; isOriginToken: boolean; @@ -103,9 +112,31 @@ export default function ChainTokenSelectorModal({ }; }); - // Return ordering top 100 tokens ordering highest balanceUsd to lowest (fallback alphabetical) - // Push disabled tokens to the bottom - const sortedTokens = enrichedTokens.slice(0, 100).sort((a, b) => { + // Filter by search first + const filteredTokens = enrichedTokens.filter((t) => { + if (tokenSearch === "") { + return true; + } + const keywords = [ + t.symbol.toLowerCase().replaceAll(" ", ""), + t.name.toLowerCase().replaceAll(" ", ""), + t.address.toLowerCase().replaceAll(" ", ""), + ]; + return keywords.some((keyword) => + keyword.includes(tokenSearch.toLowerCase().replaceAll(" ", "")) + ); + }); + + // Separate tokens with balance from tokens without balance + const tokensWithBalance = filteredTokens.filter( + (token) => token.balance.gt(0) && token.balanceUsd > 0.01 + ); + const tokensWithoutBalance = filteredTokens.filter( + (token) => token.balance.eq(0) || token.balanceUsd <= 0.01 + ); + + // Sort tokens with balance by balanceUsd (highest first), then alphabetically + const sortedTokensWithBalance = tokensWithBalance.sort((a, b) => { // First, sort by disabled status - disabled tokens go to bottom const aDisabled = a.isReachable === false; const bDisabled = b.isReachable === false; @@ -114,26 +145,31 @@ export default function ChainTokenSelectorModal({ return aDisabled ? 1 : -1; } - // Then sort by balance (for enabled tokens) or alphabetically (for disabled tokens) + // Then sort by balance if (Math.abs(b.balanceUsd - a.balanceUsd) < 0.0001) { return a.symbol.toLocaleLowerCase().localeCompare(b.symbol); } return b.balanceUsd - a.balanceUsd; }); - return sortedTokens.filter((t) => { - if (tokenSearch === "") { - return true; + // Sort tokens without balance alphabetically, with disabled tokens at bottom + const sortedTokensWithoutBalance = tokensWithoutBalance.sort((a, b) => { + // First, sort by disabled status - disabled tokens go to bottom + const aDisabled = a.isReachable === false; + const bDisabled = b.isReachable === false; + + if (aDisabled !== bDisabled) { + return aDisabled ? 1 : -1; } - const keywords = [ - t.symbol.toLowerCase().replaceAll(" ", ""), - t.name.toLowerCase().replaceAll(" ", ""), - t.address.toLowerCase().replaceAll(" ", ""), - ]; - return keywords.some((keyword) => - keyword.includes(tokenSearch.toLowerCase().replaceAll(" ", "")) - ); + + // Then sort alphabetically + return a.symbol.toLocaleLowerCase().localeCompare(b.symbol); }); + + return { + withBalance: sortedTokensWithBalance.slice(0, 50), // Limit to 50 tokens with balance + withoutBalance: sortedTokensWithoutBalance.slice(0, 50), // Limit to 50 tokens without balance + }; }, [selectedChain, balances, tokenSearch, otherToken, crossChainRoutes.data]); const displayedChains = useMemo(() => { @@ -162,15 +198,38 @@ export default function ChainTokenSelectorModal({ const isDisabled = false; return [chainId, { tokens, isDisabled }]; - }) - // Sort chains alphabetically by name (no need to sort by disabled status since none are disabled) - .sort(([chainIdA], [chainIdB]) => { + }); + + // Separate popular chains from all chains + const popularChainsData: typeof chainsWithDisabledState = []; + + chainsWithDisabledState.forEach((entry) => { + const [chainId] = entry; + if (popularChains.includes(Number(chainId))) { + popularChainsData.push(entry); + } + }); + + // Sort popular chains by the order they appear in popularChains array + popularChainsData.sort(([chainIdA], [chainIdB]) => { + const indexA = popularChains.indexOf(Number(chainIdA)); + const indexB = popularChains.indexOf(Number(chainIdB)); + return indexA - indexB; + }); + + // Combine all chains for the "All Chains" section (sorted alphabetically) + const allChainsData = [...chainsWithDisabledState].sort( + ([chainIdA], [chainIdB]) => { const chainInfoA = getChainInfo(Number(chainIdA)); const chainInfoB = getChainInfo(Number(chainIdB)); return chainInfoA.name.localeCompare(chainInfoB.name); - }); + } + ); - return Object.fromEntries(chainsWithDisabledState); + return { + popular: Object.fromEntries(popularChainsData), + all: Object.fromEntries(allChainsData), + }; }, [chainSearch, crossChainRoutes.data, otherToken]); return isMobile ? ( @@ -239,7 +298,10 @@ const MobileModal = ({ tokenSearch: string; setTokenSearch: (search: string) => void; displayedChains: any; - displayedTokens: any[]; + displayedTokens: { + withBalance: any[]; + withoutBalance: any[]; + }; onChainSelect: (chainId: number | null) => void; onTokenSelect: (token: EnrichedTokenSelect) => void; }) => { @@ -314,7 +376,10 @@ const DesktopModal = ({ tokenSearch: string; setTokenSearch: (search: string) => void; displayedChains: any; - displayedTokens: any[]; + displayedTokens: { + withBalance: any[]; + withoutBalance: any[]; + }; onChainSelect: (chainId: number | null) => void; onTokenSelect: (token: EnrichedTokenSelect) => void; }) => { @@ -367,7 +432,10 @@ const MobileLayout = ({ tokenSearch: string; setTokenSearch: (search: string) => void; displayedChains: any; - displayedTokens: any[]; + displayedTokens: { + withBalance: any[]; + withoutBalance: any[]; + }; onChainSelect: (chainId: number | null) => void; onTokenSelect: (token: EnrichedTokenSelect) => void; onModalClose: () => void; @@ -388,7 +456,31 @@ const MobileLayout = ({ isSelected={selectedChain === null} onClick={() => onChainSelect(null)} /> - {Object.entries(displayedChains).map(([chainId, chainData]) => ( + + {/* Popular Chains Section */} + {Object.keys(displayedChains.popular).length > 0 && ( + <> + Popular Chains + {Object.entries(displayedChains.popular).map( + ([chainId, chainData]) => ( + onChainSelect(Number(chainId))} + /> + ) + )} + + )} + + {/* All Chains Section */} + All Chains + {Object.entries(displayedChains.all).map(([chainId, chainData]) => ( - {displayedTokens.map((token) => ( + {/* Your Tokens Section */} + {displayedTokens.withBalance.length > 0 && ( + <> + Your Tokens + {displayedTokens.withBalance.map((token) => ( + { + onTokenSelect({ + chainId: token.chainId, + symbolUri: token.logoURI, + symbol: token.symbol, + address: token.address, + balance: token.balance, + priceUsd: parseUnits(token.priceUSD, 18), + decimals: token.decimals, + }); + onModalClose(); + }} + /> + ))} + + )} + + {/* All Tokens Section */} + All Tokens + {displayedTokens.withoutBalance.map((token) => ( void; displayedChains: any; - displayedTokens: any[]; + displayedTokens: { + withBalance: any[]; + withoutBalance: any[]; + }; onChainSelect: (chainId: number | null) => void; onTokenSelect: (token: EnrichedTokenSelect) => void; onModalClose: () => void; @@ -474,7 +597,31 @@ const DesktopLayout = ({ isSelected={selectedChain === null} onClick={() => onChainSelect(null)} /> - {Object.entries(displayedChains).map(([chainId, chainData]) => ( + + {/* Popular Chains Section */} + {Object.keys(displayedChains.popular).length > 0 && ( + <> + Popular Chains + {Object.entries(displayedChains.popular).map( + ([chainId, chainData]) => ( + onChainSelect(Number(chainId))} + /> + ) + )} + + )} + + {/* All Chains Section */} + All Chains + {Object.entries(displayedChains.all).map(([chainId, chainData]) => ( - {displayedTokens.map((token) => ( + {/* Your Tokens Section */} + {displayedTokens.withBalance.length > 0 && ( + <> + Your Tokens + {displayedTokens.withBalance.map((token) => ( + { + onTokenSelect({ + chainId: token.chainId, + symbolUri: token.logoURI, + symbol: token.symbol, + address: token.address, + balance: token.balance, + priceUsd: parseUnits(token.priceUSD, 18), + decimals: token.decimals, + }); + onModalClose(); + }} + /> + ))} + + )} + + {/* All Tokens Section */} + All Tokens + {displayedTokens.withoutBalance.map((token) => ( Date: Wed, 1 Oct 2025 14:22:17 +0200 Subject: [PATCH 25/39] better types Signed-off-by: Gerhard Steenkamp --- .../components/ChainTokenSelector/Modal.tsx | 85 +++++++++---------- 1 file changed, 42 insertions(+), 43 deletions(-) diff --git a/src/views/SwapAndBridge/components/ChainTokenSelector/Modal.tsx b/src/views/SwapAndBridge/components/ChainTokenSelector/Modal.tsx index 784eb7336..e701fc5a9 100644 --- a/src/views/SwapAndBridge/components/ChainTokenSelector/Modal.tsx +++ b/src/views/SwapAndBridge/components/ChainTokenSelector/Modal.tsx @@ -30,6 +30,29 @@ const popularChains = [ CHAIN_IDs.POLYGON, ]; +// Type definitions for better typing +type ChainData = { + tokens: LifiToken[]; + isDisabled: boolean; +}; + +type DisplayedChains = { + popular: Record; + all: Record; +}; + +type EnrichedToken = LifiToken & { + balance: BigNumber; + balanceUsd: number; + isReachable?: boolean; + routeSource: "bridge" | "swap"; +}; + +type DisplayedTokens = { + withBalance: EnrichedToken[]; + withoutBalance: EnrichedToken[]; +}; + type Props = { onSelect: (token: EnrichedTokenSelect) => void; isOriginToken: boolean; @@ -180,11 +203,6 @@ export default function ChainTokenSelectorModal({ return false; } - // Filter out the chain of the other token (same chain can't be both input and output) - if (otherToken && Number(chainId) === otherToken.chainId) { - return false; - } - const keywords = [ String(chainId), getChainInfo(Number(chainId)).name.toLowerCase().replace(" ", ""), @@ -194,10 +212,13 @@ export default function ChainTokenSelectorModal({ ); }) .map(([chainId, tokens]) => { - // Never disable chains - requirement 1 - const isDisabled = false; - - return [chainId, { tokens, isDisabled }]; + return [ + chainId, + { + tokens, + isDisabled: otherToken && Number(chainId) === otherToken.chainId, // same chain can't be both input and output + }, + ]; }); // Separate popular chains from all chains @@ -297,11 +318,8 @@ const MobileModal = ({ setChainSearch: (search: string) => void; tokenSearch: string; setTokenSearch: (search: string) => void; - displayedChains: any; - displayedTokens: { - withBalance: any[]; - withoutBalance: any[]; - }; + displayedChains: DisplayedChains; + displayedTokens: DisplayedTokens; onChainSelect: (chainId: number | null) => void; onTokenSelect: (token: EnrichedTokenSelect) => void; }) => { @@ -375,11 +393,8 @@ const DesktopModal = ({ setChainSearch: (search: string) => void; tokenSearch: string; setTokenSearch: (search: string) => void; - displayedChains: any; - displayedTokens: { - withBalance: any[]; - withoutBalance: any[]; - }; + displayedChains: DisplayedChains; + displayedTokens: DisplayedTokens; onChainSelect: (chainId: number | null) => void; onTokenSelect: (token: EnrichedTokenSelect) => void; }) => { @@ -431,11 +446,8 @@ const MobileLayout = ({ setChainSearch: (search: string) => void; tokenSearch: string; setTokenSearch: (search: string) => void; - displayedChains: any; - displayedTokens: { - withBalance: any[]; - withoutBalance: any[]; - }; + displayedChains: DisplayedChains; + displayedTokens: DisplayedTokens; onChainSelect: (chainId: number | null) => void; onTokenSelect: (token: EnrichedTokenSelect) => void; onModalClose: () => void; @@ -467,10 +479,7 @@ const MobileLayout = ({ key={chainId} chainId={Number(chainId)} isSelected={selectedChain === Number(chainId)} - isDisabled={ - (chainData as { tokens: any; isDisabled: boolean }) - .isDisabled - } + isDisabled={chainData.isDisabled} onClick={() => onChainSelect(Number(chainId))} /> ) @@ -485,9 +494,7 @@ const MobileLayout = ({ key={chainId} chainId={Number(chainId)} isSelected={selectedChain === Number(chainId)} - isDisabled={ - (chainData as { tokens: any; isDisabled: boolean }).isDisabled - } + isDisabled={chainData.isDisabled} onClick={() => onChainSelect(Number(chainId))} /> ))} @@ -574,11 +581,8 @@ const DesktopLayout = ({ setChainSearch: (search: string) => void; tokenSearch: string; setTokenSearch: (search: string) => void; - displayedChains: any; - displayedTokens: { - withBalance: any[]; - withoutBalance: any[]; - }; + displayedChains: DisplayedChains; + displayedTokens: DisplayedTokens; onChainSelect: (chainId: number | null) => void; onTokenSelect: (token: EnrichedTokenSelect) => void; onModalClose: () => void; @@ -608,10 +612,7 @@ const DesktopLayout = ({ key={chainId} chainId={Number(chainId)} isSelected={selectedChain === Number(chainId)} - isDisabled={ - (chainData as { tokens: any; isDisabled: boolean }) - .isDisabled - } + isDisabled={chainData.isDisabled} onClick={() => onChainSelect(Number(chainId))} /> ) @@ -626,9 +627,7 @@ const DesktopLayout = ({ key={chainId} chainId={Number(chainId)} isSelected={selectedChain === Number(chainId)} - isDisabled={ - (chainData as { tokens: any; isDisabled: boolean }).isDisabled - } + isDisabled={chainData.isDisabled} onClick={() => onChainSelect(Number(chainId))} /> ))} From 020fedb77011a23a5e10dd912934cff2840badc9 Mon Sep 17 00:00:00 2001 From: Gerhard Steenkamp Date: Wed, 1 Oct 2025 14:46:09 +0200 Subject: [PATCH 26/39] sort Signed-off-by: Gerhard Steenkamp --- .../components/ChainTokenSelector/Modal.tsx | 62 +++++++++---------- 1 file changed, 30 insertions(+), 32 deletions(-) diff --git a/src/views/SwapAndBridge/components/ChainTokenSelector/Modal.tsx b/src/views/SwapAndBridge/components/ChainTokenSelector/Modal.tsx index e701fc5a9..86c764e6c 100644 --- a/src/views/SwapAndBridge/components/ChainTokenSelector/Modal.tsx +++ b/src/views/SwapAndBridge/components/ChainTokenSelector/Modal.tsx @@ -37,8 +37,8 @@ type ChainData = { }; type DisplayedChains = { - popular: Record; - all: Record; + popular: [string, ChainData][]; + all: [string, ChainData][]; }; type EnrichedToken = LifiToken & { @@ -196,7 +196,9 @@ export default function ChainTokenSelectorModal({ }, [selectedChain, balances, tokenSearch, otherToken, crossChainRoutes.data]); const displayedChains = useMemo(() => { - const chainsWithDisabledState = Object.entries(crossChainRoutes.data || {}) + const chainsWithDisabledState: [string, ChainData][] = Object.entries( + crossChainRoutes.data || {} + ) .filter(([chainId]) => { // why ar we filtering out Boba? if ([288].includes(Number(chainId))) { @@ -218,7 +220,7 @@ export default function ChainTokenSelectorModal({ tokens, isDisabled: otherToken && Number(chainId) === otherToken.chainId, // same chain can't be both input and output }, - ]; + ] as [string, ChainData]; }); // Separate popular chains from all chains @@ -248,8 +250,8 @@ export default function ChainTokenSelectorModal({ ); return { - popular: Object.fromEntries(popularChainsData), - all: Object.fromEntries(allChainsData), + popular: popularChainsData, + all: allChainsData, }; }, [chainSearch, crossChainRoutes.data, otherToken]); @@ -470,26 +472,24 @@ const MobileLayout = ({ /> {/* Popular Chains Section */} - {Object.keys(displayedChains.popular).length > 0 && ( + {displayedChains.popular.length > 0 && ( <> Popular Chains - {Object.entries(displayedChains.popular).map( - ([chainId, chainData]) => ( - onChainSelect(Number(chainId))} - /> - ) - )} + {displayedChains.popular.map(([chainId, chainData]) => ( + onChainSelect(Number(chainId))} + /> + ))} )} {/* All Chains Section */} All Chains - {Object.entries(displayedChains.all).map(([chainId, chainData]) => ( + {displayedChains.all.map(([chainId, chainData]) => ( {/* Popular Chains Section */} - {Object.keys(displayedChains.popular).length > 0 && ( + {displayedChains.popular.length > 0 && ( <> Popular Chains - {Object.entries(displayedChains.popular).map( - ([chainId, chainData]) => ( - onChainSelect(Number(chainId))} - /> - ) - )} + {displayedChains.popular.map(([chainId, chainData]) => ( + onChainSelect(Number(chainId))} + /> + ))} )} {/* All Chains Section */} All Chains - {Object.entries(displayedChains.all).map(([chainId, chainData]) => ( + {displayedChains.all.map(([chainId, chainData]) => ( Date: Wed, 1 Oct 2025 14:46:22 +0200 Subject: [PATCH 27/39] refactor Signed-off-by: Gerhard Steenkamp --- src/hooks/useTokenConversion.ts | 62 ++++++++++++++++++--------------- 1 file changed, 33 insertions(+), 29 deletions(-) diff --git a/src/hooks/useTokenConversion.ts b/src/hooks/useTokenConversion.ts index a1f769599..cff38f6e1 100644 --- a/src/hooks/useTokenConversion.ts +++ b/src/hooks/useTokenConversion.ts @@ -8,9 +8,10 @@ import { isDefined, getConfig, hubPoolChainId, + TokenInfo, } from "utils"; import { ConvertDecimals } from "utils/convertdecimals"; -import useAvailableCrosschainRoutes from "./useAvailableCrosschainRoutes"; +import { useSwapTokens } from "./useSwapTokens"; const config = getConfig(); @@ -19,51 +20,50 @@ export function useTokenConversion( baseCurrency: string, historicalDateISO?: string ) { - const availableCrosschainRoutes = useAvailableCrosschainRoutes(); + const { data: swapTokens } = useSwapTokens(); // Try to get token from constants first, fallback to swap API data - let token; + let token: TokenInfo | undefined; try { token = getToken(symbol); } catch (error) { // If token not found in constants, try to find it in swap API data - const swapTokens = availableCrosschainRoutes.data; if (swapTokens) { // Search across all chains for a token with matching symbol - for (const chainId of Object.keys(swapTokens)) { - const tokensOnChain = swapTokens[Number(chainId)]; - const foundToken = tokensOnChain.find( - (t) => t.symbol.toUpperCase() === symbol.toUpperCase() - ); - if (foundToken) { - // Convert LifiToken to TokenInfo format - token = { - symbol: foundToken.symbol, - name: foundToken.name, - decimals: foundToken.decimals, - addresses: { [foundToken.chainId]: foundToken.address }, - mainnetAddress: foundToken.address, // Use the found address as mainnet address - logoURI: foundToken.logoURI, - }; - break; - } + const foundToken = swapTokens.find( + (t) => t.symbol.toUpperCase() === symbol.toUpperCase() + ); + if (foundToken) { + // Convert SwapToken to TokenInfo format + token = { + symbol: foundToken.symbol, + name: foundToken.name, + decimals: foundToken.decimals, + addresses: { [foundToken.chainId]: foundToken.address }, + mainnetAddress: foundToken.address, // Use the found address as mainnet address + logoURI: foundToken.logoUrl || "", // Use logoUrl from SwapToken + }; } - } - // If still not found, re-throw the original error - if (!token) { + // If still not found, re-throw the original error + if (!token) { + throw error; + } + } else { + // If swapTokens is not available, re-throw the original error + console.error(`Unable to resolve token info for symbol ${symbol}`); throw error; } } // If the token is OP, we need to use the address of the token on Optimism const l1Token = - token.symbol === "OP" + token?.symbol === "OP" ? TOKEN_SYMBOLS_MAP["OP"].addresses[10] - : token.mainnetAddress!; + : token?.mainnetAddress; const query = useCoingeckoPrice( - l1Token, + l1Token || "", baseCurrency, historicalDateISO, isDefined(l1Token) @@ -74,7 +74,9 @@ export function useTokenConversion( const price = query.data?.price; const decimals = token?.decimals ?? - config.getTokenInfoByAddressSafe(hubPoolChainId, l1Token)?.decimals; + (l1Token + ? config.getTokenInfoByAddressSafe(hubPoolChainId, l1Token)?.decimals + : undefined); if (!isDefined(price) || !isDefined(amount) || !isDefined(decimals)) { return undefined; @@ -91,7 +93,9 @@ export function useTokenConversion( const price = query.data?.price; const decimals = token?.decimals ?? - config.getTokenInfoByAddressSafe(hubPoolChainId, l1Token)?.decimals; + (l1Token + ? config.getTokenInfoByAddressSafe(hubPoolChainId, l1Token)?.decimals + : undefined); if (!isDefined(price) || !isDefined(amount) || !isDefined(decimals)) { return undefined; From 945f63a1ec445faac6e7737ee06eb4d5e10620ad Mon Sep 17 00:00:00 2001 From: Gerhard Steenkamp Date: Thu, 2 Oct 2025 12:00:25 +0200 Subject: [PATCH 28/39] improve searchbar styles Signed-off-by: Gerhard Steenkamp --- .../components/ChainTokenSelector/Searchbar.tsx | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/views/SwapAndBridge/components/ChainTokenSelector/Searchbar.tsx b/src/views/SwapAndBridge/components/ChainTokenSelector/Searchbar.tsx index dc4a4ab51..c3ab567b2 100644 --- a/src/views/SwapAndBridge/components/ChainTokenSelector/Searchbar.tsx +++ b/src/views/SwapAndBridge/components/ChainTokenSelector/Searchbar.tsx @@ -1,6 +1,7 @@ import styled from "@emotion/styled"; import { ReactComponent as SearchIcon } from "assets/icons/search.svg"; import { ReactComponent as ProductIcon } from "assets/icons/product.svg"; +import { COLORS } from "utils"; type Props = { searchTopic: string; @@ -34,14 +35,19 @@ const Wrapper = styled.div` padding: 0px 12px; align-items: center; gap: 8px; - flex-direction: row; justify-content: space-between; border-radius: 8px; - background: rgba(224, 243, 255, 0.05); + background: transparent; width: 100%; + + &:hover, + &:active, + &:focus-visible { + background: rgba(224, 243, 255, 0.05); + } `; const StyledSearchIcon = styled(SearchIcon)` @@ -69,6 +75,12 @@ const Input = styled.input` color: #e0f3ff4d; } + &:hover, + &:active, + &:focus-visible { + color: ${COLORS.aqua}; + } + background: transparent; border: none; From 06d5f1f9cacde2b2b033507168607bbd586542fa Mon Sep 17 00:00:00 2001 From: Gerhard Steenkamp Date: Thu, 2 Oct 2025 13:18:08 +0200 Subject: [PATCH 29/39] restrict tabbing inside modal Signed-off-by: Gerhard Steenkamp --- src/components/Modal/Modal.styles.ts | 26 ++++++--- src/components/Modal/Modal.tsx | 14 ++++- src/hooks/useTabIndexManager.ts | 53 +++++++++++++++++++ .../components/ChainTokenSelector/Modal.tsx | 12 +++-- .../ChainTokenSelector/Searchbar.tsx | 19 +++++-- 5 files changed, 108 insertions(+), 16 deletions(-) create mode 100644 src/hooks/useTabIndexManager.ts diff --git a/src/components/Modal/Modal.styles.ts b/src/components/Modal/Modal.styles.ts index 90ffe8db1..823b9a076 100644 --- a/src/components/Modal/Modal.styles.ts +++ b/src/components/Modal/Modal.styles.ts @@ -1,7 +1,6 @@ import { keyframes } from "@emotion/react"; import styled from "@emotion/styled"; -import { ReactComponent as CrossIcon } from "assets/icons/cross.svg"; -import { QUERIESV2 } from "utils"; +import { COLORS, QUERIESV2 } from "utils"; import { ModalDirection } from "./Modal"; const fadeBackground = keyframes` @@ -148,10 +147,6 @@ export const Title = styled.p` } `; -export const StyledExitIcon = styled(CrossIcon)` - cursor: pointer; -`; - export const ElementRowDivider = styled.div` height: 1px; min-height: 1px; @@ -160,3 +155,22 @@ export const ElementRowDivider = styled.div` margin-left: calc(0px - var(--padding-modal-content)); width: calc(100% + (2 * var(--padding-modal-content))); `; + +export const CloseButton = styled.button` + border: none; + background-color: transparent; + display: inline-flex; + outline: none; + padding: 4px; + border-radius: 4px; + cursor: pointer; + + &:hover, + &:focus-visible { + background-color: ${COLORS["grey-400-15"]}; + } + + &:focus-visible { + outline: 2px solid ${COLORS.aqua}; + } +`; diff --git a/src/components/Modal/Modal.tsx b/src/components/Modal/Modal.tsx index 5639631e0..6948893d5 100644 --- a/src/components/Modal/Modal.tsx +++ b/src/components/Modal/Modal.tsx @@ -1,14 +1,16 @@ import usePageScrollLock from "hooks/usePageScrollLock"; +import { useTabIndexManager } from "hooks/useTabIndexManager"; import React, { useEffect, useRef, useState, useLayoutEffect } from "react"; import { createPortal } from "react-dom"; import { + CloseButton, ElementRowDivider, ModalContentWrapper, - StyledExitIcon, Title, TitleAndExitWrapper, Wrapper, } from "./Modal.styles"; +import { ReactComponent as ExitIcon } from "assets/icons/cross.svg"; type ModalDirectionOrientation = "middle" | "top" | "bottom"; export type ModalDirection = { @@ -77,6 +79,9 @@ const Modal = ({ const [forwardAnimation, setForwardAnimation] = useState(true); const { lockScroll, unlockScroll } = usePageScrollLock(); + // Manage tab indices when modal is open - only elements inside modal will be focusable + useTabIndexManager(!!isOpen, modalContentRef); + const offModalClickHandler = (event: React.MouseEvent) => { if ( modalContentRef.current && @@ -153,7 +158,12 @@ const Modal = ({
{title}
)} - externalModalExitHandler()} /> + externalModalExitHandler()} + > + + {titleBorder && } {children} diff --git a/src/hooks/useTabIndexManager.ts b/src/hooks/useTabIndexManager.ts new file mode 100644 index 000000000..6877073e8 --- /dev/null +++ b/src/hooks/useTabIndexManager.ts @@ -0,0 +1,53 @@ +import { useEffect, useRef } from "react"; + +/** + * Custom hook that manages tab indices for modal content + * When active, it sets all elements outside the modal to tabindex="-1" + * and restores their original tabindex values when inactive + * @param isActive - Whether the tab index management should be active + * @param containerRef - Reference to the container element to preserve tab indices within + */ +export const useTabIndexManager = ( + isActive: boolean, + containerRef: React.RefObject +) => { + const originalTabIndices = useRef>(new Map()); + + useEffect(() => { + if (!isActive || !containerRef.current) { + return; + } + + const modalElement = containerRef.current; + + // Only target elements that are naturally focusable + const focusableElements = document.querySelectorAll( + "button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), a[href], [tabindex]:not([tabindex='-1'])" + ) as NodeListOf; + + // Store original tabindex values and set elements outside modal to tabindex="-1" + focusableElements.forEach((element) => { + // Skip elements inside the modal + if (modalElement.contains(element)) { + return; + } + + const currentTabIndex = element.getAttribute("tabindex"); + originalTabIndices.current.set(element, currentTabIndex); + element.setAttribute("tabindex", "-1"); + }); + + // Cleanup function + return () => { + // Restore original tabindex values + originalTabIndices.current.forEach((originalTabIndex, element) => { + if (originalTabIndex === null) { + element.removeAttribute("tabindex"); + } else { + element.setAttribute("tabindex", originalTabIndex); + } + }); + originalTabIndices.current.clear(); + }; + }, [isActive, containerRef]); +}; diff --git a/src/views/SwapAndBridge/components/ChainTokenSelector/Modal.tsx b/src/views/SwapAndBridge/components/ChainTokenSelector/Modal.tsx index 86c764e6c..75abd2feb 100644 --- a/src/views/SwapAndBridge/components/ChainTokenSelector/Modal.tsx +++ b/src/views/SwapAndBridge/components/ChainTokenSelector/Modal.tsx @@ -1,7 +1,7 @@ import Modal from "components/Modal"; import { EnrichedTokenSelect } from "./SelectorButton"; import styled from "@emotion/styled"; -import Searchbar from "./Searchbar"; +import { Searchbar } from "./Searchbar"; import TokenMask from "assets/mask/token-mask-corner.svg"; import useAvailableCrosschainRoutes, { LifiToken, @@ -591,11 +591,14 @@ const DesktopLayout = ({ - + - + {/* Your Tokens Section */} {displayedTokens.withBalance.length > 0 && ( <> diff --git a/src/views/SwapAndBridge/components/ChainTokenSelector/Searchbar.tsx b/src/views/SwapAndBridge/components/ChainTokenSelector/Searchbar.tsx index c3ab567b2..333a0af50 100644 --- a/src/views/SwapAndBridge/components/ChainTokenSelector/Searchbar.tsx +++ b/src/views/SwapAndBridge/components/ChainTokenSelector/Searchbar.tsx @@ -2,32 +2,38 @@ import styled from "@emotion/styled"; import { ReactComponent as SearchIcon } from "assets/icons/search.svg"; import { ReactComponent as ProductIcon } from "assets/icons/product.svg"; import { COLORS } from "utils"; +import React from "react"; type Props = { searchTopic: string; search: string; setSearch: (search: string) => void; className?: string; + inputProps?: React.ComponentPropsWithoutRef<"input">; }; -export default function Searchbar({ +export const Searchbar = ({ searchTopic, search, setSearch, className, -}: Props) { + inputProps, +}: Props) => { return ( setSearch(e.target.value)} + {...inputProps} /> {search ? setSearch("")} /> :
} ); -} +}; const Wrapper = styled.div` display: flex; @@ -44,10 +50,13 @@ const Wrapper = styled.div` width: 100%; &:hover, - &:active, - &:focus-visible { + &:active { background: rgba(224, 243, 255, 0.05); } + + &:focus-within { + background: rgba(224, 243, 255, 0.1); + } `; const StyledSearchIcon = styled(SearchIcon)` From 2bfe97b45da199f33d147bb1e1afafcf4d295e11 Mon Sep 17 00:00:00 2001 From: Gerhard Steenkamp Date: Thu, 2 Oct 2025 13:24:17 +0200 Subject: [PATCH 30/39] clean up Signed-off-by: Gerhard Steenkamp --- src/hooks/useTabIndexManager.ts | 7 ++++--- .../SwapAndBridge/components/ChainTokenSelector/Modal.tsx | 6 +----- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/src/hooks/useTabIndexManager.ts b/src/hooks/useTabIndexManager.ts index 6877073e8..1d898af30 100644 --- a/src/hooks/useTabIndexManager.ts +++ b/src/hooks/useTabIndexManager.ts @@ -19,6 +19,7 @@ export const useTabIndexManager = ( } const modalElement = containerRef.current; + const tabIndicesMap = originalTabIndices.current; // Only target elements that are naturally focusable const focusableElements = document.querySelectorAll( @@ -33,21 +34,21 @@ export const useTabIndexManager = ( } const currentTabIndex = element.getAttribute("tabindex"); - originalTabIndices.current.set(element, currentTabIndex); + tabIndicesMap.set(element, currentTabIndex); element.setAttribute("tabindex", "-1"); }); // Cleanup function return () => { // Restore original tabindex values - originalTabIndices.current.forEach((originalTabIndex, element) => { + tabIndicesMap.forEach((originalTabIndex, element) => { if (originalTabIndex === null) { element.removeAttribute("tabindex"); } else { element.setAttribute("tabindex", originalTabIndex); } }); - originalTabIndices.current.clear(); + tabIndicesMap.clear(); }; }, [isActive, containerRef]); }; diff --git a/src/views/SwapAndBridge/components/ChainTokenSelector/Modal.tsx b/src/views/SwapAndBridge/components/ChainTokenSelector/Modal.tsx index 75abd2feb..6ceaabc9e 100644 --- a/src/views/SwapAndBridge/components/ChainTokenSelector/Modal.tsx +++ b/src/views/SwapAndBridge/components/ChainTokenSelector/Modal.tsx @@ -193,7 +193,7 @@ export default function ChainTokenSelectorModal({ withBalance: sortedTokensWithBalance.slice(0, 50), // Limit to 50 tokens with balance withoutBalance: sortedTokensWithoutBalance.slice(0, 50), // Limit to 50 tokens without balance }; - }, [selectedChain, balances, tokenSearch, otherToken, crossChainRoutes.data]); + }, [selectedChain, balances, tokenSearch, crossChainRoutes.data]); const displayedChains = useMemo(() => { const chainsWithDisabledState: [string, ChainData][] = Object.entries( @@ -970,10 +970,6 @@ const EntryItem = styled.div<{ isSelected: boolean; isDisabled?: boolean }>` cursor: ${({ isDisabled }) => (isDisabled ? "not-allowed" : "pointer")}; opacity: ${({ isDisabled }) => (isDisabled ? 0.5 : 1)}; - transition: - background 0.2s ease-in-out, - opacity 0.2s ease-in-out; - &:hover { background: ${({ isSelected, isDisabled }) => { if (isDisabled) return "transparent"; From 950be654805811924048167edac398a19f9e1220 Mon Sep 17 00:00:00 2001 From: Gerhard Steenkamp Date: Thu, 2 Oct 2025 18:51:52 +0200 Subject: [PATCH 31/39] add dialog Signed-off-by: Gerhard Steenkamp --- src/assets/icons/research.svg | 5 + src/assets/icons/siren.svg | 6 + src/components/Dialogs/Dialog.tsx | 236 +++++++++++++++++++ src/components/GlobalStyles/GlobalStyles.tsx | 4 + src/components/Modal/Modal.styles.ts | 6 +- src/components/Modal/Modal.tsx | 3 + src/utils/colors.ts | 13 + src/utils/index.ts | 1 + 8 files changed, 271 insertions(+), 3 deletions(-) create mode 100644 src/assets/icons/research.svg create mode 100644 src/assets/icons/siren.svg create mode 100644 src/components/Dialogs/Dialog.tsx create mode 100644 src/utils/colors.ts diff --git a/src/assets/icons/research.svg b/src/assets/icons/research.svg new file mode 100644 index 000000000..065b3b72c --- /dev/null +++ b/src/assets/icons/research.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/assets/icons/siren.svg b/src/assets/icons/siren.svg new file mode 100644 index 000000000..b616bd545 --- /dev/null +++ b/src/assets/icons/siren.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/components/Dialogs/Dialog.tsx b/src/components/Dialogs/Dialog.tsx new file mode 100644 index 000000000..d1f894736 --- /dev/null +++ b/src/components/Dialogs/Dialog.tsx @@ -0,0 +1,236 @@ +import Modal from "components/Modal"; +import { ModalProps } from "components/Modal/Modal"; +import styled from "@emotion/styled"; +import { COLORS, QUERIES, withOpacity } from "utils"; +import { ReactComponent as Warning } from "assets/icons/warning_triangle.svg"; +import { ReactComponent as Siren } from "assets/icons/siren.svg"; +import { ReactComponent as Info } from "assets/icons/info.svg"; +import { PropsWithChildren } from "react"; + +type Variant = "warn" | "error" | "info"; + +const defaultIcons: Record = { + warn: , + error: , + info: , +}; + +const defaultColors: Record = { + warn: "rgba(255, 149, 0, 1)", + error: COLORS.error, + info: COLORS.white, +}; + +// DialogWrapper - The main container that wraps everything +export type DialogWrapperProps = ModalProps & { + className?: string; +}; + +export function DialogWrapper({ + children, + className, + ...props +}: DialogWrapperProps) { + return ( + + {children} + + ); +} + +// DialogIcon - For displaying icons with variant-based styling +export type DialogIconProps = { + variant?: Variant; + icon?: React.ReactNode; + color?: string; + className?: string; +}; + +export function DialogIcon({ + variant = "info", + icon, + color, + className, +}: DialogIconProps) { + const Icon = icon ?? defaultIcons[variant]; + const iconColor = color ?? defaultColors[variant]; + + return ( + + {Icon} + + ); +} + +// DialogContent - For the main content area +export type DialogContentProps = { + children: React.ReactNode; + className?: string; +}; + +export function DialogContent({ children, className }: DialogContentProps) { + return {children}; +} + +// DialogButtonRow - Container for buttons +export type DialogButtonRowProps = { + children: React.ReactNode; + className?: string; +}; + +export function DialogButtonRow({ children, className }: DialogButtonRowProps) { + return {children}; +} + +// DialogButtonPrimary - Primary action button +export type DialogButtonPrimaryProps = { + children?: React.ReactNode; + onClick?: () => void; + className?: string; +}; + +export function DialogButtonPrimary({ + children, + onClick, + className, +}: DialogButtonPrimaryProps) { + return ( + + {children} + + ); +} + +// DialogButtonSecondary - Secondary action button +export type DialogButtonSecondaryProps = { + children?: React.ReactNode; + onClick?: () => void; + className?: string; +}; + +export function DialogButtonSecondary({ + children, + onClick, + className, +}: DialogButtonSecondaryProps) { + return ( + + {children} + + ); +} + +// Legacy Dialog component for backward compatibility +export type DoYourOwnResearchDialogProps = ModalProps & { + variant: Variant; + primaryAction?: () => void; + secondaryAction?: () => void; + icon?: React.ReactNode; + color?: string; + className?: string; +}; + +export function Dialog({ + children, + className, + variant, + icon, + primaryAction, + secondaryAction, + ...props +}: DoYourOwnResearchDialogProps) { + const Icon = icon ?? defaultIcons[variant]; + return ( + + {Icon} + {children} + {(secondaryAction || primaryAction) && ( + + {secondaryAction && } + {primaryAction && } + + )} + + ); +} + +// Styled components +const ButtonRow = styled.div` + display: flex; + gap: 16px; + justify-content: center; + width: 100%; + align-items: center; + flex-direction: column; + flex-wrap: wrap; + + @media ${QUERIES.tabletAndUp} { + flex-direction: row; + } +`; + +const PrimaryButton = styled.button` + display: flex; + height: 48px; + width: auto; + padding: 0 var(--Spacing-Medium, 16px); + justify-content: center; + align-items: center; + border-radius: 12px; + background: rgba(224, 243, 255, 0.1); + cursor: pointer; + color: white; + border: 1px solid transparent; + flex: 1 0 auto; + font-size: 16px; + font-weight: 600; + + &:hover { + background: none; + border: 1px solid rgba(224, 243, 255, 0.1); + } +`; + +const SecondaryButton = styled(PrimaryButton)` + background: transparent; + border: 1px solid rgba(224, 243, 255, 0.1); + + &:hover { + background: rgba(224, 243, 255, 0.1); + border-color: transparent; + } +`; + +const Wrapper = styled(Modal)` + width: 100%; + max-width: 450px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-start; + gap: 12px; +`; + +const IconWrapper = styled.div<{ color: string }>` + background-color: ${({ color }) => withOpacity(color, 0.2)}; + border-radius: 50%; + width: 56px; + height: 56px; + display: flex; + align-items: center; + justify-content: center; + + svg { + width: 32px; + height: 32px; + color: ${({ color }) => color}; + } +`; + +const ContentWrapper = styled.div` + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; +`; diff --git a/src/components/GlobalStyles/GlobalStyles.tsx b/src/components/GlobalStyles/GlobalStyles.tsx index bf4d75d9d..bb4dc96bd 100644 --- a/src/components/GlobalStyles/GlobalStyles.tsx +++ b/src/components/GlobalStyles/GlobalStyles.tsx @@ -105,6 +105,10 @@ const globalStyles = css` -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: auto; } + button { + border: none; + background-color: none; + } html, body { min-height: 100vh; diff --git a/src/components/Modal/Modal.styles.ts b/src/components/Modal/Modal.styles.ts index 823b9a076..94b39272c 100644 --- a/src/components/Modal/Modal.styles.ts +++ b/src/components/Modal/Modal.styles.ts @@ -12,6 +12,7 @@ type WrapperType = { reverseAnimation?: boolean; direction: ModalDirection; }; + export const Wrapper = styled.div` position: fixed; top: 0; @@ -27,7 +28,7 @@ export const Wrapper = styled.div` z-index: 99998; - animation: ${fadeBackground} 0.5s linear; + animation: ${fadeBackground} 0.3s linear; animation-fill-mode: forwards; opacity: ${({ reverseAnimation }) => (reverseAnimation ? 0 : 1)}; @@ -99,7 +100,6 @@ export const ModalContentWrapper = styled.div` ? `min(calc(100svh - ${minimumMargin * 2}px - ${topYOffset ?? 0}px), ${height}px)` : "calc(100svh - 64px)"}; max-width: ${({ width }) => width ?? 800}px; - height: fit-content; width: calc(100% - 32px); @@ -116,7 +116,7 @@ export const ModalContentWrapper = styled.div` background: #202024; border: 1px solid #34353b; box-shadow: 0px 8px 32px rgba(0, 0, 0, 0.32); - border-radius: 16px; + border-radius: 24px; position: relative; diff --git a/src/components/Modal/Modal.tsx b/src/components/Modal/Modal.tsx index 6948893d5..6b0a31964 100644 --- a/src/components/Modal/Modal.tsx +++ b/src/components/Modal/Modal.tsx @@ -39,6 +39,7 @@ export type ModalProps = { children?: React.ReactNode; titleBorder?: boolean; + className?: string; }; const Modal = ({ @@ -54,6 +55,7 @@ const Modal = ({ topYOffset, bottomYOffset, padding, + className, "data-cy": dataCy, titleBorder = false, }: ModalProps) => { @@ -150,6 +152,7 @@ const Modal = ({ topYOffset={topYOffset} bottomYOffset={bottomYOffset} padding={padding ?? "normal"} + className={className} > {typeof title === "string" ? ( diff --git a/src/utils/colors.ts b/src/utils/colors.ts new file mode 100644 index 000000000..83bbf4d88 --- /dev/null +++ b/src/utils/colors.ts @@ -0,0 +1,13 @@ +/** + * Simple utility to change color opacity using color-mix + */ + +/** + * Creates a color with a specific opacity using color-mix + * @param color - The base color (any valid CSS color) + * @param opacity - The opacity value (0-1) + * @returns The color with the specified opacity + */ +export function withOpacity(color: string, opacity: number): string { + return `color-mix(in srgb, ${color} ${opacity * 100}%, transparent)`; +} diff --git a/src/utils/index.ts b/src/utils/index.ts index 03639f72f..ff0a45976 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -23,3 +23,4 @@ export * from "./url"; export * from "./sdk"; export * from "./hyperliquid"; export * from "./bignumber"; +export * from "./colors"; From c6f7540a2417765d4129148f5061ec30bb2f5449 Mon Sep 17 00:00:00 2001 From: Gerhard Steenkamp Date: Fri, 3 Oct 2025 12:00:28 +0200 Subject: [PATCH 32/39] fixup Signed-off-by: Gerhard Steenkamp --- src/hooks/useAvailableCrosschainRoutes.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/hooks/useAvailableCrosschainRoutes.ts b/src/hooks/useAvailableCrosschainRoutes.ts index ff0e45923..d1ae8b874 100644 --- a/src/hooks/useAvailableCrosschainRoutes.ts +++ b/src/hooks/useAvailableCrosschainRoutes.ts @@ -143,13 +143,13 @@ export default function useAvailableCrosschainRoutes( ? otherToken!.symbol : token.symbol; - let isReachable = true; + let isReachable = false; - // For same chain (swap), always reachable + // For same chain, not reachable (no swaps allowed on same chain) if (fromChain === toChain) { - isReachable = true; + isReachable = false; } else { - // For bridge, check if there's an explicit bridge route + // For different chains, check if there's an explicit bridge route const bridgeRoutes = config.filterRoutes({ fromChain, toChain, From 2062525a1af2d9339f77a59df3a98832f662ac7f Mon Sep 17 00:00:00 2001 From: Gerhard Steenkamp Date: Fri, 3 Oct 2025 12:26:34 +0200 Subject: [PATCH 33/39] move balance call to endpoint Signed-off-by: Gerhard Steenkamp --- api/_providers.ts | 13 +++ api/user-token-balances.ts | 111 +++++++++++++++++++++ src/hooks/useEnrichedCrosschainBalances.ts | 8 +- src/hooks/useTokenBalancesOnChain.ts | 95 ++++++++---------- 4 files changed, 170 insertions(+), 57 deletions(-) create mode 100644 api/user-token-balances.ts diff --git a/api/_providers.ts b/api/_providers.ts index f0a19e3d3..c71c37040 100644 --- a/api/_providers.ts +++ b/api/_providers.ts @@ -210,3 +210,16 @@ export function getProviderHeaders( return rpcHeaders?.[String(chainId)]; } + +/** + * Gets the Alchemy RPC URL for a given chain ID from the rpc-providers.json configuration + * @param chainId The chain ID to get the Alchemy RPC URL for + * @returns The Alchemy RPC URL or undefined if not available + */ +export function getAlchemyRpcFromConfigJson( + chainId: number +): string | undefined { + const { providers } = rpcProvidersJson; + const alchemyUrls = providers.urls.alchemy as Record; + return alchemyUrls?.[String(chainId)]; +} diff --git a/api/user-token-balances.ts b/api/user-token-balances.ts new file mode 100644 index 000000000..1661ece59 --- /dev/null +++ b/api/user-token-balances.ts @@ -0,0 +1,111 @@ +import { VercelResponse } from "@vercel/node"; +import { assert, Infer, type } from "superstruct"; +import { TypedVercelRequest } from "./_types"; +import { getLogger, handleErrorCondition, validAddress } from "./_utils"; +import { getAlchemyRpcFromConfigJson } from "./_providers"; +import { MAINNET_CHAIN_IDs } from "@across-protocol/constants"; +import { BigNumber } from "ethers"; + +const UserTokenBalancesQueryParamsSchema = type({ + account: validAddress(), +}); + +type UserTokenBalancesQueryParams = Infer< + typeof UserTokenBalancesQueryParamsSchema +>; + +const fetchTokenBalancesForChain = async ( + chainId: number, + account: string +): Promise<{ + chainId: number; + balances: Array<{ address: string; balance: string }>; +}> => { + const rpcUrl = getAlchemyRpcFromConfigJson(chainId); + + if (!rpcUrl) { + throw new Error(`No Alchemy RPC URL found for chain ${chainId}`); + } + + const response = await fetch(rpcUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "alchemy_getTokenBalances", + params: [account], + }), + }); + + const data = await response.json(); + + const balances = ( + data.result.tokenBalances as { + contractAddress: string; + tokenBalance: string; + }[] + ) + .filter((t) => !!t.tokenBalance && BigNumber.from(t.tokenBalance).gt(0)) + .map((t) => ({ + address: t.contractAddress, + balance: BigNumber.from(t.tokenBalance).toString(), + })); + + return { + chainId, + balances, + }; +}; + +const handler = async ( + request: TypedVercelRequest, + response: VercelResponse +) => { + const logger = getLogger(); + + try { + const { query } = request; + assert(query, UserTokenBalancesQueryParamsSchema); + const { account } = query; + + // Get all available chain IDs that have Alchemy RPC URLs + const chainIdsAvailable = Object.values(MAINNET_CHAIN_IDs) + .sort((a, b) => a - b) + .filter((chainId) => !!getAlchemyRpcFromConfigJson(chainId)); + + // Fetch balances for all chains in parallel + const balancePromises = chainIdsAvailable.map((chainId) => + fetchTokenBalancesForChain(chainId, account) + ); + + const chainBalances = await Promise.all(balancePromises); + + const responseData = { + account, + balances: chainBalances.map(({ chainId, balances }) => ({ + chainId: chainId.toString(), + balances, + })), + }; + + logger.debug({ + at: "UserTokenBalances", + message: "Response data", + responseJson: responseData, + }); + + // Cache for 3 minutes + response.setHeader( + "Cache-Control", + "s-maxage=180, stale-while-revalidate=60" + ); + response.status(200).json(responseData); + } catch (error: unknown) { + return handleErrorCondition("user-token-balances", response, logger, error); + } +}; + +export default handler; diff --git a/src/hooks/useEnrichedCrosschainBalances.ts b/src/hooks/useEnrichedCrosschainBalances.ts index 4b07e0c29..e50d78ae8 100644 --- a/src/hooks/useEnrichedCrosschainBalances.ts +++ b/src/hooks/useEnrichedCrosschainBalances.ts @@ -11,21 +11,21 @@ export default function useEnrichedCrosschainBalances() { const availableCrosschainRoutes = useAvailableCrosschainRoutes(); return useMemo(() => { - if (availableCrosschainRoutes.isLoading) { + if (availableCrosschainRoutes.isLoading || tokenBalances.isLoading) { return {}; } const chains = Object.keys(availableCrosschainRoutes.data || {}); return chains.reduce( (acc, chainId) => { - const balancesForChain = tokenBalances.find( - (t) => t.isSuccess && t.data.chainId === Number(chainId) + const balancesForChain = tokenBalances.data?.find( + (t) => t.chainId === Number(chainId) ); const tokens = availableCrosschainRoutes.data![Number(chainId)]; const enrichedTokens = tokens .map((t) => { - const balance = balancesForChain?.data?.balances.find((b) => + const balance = balancesForChain?.balances.find((b) => compareAddressesSimple(b.address, t.address) ); return { diff --git a/src/hooks/useTokenBalancesOnChain.ts b/src/hooks/useTokenBalancesOnChain.ts index 09ad766e8..8d1fa24bc 100644 --- a/src/hooks/useTokenBalancesOnChain.ts +++ b/src/hooks/useTokenBalancesOnChain.ts @@ -1,68 +1,57 @@ import { useConnection } from "./useConnection"; -import { CHAIN_IDs, MAINNET_CHAIN_IDs } from "@across-protocol/constants"; -import { useQueries } from "@tanstack/react-query"; +import { useQuery } from "@tanstack/react-query"; import { BigNumber } from "ethers"; -const CHAIN_TO_ALCHEMY = { - [CHAIN_IDs.MAINNET]: "eth-mainnet", - [CHAIN_IDs.OPTIMISM]: "opt-mainnet", - [CHAIN_IDs.POLYGON]: "polygon-mainnet", - [CHAIN_IDs.BASE]: "base-mainnet", - [CHAIN_IDs.LINEA]: "linea-mainnet", - [CHAIN_IDs.ARBITRUM]: "arb-mainnet", +type TokenBalance = { + address: string; + balance: BigNumber; }; -// TODO: delete this, move to serverless /batch-account-balance -const getAlchemyRpcUrl = (chainId: number) => { - const chain = CHAIN_TO_ALCHEMY[chainId]; - return `https://${chain}.g.alchemy.com/v2/${process.env.REACT_APP_ALCHEMY_KEY}`; +type ChainBalances = { + chainId: number; + balances: TokenBalance[]; +}; + +type UserTokenBalancesResponse = { + account: string; + balances: Array<{ + chainId: string; + balances: Array<{ + address: string; + balance: string; + }>; + }>; }; export default function useTokenBalancesOnChain() { const { account } = useConnection(); - const chainIdsAvailable = Object.values(MAINNET_CHAIN_IDs) - .sort((a, b) => a - b) - .filter((chainId) => !!CHAIN_TO_ALCHEMY[chainId]); - return useQueries({ - queries: chainIdsAvailable.map((chainId) => ({ - queryKey: ["tokenBalancesOnChain", chainId], - enabled: account !== undefined, - queryFn: async () => { - const rpcUrl = getAlchemyRpcUrl(chainId); + return useQuery({ + queryKey: ["userTokenBalances", account], + queryFn: async (): Promise => { + const response = await fetch( + `/api/user-token-balances?account=${account}` + ); - const balances = await fetch(rpcUrl, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - jsonrpc: "2.0", - id: 1, - method: "alchemy_getTokenBalances", - params: [account], - }), - }); + if (!response.ok) { + throw new Error( + `Failed to fetch token balances: ${response.statusText}` + ); + } - const data = await balances.json(); + const data: UserTokenBalancesResponse = await response.json(); - return { - chainId, - balances: ( - data.result.tokenBalances as { - contractAddress: string; - tokenBalance: string; - }[] - ) - .filter( - (t) => !!t.tokenBalance && BigNumber.from(t.tokenBalance).gt(0) - ) - .map((t) => ({ - address: t.contractAddress, - balance: BigNumber.from(t.tokenBalance), - })), - }; - }, - })), + // Convert string balances back to BigNumber and transform the response + return data.balances.map(({ chainId, balances }) => ({ + chainId: Number(chainId), + balances: balances.map(({ address, balance }) => ({ + address, + balance: BigNumber.from(balance ?? "0"), + })), + })); + }, + enabled: !!account, + refetchInterval: 5 * 60 * 1000, // Refetch every 5 minutes + staleTime: 3 * 60 * 1000, // Consider data stale after 3 minutes }); } From 77d3c74d6c4c5e0ed22264597a822ba319b86897 Mon Sep 17 00:00:00 2001 From: Gerhard Steenkamp Date: Fri, 3 Oct 2025 12:40:54 +0200 Subject: [PATCH 34/39] refactor Signed-off-by: Gerhard Steenkamp --- src/hooks/useEnrichedCrosschainBalances.ts | 21 ++++--- src/hooks/useTokenBalancesOnChain.ts | 57 ------------------- src/hooks/useUserTokenBalances.ts | 21 +++++++ src/utils/serverless-api/mocked/index.ts | 2 + .../mocked/user-token-balances.mocked.ts | 48 ++++++++++++++++ src/utils/serverless-api/prod/index.ts | 2 + .../prod/user-token-balances.ts | 25 ++++++++ src/utils/serverless-api/types.ts | 21 +++++++ 8 files changed, 133 insertions(+), 64 deletions(-) delete mode 100644 src/hooks/useTokenBalancesOnChain.ts create mode 100644 src/hooks/useUserTokenBalances.ts create mode 100644 src/utils/serverless-api/mocked/user-token-balances.mocked.ts create mode 100644 src/utils/serverless-api/prod/user-token-balances.ts diff --git a/src/hooks/useEnrichedCrosschainBalances.ts b/src/hooks/useEnrichedCrosschainBalances.ts index e50d78ae8..acc5c957b 100644 --- a/src/hooks/useEnrichedCrosschainBalances.ts +++ b/src/hooks/useEnrichedCrosschainBalances.ts @@ -2,12 +2,12 @@ import { useMemo } from "react"; import useAvailableCrosschainRoutes, { LifiToken, } from "./useAvailableCrosschainRoutes"; -import useTokenBalancesOnChain from "./useTokenBalancesOnChain"; +import { useUserTokenBalances } from "./useUserTokenBalances"; import { compareAddressesSimple } from "utils"; import { BigNumber, utils } from "ethers"; export default function useEnrichedCrosschainBalances() { - const tokenBalances = useTokenBalancesOnChain(); + const tokenBalances = useUserTokenBalances(); const availableCrosschainRoutes = useAvailableCrosschainRoutes(); return useMemo(() => { @@ -18,8 +18,8 @@ export default function useEnrichedCrosschainBalances() { return chains.reduce( (acc, chainId) => { - const balancesForChain = tokenBalances.data?.find( - (t) => t.chainId === Number(chainId) + const balancesForChain = tokenBalances.data?.balances.find( + (t) => t.chainId === String(chainId) ); const tokens = availableCrosschainRoutes.data![Number(chainId)]; @@ -30,14 +30,21 @@ export default function useEnrichedCrosschainBalances() { ); return { ...t, - balance: balance?.balance ?? BigNumber.from(0), + balance: balance?.balance + ? BigNumber.from(balance.balance) + : BigNumber.from(0), balanceUsd: balance?.balance && t - ? Number(utils.formatUnits(balance.balance, t.decimals)) * - Number(t.priceUSD) + ? Number( + utils.formatUnits( + BigNumber.from(balance.balance), + t.decimals + ) + ) * Number(t.priceUSD) : 0, }; }) + // TODO: consider removing // Filter out tokens that don't have a logoURI .filter((t) => t.logoURI !== undefined); diff --git a/src/hooks/useTokenBalancesOnChain.ts b/src/hooks/useTokenBalancesOnChain.ts deleted file mode 100644 index 8d1fa24bc..000000000 --- a/src/hooks/useTokenBalancesOnChain.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { useConnection } from "./useConnection"; -import { useQuery } from "@tanstack/react-query"; -import { BigNumber } from "ethers"; - -type TokenBalance = { - address: string; - balance: BigNumber; -}; - -type ChainBalances = { - chainId: number; - balances: TokenBalance[]; -}; - -type UserTokenBalancesResponse = { - account: string; - balances: Array<{ - chainId: string; - balances: Array<{ - address: string; - balance: string; - }>; - }>; -}; - -export default function useTokenBalancesOnChain() { - const { account } = useConnection(); - - return useQuery({ - queryKey: ["userTokenBalances", account], - queryFn: async (): Promise => { - const response = await fetch( - `/api/user-token-balances?account=${account}` - ); - - if (!response.ok) { - throw new Error( - `Failed to fetch token balances: ${response.statusText}` - ); - } - - const data: UserTokenBalancesResponse = await response.json(); - - // Convert string balances back to BigNumber and transform the response - return data.balances.map(({ chainId, balances }) => ({ - chainId: Number(chainId), - balances: balances.map(({ address, balance }) => ({ - address, - balance: BigNumber.from(balance ?? "0"), - })), - })); - }, - enabled: !!account, - refetchInterval: 5 * 60 * 1000, // Refetch every 5 minutes - staleTime: 3 * 60 * 1000, // Consider data stale after 3 minutes - }); -} diff --git a/src/hooks/useUserTokenBalances.ts b/src/hooks/useUserTokenBalances.ts new file mode 100644 index 000000000..e2fa9ae84 --- /dev/null +++ b/src/hooks/useUserTokenBalances.ts @@ -0,0 +1,21 @@ +import { useQuery } from "@tanstack/react-query"; +import { useConnection } from "./useConnection"; +import { UserTokenBalancesResponse } from "utils/serverless-api/types"; +import getApiEndpoint from "utils/serverless-api"; + +export function useUserTokenBalances() { + const { account } = useConnection(); + + return useQuery({ + queryKey: ["userTokenBalances", account], + queryFn: async (): Promise => { + if (!account) { + throw new Error("No account connected"); + } + return await getApiEndpoint().userTokenBalances(account); + }, + enabled: !!account, + refetchInterval: 5 * 60 * 1000, // Refetch every 5 minutes + staleTime: 3 * 60 * 1000, // Consider data stale after 3 minutes + }); +} diff --git a/src/utils/serverless-api/mocked/index.ts b/src/utils/serverless-api/mocked/index.ts index 6f7ef7496..5ff8edc78 100644 --- a/src/utils/serverless-api/mocked/index.ts +++ b/src/utils/serverless-api/mocked/index.ts @@ -13,6 +13,7 @@ import { poolsUserApiCall } from "./pools-user.mocked"; import { swapApprovalApiCall } from "../prod/swap-approval"; import { swapChainsApiCall } from "../prod/swap-chains"; import { swapTokensApiCall } from "../prod/swap-tokens"; +import { userTokenBalancesMockedApiCall } from "./user-token-balances.mocked"; export const mockedEndpoints: ServerlessAPIEndpoints = { coingecko: coingeckoMockedApiCall, @@ -33,4 +34,5 @@ export const mockedEndpoints: ServerlessAPIEndpoints = { swapApproval: swapApprovalApiCall, swapChains: swapChainsApiCall, swapTokens: swapTokensApiCall, + userTokenBalances: userTokenBalancesMockedApiCall, }; diff --git a/src/utils/serverless-api/mocked/user-token-balances.mocked.ts b/src/utils/serverless-api/mocked/user-token-balances.mocked.ts new file mode 100644 index 000000000..0d231fdc2 --- /dev/null +++ b/src/utils/serverless-api/mocked/user-token-balances.mocked.ts @@ -0,0 +1,48 @@ +import { UserTokenBalancesResponse } from "../types"; + +/** + * Mocked implementation of the user token balances API call + * @param account The Ethereum address to query token balances for + * @returns Mocked token balances data + */ +export async function userTokenBalancesMockedApiCall( + account: string +): Promise { + // Return mock data for testing/development + return { + account, + balances: [ + { + chainId: "1", + balances: [ + { + address: "0xA0b86a33E6441b8c4C8C0e4A0e4A0e4A0e4A0e4A0", + balance: "1000000000000000000", // 1 ETH + }, + { + address: "0xdAC17F958D2ee523a2206206994597C13D831ec7", + balance: "1000000000", // 1000 USDT + }, + ], + }, + { + chainId: "10", + balances: [ + { + address: "0x4200000000000000000000000000000000000006", + balance: "500000000000000000", // 0.5 WETH + }, + ], + }, + { + chainId: "137", + balances: [ + { + address: "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174", + balance: "2000000000", // 2000 USDC + }, + ], + }, + ], + }; +} diff --git a/src/utils/serverless-api/prod/index.ts b/src/utils/serverless-api/prod/index.ts index 58926c9aa..3dc618594 100644 --- a/src/utils/serverless-api/prod/index.ts +++ b/src/utils/serverless-api/prod/index.ts @@ -13,6 +13,7 @@ import { poolsUserApiCall } from "./pools-user"; import { swapApprovalApiCall } from "./swap-approval"; import { swapChainsApiCall } from "./swap-chains"; import { swapTokensApiCall } from "./swap-tokens"; +import { userTokenBalancesApiCall } from "./user-token-balances"; export const prodEndpoints: ServerlessAPIEndpoints = { coingecko: coingeckoApiCall, suggestedFees: suggestedFeesApiCall, @@ -32,4 +33,5 @@ export const prodEndpoints: ServerlessAPIEndpoints = { swapApproval: swapApprovalApiCall, swapChains: swapChainsApiCall, swapTokens: swapTokensApiCall, + userTokenBalances: userTokenBalancesApiCall, }; diff --git a/src/utils/serverless-api/prod/user-token-balances.ts b/src/utils/serverless-api/prod/user-token-balances.ts new file mode 100644 index 000000000..b9b2396da --- /dev/null +++ b/src/utils/serverless-api/prod/user-token-balances.ts @@ -0,0 +1,25 @@ +import axios from "axios"; +import { vercelApiBaseUrl } from "utils/constants"; +import { UserTokenBalancesResponse } from "../types"; + +export type UserTokenBalancesCall = typeof userTokenBalancesApiCall; + +/** + * Creates an HTTP call to the `user-token-balances` API endpoint + * @param account The Ethereum address to query token balances for + * @returns The result of the HTTP call to `api/user-token-balances` + */ +export async function userTokenBalancesApiCall( + account: string +): Promise { + const response = await axios.get( + `${vercelApiBaseUrl}/api/user-token-balances`, + { + params: { + account, + }, + } + ); + + return response.data; +} diff --git a/src/utils/serverless-api/types.ts b/src/utils/serverless-api/types.ts index 706376018..a5a66bd51 100644 --- a/src/utils/serverless-api/types.ts +++ b/src/utils/serverless-api/types.ts @@ -8,6 +8,7 @@ import { PoolsUserApiCall } from "./prod/pools-user"; import { SwapApprovalApiCall } from "./prod/swap-approval"; import { SwapChainsApiCall } from "./prod/swap-chains"; import { SwapTokensApiCall } from "./prod/swap-tokens"; +import { UserTokenBalancesCall } from "./prod/user-token-balances"; export type ServerlessAPIEndpoints = { coingecko: CoingeckoApiCall; @@ -28,6 +29,7 @@ export type ServerlessAPIEndpoints = { swapApproval: SwapApprovalApiCall; swapChains: SwapChainsApiCall; swapTokens: SwapTokensApiCall; + userTokenBalances: UserTokenBalancesCall; }; export type RewardsApiFunction = @@ -133,3 +135,22 @@ export type SwapToken = { logoUrl?: string; priceUsd: string | null; }; + +export interface UserTokenBalance { + address: string; + balance: string; +} + +export interface ChainBalances { + chainId: string; + balances: UserTokenBalance[]; +} + +export interface UserTokenBalancesResponse { + account: string; + balances: ChainBalances[]; +} + +export type UserTokenBalancesApiCall = ( + account: string +) => Promise; From fcaaf6d55b4561793c494e88076c8ed373c4cdcf Mon Sep 17 00:00:00 2001 From: Gerhard Steenkamp Date: Fri, 3 Oct 2025 13:03:56 +0200 Subject: [PATCH 35/39] fixup Signed-off-by: Gerhard Steenkamp --- api/user-token-balances.ts | 124 ++++++++++++++++++++++++++++--------- 1 file changed, 96 insertions(+), 28 deletions(-) diff --git a/api/user-token-balances.ts b/api/user-token-balances.ts index 1661ece59..61b6b9881 100644 --- a/api/user-token-balances.ts +++ b/api/user-token-balances.ts @@ -21,43 +21,111 @@ const fetchTokenBalancesForChain = async ( chainId: number; balances: Array<{ address: string; balance: string }>; }> => { + const logger = getLogger(); const rpcUrl = getAlchemyRpcFromConfigJson(chainId); if (!rpcUrl) { - throw new Error(`No Alchemy RPC URL found for chain ${chainId}`); + logger.warn({ + at: "fetchTokenBalancesForChain", + message: "No Alchemy RPC URL found for chain, returning empty balances", + chainId, + }); + return { + chainId, + balances: [], + }; } - const response = await fetch(rpcUrl, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ + try { + const requestBody = { jsonrpc: "2.0", id: 1, method: "alchemy_getTokenBalances", params: [account], - }), - }); - - const data = await response.json(); - - const balances = ( - data.result.tokenBalances as { - contractAddress: string; - tokenBalance: string; - }[] - ) - .filter((t) => !!t.tokenBalance && BigNumber.from(t.tokenBalance).gt(0)) - .map((t) => ({ - address: t.contractAddress, - balance: BigNumber.from(t.tokenBalance).toString(), - })); - - return { - chainId, - balances, - }; + }; + + logger.debug({ + at: "fetchTokenBalancesForChain", + message: "Making request to Alchemy API", + chainId, + account, + rpcUrl, + }); + + const response = await fetch(rpcUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(requestBody), + }); + + if (!response.ok) { + logger.warn({ + at: "fetchTokenBalancesForChain", + message: "HTTP error from Alchemy API, returning empty balances", + chainId, + status: response.status, + statusText: response.statusText, + }); + return { + chainId, + balances: [], + }; + } + + const data = await response.json(); + + logger.debug({ + at: "fetchTokenBalancesForChain", + message: "Received response from Alchemy API", + chainId, + responseData: data, + }); + + // Validate the response structure + if (!data || !data.result || !data.result.tokenBalances) { + logger.warn({ + at: "fetchTokenBalancesForChain", + message: "Invalid response from Alchemy API, returning empty balances", + chainId, + responseData: data, + }); + return { + chainId, + balances: [], + }; + } + + const balances = ( + data.result.tokenBalances as { + contractAddress: string; + tokenBalance: string; + }[] + ) + .filter((t) => !!t.tokenBalance && BigNumber.from(t.tokenBalance).gt(0)) + .map((t) => ({ + address: t.contractAddress, + balance: BigNumber.from(t.tokenBalance).toString(), + })); + + return { + chainId, + balances, + }; + } catch (error) { + logger.warn({ + at: "fetchTokenBalancesForChain", + message: + "Error fetching token balances from Alchemy API, returning empty balances", + chainId, + error: error instanceof Error ? error.message : String(error), + }); + return { + chainId, + balances: [], + }; + } }; const handler = async ( From 8ef24530134880dcf1a72054774e63b23f0ba93d Mon Sep 17 00:00:00 2001 From: Gerhard Steenkamp Date: Fri, 3 Oct 2025 15:26:29 +0200 Subject: [PATCH 36/39] style switch button Signed-off-by: Gerhard Steenkamp --- src/assets/icons/arrows-cross.svg | 10 +++--- .../SwapAndBridge/components/InputForm.tsx | 34 ++++++++----------- 2 files changed, 20 insertions(+), 24 deletions(-) diff --git a/src/assets/icons/arrows-cross.svg b/src/assets/icons/arrows-cross.svg index 9e67a3f24..5df5e97f1 100644 --- a/src/assets/icons/arrows-cross.svg +++ b/src/assets/icons/arrows-cross.svg @@ -1,12 +1,12 @@ - + - - - - diff --git a/src/views/SwapAndBridge/components/InputForm.tsx b/src/views/SwapAndBridge/components/InputForm.tsx index 66c3d8862..3d78bee1f 100644 --- a/src/views/SwapAndBridge/components/InputForm.tsx +++ b/src/views/SwapAndBridge/components/InputForm.tsx @@ -67,11 +67,9 @@ export const InputForm = ({ } otherToken={outputToken} /> - - - - - + + + Date: Mon, 6 Oct 2025 16:48:03 +0200 Subject: [PATCH 37/39] reset search on close Signed-off-by: Gerhard Steenkamp --- .../SwapAndBridge/components/ChainTokenSelector/Modal.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/views/SwapAndBridge/components/ChainTokenSelector/Modal.tsx b/src/views/SwapAndBridge/components/ChainTokenSelector/Modal.tsx index 6ceaabc9e..2e4142521 100644 --- a/src/views/SwapAndBridge/components/ChainTokenSelector/Modal.tsx +++ b/src/views/SwapAndBridge/components/ChainTokenSelector/Modal.tsx @@ -92,10 +92,10 @@ export default function ChainTokenSelectorModal({ // Reset mobile step when modal opens/closes useEffect(() => { - if (displayModal) { - setMobileStep("chain"); - setSelectedChain(null); - } + setMobileStep("chain"); + setChainSearch(""); + setTokenSearch(""); + setSelectedChain(null); }, [displayModal]); const displayedTokens = useMemo(() => { From 2b6fdfe2712ecae50b5a1d08069cceb55f859dfd Mon Sep 17 00:00:00 2001 From: Gerhard Steenkamp Date: Mon, 6 Oct 2025 18:27:35 +0200 Subject: [PATCH 38/39] show no results warning Signed-off-by: Gerhard Steenkamp --- src/assets/icons/search_results.svg | 15 +++++++ .../components/ChainTokenSelector/Modal.tsx | 41 +++++++++++++++++++ 2 files changed, 56 insertions(+) create mode 100644 src/assets/icons/search_results.svg diff --git a/src/assets/icons/search_results.svg b/src/assets/icons/search_results.svg new file mode 100644 index 000000000..fec5b3cfe --- /dev/null +++ b/src/assets/icons/search_results.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/src/views/SwapAndBridge/components/ChainTokenSelector/Modal.tsx b/src/views/SwapAndBridge/components/ChainTokenSelector/Modal.tsx index 2e4142521..1eb09fe61 100644 --- a/src/views/SwapAndBridge/components/ChainTokenSelector/Modal.tsx +++ b/src/views/SwapAndBridge/components/ChainTokenSelector/Modal.tsx @@ -17,10 +17,12 @@ import { import { useMemo, useState, useEffect } from "react"; import { ReactComponent as CheckmarkCircle } from "assets/icons/checkmark-circle.svg"; import { ReactComponent as ChevronRight } from "assets/icons/chevron-right.svg"; +import { ReactComponent as SearchResults } from "assets/icons/search_results.svg"; import AllChainsIcon from "assets/chain-logos/all-swap-chain.png"; import useEnrichedCrosschainBalances from "hooks/useEnrichedCrosschainBalances"; import useCurrentBreakpoint from "hooks/useCurrentBreakpoint"; import { BigNumber } from "ethers"; +import { Text } from "components"; const popularChains = [ CHAIN_IDs.MAINNET, @@ -489,6 +491,9 @@ const MobileLayout = ({ {/* All Chains Section */} All Chains + {!displayedChains.all.length && chainSearch && ( + + )} {displayedChains.all.map(([chainId, chainData]) => ( {/* Your Tokens Section */} + {!displayedTokens.withBalance.length && tokenSearch && ( + + )} {displayedTokens.withBalance.length > 0 && ( <> Your Tokens @@ -623,6 +631,9 @@ const DesktopLayout = ({ {/* All Chains Section */} All Chains + {!displayedChains.all.length && chainSearch && ( + + )} {displayedChains.all.map(([chainId, chainData]) => ( {/* Your Tokens Section */} + {!displayedTokens.withBalance.length && tokenSearch && ( + + )} {displayedTokens.withBalance.length > 0 && ( <> Your Tokens @@ -673,6 +687,9 @@ const DesktopLayout = ({ {/* All Tokens Section */} All Tokens + {!displayedTokens.withoutBalance.length && tokenSearch && ( + + )} {displayedTokens.withoutBalance.map((token) => ( { ); }; +const EmptySearchResults = ({ + className, + query, +}: { + query: string; + className?: string; +}) => { + return ( + + + No results for {query} + + ); +}; + +const SearchResultsWrapper = styled.div` + display: flex; + flex-direction: column; + align-items: center; + gap: 24px; + color: white; + padding: 24px; +`; + const SearchBarStyled = styled(Searchbar)` flex-shrink: 0; `; From d155f1cd336192a1bc022a0dff9e295a249415a4 Mon Sep 17 00:00:00 2001 From: Gerhard Steenkamp Date: Mon, 6 Oct 2025 19:09:24 +0200 Subject: [PATCH 39/39] popular tokens, refine search behaviour Signed-off-by: Gerhard Steenkamp --- .../components/ChainTokenSelector/Modal.tsx | 207 ++++++++++-------- 1 file changed, 114 insertions(+), 93 deletions(-) diff --git a/src/views/SwapAndBridge/components/ChainTokenSelector/Modal.tsx b/src/views/SwapAndBridge/components/ChainTokenSelector/Modal.tsx index 1eb09fe61..fd09033e3 100644 --- a/src/views/SwapAndBridge/components/ChainTokenSelector/Modal.tsx +++ b/src/views/SwapAndBridge/components/ChainTokenSelector/Modal.tsx @@ -13,6 +13,7 @@ import { formatUSD, getChainInfo, parseUnits, + TOKEN_SYMBOLS_MAP, } from "utils"; import { useMemo, useState, useEffect } from "react"; import { ReactComponent as CheckmarkCircle } from "assets/icons/checkmark-circle.svg"; @@ -27,9 +28,17 @@ import { Text } from "components"; const popularChains = [ CHAIN_IDs.MAINNET, CHAIN_IDs.BASE, - CHAIN_IDs.OPTIMISM, + CHAIN_IDs.UNICHAIN, CHAIN_IDs.ARBITRUM, - CHAIN_IDs.POLYGON, + CHAIN_IDs.SOLANA, +]; + +const popularTokens = [ + TOKEN_SYMBOLS_MAP.USDC.symbol, + TOKEN_SYMBOLS_MAP.USDT.symbol, + TOKEN_SYMBOLS_MAP.ETH.symbol, + TOKEN_SYMBOLS_MAP.WETH.symbol, + TOKEN_SYMBOLS_MAP.WBTC.symbol, ]; // Type definitions for better typing @@ -51,8 +60,8 @@ type EnrichedToken = LifiToken & { }; type DisplayedTokens = { - withBalance: EnrichedToken[]; - withoutBalance: EnrichedToken[]; + popular: EnrichedToken[]; + all: EnrichedToken[]; }; type Props = { @@ -86,7 +95,9 @@ export default function ChainTokenSelectorModal({ : undefined ); - const [selectedChain, setSelectedChain] = useState(null); + const [selectedChain, setSelectedChain] = useState( + popularChains[0] + ); const [mobileStep, setMobileStep] = useState<"chain" | "token">("chain"); const [tokenSearch, setTokenSearch] = useState(""); @@ -97,7 +108,7 @@ export default function ChainTokenSelectorModal({ setMobileStep("chain"); setChainSearch(""); setTokenSearch(""); - setSelectedChain(null); + setSelectedChain(popularChains[0]); }, [displayModal]); const displayedTokens = useMemo(() => { @@ -152,48 +163,53 @@ export default function ChainTokenSelectorModal({ ); }); - // Separate tokens with balance from tokens without balance - const tokensWithBalance = filteredTokens.filter( - (token) => token.balance.gt(0) && token.balanceUsd > 0.01 + // Separate popular tokens from all tokens + const popularTokensList = filteredTokens.filter((token) => + popularTokens.includes(token.symbol) ); - const tokensWithoutBalance = filteredTokens.filter( - (token) => token.balance.eq(0) || token.balanceUsd <= 0.01 + const allTokensList = filteredTokens.filter( + (token) => !popularTokens.includes(token.symbol) ); - // Sort tokens with balance by balanceUsd (highest first), then alphabetically - const sortedTokensWithBalance = tokensWithBalance.sort((a, b) => { - // First, sort by disabled status - disabled tokens go to bottom - const aDisabled = a.isReachable === false; - const bDisabled = b.isReachable === false; + // Sort function that prioritizes tokens with balance, then by balance amount, then alphabetically + const sortTokens = (tokens: EnrichedToken[]) => { + return tokens.sort((a, b) => { + // First, sort by disabled status - disabled tokens go to bottom + const aDisabled = a.isReachable === false; + const bDisabled = b.isReachable === false; - if (aDisabled !== bDisabled) { - return aDisabled ? 1 : -1; - } + if (aDisabled !== bDisabled) { + return aDisabled ? 1 : -1; + } - // Then sort by balance - if (Math.abs(b.balanceUsd - a.balanceUsd) < 0.0001) { - return a.symbol.toLocaleLowerCase().localeCompare(b.symbol); - } - return b.balanceUsd - a.balanceUsd; - }); + // Then sort by balance - tokens with balance go to top + const aHasBalance = a.balance.gt(0) && a.balanceUsd > 0.01; + const bHasBalance = b.balance.gt(0) && b.balanceUsd > 0.01; + + if (aHasBalance !== bHasBalance) { + return aHasBalance ? -1 : 1; + } - // Sort tokens without balance alphabetically, with disabled tokens at bottom - const sortedTokensWithoutBalance = tokensWithoutBalance.sort((a, b) => { - // First, sort by disabled status - disabled tokens go to bottom - const aDisabled = a.isReachable === false; - const bDisabled = b.isReachable === false; + // If both have balance or both don't have balance, sort by balance amount + if (aHasBalance && bHasBalance) { + if (Math.abs(b.balanceUsd - a.balanceUsd) < 0.0001) { + return a.symbol.toLocaleLowerCase().localeCompare(b.symbol); + } + return b.balanceUsd - a.balanceUsd; + } - if (aDisabled !== bDisabled) { - return aDisabled ? 1 : -1; - } + // If neither has balance, sort alphabetically + return a.symbol.toLocaleLowerCase().localeCompare(b.symbol); + }); + }; - // Then sort alphabetically - return a.symbol.toLocaleLowerCase().localeCompare(b.symbol); - }); + // Sort both sections + const sortedPopularTokens = sortTokens(popularTokensList); + const sortedAllTokens = sortTokens(allTokensList); return { - withBalance: sortedTokensWithBalance.slice(0, 50), // Limit to 50 tokens with balance - withoutBalance: sortedTokensWithoutBalance.slice(0, 50), // Limit to 50 tokens without balance + popular: sortedPopularTokens.slice(0, 50), // Limit to 50 popular tokens + all: sortedAllTokens.slice(0, 50), // Limit to 50 all tokens }; }, [selectedChain, balances, tokenSearch, crossChainRoutes.data]); @@ -514,14 +530,11 @@ const MobileLayout = ({ setSearch={setTokenSearch} /> - {/* Your Tokens Section */} - {!displayedTokens.withBalance.length && tokenSearch && ( - - )} - {displayedTokens.withBalance.length > 0 && ( + {/* Popular Tokens Section */} + {displayedTokens.popular.length > 0 && ( <> - Your Tokens - {displayedTokens.withBalance.map((token) => ( + Popular Tokens + {displayedTokens.popular.map((token) => ( All Tokens - {displayedTokens.withoutBalance.map((token) => ( - { - onTokenSelect({ - chainId: token.chainId, - symbolUri: token.logoURI, - symbol: token.symbol, - address: token.address, - balance: token.balance, - priceUsd: parseUnits(token.priceUSD, 18), - decimals: token.decimals, - }); - onModalClose(); - }} - /> - ))} + {!displayedTokens.all.length && tokenSearch && ( + + )} + {displayedTokens.all.length > 0 && ( + <> + All Tokens + {displayedTokens.all.map((token) => ( + { + onTokenSelect({ + chainId: token.chainId, + symbolUri: token.logoURI, + symbol: token.symbol, + address: token.address, + balance: token.balance, + priceUsd: parseUnits(token.priceUSD, 18), + decimals: token.decimals, + }); + onModalClose(); + }} + /> + ))} + + )} )} @@ -656,14 +676,11 @@ const DesktopLayout = ({ setSearch={setTokenSearch} /> - {/* Your Tokens Section */} - {!displayedTokens.withBalance.length && tokenSearch && ( - - )} - {displayedTokens.withBalance.length > 0 && ( + {/* Popular Tokens Section */} + {displayedTokens.popular.length > 0 && ( <> - Your Tokens - {displayedTokens.withBalance.map((token) => ( + Popular Tokens + {displayedTokens.popular.map((token) => ( All Tokens - {!displayedTokens.withoutBalance.length && tokenSearch && ( + {!displayedTokens.all.length && tokenSearch && ( )} - {displayedTokens.withoutBalance.map((token) => ( - { - onTokenSelect({ - chainId: token.chainId, - symbolUri: token.logoURI, - symbol: token.symbol, - address: token.address, - balance: token.balance, - priceUsd: parseUnits(token.priceUSD, 18), - decimals: token.decimals, - }); - onModalClose(); - }} - /> - ))} + {displayedTokens.all.length > 0 && ( + <> + All Tokens + {displayedTokens.all.map((token) => ( + { + onTokenSelect({ + chainId: token.chainId, + symbolUri: token.logoURI, + symbol: token.symbol, + address: token.address, + balance: token.balance, + priceUsd: parseUnits(token.priceUSD, 18), + decimals: token.decimals, + }); + onModalClose(); + }} + /> + ))} + + )}