@@ -16,13 +16,18 @@ import {
1616} from '../../shared/websocket-types'
1717import { blockCache } from './cache'
1818import { logger } from './logger'
19+ import { matchBlock , type ParsedBlockTx } from './notifications/matcher'
20+ import type { PushDispatcher } from './push/types'
1921
2022/** Default block/mempool poll interval (ms). Overridable via BLOCKCHAIN_POLL_INTERVAL. */
2123const DEFAULT_POLL_INTERVAL_MS = 4000
2224
2325/** Cap on `transaction-confirmed` events emitted per new block. */
2426const TRANSACTION_CONFIRMED_CAP = 50
2527
28+ /** Cap on transactions scanned per block/mempool batch for notification matching. */
29+ const NOTIFICATION_TX_SCAN_CAP = 500
30+
2631/** Top-N mempool txs included in `mempool-update` (aligned with GET /api/mempool). */
2732const MEMPOOL_TX_SUMMARY_LIMIT = 20
2833
@@ -80,6 +85,14 @@ export class BlockchainMonitor {
8085 private lastBroadcastVersion = new Map < NetworkType , string > ( )
8186 private lastBroadcastSubversion = new Map < NetworkType , string | undefined > ( )
8287 private lastBroadcastProtocol = new Map < NetworkType , number | undefined > ( )
88+ /**
89+ * Silent-push dispatcher for background payment notifications. Null (and the
90+ * whole notification path inert) until wired at startup with FCM/APNS creds —
91+ * Phase-1-safe: no creds → no dispatcher → no matching, no RPC overhead.
92+ */
93+ private notificationDispatcher : PushDispatcher | null = null
94+ /** Mempool txids already notified as first-seen (per network), so a lingering tx notifies once. */
95+ private notifiedMempoolTxids = new Map < NetworkType , Set < string > > ( )
8396
8497 constructor ( wsManager : WebSocketManager , config ?: Partial < BlockchainMonitorConfig > ) {
8598 this . wsManager = wsManager
@@ -111,6 +124,15 @@ export class BlockchainMonitor {
111124 logger . debug ( '[BlockchainMonitor] Initialized with config:' , this . config )
112125 }
113126
127+ /**
128+ * Wire (or clear) the silent-push dispatcher. Called at startup only when
129+ * FCM/APNS credentials are configured; while null the notification path is a
130+ * complete no-op.
131+ */
132+ setNotificationDispatcher ( dispatch : PushDispatcher | null ) : void {
133+ this . notificationDispatcher = dispatch
134+ }
135+
114136 async start ( ) : Promise < void > {
115137 if ( this . isRunning ) {
116138 logger . debug ( '[BlockchainMonitor] Already running' )
@@ -286,6 +308,13 @@ export class BlockchainMonitor {
286308 }
287309 this . wsManager . broadcast ( confirmedEvent , network )
288310 }
311+
312+ // Background payment notifications: match this mined block's txs against
313+ // watched addresses and dispatch silent pushes. Isolated below so an RPC or
314+ // DB hiccup never disrupts block broadcasting.
315+ if ( this . notificationDispatcher ) {
316+ await this . notifyBlockMatches ( network , txids )
317+ }
289318 } catch ( error ) {
290319 logger . error (
291320 `[BlockchainMonitor] Error handling new block ${ height } on ${ network } :` ,
@@ -294,6 +323,99 @@ export class BlockchainMonitor {
294323 }
295324 }
296325
326+ /**
327+ * Reduce transactions to the address facts the matcher needs. Fetches each
328+ * verbose tx (output addresses + resolved input prevout addresses), bounded by
329+ * {@link NOTIFICATION_TX_SCAN_CAP}. Best-effort: a tx that fails to load is
330+ * skipped rather than aborting the batch.
331+ */
332+ private async buildParsedTxs ( network : NetworkType , txids : string [ ] ) : Promise < ParsedBlockTx [ ] > {
333+ const capped = txids . slice ( 0 , NOTIFICATION_TX_SCAN_CAP )
334+ if ( txids . length > NOTIFICATION_TX_SCAN_CAP ) {
335+ logger . warn (
336+ `[BlockchainMonitor] ${ network } : ${ txids . length } txs exceed notification scan cap ${ NOTIFICATION_TX_SCAN_CAP } ; tail unscanned` ,
337+ )
338+ }
339+
340+ const parsed : ParsedBlockTx [ ] = [ ]
341+ for ( const txid of capped ) {
342+ try {
343+ const tx = await blockCache . getTransaction ( txid , network , true )
344+ if ( ! tx ) {
345+ continue
346+ }
347+ const outputAddresses : string [ ] = [ ]
348+ for ( const out of tx . vout ?? [ ] ) {
349+ for ( const address of out . scriptPubKey ?. addresses ?? [ ] ) {
350+ outputAddresses . push ( address )
351+ }
352+ }
353+ const inputAddresses : string [ ] = [ ]
354+ for ( const input of tx . vin ?? [ ] ) {
355+ for ( const address of input . prevout ?. addresses ?? [ ] ) {
356+ inputAddresses . push ( address )
357+ }
358+ }
359+ parsed . push ( { txid, outputAddresses, inputAddresses } )
360+ } catch ( error ) {
361+ logger . debug ( `[BlockchainMonitor] notification tx fetch failed for ${ txid } :` , error )
362+ }
363+ }
364+ return parsed
365+ }
366+
367+ /** Match a mined block (depth 1) against watched addresses and dispatch pushes. */
368+ private async notifyBlockMatches ( network : NetworkType , txids : string [ ] ) : Promise < void > {
369+ const dispatch = this . notificationDispatcher
370+ if ( ! dispatch ) {
371+ return
372+ }
373+ try {
374+ const parsed = await this . buildParsedTxs ( network , txids )
375+ if ( parsed . length === 0 ) {
376+ return
377+ }
378+ await matchBlock ( { network, depth : 1 , txs : parsed } , { dispatch } )
379+ } catch ( error ) {
380+ logger . error ( `[BlockchainMonitor] notification match failed on ${ network } :` , error )
381+ }
382+ }
383+
384+ /**
385+ * Match newly-seen mempool transactions (depth 0) for first-seen
386+ * `incoming_pending` alerts. Dedupes against txids already notified while they
387+ * linger in the mempool, and prunes that set as txids leave.
388+ */
389+ private async notifyMempoolMatches ( network : NetworkType , rawMempool : string [ ] ) : Promise < void > {
390+ const dispatch = this . notificationDispatcher
391+ if ( ! dispatch ) {
392+ return
393+ }
394+ try {
395+ const notified = this . notifiedMempoolTxids . get ( network ) ?? new Set < string > ( )
396+ const fresh = rawMempool . filter ( ( txid ) => ! notified . has ( txid ) )
397+ if ( fresh . length > 0 ) {
398+ const parsed = await this . buildParsedTxs ( network , fresh )
399+ if ( parsed . length > 0 ) {
400+ await matchBlock ( { network, depth : 0 , txs : parsed } , { dispatch } )
401+ }
402+ for ( const txid of fresh ) {
403+ notified . add ( txid )
404+ }
405+ }
406+ // Prune txids that have left the mempool so the set stays bounded.
407+ const inMempool = new Set ( rawMempool )
408+ for ( const txid of notified ) {
409+ if ( ! inMempool . has ( txid ) ) {
410+ notified . delete ( txid )
411+ }
412+ }
413+ this . notifiedMempoolTxids . set ( network , notified )
414+ } catch ( error ) {
415+ logger . error ( `[BlockchainMonitor] mempool notification match failed on ${ network } :` , error )
416+ }
417+ }
418+
297419 /**
298420 * Top-N mempool summary — same fields and limit as GET /api/mempool.
299421 */
@@ -363,6 +485,14 @@ export class BlockchainMonitor {
363485 } ,
364486 }
365487 this . wsManager . broadcast ( mempoolEvent , network )
488+
489+ // First-seen payment notifications from the mempool (depth 0).
490+ if ( this . notificationDispatcher ) {
491+ const rawMempool = await rpcWithNetwork < string [ ] > ( 'getrawmempool' , [ ] , network ) . catch (
492+ ( ) => [ ] as string [ ] ,
493+ )
494+ await this . notifyMempoolMatches ( network , rawMempool )
495+ }
366496 }
367497 } catch ( error ) {
368498 logger . error ( `[BlockchainMonitor] Error polling mempool for ${ network } :` , error )
0 commit comments