Skip to content

Commit 38330af

Browse files
NateIsernclaude
andcommitted
feat(notifications): block matcher + gap-limit advancement
matchBlock matches a parsed block's output addresses (incoming) and spent-input prevout addresses (outgoing) against WatchedAddress, advances the BIP44 gap-limit window (used index i -> derive up to i+gapLimit), and dispatches one deduped silent push per (subscription, tx, event) for opted-in events only. Wires it into BlockchainMonitor's new-block (depth 1) and mempool (depth 0, first-seen) paths behind an injected dispatcher — inert until wired. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 8d170eb commit 38330af

4 files changed

Lines changed: 527 additions & 0 deletions

File tree

server/lib/blockchain-monitor.ts

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,18 @@ import {
1616
} from '../../shared/websocket-types'
1717
import { blockCache } from './cache'
1818
import { 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. */
2123
const DEFAULT_POLL_INTERVAL_MS = 4000
2224

2325
/** Cap on `transaction-confirmed` events emitted per new block. */
2426
const 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). */
2732
const 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)
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'
2+
import { setupMemoryMongo, teardownMemoryMongo, clearCollections } from './test-support'
3+
import { registerSubscription } from './subscriptions'
4+
import { deriveWindow } from './derive'
5+
import { matchBlock, type ParsedBlock } from './matcher'
6+
import type { PushTarget, PushMessage } from '../push/types'
7+
import WatchedAddress from '../db/models/WatchedAddress'
8+
import NotificationSubscription from '../db/models/NotificationSubscription'
9+
10+
const XPUB =
11+
'ToEA6m5JcxaE72JyzLAvHoonWB3GgAxzt5jADQC9Pc43q4V3omj94DdKe98KkRgY2nqwbNHxWCceM3o62VhFVe7HCnAW7yBGEvfbfwrYsK3N7t7'
12+
const GAP_LIMIT = 5
13+
14+
/** Address at a receive/change index for the fixed test xpub. */
15+
function addr(chain: 0 | 1, index: number): string {
16+
return deriveWindow(XPUB, chain, index, 1, 'mainnet')[0].address
17+
}
18+
19+
async function seedSubscription(
20+
events = ['incoming_pending', 'incoming_confirmed', 'outgoing_confirmed'],
21+
): Promise<string> {
22+
const { subscriptionId } = await registerSubscription({
23+
xpub: XPUB,
24+
scriptType: 'p2pkh',
25+
gapLimit: GAP_LIMIT,
26+
network: 'mainnet',
27+
deviceToken: 'device-token-abc',
28+
platform: 'android',
29+
confirmations: 1,
30+
events: events as never,
31+
})
32+
return subscriptionId
33+
}
34+
35+
/** Collect dispatcher calls. */
36+
function recordingDispatcher() {
37+
const calls: Array<{ target: PushTarget; message: PushMessage }> = []
38+
return {
39+
calls,
40+
dispatch: async (target: PushTarget, message: PushMessage) => {
41+
calls.push({ target, message })
42+
},
43+
}
44+
}
45+
46+
describe('matchBlock', () => {
47+
beforeAll(async () => {
48+
await setupMemoryMongo()
49+
}, 60000)
50+
afterAll(async () => {
51+
await teardownMemoryMongo()
52+
})
53+
beforeEach(async () => {
54+
await clearCollections()
55+
})
56+
57+
it('dispatches incoming_pending for a first-seen output and advances the gap window', async () => {
58+
const subscriptionId = await seedSubscription()
59+
const recorder = recordingDispatcher()
60+
61+
const block: ParsedBlock = {
62+
network: 'mainnet',
63+
depth: 0, // mempool / first-seen
64+
txs: [
65+
{
66+
txid: 'a'.repeat(64),
67+
outputAddresses: [addr(0, 3)],
68+
inputAddresses: [],
69+
},
70+
],
71+
}
72+
await matchBlock(block, { dispatch: recorder.dispatch })
73+
74+
expect(recorder.calls).toHaveLength(1)
75+
expect(recorder.calls[0].target.subscriptionId).toBe(subscriptionId)
76+
expect(recorder.calls[0].message).toEqual({ txid: 'a'.repeat(64), event: 'incoming_pending' })
77+
78+
// Using receive index 3 extends the watched window to 3 + gapLimit.
79+
const sub = await NotificationSubscription.findOne({ subscriptionId })
80+
expect(sub?.derivedTo.receive).toBe(3 + GAP_LIMIT)
81+
const receiveRows = await WatchedAddress.find({ subscriptionId, chain: 0 })
82+
expect(receiveRows).toHaveLength(3 + GAP_LIMIT)
83+
// The new frontier index (3+gapLimit-1) is now watched.
84+
expect(await WatchedAddress.countDocuments({ subscriptionId, chain: 0, index: 3 + GAP_LIMIT - 1 })).toBe(1)
85+
})
86+
87+
it('dispatches incoming_confirmed when a mined block reaches the confirmation depth', async () => {
88+
const subscriptionId = await seedSubscription()
89+
const recorder = recordingDispatcher()
90+
91+
const block: ParsedBlock = {
92+
network: 'mainnet',
93+
depth: 1, // confirmations default is 1
94+
txs: [{ txid: 'b'.repeat(64), outputAddresses: [addr(0, 0)], inputAddresses: [] }],
95+
}
96+
await matchBlock(block, { dispatch: recorder.dispatch })
97+
98+
expect(recorder.calls).toHaveLength(1)
99+
expect(recorder.calls[0].message).toEqual({ txid: 'b'.repeat(64), event: 'incoming_confirmed' })
100+
expect(recorder.calls[0].target.subscriptionId).toBe(subscriptionId)
101+
})
102+
103+
it('dispatches outgoing_confirmed when a mined tx spends a watched output', async () => {
104+
await seedSubscription()
105+
const recorder = recordingDispatcher()
106+
107+
const block: ParsedBlock = {
108+
network: 'mainnet',
109+
depth: 1,
110+
txs: [
111+
{
112+
txid: 'c'.repeat(64),
113+
outputAddresses: ['FUnrelatedAddressNotWatched1111111'],
114+
inputAddresses: [addr(0, 0)], // spending a previously watched output
115+
},
116+
],
117+
}
118+
await matchBlock(block, { dispatch: recorder.dispatch })
119+
120+
expect(recorder.calls).toHaveLength(1)
121+
expect(recorder.calls[0].message.event).toBe('outgoing_confirmed')
122+
})
123+
124+
it('sends only one push when a tx pays two watched addresses of the same subscription', async () => {
125+
await seedSubscription()
126+
const recorder = recordingDispatcher()
127+
128+
const block: ParsedBlock = {
129+
network: 'mainnet',
130+
depth: 0,
131+
txs: [
132+
{
133+
txid: 'd'.repeat(64),
134+
outputAddresses: [addr(0, 0), addr(0, 1)],
135+
inputAddresses: [],
136+
},
137+
],
138+
}
139+
await matchBlock(block, { dispatch: recorder.dispatch })
140+
141+
expect(recorder.calls).toHaveLength(1)
142+
})
143+
144+
it('does not dispatch an event the subscription did not opt into', async () => {
145+
await seedSubscription(['incoming_pending', 'incoming_confirmed']) // no outgoing
146+
const recorder = recordingDispatcher()
147+
148+
const block: ParsedBlock = {
149+
network: 'mainnet',
150+
depth: 1,
151+
txs: [{ txid: 'e'.repeat(64), outputAddresses: [], inputAddresses: [addr(0, 0)] }],
152+
}
153+
await matchBlock(block, { dispatch: recorder.dispatch })
154+
155+
expect(recorder.calls).toHaveLength(0)
156+
})
157+
158+
it('does not dispatch for a block with no watched addresses', async () => {
159+
await seedSubscription()
160+
const recorder = recordingDispatcher()
161+
162+
const block: ParsedBlock = {
163+
network: 'mainnet',
164+
depth: 1,
165+
txs: [{ txid: 'f'.repeat(64), outputAddresses: ['FSomeoneElse'], inputAddresses: [] }],
166+
}
167+
await matchBlock(block, { dispatch: recorder.dispatch })
168+
169+
expect(recorder.calls).toHaveLength(0)
170+
})
171+
172+
it('does not match a subscription on a different network', async () => {
173+
await seedSubscription() // mainnet
174+
const recorder = recordingDispatcher()
175+
176+
const block: ParsedBlock = {
177+
network: 'testnet',
178+
depth: 0,
179+
txs: [{ txid: '0'.repeat(64), outputAddresses: [addr(0, 0)], inputAddresses: [] }],
180+
}
181+
await matchBlock(block, { dispatch: recorder.dispatch })
182+
183+
expect(recorder.calls).toHaveLength(0)
184+
})
185+
})

0 commit comments

Comments
 (0)