From 7e2bd38599f621c8995e86c24a9615ba6a7640dd Mon Sep 17 00:00:00 2001 From: "smx.pusha" <139338836+smxpusha@users.noreply.github.com> Date: Tue, 18 Aug 2026 08:47:05 +0200 Subject: [PATCH] ADD - implement secure VaultX exchange Add the VaultX phone UI, stateful browser mocks, localized copy, and generated WebP icon. Implement character-bound authentication, fixed-point balances, finite treasury markets, idempotent quote execution, settlement reconciliation, Scrypt credentials, SQL schema, limits, audit records, and contract tests. --- frontend/src/assets/img/app-icons/crypto.webp | Bin 0 -> 20330 bytes frontend/src/config/apps.test.ts | 7 + frontend/src/config/apps.ts | 16 + frontend/src/stores/crypto.test.ts | 87 ++ frontend/src/stores/crypto.ts | 99 ++ frontend/src/stores/phone.ts | 105 ++ frontend/src/types/apps.ts | 1 + frontend/src/types/crypto.ts | 56 + frontend/src/utils/preferences.ts | 1 + .../src/views/apps/CryptoApp.contract.test.ts | 71 ++ frontend/src/views/apps/CryptoApp.vue | 831 +++++++++++++ frontend/testserver/index.cjs | 274 ++++- frontend/testserver/smoke.cjs | 2 + sky_phone/config/config.lua | 67 ++ sky_phone/config/locales/de.lua | 16 + sky_phone/config/locales/en.lua | 16 + sky_phone/fxmanifest.lua | 3 + sky_phone/source/client/main.lua | 8 + sky_phone/source/server/crypto.lua | 1066 +++++++++++++++++ sky_phone/source/server/crypto_password.js | 58 + sky_phone/source/shared/custom_apps.lua | 1 + sky_phone/sql/install.sql | 136 +++ 22 files changed, 2917 insertions(+), 4 deletions(-) create mode 100644 frontend/src/assets/img/app-icons/crypto.webp create mode 100644 frontend/src/stores/crypto.test.ts create mode 100644 frontend/src/stores/crypto.ts create mode 100644 frontend/src/types/crypto.ts create mode 100644 frontend/src/views/apps/CryptoApp.contract.test.ts create mode 100644 frontend/src/views/apps/CryptoApp.vue create mode 100644 sky_phone/source/server/crypto.lua create mode 100644 sky_phone/source/server/crypto_password.js diff --git a/frontend/src/assets/img/app-icons/crypto.webp b/frontend/src/assets/img/app-icons/crypto.webp new file mode 100644 index 0000000000000000000000000000000000000000..0cb312955ce08dfede4cc37937d211ae78aa1ff3 GIT binary patch literal 20330 zcmV(lK=i*-Nk&FuPXGW{MM6+kP&gn~PXGYW)Bv3UDgXfh0X{JjheDzu3#NNg5CMW) z8rqkcTlCVKe~SE`_A#kHPyWmPFZ=)Z5B{I){!{l+(f&;yZTm0#Z}7j9e=|SBf7Sm% z?z8kS{jbmepkLJA_w=$djHMq%j}W0p`CHT|4lH@xP(}3;oypzx$u=-ya{G=Ks_`rvJ<6Kk`q&Kdt`- z`=$Or-f#Oq;=Ggm)A;}S-k~3ne@g#3`Fr`z^{MuM^SmK<-}V3S{YCxD`&auP@_)Mj z;k``tr|^&cAI)E&FV-LTznh=rJ+StCHG9t{e-jON7XDxBT=s-R%x4(Y zAVVsYhMfB(6KU!tI#_)w%vzoagwC?p-M}9=ci}1qyvb}!hIdXwV&1;P+{<}j;@`w*y2hD3B%4-->K|lT9o{?zj4t{5Gz#m=d}fR> zb|ZdgdLHkM?e$(9x3SrmwD!3LSWH~7eAGJF5oU@?V01es<<&Ed@i*hTbBAD`|NIfK z-?57+;K$yoDgRU>S{K5KmtHoWhUR;-Ao_@D-DYbzehL6%Ck&5td-?isz>aE~K3SM4 zhb~u`+g}(psKSI2bF@Ps(?D~3fcsp16mD;RJ6q2@Ls);*Fe^$^&N|SbQ;IV1o%sb^e;r5r}hCplc%#t z)Tn0NvX!R4a3o2uIP0ZI=I5P!0?+IstZmR(2=;Oc>260HBR3W~4U&`%u4pbh!K2w7 zuW7iUm8uvp2rG5*od=A{^Wh_)AVW%Cdv~Wz+L=XLnwa)KED8OX7v~Pw4CqV47?h)k zb-}>MGH_HASbi<4*^WQF#ZocHs8%D~JJlr^ZreW#mj5(a;l{H_M9};^PjaSd>j&2P z>sqdc?xL1)3c?B56ZCGg!C|?4^_upa4Vftua-1Es3<_!e9vMDgWC%K(ZN6z*%Efqx zF;=eLT2li;Uo+_a0QsYE;EUl1s5*oJSm5hoachIjuf-#vUu8%>n$7G>i*FTtIH6?o zpU)LiL*p82W&NLc4znUy#UEi=V^B;>%#U>VO|1IbU}-fw^h9IYiH#Q!UQ$#6q0EI> zJB4j{i&Xf&mvxZBo&dEB$xD8(iWeBy&~uHm%V%hf5-k<+rjkYqUD8F4hfdAu2!n+F zHkj7@L}FgY&;T{YRTy#Sh^}S0i6vG-mZ|t^I{yN`!*+8d^0nYlS z2jCiu5a`tE!-j-+hYizMcm9>=%H-HB@2b&X98$&mq@1?PNXKGf z>lb9iKYpYK4?U%>hjZ_!>-vF5wly~K+@V(a*!qnpj|d}!!=;9CpE z_siXTz`K_H+wzdD&IeN>odBdqo`iK-HtxNvOKf7T)bDu}z2HPWWnFYl3j0n>uvLw; zh@VIptKK$v1+H<0K@@kwKsRaOlL=xr*zwo<(Jd4xpD`WW(ggG08~0{)=ZEiO_Ex2n z+PTNaq-AOZ(t|=KalLN%7gS3UM~~ip^#GMd-ILk^=$vXG!5QGk5B(Nmgf&!uNwOy%fQ@I#_dn6~$(XP!A)rZ3V zhb&;6`f*k1rqKToHa$2pnp0?zU;qIA|NUz_?4+;B!F{0Q__7#5!d*c?(|;oNdcZL% z+Y^VXHI~D9Jq>WI+UexvRmL(F0?N72*60~QG_$a^bz)gAby{x97a~GcXy17(hbHp* z=kbeaiupf^k^XWpGRhht4p?N}2+O^DGi;cHcBk@ee745ZuJuTW_dCyf1sDiSLVnI&7nqX~n8G=Sc z#!oRHGgYk<*u|oG!Vn2s?2cE$!zpAb-M8(Z`kqV-WH2vZ5TU_#>^DEaZMVoE2YyL@ ztFe>CV#mf*MGe3L6QBM0TDbwnu1or|3)jK3WPn_&*}KkkYq>|@ZcA^{GkIyUy^ZYD z82#;e0~t?Zc~$a~3DRB*1{z&l3W{vuf(qI_ZCYUP@RmPyDLHB~3F$pD6``$b{?=u2 zw4hzu?gBBgaQ;Dyi9p4D=heSrU7eY+Kn0bVL{P-;*6Rh4z5;EiHard;iIuC6lot0w z`dLGGK|BJB6$cLfGjI0BQk+0!LhqGeh-o)v*6Up;?sQ59qet$>0Z+iWv%ewInY?12 zkyx>5L79jhHv}t#m9@5r0fe^4^tsn2UI9{ykHJD!X!L3ke;ApD<_%0WTuf8;t{l0{2nZ%j|57IMfNCW{Nu%y%ZFBWxw#B(9wvgNYz0; zq4*KF=fbc#(j*iOShs_p(~gg-#FnE-R&XpWK+(3wV3wx?^S6=gfBH_1)Ia35mUmz|>6$O9BU8 zf%uXJR4qTvU|Ik2UNA#Buhk91d5D-?pc(yFW|Jhi!rM*zKGfTZ@5nu|-bnNjK3Y6O zOHk$n&4=*%^GM+V1=Hp}D_FGG4TV7ki)bV{r1wnvDNQHWIHCy>U;5}f7X+y~G9f~4 zXudMN&(F-A1XQMoAS6gG_Lz|$XvB_LSbAXpAG z=R?bwlzVOGJeiZ6wMiAP7a@j~0R&&6|&aN=n@A3mQ`PB+1T z(ub%3vLZ{{=rt2kz5vH2c*RK8!&7JR7bWY7Ibz!pY2Kg)oLx zk^}C;8P$iNifyA+KL=N356%tQDH6{X_?Gl36efIJK;4FS+QVE1KHBC+(pD7krfZ1k zGUPA99iJU!iU{D~$%+UzEVac57ug#rd&B61q1*fkilE_FIp8Dja)4M zJxfg5)jN7Pj+dLVv@F$HVwzEP4#wADEw7~v8GdB(u>f0BvPbepA{_W`Z(ldY9TtnA^~c3Q4&z`YN@YTZO5-3g}4dxxPA0{-%{biHT?!zv4s9f5pnB*L@e+ z_e$8|AQ7|*C@b6Fh!j7s9nL31=;Rs9Bc^M`ax&x;L&0UfA9VGlc{`aX|62-!w0@G3 z^`pzI5m-AiDlOKfq6F9!J%KxV`mqg^k!~u(CpD7(y~$Cs&=xH^l`6C-$*}4~ zx^@gxp1l+53N#DEV98UQBU8`B`{91{g24Wv6Mytw+t4KYJWyMNdYqPD#y`?y4grQ9 zyXWozMiF=D;6AMox}ab(vGGQy&#SiB>85){;`i-WqR{}Fp@UozxaW#ZS6Ni&)<_UE z_aNm$#;il<6Ha&XJu!#?ucrRzH)B@pY`snWt2MKSn?7g9IJ?(~s`FxFH8mkX7?DKs zGC+m~qAETJ4+K;!3%eTsIi8*_P{2Wf-RH|_7)bit%Hgi}MS?v{ zG(mb9xKwZhccP*n&(-eY5~Iab+0#s5gub1F8nB(HN_sjAOz-`UOiHU;^6y~op&E0MIR6=$1zX}@CMo>)c3m`!Gp}7_tvt}?Dxz}BNyE;M|N^6`dH7@#+sSDJ~ zVl_x`@CE%DVzDmIs3MD|X_Y6f7O0n-Q;5qG4(A$5sv_dQbM&{<87`kLgy|d%dGMKC z4)Rf{-WV9r3_n|dC3rx4J-RSHx0;bs2~{h_ZVbfu9t^Fg8A2{l73=d@));B4_9uM6 zpa4$zR7$P?>agfU?fyKio|vF-|Iq7OWPM3b;|A$c9&F(b|4EIwnI2h`+HM~AW<#~+_*$<|Rn++jU$*3&O7zR|SR&}?+5=G!mHS34mstle7NR|K`KZaq)lm z{38X?57!N7k~`PB(#vF+IO#E5WR(1G;`qicb8-MG1Cog1@Ae~ktdr%i8eD*v;`di6 zQXD<`T4xpr#=tSohC9W;H!wSq(Bp+uzK%WJL zyub6|NDe)A?(q*h8pRyxKG8n0-if97Filp_sk_=b;wNQQ^8}Rbw9L9N$2vHLA|0=S zDK1=kbc`^jktvjEsWTyxeTz$rUUQ_?W^bFuuQAC@t6j#W^vf+MET2cTm3`v27eJ3=1lHfSxFvZ4Q{m5LbHWCf~Cl9)1$cel8HVReBO=cay+%WSTqRv>}rOWcf z%!L4N4SgNsw|Ty&;1({f+j&z&EBaoyI!juGG?@{fX@Kfoth(W&bb=|FyrJD5jW>pZ$vJ&YansCC!NcWP?f zbZ1{T%?QBuE5_anTxM(Uqh!;?=$socejV;U#J+~L=SO@)g$79X?nZ&x(s#F|OTnK@5u!@4|8PzBPQcfml|Ms0?@%dCP4Ii3W$kBi$ZWoeF$fhGHnuf2S)7n!=PtmN}vW zv|2CX{?cO^Pe;J8^5@YXWi`keE2(@gkM3~*%B^hQZ*DdS*zk2>?8B%nN4be2(MQI1 zH3XKPgxtVj6@8v5tel53Yz#8RHVNCq7Ab}0QQ7$T<_zI1NMBEIywM*Y)LklL8NcD6 z26@^-`&+8cy5ESbJgVVuTXWOUDrjh57NfRimNL zqI8~P7TVFW*g@(Cv#X~=@#t&IkOy^PrKBRWWsI>or`8pq;J7WutP}|Xz!33FLjqy; z^7SZ4{4|hlHwVL8%LDy*Ai5Du1f};&ej-Ca_^0JIvb}L>d+!cv$f2Pv)Oyv2_ja@l zJ&a_yiVI!EtQ|FlO{X_w*8QeazB`^;b+6o}x6Fps1gt;`F1*wh?nCVF~KK-bq{(;jP--uc)Kq5km2Y7GkiWcvG z_V?HH_Y0VMd^in!?^F}buevm+K~}Dg=BCE%;D2;HY?{Hz)-`Cao7$+fgb|xPyw=s^_fp8^POi@g1JbbarV6IV6ZDS39R#VXW3Nn;%%> zI9xAY9x_d;Uvqnwd{7;bQjAlgJUb3a%Q!+J)FFKCQhS+N99oLVVtDW$a0nN{n{=B_ zKu$879$=XR&WUKLup~Qk9XyMS#83FM&dE>!P}9o!Z5RFreuC1t)`#fockIVZq6wxk zqK1a`b%t~0>iwj6Slv>!+dqGseNnuRY$iaMF?WtQKB)ShZ5Z)`SgbJ)w!Mx1QRRrF zbp2=Hp>BbTBQyvJ$I&f)_I=Q5HZb4EG3u)>-S}A+U-0%e$sQi60#f4_oQjz{t+_Fb z5fG}YO7andUOVN+NwYQ!=Rqfk&oVm_N-b^S1v8yq7@5Srd&h8B$}G@-suLSX;I(y9 ztl#{tqW=1U>MNz061Izz{TKHZ@WRS^uBe!wvMAz5Iv)-S;YZ<4vm&KNO{hJ-X(RNCi++~O=Sji$<;`UcERV44x>t<5F2H2d?_k5?vmz5zByexRpq5LYFUG2cJ&A28?(3Ep%w)>#{LUsbs1K&_ZuQ0)AC z`qqVo-~vOx;T5c2_3iE(Lk92l-oNX}a-@XdF^AKP$SH{r>x?_x$5Xg7rB8N1on1h1 zkYoa;+qC?_k;G09Att2qoC$8{?qpdtkL$=b0p|PTemHL+(TB1PpFX_y{O9H@ol>~9 zoB&aS**mb=l%2a}bBS1D43&~b9R59(x@Mq}!?H=DHoGk*ZyMB~alQcObrM83&8|20 zUieqNUu|}0(n_;}_Z&c|TykK+I9E>N6+vpkXY0-jpx>+~&S(ro*`It%9v2Bzb$#{h(+$!gQb1i! zH}mTGIX*n=9uK4L;_&s6M=|8v4&4r|)O#gIX`4Z!)pWIvtqKp5A@h!rq6f+bXc#>I z7T#JEt+vZNaR76E(J=_~&(#Y^=+N2vrDiE9Y2Rw9b?&M=bG)o3=V_5({Hk$5HqV?t z@g=Ujgfn$){e4|{RKh2YvoHd7u0x!D{}APEo7OJ5)T!Y=G~L5NU9zhfrBTrs(#>GX z0U$O4?ua>5j__l9=3uLbRr8^Mm=_tuhQz|e=d}@t8T}t&DoVmBPcwN?#*IX#mbXRy z!&P0#Y#s)LM;iWSfoJBdkw6BDfy?(@dJt$?G}xG7G@&0=dsOwNJ{)pYAyy3NT`Mkq z+LHaoR&esK`9IM)D>~KR52Ajde-f&KM ztU}u(%!E+0nu}`pejti~USmHf=Zx2m16DiO!xm$y)&1BER2gP%i^ePw*!w8jh$dib z6as0&Kp&hBbc3%`Ya_Tm)sd!TAxekw->KoGp;Bp|KR-kzfH6aB9JP!a-))>`0}Kmb zyJC**DKg-io{Et0Kl)(%gBU6nkpMQjKJ*K47Fvb%lHu~`2qvU*kJKFR_pOr zq3MW|pVkex8gPlWf%tIa?)5aBfz`9WapZr7F77)I5x%GoiLv{UyYHzl$ZL+vJLzT? z&xz}uK5N3JWJ2z``mD%h1JXaNOQe9qm99$GIqeI7L@HKmpLH7Xdeg@w<Of^n}1ol?PhNLbJeC49yjD7QS+dDyyxp!~Z2+!<- zV0PRnLtY75G$#NcxHJdiI-mor$bfL03{t6E9bU%(bcJ@wR~RIpJ}SC2zq_w5h!`

#V)XzyjMRlnm_XlydQ1b=>v-PV8dSqz-@siyO{YIO^sA2R`rj|`>=sb^1+zz{uO3~lQ6BiL0n^g zG+W2UMMCQ&xgHX6HlUXpiCtYckA+9$ zj{U-rkYyA#N>vB)IWq{5`@bGORlU6{^i>v%!X8t=gIPF)T+_jiq}v`r6g;D4$l|O6 zmU-{o`277Y@>YS}JVwnhHexHd5dD888TL^E3bA0ycMASv$ zD=HCu6>(5x7rTnts$lFL(ZZ;ZhPb>xTLe{^vjQH3ARMtG6M13nAN1w??KMfJjxY2F z>-zHuRi2afQy=(A&JJjL!SnnbJ1N+ zB!4At`0)es9IRI1I)fb2gp)S}+luShjyG~Rj;vCNG8vnn+!poM%GMR;e*(cC;8K;k;BDxKpnjnCgoA;4z5AC^-1#7>p zkfVo^w28S>25#TBKl~Sv$5F5UQ}b{}*1=wX9&g2;|DlxpKMZ~9F6xuS0^#4}vL1UY z;@pHwhH+h|J(lhvWYX{Q_R(EXJt6Pf2t|sO%XhTjWmhx!p%KV zJT0JvBeyBWbKj7E{0Ag(dq=s;Mm1vL?x)404RzdK_3tiHQK^fI>$~OF!(%Hj-3ddQ z`cPBiRUc0ezkBx^( z2YgTIc>a^%jz?>!2aARp0~;Nq@PUSWf?w1cPg-xdZZl*k6KYqh1)SNy#QY9kHYWjw z89Csn!n`hRj!aiK#G-d3of`}-;LZiR&7jb631uf%C#vb`=_8U4>kh4I_0_M0A+*h> zbN-ty^=Q7T_i4xUDQ@j^q$Hpj7J-t?e0K(OZ^h%#^UCv;17x{q8i28eGYBkQs1f^D z^)#rZ`A*Ce&tqv+?p1ubyhoktoi?D$5={xtJ9b(jJ#w#`H;ip%!tW2GL~|GJc#wQ8 zv5!Vfhr9-_D-8=uXsc3~Fir!r?`l@z!%#Cvh39Q|{|$0T5nUDPK*hVB3GHr0&jn0_ zCN`NN7IoCY`|~!39j8;XMu-x4Ut|aXTc?_@ zWDL@$?ZZgAtn9|t*N6ylr#8`sRE6EZUzf)CLXlvs{Fx)_-JTCCe@|@B~y zdbj`kHjSh%#yuV9;k|B^wb1KH^iWiS<7`sI(^U87Ofl`Ad!q+ujc|UY!<51VSfRQ@ z3A=pRPpWG~Gs8DX<4cE%xQA)MV8N#c?EPi*qhWqfgC~DRIBUUAK|QP`OI3`mN3ZFC&wN$ER<$P zm2h*BZ|6*AZ#z6qgTO6fF?ElPz--E$yGakYU8?jdQcPqsSSu~ov7SNLpS4_m3 z#op2T_)$+}Y8Bg=FF^kOOTLiue^Ih;lR?B_Hz2RTkR~qekeaeC@m4C=TW5g{#K#3W zzUhH%X6ukgiP0@$BeE)GCP(n!zv#HB?`dWL1;`{!=`#CAIr}BjuUGx4NLRX@>c~OQ z9-x4XgD75tvt+tM?i^+=@>m(Dc;*|aYTRVBwn~APEY$hLVFTtO7@Y^v+S73h$k|0e7004Ltp#}uzB z01iuRAb|f=`x_;ppf*?E$THizu&g~MQ{+D@#DyWGMKM}!{?IZUB|y|$&&O1Xpo@*; zQ2nw}!@ET=ME|s49agr2@=4MHemb9Qsn2vbR3Yts-37r+#HDyCYX@ zbZ_H@6bd7=+!!LW#8p(!*LR2uRb5Z#dJBuisfM%RXFGC?vJ&=G$EoRl$Tw;5WYlJJ zroV2yuPt4WN{L00pW=N8oE->2g^D6PpL1G*Ov4a+Psg(?3n^B?bN_n89nAG($cXp& z3D4<=S!^@){k3w7ePZq= z8wxewE!7UQn4F5;ovq};%95-ODRe#Y8W+=Y%?Zkt?y<2O-;$irB+5>jn?uH*iUk2^ z)_Q0d%&@eXk7)sJaYb8!K(hsJIgR}u8W9Hr08jn^xP4dl=$x(bTF&Qv~^`7j|a(6>i}@-rMdSlK}!ch;alJBX*TUgCE*BWD4#3gcv1l?cwDmP^Hp2CmRG$fbH$6U^sdo#c`4 zlU>G5Jqc>v#zA`4x#!F&>zVOZvMab*u&M<3iI#{IsR@fzlQ;Jz?2Tn7{C(-K83!3R z{i(=OTxQ5Hir`my#?c9rW15TMa>u|?PP4z|WnvMO%+^vUs2_3or_{_CvH&IqAdA_* zNGamWOHn8f)KX0k0rl$YBig-pj<{R%t>n#+v&xa;UhN^M#7#|yw4MnPwU%ZPF@}Ut z<8`vqYqT!(V{d^3v-}x>`=Tu^PHG}8mUYRnAl@=!+YMROWn+l8dd;_l=8y_KA18Ln z#s7ov3Vi>2iHA>}qc+~qHoLQrsPxuxOp|<^m4bQORTPD%M*w^(ZM&(>!3F6oR1KSsO zhf+MY*U>Y&PX-in;l*~JQcygTP@*rW60l?gwT*FkeLUl33_TNie@V;3(tBDJ#M?zl zO>}XEN1~O6p(4<4R=axmZN4r1sdUE9f++nfh27?f%%^rn@*a=VQ6jZ#ly&yv@u zB6$F;SZhO<9pPSoa&6e+Qym+UD&CaMU9^@;s)5>kcR$n4`fNQ3GYaw#tuo6(DuoR<45iJFAL}isa;bEpB1|FXl-uGX5AN86K!0 zkKtH~<@vbe1>g4euG~z_8lfDihAgpO=75p;)nC&(H+@qk$}AgL-lF};{7gK48fYaq)wR)E-a~*J4S7pX-sK4?3(Gkd@{CKkvVh^|2*de6hx+a8B8`+ zo`eIEiatZh!5c`S4qgmc9&&@KORg+p^4a5t8VOm#jT?xB;d42+@L6$wGk=@?6hn^^ z+?gjPP>(B`Sa1}1G2%@k$*`(9hkpXn10Z4Acd^b-+CRP(36O$l^>*!zU%vRZd=&hR zREc$2SC2`avj=zkMveX@9eIDd0Krq;5F>hs)`nUa)XasA6Ls$VcRU-J0m^AS353{% z*8R)ljZHuQfW70&tfS7YH}#KXDb#afq)(p{u{adSj+XG78q-fR8RzO=)`0x;B&e1U z&s(S+=UDsaiFbV)EELNUsql->D1Z4Fbi#ntWd5} zr=HT$qH_`N;d?YNQZe4=(c?Bv%8Rw0-C$RIM1MVOtMVQ!dlowDi1_q-GkGU0BaDA9 z84O;$xO{4fiJhx8HT^Hq-K7VD)Qpd_@g&-@UrdoxRKDdG5HQ#|gABYbOWk0)sMW!k zc1v)#(G2U)v^NZ>Q%WnjKAoID6~Lirvt2!2*$vAe;}ULpys%56uM#p^vRyqjT37#5 zV^Vqm7kw_)53br_3W70h8sC>gf*@+Hwqg0kUYCt(DjpA>tm&2u{(qkU_T z3)*wDb5{tVSk2)QBdUy|7bjxT>nfuIC4boFXn_BBM?iR|Xg5=T*K7PB)+E^}Y$G-q zVy*}P=EJTWij45)MSjMz{tnjtNQw%DeB<0C$Po3B_L-#!jc%(ay3JcL{VqVR>3O&z zX>X;~)gFy0%oFk&`8-{*B}B<7^cceXl@Jj0bpJXBuMaH<7X>D(iGajSfFG0o+`|=5 znkhBwxfPO!lU;f8xP7erNOywN5TzhKmyERst1X2HN7R`>R8aN@vXwY`W$i_&0F>>w z%}7@v-cn!o0FDZq$-yYmc?D1?TC<>v&3APgx!GQ??!{4ILP6u6`%|jz5@JjI=bruT zXg;Sy**1+h5K@EV@QH|Hr?UndO73=tW&eLoQVYO9A~P z+B!D5?R|oZ8fY?^0E}W@_GJ3ba1`O;hFb;hQ4Q{^$9qh0=S%vEH}oC93p;?|6TgD3 zvmx5&#=P#N<+THKz)a;?_&~At^wdVDbPG9z1na7(JrB)6CO+=aNwr&2B!7~PbS3Vq zH_vwPomd96kin^!{gJHqPTG(Hcv1POzvd8`#)4?Q2lf{9@I$N_y30-rJIm9U*2G)m zG>bSZLVvlxuA{Q<_vxA_#4<%j}-Hc3m;t+W<5O&V+*IAkW1Yp6^jn)dJCd-dc z*0TMcekApTKP8Sd7{?E3y10To2544(C{P9E*|l1Vw=|4GY)Y~A9|ze2<|qWoc=r|5 z>2icPozY{tURgIj8ozF;kL|BLoT^eGgN5xEC%6`EVP#)Je-IJ_Nvb}}tqRllgO`*i zjc$PB)*n4-kOk!Z$k(aL)2{%H+ZCb_jDzP(P9zrq%XQF=Ift+Bz=u3tasA*EL#fu` zt2v9mq`}r}z>=`##346m+%d{KH!-JihoV@HnV4j9@{Yw1z~G&1p0+J33fxTb(%NSi zc^DImdD}j-wwemYl22hJJ^w8Sj^QB!l~+03rb@f`&1GLiJsk|AvB}a&> z*k0?rMc#gck*`%8G4(%m)ee_}!83yZk|=3)G-mA|r_C^>n$q`wMMD4Gh2{u6`J?%e zXfINgGxR{9Z3cs1Gdq#D{{Srm*r9^xt|#N$8(8n{F{BO-AHor{x2}5}F-d{wT>`b5 zBi=34sWLYM+C`{vqYPTG7+bodpP9bHQVRJZwHV%lJCK{%wGwdPI=CuEp7=~MH`K9H z#^)!qheo1?q2x6~A5gc~ANFFkP1#eL8zH!pUB1$tnnxy&zo3z9f&OznJ@5GO-v^KwZKqp`lE>A*4u%Z#Ri~5Amkx$6ckmO zJ(QTC82ejvfi58=zR(#``5x84U^__&{@sS4z+>$Km!+KlWHHy-3%}XMSV@#Xv`IM!+h=Z{@bT6TofvxxT$|VE@O5?Lw zud50(#oN3Y&CItT<92uOVKs}>B~x#1xa{J@U8|yEg{NU{PP&!*np8XRqWyTKwo(ek zjCSC3%Pg>PUS^<2Ke0&?S|epBaZuhEh*fyjf{5Zr+LxD9U+(4KxHH*Wtb>v6jZiht z17)u~s*tO3Y@6aM*WN~ET(OTbQIwo(3J;;g2xzyva14*f$?-BPeec@hZ6NmMV2pcC z;%v15xYw^c%Hj7nu)6(8Ur0DZy^)@z`|N_Aon3UKz~SHcS!((ac~x#-!J23rT18mq zF*_2!jjm4r;;q6v3*+vpprmx)q4V3i#*i7!w#}`be{F{DogngbkdrH%tuJu}8Mu!~ zY30M)d892yO${D1fm(;DITtT&r~sbiRxzgQfI0}@N&VTOtbvEqcOrrrud&-fPD`JR4)a`3tcyGj{dfbt@vt9-F@lX~{09|@Fg!3kU~nnZ znx57QTTJ3*l@fJj8DNgoG)zo2lJUeK*X`WR)NOQ;IYFoc^80Xw*Vtk|k3tP@z|GcC zrSrJAUrg%Jk^vTPW2dqqJTL@e8LZe|7oFkButhl`iAmmoe??azA96 zfX38LZv#_ntos|`Eci!^o5fAbUE={?51{aOJiZAj1BYfWkbS;)UZ|SwQ#Ruuh+y89 z8cL772BYkHp3>@qQ}fMeAwWQJ(h+Ta-!9JV% z@z@|vbtd==+0R8-M>CoOtvwdN8h5LDf?%6?cuSsTmluLwS(Qt)Mp%=-(0M6JC@y5C zK0`6i9b|`FmFe_)(eIB-k;bse|Ao!z|Bc9`IZ@A@`=>s@nHN?$k}6&5Im%seQY;m_ zq$O(n=xdxU?H^&5P1ah3^|W~7g4(}F&Ysqsq0euIK4!)^vi;uKlEVSjafk6(?~vtS zoOIPGBnGJ}UHIlc0}Itoq=Gf2Iw#p!(D9QDyF|}^QfT6zKrvp-fus;0v0){BjLVc7Ot)7L_|DJ3A)`s7UiL-w3xp_fe)EF%7fc` z+|li0enu97V4dpsW*>a5?lP?QyK>>W0|L@U;oVR;MVPm$VLRJsx;>~(T_C+uKB2Fj zOS~RINL{DP5TzHLF^TCZ=|7zc8f7C&a_Ahgwrp;0>SjkT-9?VQ*^iN?bu! zYVS7YA7rnX;jV?sggWLvHC&b0=%kP*j1UO;wcB{U(^xQjhk67;mNv(wub!B*ac^ zQzP54SzZ3*Wk#|Dd<4e>jf@!33Zts02L^yLqWvA5G+~tyHq8?A;$AR?wOg2t#yI0Z zm8nl+G;r~W7`^bEFzSGBJP?lv7JWX03ZqH5QL`=}wL>Nuxg1lyPdspR`~UCWdaX;- zoEZwcIZ8K8l(RbTeT`>%vKnk-kwWey7Q}qd&OG~m3gN5>8>h~0PKt1WWhrZ2xc8)V z*|{_gmJ2b9S;(3U%Nbv}qvx?JRlo{;zB?tahB~cK;;x5q(uHp2P4@cJ9M}&&20en? zidvY94&EHn!;V|i(l%DEWbC4L4mA!fC^OD|_dL&7Ww#Zs)0H7DFqXsPjOB9NnPpU6 zwfynDnIBX76dYI<{T}Lger|1f(tz%l`sOB5sK5(Cf0Ei|wy$MKLd}=9Q}A}u&$Ivh zp5v_LZazsi+1FiA4!}>pL;zUl8!Gvg#OAz5$#7uziphK7O0Nt4-EEI({`?(XM&Ku_ zj;m~6m~{jf_a+LY*hZw&QjLC^M>SnX@bu!Ah^y9=wgb08RM}ZoZcUF}%~V(M2h=FJ z#zY#r(}D|xspIzn*XQN^^#;7EqY7+2W!-Ml)u1!`(!*Ueuj=Em&)L25z0IR6KOh>2 zIF!|wIAo5iw9#yab1t~f-#HXA4PW$Dg3f1L97&o_W3EUa#PN9~V&SAD41Zr_Hx|8_ zk3e&eMkCU{QpmZ2i(r2ts{)&II>;+d&6horLv@XsoPpmaRu-3hZi^`5bW3=*50+I7 zO(7UjL}|0%9Wbh?kdCDl7u^GbqiYBkYu|Mp0U{-OW*hJv}< zL?*D%0o!PZFTKeUm8PZKud043+v$UAxp7-pWqaAl46@|cYw^V=4+dfRDiOUp#+vYk zFkreh8BafWv@Wvr@-@wbK~*M+Cg2-7Taz|o?@8_Q(T94k<6dzlA3C|R>kBP|6KUBM z5JQGPpw38X=R2)8H_XN&Bq{>T?nFBIx<`9Q2noNbEE%mgXRmaCOfP8q&!PBOrWO}J zcb5umRt~*{?(Sowi{K(Q2h~LHbvVwp!L1)=5`v^5HKlw&HZJ5=y}R5RzOObi5>l>U z9g)W^pkEZ9UET-YLQz-*hF@n!)m7F~cu4;)1w!FaiSj~nJ<*?#cW5Q9a?S9qcTC`% z)2bRH;y$^^Bm{tuOP=B4NF%}8DtJvkj%pD^GWE_KM&Aq?RKvRaJBOG(Ag8y>Bc4;{ zY(M%#%F+dkG)iV4l%hmd(7j?0{~MaV@0<%oSpdnLboZzma{a%{0gBK+CbzAk1MSMQ^96SP%nE`Y_6iHfUoVU4w0|qRhvU#a@P`JF;!~zB8GF+;1$leo?@i)lA z`{tQuh=euwVDTujyDI$H&*lOyO=4>kkK}En3evaN{0lF{F^0|&<^1J3`W64wkTJ^=??iLUi+o5Vto#nf6YBT0k3G-513 zN>I+VyiEQz%&~%7aNFdpk(62u_tZTfm6QxWG1$L)Z$?#YT?JNuLTjK*dM4JTo-#Vt zwpX*3hUUGOmoX@gX+Qlz(hE<@Wl8 z_VAV++e{r10LL>!TGbol%8_bbKsmGA#@LhIaU9tDHQ`rC6wXJr`tGHc-8@%O=dQHOcPSMr;Lw^UtCC?132)dLhtdX+y{8HNnks# zXI%{louEsw7LOJ$qZlor5j$p}WDNaz@QTmAYllats!@$2dnDCPK#Nl zt5~FK3u_ObW`SFW=u%1;aI74i%oFVf%?@HGK0)gP!4{pPv#2!OHBrV|4h3344h4;K zo1|bT!rq2Y{z=fIo;dei!c@+XG~co`Q!XoDs!TN2O>x3vD2B~;R7CWKt8k2pj8){R z)eNM-**TyJ17@h~%p)&IoqMOsl#7ihk1RGH^O|H(;q;)DtNuO)KvC>o=5>1a6HS02 z;@MzG?1Uih)?FRnkW5|P(neP5rHx1Q;r80xsqPjJ>bp}D!=!?g0?ASC{J3g>*e~3N zJdw6w4(Y$E39AvcGiHq^1PlhjG6lnE1WQzYR0e5VP#a?%a4N_npb(9CSdTu=)z%R2 zlO-^aka>nB0;`L!5NSI!xq^ zBiH)N0A^O7YrhWdV^+FIi^cn!=~EWjV-Yi!JFX@o4y(~JCGt1qFqc4}N)`-@x^Zts z*@6~^!37B0hy=7SMaA?scVr`lI+^I0HH%h8J-;GD0s`>)440KHclM2s!(fIT2{Zs_ zomAbikEARDO3ju{82lPIjrBUnW#Y=kthxk@_h(j4;{QhdhiOX!R!x0=ZvXHW`1~|{ zs5I~e3a55v<92E?{y$c(h_7gqp4d^2joYWT*O@dD1hTmXzhxbZ-$-!^^#ung{ zt~!~0L$c&F0bINsfuUEuaL?j!ZSbF??*0lx`OXx?s7gW;oHQi_#(l-qr5j-J)&JzN zLmnGBe~T4m-(&#)OH>_m8C&R)aMqH1w&3?$cFSheVk*|rs0-6y&Iu7A;eFM?gSI{f zZGsITjBd#`Xxb_2=fb9NRJ%qv2OB~+qDo_e`zmU6Qd&H(0%J{=y4pnl9^FL{2rxI> zSaDlohMhskt9ja$|774L`6}e_J{qmr_Fhs4p!fyqx1g3O0@COc_U1cY&%$vhO^WCP z6|P6tJAzR4+Q}S((>zQ(!*LGr=vJf&n?%?$?UW9A5jivm^aF6}P#LZZjQ2a~tf@&iK3 zg+K;>$#_ { route: '/apps/health', }) expect(isPhoneAppId('health')).toBe(true) + expect(PHONE_APPS.find((app) => app.id === 'crypto')).toMatchObject({ + category: 'utilities', + gridOrder: 7, + labelKey: 'Apps.crypto.name', + route: '/apps/crypto', + }) + expect(isPhoneAppId('crypto')).toBe(true) expect(PHONE_APPS.find((app) => app.id === 'banking')).toMatchObject({ gridOrder: 5, labelKey: 'Apps.banking.name', diff --git a/frontend/src/config/apps.ts b/frontend/src/config/apps.ts index 36e622b..6dbeeae 100644 --- a/frontend/src/config/apps.ts +++ b/frontend/src/config/apps.ts @@ -36,6 +36,7 @@ import { Building2, Newspaper, HeartPulse, + ChartCandlestick, } from 'lucide-vue-next' import { defineAsyncComponent, markRaw, shallowReactive } from 'vue' @@ -64,6 +65,7 @@ import neonDropIcon from '@/assets/img/app-icons/neon-drop.webp' import weatherIcon from '@/assets/img/app-icons/weather.webp' import healthIcon from '@/assets/img/app-icons/health.webp' import bankingIcon from '@/assets/img/app-icons/banking.webp' +import cryptoIcon from '@/assets/img/app-icons/crypto.webp' import billingIcon from '@/assets/img/app-icons/billing.svg' import garageIcon from '@/assets/img/app-icons/garage.webp' import houseIcon from '@/assets/img/app-icons/house.svg' @@ -89,6 +91,20 @@ import type { } from '@/types/apps' export const PHONE_APPS = shallowReactive([ + { + category: 'utilities', + component: markRaw( + defineAsyncComponent(() => import('@/views/apps/CryptoApp.vue')), + ), + dockOrder: null, + gridOrder: 7, + icon: markRaw(ChartCandlestick), + iconClass: 'app-icon--crypto', + iconImage: cryptoIcon, + id: 'crypto', + labelKey: 'Apps.crypto.name', + route: '/apps/crypto', + }, { category: 'utilities', component: markRaw( diff --git a/frontend/src/stores/crypto.test.ts b/frontend/src/stores/crypto.test.ts new file mode 100644 index 0000000..07b1987 --- /dev/null +++ b/frontend/src/stores/crypto.test.ts @@ -0,0 +1,87 @@ +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { useCryptoStore } from '@/stores/crypto' +import type { CryptoBootstrap, CryptoQuote } from '@/types/crypto' +import { nuiCall } from '@/utils/nui' + +vi.mock('@/utils/nui', () => ({ nuiCall: vi.fn() })) + +const mockNuiCall = vi.mocked(nuiCall) +const bootstrap: CryptoBootstrap = { + activity: [], + authenticated: true, + cashBalance: '25000', + holdings: [], + markets: [], + portfolioValue: '25000', + profile: { handle: 'skyline', id: 'profile-1', status: 'active' }, +} +const quote: CryptoQuote = { + expiresAt: Date.now() + 8000, + fee: '0.97', + gross: '128.50', + id: 'quote-1', + marketId: 'aurora', + net: '129.47', + price: '128.50', + quantity: '1', + side: 'buy', +} + +describe('crypto store', () => { + beforeEach(() => { + setActivePinia(createPinia()) + mockNuiCall.mockReset() + }) + + it('loads the server-authoritative portfolio', async () => { + mockNuiCall.mockResolvedValueOnce({ data: bootstrap, success: true }) + const crypto = useCryptoStore() + + expect(await crypto.load()).toBe(true) + expect(crypto.data).toEqual(bootstrap) + expect(mockNuiCall).toHaveBeenCalledWith('crypto:bootstrap', {}) + }) + + it('sends only market, side and quantity when requesting a quote', async () => { + mockNuiCall.mockResolvedValueOnce({ data: quote, success: true }) + const crypto = useCryptoStore() + + await crypto.quote('aurora', 'buy', '1') + + expect(crypto.pendingQuote).toEqual(quote) + expect(mockNuiCall).toHaveBeenCalledWith('crypto:quote', { + marketId: 'aurora', + quantity: '1', + side: 'buy', + }) + }) + + it('executes a quote with an opaque id and generated idempotency key', async () => { + mockNuiCall.mockResolvedValueOnce({ data: bootstrap, success: true }) + const crypto = useCryptoStore() + crypto.pendingQuote = quote + + expect(await crypto.executeQuote()).toBe(true) + expect(mockNuiCall).toHaveBeenCalledWith( + 'crypto:execute', + expect.objectContaining({ quoteId: 'quote-1' }), + ) + expect(crypto.pendingQuote).toBeNull() + }) + + it('keeps server errors and does not replace portfolio state', async () => { + mockNuiCall.mockResolvedValueOnce({ + error: 'quote_expired', + success: false, + }) + const crypto = useCryptoStore() + crypto.data = bootstrap + + await crypto.quote('aurora', 'buy', '1') + + expect(crypto.data).toEqual(bootstrap) + expect(crypto.error).toBe('quote_expired') + }) +}) diff --git a/frontend/src/stores/crypto.ts b/frontend/src/stores/crypto.ts new file mode 100644 index 0000000..19e0f22 --- /dev/null +++ b/frontend/src/stores/crypto.ts @@ -0,0 +1,99 @@ +import { defineStore } from 'pinia' + +import type { CryptoBootstrap, CryptoQuote, CryptoSide } from '@/types/crypto' +import { nuiCall, type NuiResponse } from '@/utils/nui' + +function requestKey(prefix: string): string { + const random = Math.random().toString(36).slice(2) + return `${prefix}-${Date.now()}-${random}` +} + +export const useCryptoStore = defineStore('crypto', { + state: () => ({ + data: null as CryptoBootstrap | null, + error: '', + isLoading: false, + pendingQuote: null as CryptoQuote | null, + }), + actions: { + async call(endpoint: string, payload: Record = {}) { + this.isLoading = true + this.error = '' + const response = await nuiCall(`crypto:${endpoint}`, payload).finally( + () => { + this.isLoading = false + }, + ) + if (!response.success) this.error = response.error ?? 'request_failed' + return response + }, + async load(): Promise { + const response = await this.call('bootstrap') + if (!response.success || !response.data) return false + this.data = response.data + return true + }, + async register(handle: string, password: string): Promise { + const response = await this.call('register', { + handle, + password, + }) + if (!response.success || !response.data) return false + this.data = response.data + return true + }, + async login(password: string): Promise { + const response = await this.call('login', { password }) + if (!response.success || !response.data) return false + this.data = response.data + return true + }, + async logout(): Promise { + const response = await this.call('logout') + if (response.success) { + this.data = this.data + ? { ...this.data, authenticated: false, profile: null } + : null + this.pendingQuote = null + } + }, + async settle( + kind: 'deposit' | 'withdraw', + amount: string, + password: string, + ): Promise { + const response = await this.call(kind, { + amount, + idempotencyKey: requestKey(kind), + password, + }) + if (!response.success || !response.data) return false + this.data = response.data + return true + }, + async quote( + marketId: string, + side: CryptoSide, + quantity: string, + ): Promise> { + const response = await this.call('quote', { + marketId, + quantity, + side, + }) + this.pendingQuote = response.success ? (response.data ?? null) : null + return response + }, + async executeQuote(): Promise { + if (!this.pendingQuote) return false + const response = await this.call('execute', { + idempotencyKey: requestKey('trade'), + quoteId: this.pendingQuote.id, + }) + if (!response.success || !response.data) return false + this.data = response.data + this.pendingQuote = null + return true + }, + }, +}) diff --git a/frontend/src/stores/phone.ts b/frontend/src/stores/phone.ts index 6cab93c..ce6df27 100644 --- a/frontend/src/stores/phone.ts +++ b/frontend/src/stores/phone.ts @@ -416,8 +416,113 @@ const healthFallbackLocales = { }, } +const cryptoFallbackLocales = { + name: 'VaultX', + subtitle: 'Fictional asset exchange', + loading: 'Opening secure exchange…', + logout: 'Sign out', + refresh: 'Refresh portfolio', + navigation: 'VaultX navigation', + auth: { + eyebrow: 'Character-bound exchange', + loginTitle: 'Welcome back', + registerTitle: 'Create your VaultX profile', + body: "Trade fictional digital assets with your character's in-game funds.", + login: 'Log in', + register: 'Register', + handle: 'VaultX handle', + handlePlaceholder: '3–20 letters or numbers', + password: 'Password', + passwordPlaceholder: '8–72 characters', + showPassword: 'Show password', + hidePassword: 'Hide password', + security: 'Use an in-character password. Never reuse a real password.', + loginAction: 'Open portfolio', + registerAction: 'Create secure profile', + }, + tabs: { portfolio: 'Portfolio', markets: 'Markets', activity: 'Activity' }, + portfolio: { + total: 'Total portfolio', + cash: 'Available cash', + holdings: 'Your assets', + empty: 'No assets yet', + emptyBody: 'Open Markets to request a protected quote.', + avg: 'Avg.', + }, + actions: { deposit: 'Deposit', withdraw: 'Withdraw' }, + markets: { + title: 'Fictional markets', + live: 'Server priced', + noticeTitle: 'In-game exchange', + notice: + 'Prices are synthetic and have no real-world value. Every trade is checked against finite exchange liquidity.', + }, + trade: { + buy: 'Buy', + sell: 'Sell', + quantity: 'Quantity', + available: 'Available:', + price: 'Quote price', + gross: 'Gross', + fee: 'Exchange fee', + total: 'Total', + quoteExpiry: + 'This server quote expires after 8 seconds and can only be used once.', + getQuote: 'Get protected quote', + confirm: 'Confirm trade', + }, + settlement: { + depositBody: 'Move bank funds into your settled VaultX cash balance.', + withdrawBody: + "Return settled VaultX cash to your character's bank account.", + amount: 'Whole bank amount', + confirm: 'Confirm securely', + }, + activity: { + title: 'Financial activity', + empty: 'No activity', + emptyBody: 'Deposits, withdrawals and trades will appear here.', + }, + activityTypes: { + buy: 'Asset purchase', + sell: 'Asset sale', + deposit: 'Bank deposit', + withdrawal: 'Bank withdrawal', + }, + statuses: { + completed: 'Completed', + pending: 'Pending review', + failed: 'Failed', + manual_review: 'Manual review', + }, + errors: { + invalid_handle: 'Use 3–20 letters, numbers, dots or underscores.', + handle_taken: 'That VaultX handle is already taken.', + profile_exists: 'This character already owns a VaultX profile.', + invalid_password: 'Password must be 8–72 characters.', + invalid_credentials: 'The password is incorrect.', + locked: 'Too many attempts. Try again later.', + not_authenticated: 'Sign in to VaultX first.', + invalid_amount: 'Enter a positive whole amount.', + invalid_quantity: 'Enter a quantity with up to six decimals.', + insufficient_funds: 'Your available balance is too low.', + insufficient_liquidity: 'The exchange cannot fill that amount right now.', + quote_expired: 'This quote expired. Request a new one.', + quote_unavailable: 'The quote is no longer available.', + market_unavailable: 'This market is temporarily paused.', + limit_exceeded: 'This request exceeds an exchange limit.', + duplicate_request: 'This request was already processed.', + rate_limited: 'Too many requests. Try again shortly.', + settlement_pending: 'A money transfer is already pending review.', + service_unavailable: 'VaultX is temporarily unavailable.', + request_failed: 'The secure exchange request failed.', + default: 'VaultX could not complete the request.', + }, +} + const defaultLocales: LocaleTree = { Apps: { + crypto: cryptoFallbackLocales, easyShare: { name: 'EasyShare', incoming: 'Incoming Share', diff --git a/frontend/src/types/apps.ts b/frontend/src/types/apps.ts index 51d903e..388590c 100644 --- a/frontend/src/types/apps.ts +++ b/frontend/src/types/apps.ts @@ -11,6 +11,7 @@ export type BuiltinPhoneAppId = | 'weather' | 'health' | 'banking' + | 'crypto' | 'billing' | 'garage' | 'house' diff --git a/frontend/src/types/crypto.ts b/frontend/src/types/crypto.ts new file mode 100644 index 0000000..ddb5cf9 --- /dev/null +++ b/frontend/src/types/crypto.ts @@ -0,0 +1,56 @@ +export type CryptoSide = 'buy' | 'sell' + +export type CryptoMarket = { + changePercent: number + color: string + enabled: boolean + id: string + name: string + price: string + sparkline: number[] + symbol: string +} + +export type CryptoHolding = { + assetId: string + averagePrice: string + quantity: string + value: string +} + +export type CryptoActivity = { + amount: string + createdAt: number + id: string + marketId?: string + status: string + type: 'buy' | 'sell' | 'deposit' | 'withdrawal' +} + +export type CryptoProfile = { + handle: string + id: string + status: 'active' | 'frozen' | 'closed' +} + +export type CryptoBootstrap = { + activity: CryptoActivity[] + authenticated: boolean + cashBalance: string + holdings: CryptoHolding[] + markets: CryptoMarket[] + portfolioValue: string + profile: CryptoProfile | null +} + +export type CryptoQuote = { + expiresAt: number + fee: string + gross: string + id: string + marketId: string + net: string + price: string + quantity: string + side: CryptoSide +} diff --git a/frontend/src/utils/preferences.ts b/frontend/src/utils/preferences.ts index ce56b9f..5a4c3af 100644 --- a/frontend/src/utils/preferences.ts +++ b/frontend/src/utils/preferences.ts @@ -125,6 +125,7 @@ const DEFAULT_APP_NOTIFICATIONS: Record< calendar: { enabled: true, sounds: true }, weather: { enabled: true, sounds: true }, banking: { enabled: true, sounds: true }, + crypto: { enabled: true, sounds: true }, billing: { enabled: true, sounds: true }, garage: { enabled: true, sounds: true }, skyride: { enabled: true, sounds: true }, diff --git a/frontend/src/views/apps/CryptoApp.contract.test.ts b/frontend/src/views/apps/CryptoApp.contract.test.ts new file mode 100644 index 0000000..59d3b09 --- /dev/null +++ b/frontend/src/views/apps/CryptoApp.contract.test.ts @@ -0,0 +1,71 @@ +import { readFileSync } from 'node:fs' + +import { describe, expect, it } from 'vitest' + +const source = readFileSync(new URL('./CryptoApp.vue', import.meta.url), 'utf8') +const server = readFileSync( + new URL('../../../../sky_phone/source/server/crypto.lua', import.meta.url), + 'utf8', +) +const passwordProvider = readFileSync( + new URL( + '../../../../sky_phone/source/server/crypto_password.js', + import.meta.url, + ), + 'utf8', +) + +describe('VaultX crypto app contracts', () => { + it('uses Sky UI without introducing Konsta components', () => { + expect(source).not.toContain("from 'konsta/vue'") + for (const component of [ + 'SkyAppPage', + 'SkyNavbar', + 'SkyScrollArea', + 'SkyField', + 'SkyButton', + 'SkySheet', + 'SkyPillNavigation', + 'SkySegmented', + ]) { + expect(source).toContain(`<${component}`) + } + }) + + it('keeps all consequential calculations and state transitions on the server', () => { + expect(server).toContain( + 'Bridge.Callbacks.Register("sky_phone:crypto:quote"', + ) + expect(server).toContain( + 'Bridge.Callbacks.Register("sky_phone:crypto:execute"', + ) + expect(server).toContain('`consumed_operation_id`') + expect(server).toContain('`idempotency_key`') + expect(server).toContain("`status` = 'manual_review'") + expect(server).toContain('Bridge.Framework.RemoveMoney') + expect(server).toContain('Bridge.Framework.AddMoney') + expect(server).toContain('local function with_exchange_lock') + expect(server).toContain('local function reconcile_settlements') + expect(server).toContain('settlement_ledger_queries') + }) + + it('stores cash in price-scale minor units throughout the ledger', () => { + expect(server).toContain( + 'local ledger_amount = amount * Config.Crypto.PriceScale', + ) + expect(server).toContain( + 'Config.Crypto.TreasuryCash * Config.Crypto.PriceScale', + ) + expect(server).toContain( + 'amount = decimal_string(row.amount, Config.Crypto.PriceScale)', + ) + }) + + it('uses a memory-hard password provider with constant-time verification', () => { + expect(passwordProvider).toContain('scryptSync') + expect(passwordProvider).toContain('timingSafeEqual') + expect(passwordProvider).toContain('randomBytes(16)') + expect(server).not.toContain('data.price') + expect(server).not.toContain('data.fee') + }) +}) diff --git a/frontend/src/views/apps/CryptoApp.vue b/frontend/src/views/apps/CryptoApp.vue new file mode 100644 index 0000000..53bf1c7 --- /dev/null +++ b/frontend/src/views/apps/CryptoApp.vue @@ -0,0 +1,831 @@ + + + + + diff --git a/frontend/testserver/index.cjs b/frontend/testserver/index.cjs index ec2f20e..b21a912 100644 --- a/frontend/testserver/index.cjs +++ b/frontend/testserver/index.cjs @@ -197,6 +197,90 @@ async function fetchYoutubeMetadata(videoId) { } } let mockBankBalance = 24787 +let cryptoAuthenticated = true +let cryptoCashBalance = 18420 +let cryptoQuote = null +let nextCryptoActivityId = 5 +const cryptoPassword = 'VaultX123!' +const cryptoMarkets = [ + { + id: 'aurora', + symbol: 'AUR', + name: 'Aurora', + color: '#25d9ad', + price: '128.50', + changePercent: 4.82, + enabled: true, + sparkline: [0.12, 0.24, 0.2, 0.38, 0.51, 0.46, 0.68, 0.62, 0.81, 0.92], + }, + { + id: 'vertex', + symbol: 'VTX', + name: 'Vertex', + color: '#4d8cff', + price: '42.75', + changePercent: -1.37, + enabled: true, + sparkline: [0.84, 0.72, 0.76, 0.61, 0.69, 0.52, 0.46, 0.41, 0.35, 0.39], + }, + { + id: 'ember', + symbol: 'EMB', + name: 'Ember', + color: '#ff9d54', + price: '9.80', + changePercent: 7.21, + enabled: true, + sparkline: [0.08, 0.11, 0.18, 0.26, 0.23, 0.4, 0.55, 0.64, 0.79, 0.93], + }, +] +let cryptoHoldings = [ + { + assetId: 'aurora', + averagePrice: '112.30', + quantity: '32.500000', + value: '4176.25', + }, + { + assetId: 'vertex', + averagePrice: '46.10', + quantity: '85.250000', + value: '3644.44', + }, +] +let cryptoActivity = [ + { + id: 'crypto-4', + type: 'buy', + amount: '3650', + marketId: 'vertex', + status: 'completed', + createdAt: Date.now() - 2 * 3600000, + }, + { + id: 'crypto-3', + type: 'sell', + amount: '920', + marketId: 'ember', + status: 'completed', + createdAt: Date.now() - 26 * 3600000, + }, + { + id: 'crypto-2', + type: 'buy', + amount: '4020', + marketId: 'aurora', + status: 'completed', + createdAt: Date.now() - 3 * 86400000, + }, + { + id: 'crypto-1', + type: 'deposit', + amount: '25000', + status: 'completed', + createdAt: Date.now() - 4 * 86400000, + }, +] let mockCashBalance = 2350 let nextBankTransactionId = 7 let mockMapMarkers = [ @@ -6447,6 +6531,23 @@ app.post('/api/:endpoint', (request, response) => { playerName: 'Alex Morgan', transactions: mockBankTransactions, }) + const cryptoOverview = () => ({ + activity: cryptoActivity, + authenticated: cryptoAuthenticated, + cashBalance: String(cryptoCashBalance), + holdings: cryptoHoldings, + markets: cryptoMarkets, + portfolioValue: String( + cryptoCashBalance + + cryptoHoldings.reduce( + (total, holding) => total + Number(holding.value), + 0, + ), + ), + profile: cryptoAuthenticated + ? { handle: 'skyline', id: 'crypto-profile-demo', status: 'active' } + : null, + }) const billingInvoice = (invoice) => ({ ...invoice, canDispute: invoice.direction === 'inbox' && invoice.status === 'open', @@ -7918,6 +8019,169 @@ app.post('/api/:endpoint', (request, response) => { response.json({ success: true, data: bankingOverview() }) return } + if (endpoint === 'crypto:bootstrap') { + response.json({ success: true, data: cryptoOverview() }) + return + } + if (endpoint === 'crypto:login') { + if (request.body.password !== cryptoPassword) { + response.json({ success: false, error: 'invalid_credentials' }) + return + } + cryptoAuthenticated = true + response.json({ success: true, data: cryptoOverview() }) + return + } + if (endpoint === 'crypto:register') { + const handle = String(request.body.handle ?? '').trim() + const password = String(request.body.password ?? '') + if (!/^[A-Za-z0-9][A-Za-z0-9._]{1,18}[A-Za-z0-9]$/.test(handle)) { + response.json({ success: false, error: 'invalid_handle' }) + return + } + if (password.length < 8 || password.length > 72) { + response.json({ success: false, error: 'invalid_password' }) + return + } + cryptoAuthenticated = true + response.json({ + success: true, + data: { + ...cryptoOverview(), + profile: { handle, id: 'crypto-profile-new', status: 'active' }, + }, + }) + return + } + if (endpoint === 'crypto:logout') { + cryptoAuthenticated = false + cryptoQuote = null + response.json({ success: true }) + return + } + if (endpoint === 'crypto:deposit' || endpoint === 'crypto:withdraw') { + const amount = Number(request.body.amount) + if (!Number.isSafeInteger(amount) || amount < 10) { + response.json({ success: false, error: 'invalid_amount' }) + return + } + if (request.body.password !== cryptoPassword) { + response.json({ success: false, error: 'invalid_credentials' }) + return + } + if (endpoint === 'crypto:deposit') { + if (mockBankBalance < amount) { + response.json({ success: false, error: 'insufficient_funds' }) + return + } + mockBankBalance -= amount + cryptoCashBalance += amount + } else { + if (cryptoCashBalance < amount) { + response.json({ success: false, error: 'insufficient_funds' }) + return + } + cryptoCashBalance -= amount + mockBankBalance += amount + } + cryptoActivity.unshift({ + amount: String(amount), + createdAt: Date.now(), + id: `crypto-${nextCryptoActivityId++}`, + status: 'completed', + type: endpoint === 'crypto:deposit' ? 'deposit' : 'withdrawal', + }) + response.json({ success: true, data: cryptoOverview() }) + return + } + if (endpoint === 'crypto:quote') { + const market = cryptoMarkets.find( + (item) => item.id === request.body.marketId, + ) + const quantity = Number(request.body.quantity) + if (!market || !Number.isFinite(quantity) || quantity <= 0) { + response.json({ success: false, error: 'invalid_quantity' }) + return + } + const side = request.body.side === 'sell' ? 'sell' : 'buy' + const gross = quantity * Number(market.price) + const fee = Math.max(0.01, Math.ceil(gross * 0.0075 * 100) / 100) + const net = side === 'buy' ? gross + fee : gross - fee + cryptoQuote = { + expiresAt: Date.now() + 8000, + fee: fee.toFixed(2), + gross: gross.toFixed(2), + id: `crypto-quote-${Date.now()}`, + marketId: market.id, + net: net.toFixed(2), + price: market.price, + quantity: quantity.toFixed(6), + side, + } + response.json({ success: true, data: cryptoQuote }) + return + } + if (endpoint === 'crypto:execute') { + if ( + !cryptoQuote || + cryptoQuote.id !== request.body.quoteId || + cryptoQuote.expiresAt < Date.now() + ) { + response.json({ success: false, error: 'quote_expired' }) + return + } + const market = cryptoMarkets.find( + (item) => item.id === cryptoQuote.marketId, + ) + const holding = cryptoHoldings.find( + (item) => item.assetId === cryptoQuote.marketId, + ) + const quantity = Number(cryptoQuote.quantity) + const net = Number(cryptoQuote.net) + if (cryptoQuote.side === 'buy') { + if (cryptoCashBalance < net) { + response.json({ success: false, error: 'insufficient_funds' }) + return + } + cryptoCashBalance -= net + if (holding) + holding.quantity = (Number(holding.quantity) + quantity).toFixed(6) + else + cryptoHoldings.push({ + assetId: market.id, + averagePrice: market.price, + quantity: quantity.toFixed(6), + value: '0', + }) + } else { + if (!holding || Number(holding.quantity) < quantity) { + response.json({ success: false, error: 'insufficient_funds' }) + return + } + holding.quantity = (Number(holding.quantity) - quantity).toFixed(6) + cryptoCashBalance += net + cryptoHoldings = cryptoHoldings.filter( + (item) => Number(item.quantity) > 0, + ) + } + for (const item of cryptoHoldings) { + const itemMarket = cryptoMarkets.find( + (marketItem) => marketItem.id === item.assetId, + ) + item.value = (Number(item.quantity) * Number(itemMarket.price)).toFixed(2) + } + cryptoActivity.unshift({ + amount: String(Math.round(net)), + createdAt: Date.now(), + id: `crypto-${nextCryptoActivityId++}`, + marketId: cryptoQuote.marketId, + status: 'completed', + type: cryptoQuote.side, + }) + cryptoQuote = null + response.json({ success: true, data: cryptoOverview() }) + return + } if (endpoint === 'health:overview') { response.json({ success: true, data: healthOverview() }) return @@ -8610,10 +8874,12 @@ app.post('/api/:endpoint', (request, response) => { return } if (endpoint === 'development:bootstrap') { - crewLinkAuthenticated = ![ - 'crewlink-login', - 'crewlink-register', - ].includes(testScenario) + cryptoAuthenticated = !['crypto-login', 'crypto-register'].includes( + testScenario, + ) + crewLinkAuthenticated = !['crewlink-login', 'crewlink-register'].includes( + testScenario, + ) authenticated = testScenario !== 'setup-account-unlinked' linkedAccount = authenticated ? { diff --git a/frontend/testserver/smoke.cjs b/frontend/testserver/smoke.cjs index aaec0a8..c1c1608 100644 --- a/frontend/testserver/smoke.cjs +++ b/frontend/testserver/smoke.cjs @@ -7,6 +7,8 @@ const browserDataRequests = [ ['development:bootstrap', {}], ['account:devices', {}], ['banking:overview', {}], + ['crypto:bootstrap', {}], + ['crypto:quote', { marketId: 'aurora', quantity: '1', side: 'buy' }], ['health:overview', {}], ['billing:overview', {}], ['billing:list', { filter: 'all', limit: 20, offset: 0 }], diff --git a/sky_phone/config/config.lua b/sky_phone/config/config.lua index c1f4d6d..032f60f 100644 --- a/sky_phone/config/config.lua +++ b/sky_phone/config/config.lua @@ -648,6 +648,73 @@ Config.SkyRide = { }, } +Config.Crypto = { + Enabled = true, + Currency = "$", + BankAccount = "bank", + AssetScale = 1000000, + PriceScale = 100, + QuoteLifetimeSeconds = 8, + PriceTickSeconds = 60, + SessionSeconds = 30 * 60, + RecentAuthenticationSeconds = 5 * 60, + PasswordMinLength = 8, + PasswordMaxLength = 72, + HandleMinLength = 3, + HandleMaxLength = 20, + FeeBasisPoints = 75, + MinimumFee = 1, + MinimumSettlement = 10, + MaximumSettlement = 250000, + MaximumTradeNotional = 100000, + MaximumPositionQuantity = 100000, + DailyDepositLimit = 500000, + DailyWithdrawalLimit = 250000, + DailyTradeLimit = 500000, + ActionsPerMinute = 30, + LoginAttempts = 5, + LockoutSeconds = 5 * 60, + TreasuryCash = 5000000, + Markets = { + { + Id = "aurora", + Symbol = "AUR", + Name = "Aurora", + Color = "#25d9ad", + InitialPrice = 12850, + MinimumPrice = 3000, + MaximumPrice = 80000, + VolatilityBasisPoints = 180, + IssuedSupply = 1000000, + TreasuryInventory = 850000, + }, + { + Id = "vertex", + Symbol = "VTX", + Name = "Vertex", + Color = "#4d8cff", + InitialPrice = 4275, + MinimumPrice = 800, + MaximumPrice = 30000, + VolatilityBasisPoints = 260, + IssuedSupply = 2500000, + TreasuryInventory = 2100000, + }, + { + Id = "ember", + Symbol = "EMB", + Name = "Ember", + Color = "#ff9d54", + InitialPrice = 980, + MinimumPrice = 100, + MaximumPrice = 12000, + VolatilityBasisPoints = 340, + IssuedSupply = 8000000, + TreasuryInventory = 6800000, + }, + }, +} + -- ============================================================================= -- Server-only configuration -- ============================================================================= diff --git a/sky_phone/config/locales/de.lua b/sky_phone/config/locales/de.lua index 1bf64d3..486a3f9 100644 --- a/sky_phone/config/locales/de.lua +++ b/sky_phone/config/locales/de.lua @@ -1049,6 +1049,22 @@ Locales["de"] = { request_failed = "Die Funkanfrage ist fehlgeschlagen.", default = "Die Funkanfrage ist fehlgeschlagen.", }, }, + crypto = { + name = "VaultX", subtitle = "Börse für fiktive Assets", loading = "Sichere Börse wird geöffnet…", logout = "Abmelden", refresh = "Portfolio aktualisieren", navigation = "VaultX Navigation", + auth = { + eyebrow = "Charaktergebundene Börse", loginTitle = "Willkommen zurück", registerTitle = "Erstelle dein VaultX-Profil", body = "Handle fiktive digitale Assets mit dem Ingame-Geld deines Charakters.", login = "Einloggen", register = "Registrieren", handle = "VaultX-Benutzername", handlePlaceholder = "3–20 Buchstaben oder Zahlen", password = "Passwort", passwordPlaceholder = "8–72 Zeichen", showPassword = "Passwort anzeigen", hidePassword = "Passwort ausblenden", security = "Nutze ein In-Character-Passwort. Verwende niemals ein echtes Passwort erneut.", loginAction = "Portfolio öffnen", registerAction = "Sicheres Profil erstellen", + }, + tabs = { portfolio = "Portfolio", markets = "Märkte", activity = "Aktivität" }, + portfolio = { total = "Gesamtes Portfolio", cash = "Verfügbares Guthaben", holdings = "Deine Assets", empty = "Noch keine Assets", emptyBody = "Öffne Märkte und fordere ein geschütztes Angebot an.", avg = "Ø" }, + actions = { deposit = "Einzahlen", withdraw = "Auszahlen" }, + markets = { title = "Fiktive Märkte", live = "Serverpreise", noticeTitle = "Ingame-Börse", notice = "Die Kurse sind synthetisch und haben keinen realen Wert. Jeder Handel wird gegen die begrenzte Liquidität der Börse geprüft." }, + trade = { buy = "Kaufen", sell = "Verkaufen", quantity = "Menge", available = "Verfügbar:", price = "Angebotspreis", gross = "Brutto", fee = "Börsengebühr", total = "Gesamt", quoteExpiry = "Dieses Serverangebot läuft nach 8 Sekunden ab und kann nur einmal genutzt werden.", getQuote = "Geschütztes Angebot", confirm = "Handel bestätigen" }, + settlement = { depositBody = "Übertrage Bankguthaben in dein verbuchtes VaultX-Guthaben.", withdrawBody = "Zahle verbuchtes VaultX-Guthaben auf das Bankkonto deines Charakters aus.", amount = "Ganzer Bankbetrag", confirm = "Sicher bestätigen" }, + activity = { title = "Finanzaktivität", empty = "Keine Aktivität", emptyBody = "Einzahlungen, Auszahlungen und Handel erscheinen hier." }, + activityTypes = { buy = "Asset-Kauf", sell = "Asset-Verkauf", deposit = "Bankeinzahlung", withdrawal = "Bankauszahlung" }, + statuses = { completed = "Abgeschlossen", pending = "Prüfung ausstehend", failed = "Fehlgeschlagen", manual_review = "Manuelle Prüfung" }, + errors = { invalid_handle = "Nutze 3–20 Buchstaben, Zahlen, Punkte oder Unterstriche.", handle_taken = "Dieser VaultX-Benutzername ist bereits vergeben.", profile_exists = "Dieser Charakter besitzt bereits ein VaultX-Profil.", invalid_password = "Das Passwort muss 8–72 Zeichen lang sein.", invalid_credentials = "Das Passwort ist falsch.", locked = "Zu viele Versuche. Versuche es später erneut.", not_authenticated = "Logge dich zuerst bei VaultX ein.", invalid_amount = "Gib einen positiven ganzen Betrag ein.", invalid_quantity = "Gib eine Menge mit bis zu sechs Nachkommastellen ein.", insufficient_funds = "Dein verfügbares Guthaben reicht nicht aus.", insufficient_liquidity = "Die Börse kann diese Menge derzeit nicht ausführen.", quote_expired = "Das Angebot ist abgelaufen. Fordere ein neues an.", quote_unavailable = "Das Angebot ist nicht mehr verfügbar.", market_unavailable = "Dieser Markt ist vorübergehend pausiert.", limit_exceeded = "Die Anfrage überschreitet ein Börsenlimit.", duplicate_request = "Diese Anfrage wurde bereits verarbeitet.", rate_limited = "Zu viele Anfragen. Versuche es gleich erneut.", settlement_pending = "Eine Geldbewegung wartet bereits auf Prüfung.", service_unavailable = "VaultX ist vorübergehend nicht verfügbar.", request_failed = "Die sichere Börsenanfrage ist fehlgeschlagen.", default = "VaultX konnte die Anfrage nicht abschließen." }, + }, banking = { name = "Banking", welcome = "Willkommen zurück", totalBalance = "Gesamtsaldo", recentPeriod = "in jüngster Zeit", actions = "Bankgeschäfte", send = "Senden", diff --git a/sky_phone/config/locales/en.lua b/sky_phone/config/locales/en.lua index cffabfe..44b8e99 100644 --- a/sky_phone/config/locales/en.lua +++ b/sky_phone/config/locales/en.lua @@ -1049,6 +1049,22 @@ Locales["en"] = { request_failed = "The radio request failed.", default = "The radio request failed.", }, }, + crypto = { + name = "VaultX", subtitle = "Fictional asset exchange", loading = "Opening secure exchange…", logout = "Sign out", refresh = "Refresh portfolio", navigation = "VaultX navigation", + auth = { + eyebrow = "Character-bound exchange", loginTitle = "Welcome back", registerTitle = "Create your VaultX profile", body = "Trade fictional digital assets with your character's in-game funds.", login = "Log in", register = "Register", handle = "VaultX handle", handlePlaceholder = "3–20 letters or numbers", password = "Password", passwordPlaceholder = "8–72 characters", showPassword = "Show password", hidePassword = "Hide password", security = "Use an in-character password. Never reuse a real password.", loginAction = "Open portfolio", registerAction = "Create secure profile", + }, + tabs = { portfolio = "Portfolio", markets = "Markets", activity = "Activity" }, + portfolio = { total = "Total portfolio", cash = "Available cash", holdings = "Your assets", empty = "No assets yet", emptyBody = "Open Markets to request a protected quote.", avg = "Avg." }, + actions = { deposit = "Deposit", withdraw = "Withdraw" }, + markets = { title = "Fictional markets", live = "Server priced", noticeTitle = "In-game exchange", notice = "Prices are synthetic and have no real-world value. Every trade is checked against finite exchange liquidity." }, + trade = { buy = "Buy", sell = "Sell", quantity = "Quantity", available = "Available:", price = "Quote price", gross = "Gross", fee = "Exchange fee", total = "Total", quoteExpiry = "This server quote expires after 8 seconds and can only be used once.", getQuote = "Get protected quote", confirm = "Confirm trade" }, + settlement = { depositBody = "Move bank funds into your settled VaultX cash balance.", withdrawBody = "Return settled VaultX cash to your character's bank account.", amount = "Whole bank amount", confirm = "Confirm securely" }, + activity = { title = "Financial activity", empty = "No activity", emptyBody = "Deposits, withdrawals and trades will appear here." }, + activityTypes = { buy = "Asset purchase", sell = "Asset sale", deposit = "Bank deposit", withdrawal = "Bank withdrawal" }, + statuses = { completed = "Completed", pending = "Pending review", failed = "Failed", manual_review = "Manual review" }, + errors = { invalid_handle = "Use 3–20 letters, numbers, dots or underscores.", handle_taken = "That VaultX handle is already taken.", profile_exists = "This character already owns a VaultX profile.", invalid_password = "Password must be 8–72 characters.", invalid_credentials = "The password is incorrect.", locked = "Too many attempts. Try again later.", not_authenticated = "Sign in to VaultX first.", invalid_amount = "Enter a positive whole amount.", invalid_quantity = "Enter a quantity with up to six decimals.", insufficient_funds = "Your available balance is too low.", insufficient_liquidity = "The exchange cannot fill that amount right now.", quote_expired = "This quote expired. Request a new one.", quote_unavailable = "The quote is no longer available.", market_unavailable = "This market is temporarily paused.", limit_exceeded = "This request exceeds an exchange limit.", duplicate_request = "This request was already processed.", rate_limited = "Too many requests. Try again shortly.", settlement_pending = "A money transfer is already pending review.", service_unavailable = "VaultX is temporarily unavailable.", request_failed = "The secure exchange request failed.", default = "VaultX could not complete the request." }, + }, banking = { name = "Banking", welcome = "Welcome back", totalBalance = "Total Balance", recentPeriod = "in recent activity", actions = "Banking actions", send = "Send", diff --git a/sky_phone/fxmanifest.lua b/sky_phone/fxmanifest.lua index f58cd71..a5283e1 100644 --- a/sky_phone/fxmanifest.lua +++ b/sky_phone/fxmanifest.lua @@ -1,6 +1,7 @@ fx_version 'cerulean' game 'gta5' lua54 'yes' +node_version '22' use_experimental_fxv2_oal 'yes' author 'Sky-Systems' @@ -49,6 +50,7 @@ client_scripts { server_scripts { '@oxmysql/lib/MySQL.lua', + 'source/server/crypto_password.js', 'config/config.lua', 'config/media.lua', 'config/locales/en.lua', @@ -87,6 +89,7 @@ server_scripts { 'source/server/notes.lua', 'source/server/mail.lua', 'source/server/banking.lua', + 'source/server/crypto.lua', 'source/server/health.lua', 'source/server/billing.lua', 'source/server/garage.lua', diff --git a/sky_phone/source/client/main.lua b/sky_phone/source/client/main.lua index fc393c1..d3f3c16 100644 --- a/sky_phone/source/client/main.lua +++ b/sky_phone/source/client/main.lua @@ -242,6 +242,14 @@ local server_callbacks = { "calls:block", "banking:overview", "banking:transfer", + "crypto:bootstrap", + "crypto:register", + "crypto:login", + "crypto:logout", + "crypto:quote", + "crypto:execute", + "crypto:deposit", + "crypto:withdraw", "billing:overview", "billing:list", "billing:detail", diff --git a/sky_phone/source/server/crypto.lua b/sky_phone/source/server/crypto.lua new file mode 100644 index 0000000..d9807e4 --- /dev/null +++ b/sky_phone/source/server/crypto.lua @@ -0,0 +1,1066 @@ +Bridge.Database.AfterMigration("sky_phone", function() + +local sessions = {} +local profile_locks = {} +local exchange_lock = false +local markets = {} +local market_order = {} + +local function ensure_schema() + local statements = { + [[CREATE TABLE IF NOT EXISTS `sky_phone_crypto_profiles` ( + `id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `owner_identifier` VARCHAR(80) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `account_id` BIGINT UNSIGNED NULL, + `handle` VARCHAR(20) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL, + `password_hash` VARCHAR(255) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `status` ENUM('active','frozen','closed') NOT NULL DEFAULT 'active', + `failed_logins` TINYINT UNSIGNED NOT NULL DEFAULT 0, + `locked_until` DATETIME NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_sky_phone_crypto_owner` (`owner_identifier`), + UNIQUE KEY `uniq_sky_phone_crypto_handle` (`handle`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci]], + [[CREATE TABLE IF NOT EXISTS `sky_phone_crypto_markets` ( + `id` VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `asset_scale` BIGINT UNSIGNED NOT NULL, + `price_scale` BIGINT UNSIGNED NOT NULL, + `issued_supply` DECIMAL(36,0) UNSIGNED NOT NULL, + `price` DECIMAL(36,0) UNSIGNED NOT NULL, + `version` BIGINT UNSIGNED NOT NULL DEFAULT 1, + `status` ENUM('active','halted','stale') NOT NULL DEFAULT 'active', + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`) + ) ENGINE=InnoDB]], + [[CREATE TABLE IF NOT EXISTS `sky_phone_crypto_market_ticks` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `market_id` VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `version` BIGINT UNSIGNED NOT NULL, + `price` DECIMAL(36,0) UNSIGNED NOT NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_sky_phone_crypto_tick` (`market_id`,`version`), + KEY `idx_sky_phone_crypto_ticks` (`market_id`,`created_at`,`id`) + ) ENGINE=InnoDB]], + [[CREATE TABLE IF NOT EXISTS `sky_phone_crypto_balances` ( + `account_id` VARCHAR(48) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `asset_id` VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `available` DECIMAL(36,0) UNSIGNED NOT NULL DEFAULT 0, + `locked` DECIMAL(36,0) UNSIGNED NOT NULL DEFAULT 0, + `version` BIGINT UNSIGNED NOT NULL DEFAULT 0, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`account_id`,`asset_id`) + ) ENGINE=InnoDB]], + [[CREATE TABLE IF NOT EXISTS `sky_phone_crypto_operations` ( + `id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `profile_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `type` ENUM('buy','sell','deposit','withdrawal') NOT NULL, + `idempotency_key` VARCHAR(96) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `request_hash` CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `status` ENUM('prepared','external_pending','external_applied','ledger_applied','completed','failed','compensation_pending','manual_review','cancelled') NOT NULL, + `amount` DECIMAL(36,0) UNSIGNED NOT NULL DEFAULT 0, + `market_id` VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NULL, + `detail` VARCHAR(255) NOT NULL DEFAULT '', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_sky_phone_crypto_operation` (`profile_id`,`type`,`idempotency_key`), + KEY `idx_sky_phone_crypto_activity` (`profile_id`,`created_at`,`id`) + ) ENGINE=InnoDB]], + [[CREATE TABLE IF NOT EXISTS `sky_phone_crypto_ledger_entries` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `operation_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `account_id` VARCHAR(48) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `asset_id` VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `delta` DECIMAL(36,0) NOT NULL, + `balance_after` DECIMAL(36,0) NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_sky_phone_crypto_ledger_operation` (`operation_id`,`id`), + KEY `idx_sky_phone_crypto_ledger_account` (`account_id`,`created_at`,`id`) + ) ENGINE=InnoDB]], + [[CREATE TABLE IF NOT EXISTS `sky_phone_crypto_quotes` ( + `id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `profile_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `market_id` VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `side` ENUM('buy','sell') NOT NULL, + `quantity` DECIMAL(36,0) UNSIGNED NOT NULL, + `price` DECIMAL(36,0) UNSIGNED NOT NULL, + `gross` DECIMAL(36,0) UNSIGNED NOT NULL, + `fee` DECIMAL(36,0) UNSIGNED NOT NULL, + `net` DECIMAL(36,0) UNSIGNED NOT NULL, + `market_version` BIGINT UNSIGNED NOT NULL, + `expires_at` DATETIME NOT NULL, + `consumed_operation_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_sky_phone_crypto_quote_profile` (`profile_id`,`expires_at`) + ) ENGINE=InnoDB]], + [[CREATE TABLE IF NOT EXISTS `sky_phone_crypto_fills` ( + `id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `operation_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `quote_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `market_id` VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `side` ENUM('buy','sell') NOT NULL, + `quantity` DECIMAL(36,0) UNSIGNED NOT NULL, + `price` DECIMAL(36,0) UNSIGNED NOT NULL, + `gross` DECIMAL(36,0) UNSIGNED NOT NULL, + `fee` DECIMAL(36,0) UNSIGNED NOT NULL, + `net` DECIMAL(36,0) UNSIGNED NOT NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_sky_phone_crypto_fill_quote` (`quote_id`) + ) ENGINE=InnoDB]], + [[CREATE TABLE IF NOT EXISTS `sky_phone_crypto_settlements` ( + `operation_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `owner_identifier` VARCHAR(80) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `framework_account` VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `amount` DECIMAL(36,0) UNSIGNED NOT NULL, + `state` ENUM('prepared','external_pending','external_applied','ledger_applied','completed','failed','compensation_pending','manual_review','cancelled') NOT NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`operation_id`), + KEY `idx_sky_phone_crypto_settlement_state` (`state`,`updated_at`) + ) ENGINE=InnoDB]], + [[CREATE TABLE IF NOT EXISTS `sky_phone_crypto_audit_events` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `profile_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NULL, + `owner_identifier` VARCHAR(80) CHARACTER SET ascii COLLATE ascii_bin NULL, + `event_type` VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `detail` VARCHAR(255) NOT NULL DEFAULT '', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_sky_phone_crypto_audit` (`profile_id`,`created_at`,`id`) + ) ENGINE=InnoDB]], + } + for _, statement in ipairs(statements) do + Bridge.Database.Query(statement, {}) + end +end + +local function new_id() + local row = Bridge.Database.Query("SELECT UUID() AS `id`", {})[1] + if not row or type(row.id) ~= "string" then + error("[sky_phone] Database did not generate a Crypto id.") + end + return row.id +end + +local function affected_rows(result) + if type(result) == "number" then + return result + end + return type(result) == "table" and tonumber(result.affectedRows) or 0 +end + +local function account_id(profile_id) + return "profile:" .. profile_id +end + +local function valid_handle(value) + if type(value) ~= "string" then + return nil + end + local handle = value:match("^%s*(.-)%s*$") + local length = utf8.len(handle) + if not length or length < Config.Crypto.HandleMinLength or length > Config.Crypto.HandleMaxLength + or not handle:match("^[A-Za-z0-9][A-Za-z0-9._]*[A-Za-z0-9]$") + then + return nil + end + return handle +end + +local function valid_password(value) + local length = type(value) == "string" and utf8.len(value) or nil + return length and length >= Config.Crypto.PasswordMinLength + and length <= Config.Crypto.PasswordMaxLength +end + +local function parse_whole(value, minimum, maximum) + if type(value) ~= "string" or not value:match("^%d+$") then + return nil + end + local amount = tonumber(value) + if not amount or amount ~= math.floor(amount) or amount < minimum or amount > maximum then + return nil + end + return amount +end + +local function parse_quantity(value) + if type(value) ~= "string" then + return nil + end + local whole, decimal = value:match("^(%d+)%.?(%d*)$") + if not whole or #decimal > 6 then + return nil + end + decimal = decimal .. string.rep("0", 6 - #decimal) + local quantity = tonumber(whole) * Config.Crypto.AssetScale + tonumber(decimal) + if quantity <= 0 or quantity > 1000000 * Config.Crypto.AssetScale then + return nil + end + return quantity +end + +local function decimal_string(value, scale) + value = math.floor(tonumber(value) or 0) + if scale == 1 then + return tostring(value) + end + local digits = tostring(scale):len() - 1 + local whole = math.floor(value / scale) + local fraction = tostring(value % scale) + fraction = string.rep("0", digits - #fraction) .. fraction + fraction = fraction:gsub("0+$", "") + return fraction == "" and tostring(whole) or (tostring(whole) .. "." .. fraction) +end + +local function ceil_div(value, divisor) + return math.floor((value + divisor - 1) / divisor) +end + +local function initialize_markets() + for _, config in ipairs(Config.Crypto.Markets) do + markets[config.Id] = config + market_order[#market_order + 1] = config.Id + Bridge.Database.Query([[ + INSERT INTO `sky_phone_crypto_markets` + (`id`,`asset_scale`,`price_scale`,`issued_supply`,`price`,`version`,`status`) + VALUES (?, ?, ?, ?, ?, 1, 'active') ON DUPLICATE KEY UPDATE `id` = VALUES(`id`) + ]], { + config.Id, + Config.Crypto.AssetScale, + Config.Crypto.PriceScale, + config.IssuedSupply * Config.Crypto.AssetScale, + config.InitialPrice, + }) + local persisted = Bridge.Database.Query([[ + SELECT `asset_scale`,`price_scale`,`issued_supply` + FROM `sky_phone_crypto_markets` WHERE `id` = ? LIMIT 1 + ]], { config.Id })[1] + if not persisted + or tonumber(persisted.asset_scale) ~= Config.Crypto.AssetScale + or tonumber(persisted.price_scale) ~= Config.Crypto.PriceScale + or tonumber(persisted.issued_supply) ~= config.IssuedSupply * Config.Crypto.AssetScale + then + error(("[sky_phone] Crypto market scale or supply changed without a migration: %s"):format(config.Id)) + end + Bridge.Database.Query([[ + INSERT INTO `sky_phone_crypto_balances` (`account_id`,`asset_id`,`available`) + VALUES ('treasury', ?, ?) ON DUPLICATE KEY UPDATE `account_id` = VALUES(`account_id`) + ]], { config.Id, config.TreasuryInventory * Config.Crypto.AssetScale }) + Bridge.Database.Query([[ + INSERT INTO `sky_phone_crypto_balances` (`account_id`,`asset_id`,`available`) + VALUES ('reserve', ?, ?) ON DUPLICATE KEY UPDATE `account_id` = VALUES(`account_id`) + ]], { + config.Id, + (config.IssuedSupply - config.TreasuryInventory) * Config.Crypto.AssetScale, + }) + end + Bridge.Database.Query([[ + INSERT INTO `sky_phone_crypto_balances` (`account_id`,`asset_id`,`available`) + VALUES ('treasury', 'CASH', ?) ON DUPLICATE KEY UPDATE `account_id` = VALUES(`account_id`) + ]], { Config.Crypto.TreasuryCash * Config.Crypto.PriceScale }) +end + +local function require_phone(source) + local phone_session, error_response = SkyPhone.RequireSession(source) + if not phone_session then + return nil, nil, error_response + end + local identifier = Bridge.Framework.GetIdentifier(source) + if type(identifier) ~= "string" or identifier == "" then + return nil, nil, { success = false, error = "service_unavailable" } + end + return phone_session, identifier +end + +local function profile_by_owner(identifier) + return Bridge.Database.Query([[ + SELECT `id`,`owner_identifier`,`account_id`,`handle`,`status`,`failed_logins`, + UNIX_TIMESTAMP(`locked_until`) AS `locked_until` + FROM `sky_phone_crypto_profiles` WHERE `owner_identifier` = ? LIMIT 1 + ]], { identifier })[1] +end + +local function authenticated_profile(source) + local phone_session, identifier, error_response = require_phone(source) + if not phone_session then + return nil, error_response + end + local session = sessions[source] + if not session or session.identifier ~= identifier or session.imei ~= phone_session.imei + or session.expires_at < os.time() + then + sessions[source] = nil + return nil, { success = false, error = "not_authenticated" } + end + local profile = profile_by_owner(identifier) + if not profile or profile.id ~= session.profile_id or profile.status ~= "active" then + sessions[source] = nil + return nil, { success = false, error = "not_authenticated" } + end + session.expires_at = os.time() + Config.Crypto.SessionSeconds + return profile +end + +local function verify_password(profile_id, password) + if not valid_password(password) then + return false + end + local row = Bridge.Database.Query( + "SELECT `password_hash` FROM `sky_phone_crypto_profiles` WHERE `id` = ? LIMIT 1", + { profile_id } + )[1] + return row and exports[GetCurrentResourceName()]:CryptoVerifyPassword(password, row.password_hash) or false +end + +local function set_session(source, phone_session, profile, recently_authenticated) + sessions[source] = { + expires_at = os.time() + Config.Crypto.SessionSeconds, + identifier = profile.owner_identifier, + imei = phone_session.imei, + profile_id = profile.id, + recently_authenticated_at = recently_authenticated and os.time() or 0, + } +end + +local function balance(account, asset) + local row = Bridge.Database.Query([[ + SELECT `available`,`locked`,`version` FROM `sky_phone_crypto_balances` + WHERE `account_id` = ? AND `asset_id` = ? LIMIT 1 + ]], { account, asset })[1] + return row and (tonumber(row.available) or 0) or 0, + row and (tonumber(row.locked) or 0) or 0, + row and (tonumber(row.version) or 0) or 0 +end + +local function market_rows() + local rows = Bridge.Database.Query([[ + SELECT `id`,`price`,`version`,`status`, UNIX_TIMESTAMP(`updated_at`) AS `updated_at` + FROM `sky_phone_crypto_markets` + ]], {}) + local indexed = {} + for _, row in ipairs(rows) do + indexed[row.id] = row + end + return indexed +end + +local function market_dtos() + local current = market_rows() + local result = {} + for _, market_id in ipairs(market_order) do + local config = markets[market_id] + local row = current[market_id] + local ticks = Bridge.Database.Query([[ + SELECT `price` FROM `sky_phone_crypto_market_ticks` + WHERE `market_id` = ? ORDER BY `id` DESC LIMIT 12 + ]], { market_id }) + local prices = {} + for index = #ticks, 1, -1 do + prices[#prices + 1] = tonumber(ticks[index].price) or tonumber(row.price) + end + if #prices == 0 then + prices[1] = tonumber(row.price) + end + local minimum = math.min(table.unpack(prices)) + local maximum = math.max(table.unpack(prices)) + local span = math.max(1, maximum - minimum) + local sparkline = {} + for index, price in ipairs(prices) do + sparkline[index] = (price - minimum) / span + end + local first = prices[1] + local price = tonumber(row.price) or config.InitialPrice + result[#result + 1] = { + id = market_id, + symbol = config.Symbol, + name = config.Name, + color = config.Color, + price = decimal_string(price, Config.Crypto.PriceScale), + changePercent = first > 0 and ((price - first) / first) * 100 or 0, + enabled = row.status == "active", + sparkline = sparkline, + } + end + return result +end + +local function activity(profile_id) + local rows = Bridge.Database.Query([[ + SELECT `id`,`type`,`amount`,`market_id`,`status`, UNIX_TIMESTAMP(`created_at`) AS `created_at` + FROM `sky_phone_crypto_operations` WHERE `profile_id` = ? + ORDER BY `created_at` DESC, `id` DESC LIMIT 50 + ]], { profile_id }) + local result = {} + for _, row in ipairs(rows) do + result[#result + 1] = { + id = row.id, + type = row.type, + amount = decimal_string(row.amount, Config.Crypto.PriceScale), + marketId = row.market_id, + status = row.status, + createdAt = (tonumber(row.created_at) or 0) * 1000, + } + end + return result +end + +local function daily_total(profile_id, operation_type) + local row = Bridge.Database.Query([[ + SELECT COALESCE(SUM(`amount`), 0) AS `total` + FROM `sky_phone_crypto_operations` + WHERE `profile_id` = ? AND `type` = ? AND `status` = 'completed' + AND `created_at` >= CURRENT_DATE + ]], { profile_id, operation_type })[1] + return tonumber(row and row.total) or 0 +end + +local function bootstrap(profile) + local cash = balance(account_id(profile.id), "CASH") + local current_markets = market_rows() + local holdings = {} + local portfolio = cash + for _, market_id in ipairs(market_order) do + local available = balance(account_id(profile.id), market_id) + if available > 0 then + local price = tonumber(current_markets[market_id].price) or 0 + local value = math.floor(available * price / Config.Crypto.AssetScale) + local fill = Bridge.Database.Query([[ + SELECT FLOOR(SUM(fill.`gross`) * ? / NULLIF(SUM(fill.`quantity`), 0)) AS `price` + FROM `sky_phone_crypto_fills` fill + JOIN `sky_phone_crypto_operations` operation ON operation.`id` = fill.`operation_id` + WHERE operation.`profile_id` = ? AND fill.`market_id` = ? AND fill.`side` = 'buy' + ]], { Config.Crypto.AssetScale, profile.id, market_id })[1] + holdings[#holdings + 1] = { + assetId = market_id, + quantity = decimal_string(available, Config.Crypto.AssetScale), + value = decimal_string(value, Config.Crypto.PriceScale), + averagePrice = decimal_string(fill and fill.price or price, Config.Crypto.PriceScale), + } + portfolio = portfolio + value + end + end + return { + authenticated = true, + profile = { id = profile.id, handle = profile.handle, status = profile.status }, + cashBalance = decimal_string(cash, Config.Crypto.PriceScale), + portfolioValue = decimal_string(portfolio, Config.Crypto.PriceScale), + holdings = holdings, + markets = market_dtos(), + activity = activity(profile.id), + } +end + +local function audit(profile_id, identifier, event_type, detail) + Bridge.Database.Query([[ + INSERT INTO `sky_phone_crypto_audit_events` + (`profile_id`,`owner_identifier`,`event_type`,`detail`) VALUES (?, ?, ?, ?) + ]], { profile_id, identifier, event_type, detail or "" }) +end + +local function idempotency_key(value) + if type(value) ~= "string" or #value < 8 or #value > 96 + or not value:match("^[A-Za-z0-9._-]+$") + then + return nil + end + return value +end + +local function with_profile_lock(profile_id, callback) + if profile_locks[profile_id] then + return { success = false, error = "rate_limited" } + end + profile_locks[profile_id] = true + local success, result = pcall(callback) + profile_locks[profile_id] = nil + if not success then + error(result) + end + return result +end + +local function with_exchange_lock(callback) + if exchange_lock then + return { success = false, error = "rate_limited" } + end + exchange_lock = true + local success, result = pcall(callback) + exchange_lock = false + if not success then + error(result) + end + return result +end + +Bridge.Callbacks.Register("sky_phone:crypto:bootstrap", function(source) + if not Config.Crypto.Enabled then + return { success = false, error = "service_unavailable" } + end + local profile, error_response = authenticated_profile(source) + if profile then + return { success = true, data = bootstrap(profile) } + end + local _, identifier, phone_error = require_phone(source) + if not identifier then + return phone_error + end + local existing = profile_by_owner(identifier) + return { + success = true, + data = { + authenticated = false, + profile = nil, + cashBalance = "0", + portfolioValue = "0", + holdings = {}, + markets = market_dtos(), + activity = {}, + registered = existing ~= nil, + }, + } +end) + +Bridge.Callbacks.Register("sky_phone:crypto:register", function(source, data) + if not SkyPhone.AllowOperation(source, "crypto:register", 5, 60) then + return { success = false, error = "rate_limited" } + end + local phone_session, identifier, error_response = require_phone(source) + if not phone_session then + return error_response + end + local account, account_error = SkyPhone.RequireAccount(source) + if not account then + return account_error + end + data = type(data) == "table" and data or {} + local handle = valid_handle(data.handle) + if not handle then + return { success = false, error = "invalid_handle" } + end + if not valid_password(data.password) then + return { success = false, error = "invalid_password" } + end + if profile_by_owner(identifier) then + return { success = false, error = "profile_exists" } + end + local duplicate = Bridge.Database.Query( + "SELECT 1 FROM `sky_phone_crypto_profiles` WHERE `handle` = ? LIMIT 1", + { handle } + ) + if duplicate[1] then + return { success = false, error = "handle_taken" } + end + local entropy = Bridge.Database.Query("SELECT UUID() AS `id`", {})[1] + local password_hash = exports[GetCurrentResourceName()]:CryptoHashPassword(data.password) + if type(password_hash) ~= "string" then + error("[sky_phone] Crypto password provider did not return a password hash.") + end + local queries = { + { + query = [[INSERT INTO `sky_phone_crypto_profiles` + (`id`,`owner_identifier`,`account_id`,`handle`,`password_hash`) + VALUES (?, ?, ?, ?, ?)]], + params = { entropy.id, identifier, account.id, handle, password_hash }, + }, + { + query = [[INSERT INTO `sky_phone_crypto_balances` + (`account_id`,`asset_id`,`available`) VALUES (?, 'CASH', 0)]], + params = { account_id(entropy.id) }, + }, + } + if not Bridge.Database.Transaction(queries) then + return { success = false, error = "request_failed" } + end + local profile = profile_by_owner(identifier) + set_session(source, phone_session, profile, true) + audit(profile.id, identifier, "profile_registered", "") + return { success = true, data = bootstrap(profile) } +end) + +Bridge.Callbacks.Register("sky_phone:crypto:login", function(source, data) + if not SkyPhone.AllowOperation(source, "crypto:login", 10, 60) then + return { success = false, error = "rate_limited" } + end + local phone_session, identifier, error_response = require_phone(source) + if not phone_session then + return error_response + end + local profile = profile_by_owner(identifier) + if not profile then + return { success = false, error = "invalid_credentials" } + end + if tonumber(profile.locked_until) and tonumber(profile.locked_until) > os.time() then + return { success = false, error = "locked" } + end + if not verify_password(profile.id, type(data) == "table" and data.password or nil) then + local failed = (tonumber(profile.failed_logins) or 0) + 1 + local lock = failed >= Config.Crypto.LoginAttempts + Bridge.Database.Query([[ + UPDATE `sky_phone_crypto_profiles` SET `failed_logins` = ?, + `locked_until` = IF(?, DATE_ADD(CURRENT_TIMESTAMP, INTERVAL ? SECOND), NULL) + WHERE `id` = ? + ]], { lock and 0 or failed, lock and 1 or 0, Config.Crypto.LockoutSeconds, profile.id }) + audit(profile.id, identifier, "login_failed", lock and "profile_locked" or "invalid_password") + return { success = false, error = lock and "locked" or "invalid_credentials" } + end + Bridge.Database.Query( + "UPDATE `sky_phone_crypto_profiles` SET `failed_logins` = 0, `locked_until` = NULL WHERE `id` = ?", + { profile.id } + ) + set_session(source, phone_session, profile, true) + audit(profile.id, identifier, "login_succeeded", phone_session.imei) + return { success = true, data = bootstrap(profile) } +end) + +Bridge.Callbacks.Register("sky_phone:crypto:logout", function(source) + local session = sessions[source] + if session then + audit(session.profile_id, session.identifier, "logout", "") + end + sessions[source] = nil + return { success = true } +end) + +Bridge.Callbacks.Register("sky_phone:crypto:quote", function(source, data) + if not SkyPhone.AllowOperation(source, "crypto:quote", Config.Crypto.ActionsPerMinute, 60) then + return { success = false, error = "rate_limited" } + end + local profile, error_response = authenticated_profile(source) + if not profile then + return error_response + end + data = type(data) == "table" and data or {} + local config = markets[data.marketId] + local side = data.side == "buy" and "buy" or data.side == "sell" and "sell" or nil + local quantity = parse_quantity(data.quantity) + if not config or not side or not quantity then + return { success = false, error = "invalid_quantity" } + end + local market = Bridge.Database.Query( + "SELECT `price`,`version`,`status` FROM `sky_phone_crypto_markets` WHERE `id` = ? LIMIT 1", + { config.Id } + )[1] + if not market or market.status ~= "active" then + return { success = false, error = "market_unavailable" } + end + local spread_bps = 40 + local reference = tonumber(market.price) + local price = side == "buy" + and ceil_div(reference * (10000 + spread_bps), 10000) + or math.floor(reference * (10000 - spread_bps) / 10000) + local gross = side == "buy" + and ceil_div(quantity * price, Config.Crypto.AssetScale) + or math.floor(quantity * price / Config.Crypto.AssetScale) + local fee = math.max(Config.Crypto.MinimumFee, ceil_div(gross * Config.Crypto.FeeBasisPoints, 10000)) + local net = side == "buy" and gross + fee or gross - fee + if gross <= 0 or net <= 0 or gross > Config.Crypto.MaximumTradeNotional * Config.Crypto.PriceScale then + return { success = false, error = "limit_exceeded" } + end + if side == "buy" then + if balance(account_id(profile.id), config.Id) + quantity + > Config.Crypto.MaximumPositionQuantity * Config.Crypto.AssetScale + then + return { success = false, error = "limit_exceeded" } + end + if balance(account_id(profile.id), "CASH") < net then + return { success = false, error = "insufficient_funds" } + end + if balance("treasury", config.Id) < quantity then + return { success = false, error = "insufficient_liquidity" } + end + else + if balance(account_id(profile.id), config.Id) < quantity then + return { success = false, error = "insufficient_funds" } + end + if balance("treasury", "CASH") < net then + return { success = false, error = "insufficient_liquidity" } + end + end + if daily_total(profile.id, side) + net > Config.Crypto.DailyTradeLimit * Config.Crypto.PriceScale then + return { success = false, error = "limit_exceeded" } + end + local quote_id = new_id() + Bridge.Database.Query([[ + INSERT INTO `sky_phone_crypto_quotes` + (`id`,`profile_id`,`market_id`,`side`,`quantity`,`price`,`gross`,`fee`,`net`,`market_version`,`expires_at`) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, DATE_ADD(CURRENT_TIMESTAMP, INTERVAL ? SECOND)) + ]], { + quote_id, profile.id, config.Id, side, quantity, price, gross, fee, net, + market.version, Config.Crypto.QuoteLifetimeSeconds, + }) + return { + success = true, + data = { + id = quote_id, + marketId = config.Id, + side = side, + quantity = decimal_string(quantity, Config.Crypto.AssetScale), + price = decimal_string(price, Config.Crypto.PriceScale), + gross = decimal_string(gross, Config.Crypto.PriceScale), + fee = decimal_string(fee, Config.Crypto.PriceScale), + net = decimal_string(net, Config.Crypto.PriceScale), + expiresAt = (os.time() + Config.Crypto.QuoteLifetimeSeconds) * 1000, + }, + } +end) + +local function execute_trade(profile, data) + local key = idempotency_key(data.idempotencyKey) + if not key or type(data.quoteId) ~= "string" or #data.quoteId ~= 36 then + return { success = false, error = "quote_unavailable" } + end + local existing = Bridge.Database.Query([[ + SELECT `status` FROM `sky_phone_crypto_operations` + WHERE `profile_id` = ? AND `idempotency_key` = ? LIMIT 1 + ]], { profile.id, key })[1] + if existing then + return existing.status == "completed" + and { success = true, data = bootstrap(profile) } + or { success = false, error = "duplicate_request" } + end + local quote = Bridge.Database.Query([[ + SELECT quote.*, market.`status` AS `market_status`, market.`version` AS `current_version` + FROM `sky_phone_crypto_quotes` quote + JOIN `sky_phone_crypto_markets` market ON market.`id` = quote.`market_id` + WHERE quote.`id` = ? AND quote.`profile_id` = ? LIMIT 1 + ]], { data.quoteId, profile.id })[1] + if not quote or quote.consumed_operation_id then + return { success = false, error = "quote_unavailable" } + end + local expiry = Bridge.Database.Query( + "SELECT UNIX_TIMESTAMP(`expires_at`) AS `expires_at` FROM `sky_phone_crypto_quotes` WHERE `id` = ?", + { quote.id } + )[1] + if not expiry or tonumber(expiry.expires_at) < os.time() then + return { success = false, error = "quote_expired" } + end + if quote.market_status ~= "active" or tonumber(quote.current_version) ~= tonumber(quote.market_version) then + return { success = false, error = "quote_expired" } + end + local quantity = tonumber(quote.quantity) + local net_currency = tonumber(quote.net) + local player_account = account_id(profile.id) + if daily_total(profile.id, quote.side) + net_currency + > Config.Crypto.DailyTradeLimit * Config.Crypto.PriceScale + then + return { success = false, error = "limit_exceeded" } + end + if quote.side == "buy" then + if balance(player_account, quote.market_id) + quantity + > Config.Crypto.MaximumPositionQuantity * Config.Crypto.AssetScale + then + return { success = false, error = "limit_exceeded" } + end + if balance(player_account, "CASH") < net_currency then + return { success = false, error = "insufficient_funds" } + end + if balance("treasury", quote.market_id) < quantity then + return { success = false, error = "insufficient_liquidity" } + end + else + if balance(player_account, quote.market_id) < quantity then + return { success = false, error = "insufficient_funds" } + end + if balance("treasury", "CASH") < net_currency then + return { success = false, error = "insufficient_liquidity" } + end + end + local operation_id = new_id() + local fill_id = new_id() + local operation_type = quote.side + local request_hash = Bridge.Database.Query( + "SELECT SHA2(CONCAT(?, ':', ?), 256) AS `hash`", + { quote.id, key } + )[1].hash + local queries = { + { + query = [[INSERT INTO `sky_phone_crypto_operations` + (`id`,`profile_id`,`type`,`idempotency_key`,`request_hash`,`status`,`amount`,`market_id`) + VALUES (?, ?, ?, ?, ?, 'prepared', ?, ?)]], + params = { operation_id, profile.id, operation_type, key, request_hash, net_currency, quote.market_id }, + }, + } + if quote.side == "buy" then + queries[#queries + 1] = { query = [[UPDATE `sky_phone_crypto_balances` SET `available` = `available` - ?, `version` = `version` + 1 WHERE `account_id` = ? AND `asset_id` = 'CASH' AND `available` >= ?]], params = { net_currency, player_account, net_currency } } + queries[#queries + 1] = { query = [[UPDATE `sky_phone_crypto_balances` SET `available` = `available` + ?, `version` = `version` + 1 WHERE `account_id` = 'treasury' AND `asset_id` = 'CASH']], params = { net_currency } } + queries[#queries + 1] = { query = [[UPDATE `sky_phone_crypto_balances` SET `available` = `available` - ?, `version` = `version` + 1 WHERE `account_id` = 'treasury' AND `asset_id` = ? AND `available` >= ?]], params = { quantity, quote.market_id, quantity } } + queries[#queries + 1] = { query = [[INSERT INTO `sky_phone_crypto_balances` (`account_id`,`asset_id`,`available`,`version`) VALUES (?, ?, ?, 1) ON DUPLICATE KEY UPDATE `available` = `available` + VALUES(`available`), `version` = `version` + 1]], params = { player_account, quote.market_id, quantity } } + else + queries[#queries + 1] = { query = [[UPDATE `sky_phone_crypto_balances` SET `available` = `available` - ?, `version` = `version` + 1 WHERE `account_id` = ? AND `asset_id` = ? AND `available` >= ?]], params = { quantity, player_account, quote.market_id, quantity } } + queries[#queries + 1] = { query = [[UPDATE `sky_phone_crypto_balances` SET `available` = `available` + ?, `version` = `version` + 1 WHERE `account_id` = 'treasury' AND `asset_id` = ?]], params = { quantity, quote.market_id } } + queries[#queries + 1] = { query = [[UPDATE `sky_phone_crypto_balances` SET `available` = `available` - ?, `version` = `version` + 1 WHERE `account_id` = 'treasury' AND `asset_id` = 'CASH' AND `available` >= ?]], params = { net_currency, net_currency } } + queries[#queries + 1] = { query = [[UPDATE `sky_phone_crypto_balances` SET `available` = `available` + ?, `version` = `version` + 1 WHERE `account_id` = ? AND `asset_id` = 'CASH']], params = { net_currency, player_account } } + end + queries[#queries + 1] = { query = [[INSERT INTO `sky_phone_crypto_ledger_entries` (`operation_id`,`account_id`,`asset_id`,`delta`) VALUES (?, ?, 'CASH', ?), (?, 'treasury', 'CASH', ?), (?, ?, ?, ?), (?, 'treasury', ?, ?)]], params = { + operation_id, player_account, quote.side == "buy" and -net_currency or net_currency, + operation_id, quote.side == "buy" and net_currency or -net_currency, + operation_id, player_account, quote.market_id, quote.side == "buy" and quantity or -quantity, + operation_id, quote.market_id, quote.side == "buy" and -quantity or quantity, + } } + queries[#queries + 1] = { query = [[INSERT INTO `sky_phone_crypto_fills` (`id`,`operation_id`,`quote_id`,`market_id`,`side`,`quantity`,`price`,`gross`,`fee`,`net`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)]], params = { fill_id, operation_id, quote.id, quote.market_id, quote.side, quantity, quote.price, quote.gross, quote.fee, quote.net } } + queries[#queries + 1] = { query = [[UPDATE `sky_phone_crypto_quotes` SET `consumed_operation_id` = ? WHERE `id` = ? AND `consumed_operation_id` IS NULL]], params = { operation_id, quote.id } } + queries[#queries + 1] = { query = [[UPDATE `sky_phone_crypto_operations` SET `status` = 'completed' WHERE `id` = ?]], params = { operation_id } } + if not Bridge.Database.Transaction(queries) then + return { success = false, error = "request_failed" } + end + audit(profile.id, profile.owner_identifier, "trade_completed", operation_id) + return { success = true, data = bootstrap(profile) } +end + +Bridge.Callbacks.Register("sky_phone:crypto:execute", function(source, data) + if not SkyPhone.AllowOperation(source, "crypto:trade", Config.Crypto.ActionsPerMinute, 60) then + return { success = false, error = "rate_limited" } + end + local profile, error_response = authenticated_profile(source) + if not profile then + return error_response + end + return with_profile_lock(profile.id, function() + return with_exchange_lock(function() + return execute_trade(profile, type(data) == "table" and data or {}) + end) + end) +end) + +local function settlement_ledger_queries(operation_id, profile_id, kind, ledger_amount) + local player_account = account_id(profile_id) + local queries = {} + if kind == "deposit" then + queries[#queries + 1] = { query = [[UPDATE `sky_phone_crypto_balances` SET `available` = `available` + ?, `version` = `version` + 1 WHERE `account_id` = ? AND `asset_id` = 'CASH']], params = { ledger_amount, player_account } } + queries[#queries + 1] = { query = [[INSERT INTO `sky_phone_crypto_ledger_entries` (`operation_id`,`account_id`,`asset_id`,`delta`) VALUES (?, ?, 'CASH', ?), (?, 'external:bank', 'CASH', ?)]], params = { operation_id, player_account, ledger_amount, operation_id, -ledger_amount } } + else + queries[#queries + 1] = { query = [[UPDATE `sky_phone_crypto_balances` SET `locked` = `locked` - ?, `version` = `version` + 1 WHERE `account_id` = ? AND `asset_id` = 'CASH' AND `locked` >= ?]], params = { ledger_amount, player_account, ledger_amount } } + queries[#queries + 1] = { query = [[INSERT INTO `sky_phone_crypto_ledger_entries` (`operation_id`,`account_id`,`asset_id`,`delta`) VALUES (?, ?, 'CASH', ?), (?, 'external:bank', 'CASH', ?)]], params = { operation_id, player_account, -ledger_amount, operation_id, ledger_amount } } + end + queries[#queries + 1] = { query = [[UPDATE `sky_phone_crypto_operations` SET `status` = 'completed' WHERE `id` = ? AND `status` = 'external_applied']], params = { operation_id } } + queries[#queries + 1] = { query = [[UPDATE `sky_phone_crypto_settlements` SET `state` = 'completed' WHERE `operation_id` = ? AND `state` = 'external_applied']], params = { operation_id } } + return queries +end + +local function settle(source, profile, kind, data) + local key = idempotency_key(data.idempotencyKey) + local amount = parse_whole(data.amount, Config.Crypto.MinimumSettlement, Config.Crypto.MaximumSettlement) + if not key or not amount then + return { success = false, error = "invalid_amount" } + end + if not verify_password(profile.id, data.password) then + audit(profile.id, profile.owner_identifier, "settlement_reauth_failed", kind) + return { success = false, error = "invalid_credentials" } + end + local existing = Bridge.Database.Query([[ + SELECT `status` FROM `sky_phone_crypto_operations` + WHERE `profile_id` = ? AND `type` = ? AND `idempotency_key` = ? LIMIT 1 + ]], { profile.id, kind, key })[1] + if existing then + return existing.status == "completed" + and { success = true, data = bootstrap(profile) } + or { success = false, error = "settlement_pending" } + end + local ledger_amount = amount * Config.Crypto.PriceScale + local daily_limit = kind == "deposit" and Config.Crypto.DailyDepositLimit + or Config.Crypto.DailyWithdrawalLimit + if daily_total(profile.id, kind) + ledger_amount > daily_limit * Config.Crypto.PriceScale then + return { success = false, error = "limit_exceeded" } + end + if kind == "withdrawal" and balance(account_id(profile.id), "CASH") < ledger_amount then + return { success = false, error = "insufficient_funds" } + end + local operation_id = new_id() + local request_hash = Bridge.Database.Query( + "SELECT SHA2(CONCAT(?, ':', ?, ':', ?), 256) AS `hash`", + { kind, amount, key } + )[1].hash + local prepared = { + { + query = [[INSERT INTO `sky_phone_crypto_operations` + (`id`,`profile_id`,`type`,`idempotency_key`,`request_hash`,`status`,`amount`) + VALUES (?, ?, ?, ?, ?, 'prepared', ?)]], + params = { operation_id, profile.id, kind, key, request_hash, ledger_amount }, + }, + { + query = [[INSERT INTO `sky_phone_crypto_settlements` + (`operation_id`,`owner_identifier`,`framework_account`,`amount`,`state`) + VALUES (?, ?, ?, ?, 'prepared')]], + params = { operation_id, profile.owner_identifier, Config.Crypto.BankAccount, amount }, + }, + } + if kind == "withdrawal" then + prepared[#prepared + 1] = { + query = [[UPDATE `sky_phone_crypto_balances` + SET `available` = `available` - ?, `locked` = `locked` + ?, `version` = `version` + 1 + WHERE `account_id` = ? AND `asset_id` = 'CASH' AND `available` >= ?]], + params = { ledger_amount, ledger_amount, account_id(profile.id), ledger_amount }, + } + end + if not Bridge.Database.Transaction(prepared) then + return { success = false, error = "request_failed" } + end + Bridge.Database.Query("UPDATE `sky_phone_crypto_operations` SET `status` = 'external_pending' WHERE `id` = ?", { operation_id }) + Bridge.Database.Query("UPDATE `sky_phone_crypto_settlements` SET `state` = 'external_pending' WHERE `operation_id` = ?", { operation_id }) + local money_success, money_result = pcall( + kind == "deposit" and Bridge.Framework.RemoveMoney or Bridge.Framework.AddMoney, + source, + Config.Crypto.BankAccount, + amount + ) + if not money_success then + Bridge.Database.Query("UPDATE `sky_phone_crypto_operations` SET `status` = 'manual_review', `detail` = 'framework_call_ambiguous' WHERE `id` = ?", { operation_id }) + Bridge.Database.Query("UPDATE `sky_phone_crypto_settlements` SET `state` = 'manual_review' WHERE `operation_id` = ?", { operation_id }) + audit(profile.id, profile.owner_identifier, "settlement_manual_review", operation_id) + return { success = false, error = "settlement_pending" } + end + if not money_result then + local failed = { + { query = [[UPDATE `sky_phone_crypto_operations` SET `status` = 'failed', `detail` = 'framework_rejected' WHERE `id` = ?]], params = { operation_id } }, + { query = [[UPDATE `sky_phone_crypto_settlements` SET `state` = 'failed' WHERE `operation_id` = ?]], params = { operation_id } }, + } + if kind == "withdrawal" then + failed[#failed + 1] = { query = [[UPDATE `sky_phone_crypto_balances` SET `available` = `available` + ?, `locked` = `locked` - ?, `version` = `version` + 1 WHERE `account_id` = ? AND `asset_id` = 'CASH' AND `locked` >= ?]], params = { ledger_amount, ledger_amount, account_id(profile.id), ledger_amount } } + end + Bridge.Database.Transaction(failed) + return { success = false, error = kind == "deposit" and "insufficient_funds" or "request_failed" } + end + Bridge.Database.Query("UPDATE `sky_phone_crypto_operations` SET `status` = 'external_applied' WHERE `id` = ?", { operation_id }) + Bridge.Database.Query("UPDATE `sky_phone_crypto_settlements` SET `state` = 'external_applied' WHERE `operation_id` = ?", { operation_id }) + local ledger_queries = settlement_ledger_queries(operation_id, profile.id, kind, ledger_amount) + if not Bridge.Database.Transaction(ledger_queries) then + Bridge.Database.Query("UPDATE `sky_phone_crypto_operations` SET `status` = 'manual_review', `detail` = 'ledger_apply_failed' WHERE `id` = ?", { operation_id }) + Bridge.Database.Query("UPDATE `sky_phone_crypto_settlements` SET `state` = 'manual_review' WHERE `operation_id` = ?", { operation_id }) + audit(profile.id, profile.owner_identifier, "settlement_manual_review", operation_id) + return { success = false, error = "settlement_pending" } + end + sessions[source].recently_authenticated_at = os.time() + audit(profile.id, profile.owner_identifier, "settlement_completed", operation_id) + return { success = true, data = bootstrap(profile) } +end + +for _, callback in ipairs({ + { name = "deposit", kind = "deposit" }, + { name = "withdraw", kind = "withdrawal" }, +}) do + Bridge.Callbacks.Register("sky_phone:crypto:" .. callback.name, function(source, data) + if not SkyPhone.AllowOperation(source, "crypto:settlement", 8, 60) then + return { success = false, error = "rate_limited" } + end + local profile, error_response = authenticated_profile(source) + if not profile then + return error_response + end + return with_profile_lock(profile.id, function() + return settle(source, profile, callback.kind, type(data) == "table" and data or {}) + end) + end) +end + +AddEventHandler("playerDropped", function() + sessions[source] = nil +end) + +ensure_schema() +initialize_markets() + +local function reconcile_settlements(include_recent) + local age_clause = include_recent and "" or " AND settlement.`updated_at` < DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 5 MINUTE)" + local rows = Bridge.Database.Query([[ + SELECT operation.`id`, operation.`profile_id`, operation.`type`, operation.`amount`, + operation.`status`, settlement.`state` + FROM `sky_phone_crypto_operations` operation + JOIN `sky_phone_crypto_settlements` settlement ON settlement.`operation_id` = operation.`id` + WHERE operation.`status` IN ('prepared','external_pending','external_applied') + ]] .. age_clause, {}) + for _, operation in ipairs(rows) do + local ledger_amount = tonumber(operation.amount) + if operation.status == "prepared" then + local queries = {} + if operation.type == "withdrawal" then + queries[#queries + 1] = { query = [[UPDATE `sky_phone_crypto_balances` SET `available` = `available` + ?, `locked` = `locked` - ?, `version` = `version` + 1 WHERE `account_id` = ? AND `asset_id` = 'CASH' AND `locked` >= ?]], params = { ledger_amount, ledger_amount, account_id(operation.profile_id), ledger_amount } } + end + queries[#queries + 1] = { query = [[UPDATE `sky_phone_crypto_operations` SET `status` = 'cancelled', `detail` = 'reconciled_before_external_call' WHERE `id` = ? AND `status` = 'prepared']], params = { operation.id } } + queries[#queries + 1] = { query = [[UPDATE `sky_phone_crypto_settlements` SET `state` = 'cancelled' WHERE `operation_id` = ? AND `state` = 'prepared']], params = { operation.id } } + Bridge.Database.Transaction(queries) + elseif operation.status == "external_pending" then + Bridge.Database.Transaction({ + { query = [[UPDATE `sky_phone_crypto_operations` SET `status` = 'manual_review', `detail` = 'reconciled_ambiguous_external_call' WHERE `id` = ? AND `status` = 'external_pending']], params = { operation.id } }, + { query = [[UPDATE `sky_phone_crypto_settlements` SET `state` = 'manual_review' WHERE `operation_id` = ? AND `state` = 'external_pending']], params = { operation.id } }, + }) + elseif operation.status == "external_applied" then + local entries = Bridge.Database.Query( + "SELECT COUNT(*) AS `count` FROM `sky_phone_crypto_ledger_entries` WHERE `operation_id` = ?", + { operation.id } + )[1] + if tonumber(entries and entries.count) == 0 then + Bridge.Database.Transaction(settlement_ledger_queries( + operation.id, + operation.profile_id, + operation.type, + ledger_amount + )) + else + Bridge.Database.Transaction({ + { query = [[UPDATE `sky_phone_crypto_operations` SET `status` = 'manual_review', `detail` = 'unexpected_partial_ledger' WHERE `id` = ?]], params = { operation.id } }, + { query = [[UPDATE `sky_phone_crypto_settlements` SET `state` = 'manual_review' WHERE `operation_id` = ?]], params = { operation.id } }, + }) + end + end + end +end + +reconcile_settlements(true) + +CreateThread(function() + while true do + Wait(5 * 60 * 1000) + reconcile_settlements(false) + end +end) + +CreateThread(function() + while true do + Wait(Config.Crypto.PriceTickSeconds * 1000) + with_exchange_lock(function() + for _, market_id in ipairs(market_order) do + local config = markets[market_id] + local row = Bridge.Database.Query( + "SELECT `price`,`version`,`status` FROM `sky_phone_crypto_markets` WHERE `id` = ? LIMIT 1", + { market_id } + )[1] + if row and row.status == "active" then + local price = tonumber(row.price) or config.InitialPrice + local movement = exports[GetCurrentResourceName()]:CryptoRandomInt( + -config.VolatilityBasisPoints, + config.VolatilityBasisPoints + 1 + ) + if type(movement) ~= "number" then + error("[sky_phone] Crypto entropy provider did not return a market movement.") + end + local next_price = math.floor(price * (10000 + movement) / 10000) + next_price = math.max(config.MinimumPrice, math.min(config.MaximumPrice, next_price)) + local next_version = (tonumber(row.version) or 0) + 1 + if Bridge.Database.Transaction({ + { query = [[UPDATE `sky_phone_crypto_markets` SET `price` = ?, `version` = ? WHERE `id` = ? AND `version` = ?]], params = { next_price, next_version, market_id, row.version } }, + { query = [[INSERT INTO `sky_phone_crypto_market_ticks` (`market_id`,`version`,`price`) VALUES (?, ?, ?)]], params = { market_id, next_version, next_price } }, + }) then + Bridge.Database.Query([[ + DELETE FROM `sky_phone_crypto_market_ticks` + WHERE `market_id` = ? AND `id` NOT IN ( + SELECT `id` FROM ( + SELECT `id` FROM `sky_phone_crypto_market_ticks` + WHERE `market_id` = ? ORDER BY `id` DESC LIMIT 1440 + ) retained + ) + ]], { market_id, market_id }) + end + end + end + end) + end +end) + +end) diff --git a/sky_phone/source/server/crypto_password.js b/sky_phone/source/server/crypto_password.js new file mode 100644 index 0000000..47dd12d --- /dev/null +++ b/sky_phone/source/server/crypto_password.js @@ -0,0 +1,58 @@ +'use strict' + +const { randomBytes, randomInt, scryptSync, timingSafeEqual } = require('node:crypto') + +const KEY_LENGTH = 32 +const SCRYPT_COST = 32768 +const SCRYPT_BLOCK_SIZE = 8 +const SCRYPT_PARALLELISM = 1 +const MAX_MEMORY = 64 * 1024 * 1024 + +exports('CryptoHashPassword', (password) => { + if (typeof password !== 'string' || password.length < 8 || password.length > 72) return null + + const salt = randomBytes(16) + const hash = scryptSync(password, salt, KEY_LENGTH, { + N: SCRYPT_COST, + maxmem: MAX_MEMORY, + p: SCRYPT_PARALLELISM, + r: SCRYPT_BLOCK_SIZE, + }) + return [ + 'scrypt', 'v=1', `N=${SCRYPT_COST}`, `r=${SCRYPT_BLOCK_SIZE}`, + `p=${SCRYPT_PARALLELISM}`, salt.toString('base64'), hash.toString('base64'), + ].join('$') +}) + +exports('CryptoVerifyPassword', (password, encoded) => { + if (typeof password !== 'string' || typeof encoded !== 'string') return false + + const parts = encoded.split('$') + if ( + parts.length !== 7 || parts[0] !== 'scrypt' || parts[1] !== 'v=1' || + parts[2] !== `N=${SCRYPT_COST}` || parts[3] !== `r=${SCRYPT_BLOCK_SIZE}` || + parts[4] !== `p=${SCRYPT_PARALLELISM}` + ) return false + + try { + const salt = Buffer.from(parts[5], 'base64') + const expected = Buffer.from(parts[6], 'base64') + if (salt.length !== 16 || expected.length !== KEY_LENGTH) return false + const actual = scryptSync(password, salt, KEY_LENGTH, { + N: SCRYPT_COST, + maxmem: MAX_MEMORY, + p: SCRYPT_PARALLELISM, + r: SCRYPT_BLOCK_SIZE, + }) + return timingSafeEqual(actual, expected) + } catch { + return false + } +}) + +exports('CryptoRandomInt', (minimum, maximum) => { + if (!Number.isSafeInteger(minimum) || !Number.isSafeInteger(maximum) || minimum >= maximum) { + return null + } + return randomInt(minimum, maximum) +}) diff --git a/sky_phone/source/shared/custom_apps.lua b/sky_phone/source/shared/custom_apps.lua index 826cf09..d78a916 100644 --- a/sky_phone/source/shared/custom_apps.lua +++ b/sky_phone/source/shared/custom_apps.lua @@ -37,6 +37,7 @@ local ALLOWED_PERMISSIONS = { local RESERVED_APP_IDS = { ["app-store"] = true, banking = true, + crypto = true, billing = true, calculator = true, calendar = true, diff --git a/sky_phone/sql/install.sql b/sky_phone/sql/install.sql index 9c9bf76..5962d63 100644 --- a/sky_phone/sql/install.sql +++ b/sky_phone/sql/install.sql @@ -1344,3 +1344,139 @@ CREATE TABLE IF NOT EXISTS `sky_phone_weazel_article_media` ( FOREIGN KEY (`article_id`) REFERENCES `sky_phone_weazel_articles` (`id`) ON DELETE CASCADE, FOREIGN KEY (`media_id`) REFERENCES `sky_phone_media` (`id`) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `sky_phone_crypto_profiles` ( + `id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `owner_identifier` VARCHAR(80) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `account_id` BIGINT UNSIGNED NULL, + `handle` VARCHAR(20) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL, + `password_hash` VARCHAR(255) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `status` ENUM('active','frozen','closed') NOT NULL DEFAULT 'active', + `failed_logins` TINYINT UNSIGNED NOT NULL DEFAULT 0, + `locked_until` DATETIME NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_sky_phone_crypto_owner` (`owner_identifier`), + UNIQUE KEY `uniq_sky_phone_crypto_handle` (`handle`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `sky_phone_crypto_markets` ( + `id` VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `asset_scale` BIGINT UNSIGNED NOT NULL, + `price_scale` BIGINT UNSIGNED NOT NULL, + `issued_supply` DECIMAL(36,0) UNSIGNED NOT NULL, + `price` DECIMAL(36,0) UNSIGNED NOT NULL, + `version` BIGINT UNSIGNED NOT NULL DEFAULT 1, + `status` ENUM('active','halted','stale') NOT NULL DEFAULT 'active', + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`) +) ENGINE=InnoDB; + +CREATE TABLE IF NOT EXISTS `sky_phone_crypto_market_ticks` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `market_id` VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `version` BIGINT UNSIGNED NOT NULL, + `price` DECIMAL(36,0) UNSIGNED NOT NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_sky_phone_crypto_tick` (`market_id`,`version`), + KEY `idx_sky_phone_crypto_ticks` (`market_id`,`created_at`,`id`) +) ENGINE=InnoDB; + +CREATE TABLE IF NOT EXISTS `sky_phone_crypto_balances` ( + `account_id` VARCHAR(48) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `asset_id` VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `available` DECIMAL(36,0) UNSIGNED NOT NULL DEFAULT 0, + `locked` DECIMAL(36,0) UNSIGNED NOT NULL DEFAULT 0, + `version` BIGINT UNSIGNED NOT NULL DEFAULT 0, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`account_id`,`asset_id`) +) ENGINE=InnoDB; + +CREATE TABLE IF NOT EXISTS `sky_phone_crypto_operations` ( + `id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `profile_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `type` ENUM('buy','sell','deposit','withdrawal') NOT NULL, + `idempotency_key` VARCHAR(96) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `request_hash` CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `status` ENUM('prepared','external_pending','external_applied','ledger_applied','completed','failed','compensation_pending','manual_review','cancelled') NOT NULL, + `amount` DECIMAL(36,0) UNSIGNED NOT NULL DEFAULT 0, + `market_id` VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NULL, + `detail` VARCHAR(255) NOT NULL DEFAULT '', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_sky_phone_crypto_operation` (`profile_id`,`type`,`idempotency_key`), + KEY `idx_sky_phone_crypto_activity` (`profile_id`,`created_at`,`id`) +) ENGINE=InnoDB; + +CREATE TABLE IF NOT EXISTS `sky_phone_crypto_ledger_entries` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `operation_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `account_id` VARCHAR(48) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `asset_id` VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `delta` DECIMAL(36,0) NOT NULL, + `balance_after` DECIMAL(36,0) NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_sky_phone_crypto_ledger_operation` (`operation_id`,`id`), + KEY `idx_sky_phone_crypto_ledger_account` (`account_id`,`created_at`,`id`) +) ENGINE=InnoDB; + +CREATE TABLE IF NOT EXISTS `sky_phone_crypto_quotes` ( + `id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `profile_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `market_id` VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `side` ENUM('buy','sell') NOT NULL, + `quantity` DECIMAL(36,0) UNSIGNED NOT NULL, + `price` DECIMAL(36,0) UNSIGNED NOT NULL, + `gross` DECIMAL(36,0) UNSIGNED NOT NULL, + `fee` DECIMAL(36,0) UNSIGNED NOT NULL, + `net` DECIMAL(36,0) UNSIGNED NOT NULL, + `market_version` BIGINT UNSIGNED NOT NULL, + `expires_at` DATETIME NOT NULL, + `consumed_operation_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_sky_phone_crypto_quote_profile` (`profile_id`,`expires_at`) +) ENGINE=InnoDB; + +CREATE TABLE IF NOT EXISTS `sky_phone_crypto_fills` ( + `id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `operation_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `quote_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `market_id` VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `side` ENUM('buy','sell') NOT NULL, + `quantity` DECIMAL(36,0) UNSIGNED NOT NULL, + `price` DECIMAL(36,0) UNSIGNED NOT NULL, + `gross` DECIMAL(36,0) UNSIGNED NOT NULL, + `fee` DECIMAL(36,0) UNSIGNED NOT NULL, + `net` DECIMAL(36,0) UNSIGNED NOT NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_sky_phone_crypto_fill_quote` (`quote_id`) +) ENGINE=InnoDB; + +CREATE TABLE IF NOT EXISTS `sky_phone_crypto_settlements` ( + `operation_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `owner_identifier` VARCHAR(80) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `framework_account` VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `amount` DECIMAL(36,0) UNSIGNED NOT NULL, + `state` ENUM('prepared','external_pending','external_applied','ledger_applied','completed','failed','compensation_pending','manual_review','cancelled') NOT NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`operation_id`), + KEY `idx_sky_phone_crypto_settlement_state` (`state`,`updated_at`) +) ENGINE=InnoDB; + +CREATE TABLE IF NOT EXISTS `sky_phone_crypto_audit_events` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `profile_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NULL, + `owner_identifier` VARCHAR(80) CHARACTER SET ascii COLLATE ascii_bin NULL, + `event_type` VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `detail` VARCHAR(255) NOT NULL DEFAULT '', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_sky_phone_crypto_audit` (`profile_id`,`created_at`,`id`) +) ENGINE=InnoDB;