@pioneer-platform/pioneer-subscriptions 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts ADDED
@@ -0,0 +1,422 @@
1
+ /**
2
+ * Pioneer Subscriptions Module
3
+ *
4
+ * Session-based blockchain address subscription manager for real-time payment notifications.
5
+ *
6
+ * Features:
7
+ * - Subscribe to blockchain addresses via blockbook websockets
8
+ * - Session-based: subscriptions tied to client connection lifecycle
9
+ * - Automatic cleanup on disconnect
10
+ * - Multi-chain support
11
+ * - Event callbacks for payment notifications
12
+ *
13
+ * Usage:
14
+ * ```ts
15
+ * const manager = new SubscriptionManager();
16
+ * await manager.init();
17
+ *
18
+ * // Register callback for payment notifications
19
+ * manager.onPayment((data) => {
20
+ * console.log('Payment received:', data);
21
+ * // Push to client via websocket
22
+ * });
23
+ *
24
+ * // Subscribe addresses for a session
25
+ * await manager.subscribe({
26
+ * sessionId: 'socket-123',
27
+ * username: 'user1',
28
+ * coin: 'BTC',
29
+ * addresses: ['bc1q...', '3...']
30
+ * });
31
+ *
32
+ * // On disconnect
33
+ * await manager.unsubscribe('socket-123');
34
+ * ```
35
+ */
36
+
37
+ const log = require('@pioneer-platform/loggerdog')();
38
+ const blockbook = require('@pioneer-platform/blockbook');
39
+
40
+ const TAG = ' | PioneerSubscriptions | ';
41
+
42
+ export interface SubscriptionRequest {
43
+ sessionId: string;
44
+ username: string;
45
+ coin: string;
46
+ addresses: string[];
47
+ }
48
+
49
+ export interface PaymentNotification {
50
+ sessionId: string;
51
+ username: string;
52
+ coin: string;
53
+ address: string;
54
+ txid: string;
55
+ amount: string;
56
+ confirmations: number;
57
+ timestamp: number;
58
+ }
59
+
60
+ export interface SessionSubscription {
61
+ sessionId: string;
62
+ username: string;
63
+ coin: string;
64
+ addresses: string[];
65
+ subscribedAt: number;
66
+ }
67
+
68
+ export interface SubscriptionStats {
69
+ totalSessions: number;
70
+ totalAddresses: number;
71
+ sessionsByUsername: Record<string, number>;
72
+ addressesByCoin: Record<string, number>;
73
+ sessions: SessionSubscription[];
74
+ }
75
+
76
+ type PaymentCallback = (notification: PaymentNotification) => void;
77
+
78
+ export class SubscriptionManager {
79
+ private blockbookSockets: Record<string, any>;
80
+ private sessions: Map<string, SessionSubscription>;
81
+ private addressToSessions: Map<string, Set<string>>; // "COIN:address" -> sessionIds
82
+ private paymentCallbacks: Set<PaymentCallback>;
83
+ private isInitialized: boolean;
84
+
85
+ constructor() {
86
+ this.blockbookSockets = {};
87
+ this.sessions = new Map();
88
+ this.addressToSessions = new Map();
89
+ this.paymentCallbacks = new Set();
90
+ this.isInitialized = false;
91
+ }
92
+
93
+ /**
94
+ * Initialize the subscription manager and blockbook connections
95
+ */
96
+ async init(): Promise<void> {
97
+ const tag = TAG + ' | init | ';
98
+ try {
99
+ log.info(tag, 'Initializing Subscription Manager');
100
+
101
+ // Initialize blockbook module
102
+ await blockbook.init();
103
+
104
+ // Get blockbook websocket clients
105
+ this.blockbookSockets = blockbook.getBlockbookSockets();
106
+
107
+ const availableCoins = Object.keys(this.blockbookSockets);
108
+ log.info(tag, `Blockbook sockets available for: ${availableCoins.join(', ')}`);
109
+
110
+ // Setup listeners for each coin's blockbook socket
111
+ this.setupBlockbookListeners();
112
+
113
+ this.isInitialized = true;
114
+ log.info(tag, '✅ Subscription Manager initialized successfully');
115
+
116
+ return;
117
+ } catch (error) {
118
+ log.error(tag, 'Failed to initialize Subscription Manager:', error);
119
+ throw error;
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Register a callback to be called when payment notifications are received
125
+ */
126
+ onPayment(callback: PaymentCallback): void {
127
+ this.paymentCallbacks.add(callback);
128
+ }
129
+
130
+ /**
131
+ * Remove a payment callback
132
+ */
133
+ offPayment(callback: PaymentCallback): void {
134
+ this.paymentCallbacks.delete(callback);
135
+ }
136
+
137
+ /**
138
+ * Setup listeners for blockbook websocket notifications
139
+ */
140
+ private setupBlockbookListeners(): void {
141
+ const tag = TAG + ' | setupBlockbookListeners | ';
142
+
143
+ for (const [coin, socket] of Object.entries(this.blockbookSockets)) {
144
+ if (!socket) continue;
145
+
146
+ try {
147
+ log.info(tag, `Setting up listener for ${coin}`);
148
+
149
+ // Listen for address transaction notifications
150
+ socket.on('notification', (data: any) => {
151
+ this.handleBlockbookNotification(coin, data);
152
+ });
153
+
154
+ socket.on('error', (error: any) => {
155
+ log.error(tag, `Blockbook socket error for ${coin}:`, error);
156
+ });
157
+
158
+ socket.on('connect', () => {
159
+ log.info(tag, `Blockbook socket connected for ${coin}`);
160
+ });
161
+
162
+ socket.on('disconnect', () => {
163
+ log.warn(tag, `Blockbook socket disconnected for ${coin}`);
164
+ });
165
+
166
+ } catch (error) {
167
+ log.error(tag, `Failed to setup listener for ${coin}:`, error);
168
+ }
169
+ }
170
+ }
171
+
172
+ /**
173
+ * Handle notification from blockbook about address activity
174
+ */
175
+ private handleBlockbookNotification(coin: string, data: any): void {
176
+ const tag = TAG + ' | handleBlockbookNotification | ';
177
+
178
+ try {
179
+ log.debug(tag, `Received notification for ${coin}:`, data);
180
+
181
+ // Extract address from notification
182
+ // Blockbook notification format: { address, tx: {...} }
183
+ const address = data.address;
184
+ if (!address) {
185
+ log.warn(tag, 'Notification missing address:', data);
186
+ return;
187
+ }
188
+
189
+ // Find all sessions subscribed to this address
190
+ const addressKey = `${coin}:${address}`;
191
+ const sessionIds = this.addressToSessions.get(addressKey);
192
+
193
+ if (!sessionIds || sessionIds.size === 0) {
194
+ log.debug(tag, `No subscribers for ${addressKey}`);
195
+ return;
196
+ }
197
+
198
+ log.info(tag, `💰 Payment detected on ${addressKey}, notifying ${sessionIds.size} session(s)`);
199
+
200
+ // Notify each subscribed session
201
+ for (const sessionId of sessionIds) {
202
+ const session = this.sessions.get(sessionId);
203
+ if (!session) continue;
204
+
205
+ const notification: PaymentNotification = {
206
+ sessionId,
207
+ username: session.username,
208
+ coin,
209
+ address,
210
+ txid: data.tx?.txid || data.txid || 'unknown',
211
+ amount: data.tx?.value || data.value || '0',
212
+ confirmations: data.tx?.confirmations || 0,
213
+ timestamp: Date.now()
214
+ };
215
+
216
+ // Call all registered callbacks
217
+ this.paymentCallbacks.forEach(callback => {
218
+ try {
219
+ callback(notification);
220
+ } catch (error) {
221
+ log.error(tag, 'Error in payment callback:', error);
222
+ }
223
+ });
224
+ }
225
+
226
+ } catch (error) {
227
+ log.error(tag, 'Error handling blockbook notification:', error);
228
+ }
229
+ }
230
+
231
+ /**
232
+ * Subscribe a session to monitor specific addresses
233
+ */
234
+ async subscribe(request: SubscriptionRequest): Promise<{ success: boolean; message: string }> {
235
+ const tag = TAG + ' | subscribe | ';
236
+
237
+ try {
238
+ if (!this.isInitialized) {
239
+ throw new Error('Subscription Manager not initialized');
240
+ }
241
+
242
+ const { sessionId, username, coin, addresses } = request;
243
+ const coinUpper = coin.toUpperCase();
244
+ const blockbookSocket = this.blockbookSockets[coinUpper];
245
+
246
+ if (!blockbookSocket) {
247
+ return {
248
+ success: false,
249
+ message: `Blockbook not available for ${coinUpper}`
250
+ };
251
+ }
252
+
253
+ log.info(tag, `Subscribing session ${sessionId} (${username}) to ${addresses.length} addresses on ${coinUpper}`);
254
+
255
+ // Subscribe to blockbook for each address
256
+ const subscribedAddresses: string[] = [];
257
+ for (const address of addresses) {
258
+ try {
259
+ // Subscribe via blockbook-client
260
+ await blockbookSocket.subscribeAddresses([address]);
261
+
262
+ // Track subscription
263
+ const addressKey = `${coinUpper}:${address}`;
264
+ if (!this.addressToSessions.has(addressKey)) {
265
+ this.addressToSessions.set(addressKey, new Set());
266
+ }
267
+ this.addressToSessions.get(addressKey)!.add(sessionId);
268
+ subscribedAddresses.push(address);
269
+
270
+ log.debug(tag, `✅ Subscribed to ${addressKey}`);
271
+ } catch (error) {
272
+ log.error(tag, `Failed to subscribe to ${address}:`, error);
273
+ }
274
+ }
275
+
276
+ // Store or merge session info
277
+ const existing = this.sessions.get(sessionId);
278
+ if (existing && existing.coin === coinUpper) {
279
+ // Merge addresses
280
+ existing.addresses = Array.from(new Set([...existing.addresses, ...subscribedAddresses]));
281
+ } else {
282
+ // New session subscription
283
+ this.sessions.set(sessionId, {
284
+ sessionId,
285
+ username,
286
+ coin: coinUpper,
287
+ addresses: subscribedAddresses,
288
+ subscribedAt: Date.now()
289
+ });
290
+ }
291
+
292
+ log.info(tag, `✅ Successfully subscribed session ${sessionId} to ${subscribedAddresses.length} addresses on ${coinUpper}`);
293
+
294
+ return {
295
+ success: true,
296
+ message: `Subscribed to ${subscribedAddresses.length} addresses on ${coinUpper}`
297
+ };
298
+
299
+ } catch (error: any) {
300
+ log.error(tag, 'Error subscribing:', error);
301
+ return {
302
+ success: false,
303
+ message: error.message || 'Unknown error'
304
+ };
305
+ }
306
+ }
307
+
308
+ /**
309
+ * Unsubscribe a session from all its addresses
310
+ */
311
+ async unsubscribe(sessionId: string): Promise<void> {
312
+ const tag = TAG + ' | unsubscribe | ';
313
+
314
+ try {
315
+ const session = this.sessions.get(sessionId);
316
+ if (!session) {
317
+ log.debug(tag, `No subscription found for session ${sessionId}`);
318
+ return;
319
+ }
320
+
321
+ log.info(tag, `Unsubscribing session ${sessionId} from ${session.addresses.length} addresses on ${session.coin}`);
322
+
323
+ const blockbookSocket = this.blockbookSockets[session.coin];
324
+
325
+ // Remove from address mappings and unsubscribe if no more sessions
326
+ for (const address of session.addresses) {
327
+ const addressKey = `${session.coin}:${address}`;
328
+ const sessions = this.addressToSessions.get(addressKey);
329
+
330
+ if (sessions) {
331
+ sessions.delete(sessionId);
332
+
333
+ // If no more sessions for this address, unsubscribe from blockbook
334
+ if (sessions.size === 0) {
335
+ try {
336
+ if (blockbookSocket) {
337
+ await blockbookSocket.unsubscribeAddresses([address]);
338
+ }
339
+ this.addressToSessions.delete(addressKey);
340
+ log.debug(tag, `Unsubscribed from ${addressKey} (no more sessions)`);
341
+ } catch (error) {
342
+ log.error(tag, `Failed to unsubscribe from ${addressKey}:`, error);
343
+ }
344
+ }
345
+ }
346
+ }
347
+
348
+ // Remove session
349
+ this.sessions.delete(sessionId);
350
+
351
+ log.info(tag, `✅ Successfully cleaned up session ${sessionId}`);
352
+
353
+ } catch (error) {
354
+ log.error(tag, 'Error unsubscribing:', error);
355
+ }
356
+ }
357
+
358
+ /**
359
+ * Get subscription statistics
360
+ */
361
+ getStats(): SubscriptionStats {
362
+ const sessionsByUsername: Record<string, number> = {};
363
+ const addressesByCoin: Record<string, number> = {};
364
+ const sessions: SessionSubscription[] = [];
365
+
366
+ for (const session of this.sessions.values()) {
367
+ sessionsByUsername[session.username] = (sessionsByUsername[session.username] || 0) + 1;
368
+ addressesByCoin[session.coin] = (addressesByCoin[session.coin] || 0) + session.addresses.length;
369
+ sessions.push(session);
370
+ }
371
+
372
+ return {
373
+ totalSessions: this.sessions.size,
374
+ totalAddresses: this.addressToSessions.size,
375
+ sessionsByUsername,
376
+ addressesByCoin,
377
+ sessions
378
+ };
379
+ }
380
+
381
+ /**
382
+ * Get available coins for subscription
383
+ */
384
+ getAvailableCoins(): string[] {
385
+ return Object.keys(this.blockbookSockets);
386
+ }
387
+
388
+ /**
389
+ * Check if initialized
390
+ */
391
+ isReady(): boolean {
392
+ return this.isInitialized;
393
+ }
394
+
395
+ /**
396
+ * Cleanup and shutdown
397
+ */
398
+ async shutdown(): Promise<void> {
399
+ const tag = TAG + ' | shutdown | ';
400
+ try {
401
+ log.info(tag, 'Shutting down Subscription Manager');
402
+
403
+ // Unsubscribe all sessions
404
+ const sessionIds = Array.from(this.sessions.keys());
405
+ for (const sessionId of sessionIds) {
406
+ await this.unsubscribe(sessionId);
407
+ }
408
+
409
+ // Clear callbacks
410
+ this.paymentCallbacks.clear();
411
+
412
+ this.isInitialized = false;
413
+ log.info(tag, '✅ Subscription Manager shut down successfully');
414
+ } catch (error) {
415
+ log.error(tag, 'Error during shutdown:', error);
416
+ }
417
+ }
418
+ }
419
+
420
+ // Export singleton instance
421
+ export default SubscriptionManager;
422
+
package/tsconfig.json ADDED
@@ -0,0 +1,19 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "module": "commonjs",
5
+ "lib": ["ES2020"],
6
+ "declaration": true,
7
+ "outDir": "./lib",
8
+ "rootDir": "./src",
9
+ "strict": false,
10
+ "esModuleInterop": true,
11
+ "skipLibCheck": true,
12
+ "forceConsistentCasingInFileNames": true,
13
+ "resolveJsonModule": true,
14
+ "moduleResolution": "node"
15
+ },
16
+ "include": ["src/**/*"],
17
+ "exclude": ["node_modules", "**/*.test.ts", "lib"]
18
+ }
19
+