@waiaas/daemon 2.5.0-rc.1 → 2.6.0-rc

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.
Files changed (142) hide show
  1. package/dist/api/middleware/error-handler.d.ts +1 -1
  2. package/dist/api/middleware/error-handler.js +2 -2
  3. package/dist/api/middleware/error-handler.js.map +1 -1
  4. package/dist/api/routes/admin.d.ts.map +1 -1
  5. package/dist/api/routes/admin.js +6 -30
  6. package/dist/api/routes/admin.js.map +1 -1
  7. package/dist/api/routes/incoming.d.ts +40 -0
  8. package/dist/api/routes/incoming.d.ts.map +1 -0
  9. package/dist/api/routes/incoming.js +281 -0
  10. package/dist/api/routes/incoming.js.map +1 -0
  11. package/dist/api/routes/openapi-schemas.d.ts +243 -2
  12. package/dist/api/routes/openapi-schemas.d.ts.map +1 -1
  13. package/dist/api/routes/openapi-schemas.js +77 -0
  14. package/dist/api/routes/openapi-schemas.js.map +1 -1
  15. package/dist/api/routes/wallets.d.ts +4 -0
  16. package/dist/api/routes/wallets.d.ts.map +1 -1
  17. package/dist/api/routes/wallets.js +173 -1
  18. package/dist/api/routes/wallets.js.map +1 -1
  19. package/dist/api/routes/x402.js +1 -1
  20. package/dist/api/routes/x402.js.map +1 -1
  21. package/dist/api/server.d.ts +4 -0
  22. package/dist/api/server.d.ts.map +1 -1
  23. package/dist/api/server.js +12 -0
  24. package/dist/api/server.js.map +1 -1
  25. package/dist/infrastructure/config/loader.d.ts +43 -0
  26. package/dist/infrastructure/config/loader.d.ts.map +1 -1
  27. package/dist/infrastructure/config/loader.js +13 -1
  28. package/dist/infrastructure/config/loader.js.map +1 -1
  29. package/dist/infrastructure/database/index.d.ts +1 -1
  30. package/dist/infrastructure/database/index.d.ts.map +1 -1
  31. package/dist/infrastructure/database/index.js +1 -1
  32. package/dist/infrastructure/database/index.js.map +1 -1
  33. package/dist/infrastructure/database/migrate.d.ts +2 -2
  34. package/dist/infrastructure/database/migrate.d.ts.map +1 -1
  35. package/dist/infrastructure/database/migrate.js +83 -5
  36. package/dist/infrastructure/database/migrate.js.map +1 -1
  37. package/dist/infrastructure/database/schema.d.ts +381 -1
  38. package/dist/infrastructure/database/schema.d.ts.map +1 -1
  39. package/dist/infrastructure/database/schema.js +42 -2
  40. package/dist/infrastructure/database/schema.js.map +1 -1
  41. package/dist/infrastructure/settings/hot-reload.d.ts +9 -0
  42. package/dist/infrastructure/settings/hot-reload.d.ts.map +1 -1
  43. package/dist/infrastructure/settings/hot-reload.js +34 -5
  44. package/dist/infrastructure/settings/hot-reload.js.map +1 -1
  45. package/dist/infrastructure/settings/setting-keys.d.ts +2 -2
  46. package/dist/infrastructure/settings/setting-keys.d.ts.map +1 -1
  47. package/dist/infrastructure/settings/setting-keys.js +12 -3
  48. package/dist/infrastructure/settings/setting-keys.js.map +1 -1
  49. package/dist/lifecycle/daemon.d.ts +3 -1
  50. package/dist/lifecycle/daemon.d.ts.map +1 -1
  51. package/dist/lifecycle/daemon.js +84 -4
  52. package/dist/lifecycle/daemon.js.map +1 -1
  53. package/dist/notifications/channels/discord.d.ts.map +1 -1
  54. package/dist/notifications/channels/discord.js +17 -8
  55. package/dist/notifications/channels/discord.js.map +1 -1
  56. package/dist/notifications/channels/format-utils.d.ts +11 -0
  57. package/dist/notifications/channels/format-utils.d.ts.map +1 -0
  58. package/dist/notifications/channels/format-utils.js +19 -0
  59. package/dist/notifications/channels/format-utils.js.map +1 -0
  60. package/dist/notifications/channels/ntfy.d.ts.map +1 -1
  61. package/dist/notifications/channels/ntfy.js +15 -2
  62. package/dist/notifications/channels/ntfy.js.map +1 -1
  63. package/dist/notifications/channels/slack.d.ts.map +1 -1
  64. package/dist/notifications/channels/slack.js +16 -7
  65. package/dist/notifications/channels/slack.js.map +1 -1
  66. package/dist/notifications/channels/telegram.d.ts.map +1 -1
  67. package/dist/notifications/channels/telegram.js +17 -5
  68. package/dist/notifications/channels/telegram.js.map +1 -1
  69. package/dist/notifications/notification-service.d.ts +14 -0
  70. package/dist/notifications/notification-service.d.ts.map +1 -1
  71. package/dist/notifications/notification-service.js +83 -2
  72. package/dist/notifications/notification-service.js.map +1 -1
  73. package/dist/services/incoming/__tests__/incoming-tx-monitor-service.test.d.ts +11 -0
  74. package/dist/services/incoming/__tests__/incoming-tx-monitor-service.test.d.ts.map +1 -0
  75. package/dist/services/incoming/__tests__/incoming-tx-monitor-service.test.js +432 -0
  76. package/dist/services/incoming/__tests__/incoming-tx-monitor-service.test.js.map +1 -0
  77. package/dist/services/incoming/__tests__/incoming-tx-queue.test.d.ts +12 -0
  78. package/dist/services/incoming/__tests__/incoming-tx-queue.test.d.ts.map +1 -0
  79. package/dist/services/incoming/__tests__/incoming-tx-queue.test.js +419 -0
  80. package/dist/services/incoming/__tests__/incoming-tx-queue.test.js.map +1 -0
  81. package/dist/services/incoming/__tests__/incoming-tx-workers.test.d.ts +14 -0
  82. package/dist/services/incoming/__tests__/incoming-tx-workers.test.d.ts.map +1 -0
  83. package/dist/services/incoming/__tests__/incoming-tx-workers.test.js +452 -0
  84. package/dist/services/incoming/__tests__/incoming-tx-workers.test.js.map +1 -0
  85. package/dist/services/incoming/__tests__/integration-pitfall.test.d.ts +17 -0
  86. package/dist/services/incoming/__tests__/integration-pitfall.test.d.ts.map +1 -0
  87. package/dist/services/incoming/__tests__/integration-pitfall.test.js +653 -0
  88. package/dist/services/incoming/__tests__/integration-pitfall.test.js.map +1 -0
  89. package/dist/services/incoming/__tests__/integration-resilience.test.d.ts +14 -0
  90. package/dist/services/incoming/__tests__/integration-resilience.test.d.ts.map +1 -0
  91. package/dist/services/incoming/__tests__/integration-resilience.test.js +501 -0
  92. package/dist/services/incoming/__tests__/integration-resilience.test.js.map +1 -0
  93. package/dist/services/incoming/__tests__/integration-wiring.test.d.ts +15 -0
  94. package/dist/services/incoming/__tests__/integration-wiring.test.d.ts.map +1 -0
  95. package/dist/services/incoming/__tests__/integration-wiring.test.js +355 -0
  96. package/dist/services/incoming/__tests__/integration-wiring.test.js.map +1 -0
  97. package/dist/services/incoming/__tests__/safety-rules.test.d.ts +10 -0
  98. package/dist/services/incoming/__tests__/safety-rules.test.d.ts.map +1 -0
  99. package/dist/services/incoming/__tests__/safety-rules.test.js +165 -0
  100. package/dist/services/incoming/__tests__/safety-rules.test.js.map +1 -0
  101. package/dist/services/incoming/__tests__/subscription-multiplexer.test.d.ts +2 -0
  102. package/dist/services/incoming/__tests__/subscription-multiplexer.test.d.ts.map +1 -0
  103. package/dist/services/incoming/__tests__/subscription-multiplexer.test.js +267 -0
  104. package/dist/services/incoming/__tests__/subscription-multiplexer.test.js.map +1 -0
  105. package/dist/services/incoming/incoming-tx-monitor-service.d.ts +98 -0
  106. package/dist/services/incoming/incoming-tx-monitor-service.d.ts.map +1 -0
  107. package/dist/services/incoming/incoming-tx-monitor-service.js +336 -0
  108. package/dist/services/incoming/incoming-tx-monitor-service.js.map +1 -0
  109. package/dist/services/incoming/incoming-tx-queue.d.ts +52 -0
  110. package/dist/services/incoming/incoming-tx-queue.d.ts.map +1 -0
  111. package/dist/services/incoming/incoming-tx-queue.js +109 -0
  112. package/dist/services/incoming/incoming-tx-queue.js.map +1 -0
  113. package/dist/services/incoming/incoming-tx-workers.d.ts +89 -0
  114. package/dist/services/incoming/incoming-tx-workers.d.ts.map +1 -0
  115. package/dist/services/incoming/incoming-tx-workers.js +176 -0
  116. package/dist/services/incoming/incoming-tx-workers.js.map +1 -0
  117. package/dist/services/incoming/index.d.ts +14 -0
  118. package/dist/services/incoming/index.d.ts.map +1 -0
  119. package/dist/services/incoming/index.js +11 -0
  120. package/dist/services/incoming/index.js.map +1 -0
  121. package/dist/services/incoming/safety-rules.d.ts +70 -0
  122. package/dist/services/incoming/safety-rules.d.ts.map +1 -0
  123. package/dist/services/incoming/safety-rules.js +68 -0
  124. package/dist/services/incoming/safety-rules.js.map +1 -0
  125. package/dist/services/incoming/subscription-multiplexer.d.ts +87 -0
  126. package/dist/services/incoming/subscription-multiplexer.d.ts.map +1 -0
  127. package/dist/services/incoming/subscription-multiplexer.js +169 -0
  128. package/dist/services/incoming/subscription-multiplexer.js.map +1 -0
  129. package/dist/services/signing-sdk/approval-channel-router.d.ts +1 -1
  130. package/dist/services/signing-sdk/approval-channel-router.d.ts.map +1 -1
  131. package/dist/services/signing-sdk/approval-channel-router.js +2 -3
  132. package/dist/services/signing-sdk/approval-channel-router.js.map +1 -1
  133. package/dist/services/signing-sdk/channels/wallet-notification-channel.js +1 -1
  134. package/dist/services/signing-sdk/channels/wallet-notification-channel.js.map +1 -1
  135. package/dist/services/x402/x402-domain-policy.d.ts +6 -1
  136. package/dist/services/x402/x402-domain-policy.d.ts.map +1 -1
  137. package/dist/services/x402/x402-domain-policy.js +6 -2
  138. package/dist/services/x402/x402-domain-policy.js.map +1 -1
  139. package/package.json +4 -4
  140. package/public/admin/assets/index-D06O_cSo.js +1 -0
  141. package/public/admin/index.html +1 -1
  142. package/public/admin/assets/index-BLLOYSZp.js +0 -1
@@ -0,0 +1,653 @@
1
+ /**
2
+ * Integration tests for incoming TX monitoring pipeline pitfalls.
3
+ *
4
+ * Verifies that the 5 core pitfalls from design doc 76 Section 12 are
5
+ * defended when components work together:
6
+ *
7
+ * C-01: WebSocket listener leak (addWallet/removeWallet cycles)
8
+ * C-02: SQLite event loop contention (batch write + flush chunking)
9
+ * C-04: Duplicate event prevention (Map dedup + ON CONFLICT)
10
+ * C-05: Shutdown data loss (drain on stop())
11
+ * C-06: EVM reorg safety (block confirmation thresholds)
12
+ *
13
+ * These tests use real internal classes (IncomingTxQueue, SubscriptionMultiplexer,
14
+ * IncomingTxMonitorService) with mock boundaries (DB, IChainSubscriber).
15
+ */
16
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
17
+ import { IncomingTxQueue } from '../incoming-tx-queue.js';
18
+ import { SubscriptionMultiplexer, } from '../subscription-multiplexer.js';
19
+ import { IncomingTxMonitorService } from '../incoming-tx-monitor-service.js';
20
+ import { createConfirmationWorkerHandler, EVM_CONFIRMATION_THRESHOLDS, } from '../incoming-tx-workers.js';
21
+ // ---------------------------------------------------------------------------
22
+ // Mock generateId for deterministic UUIDs in flush
23
+ // ---------------------------------------------------------------------------
24
+ let idCounter = 0;
25
+ vi.mock('../../../infrastructure/database/id.js', () => ({
26
+ generateId: () => `uuid-${++idCounter}`,
27
+ }));
28
+ // ---------------------------------------------------------------------------
29
+ // Helpers: Transaction factory
30
+ // ---------------------------------------------------------------------------
31
+ let txCounter = 0;
32
+ function makeTx(overrides) {
33
+ txCounter++;
34
+ return {
35
+ id: '',
36
+ walletId: 'wallet-1',
37
+ chain: 'solana',
38
+ network: 'mainnet',
39
+ txHash: `tx-hash-${txCounter}`,
40
+ fromAddress: 'from-addr-1',
41
+ amount: '1000000',
42
+ tokenAddress: null,
43
+ status: 'DETECTED',
44
+ blockNumber: null,
45
+ detectedAt: Math.floor(Date.now() / 1000),
46
+ confirmedAt: null,
47
+ isSuspicious: false,
48
+ ...overrides,
49
+ };
50
+ }
51
+ function createMockSubscriber(chain = 'solana') {
52
+ let disconnectResolve = null;
53
+ return {
54
+ chain,
55
+ subscribe: vi.fn().mockResolvedValue(undefined),
56
+ unsubscribe: vi.fn().mockResolvedValue(undefined),
57
+ subscribedWallets: vi.fn().mockReturnValue([]),
58
+ connect: vi.fn().mockResolvedValue(undefined),
59
+ waitForDisconnect: vi.fn().mockImplementation(() => new Promise((resolve) => { disconnectResolve = resolve; })),
60
+ destroy: vi.fn().mockResolvedValue(undefined),
61
+ _triggerDisconnect() {
62
+ disconnectResolve?.();
63
+ disconnectResolve = null;
64
+ },
65
+ };
66
+ }
67
+ // ---------------------------------------------------------------------------
68
+ // Helpers: Mock better-sqlite3 Database
69
+ // ---------------------------------------------------------------------------
70
+ function createMockDb(changesOverride) {
71
+ let stmtRunCallIndex = 0;
72
+ const runCalls = [];
73
+ const mockStmt = {
74
+ run: (...args) => {
75
+ runCalls.push(args);
76
+ const changes = changesOverride
77
+ ? changesOverride(stmtRunCallIndex++)
78
+ : 1;
79
+ return { changes };
80
+ },
81
+ get: vi.fn().mockReturnValue(undefined),
82
+ all: vi.fn().mockReturnValue([]),
83
+ };
84
+ const mockDb = {
85
+ prepare: vi.fn().mockReturnValue(mockStmt),
86
+ transaction: vi.fn((fn) => {
87
+ return (batch) => fn(batch);
88
+ }),
89
+ exec: vi.fn(),
90
+ };
91
+ return {
92
+ db: mockDb,
93
+ getRunCalls: () => runCalls,
94
+ mockStmt,
95
+ mockDb,
96
+ resetCalls: () => {
97
+ runCalls.length = 0;
98
+ stmtRunCallIndex = 0;
99
+ },
100
+ };
101
+ }
102
+ // ---------------------------------------------------------------------------
103
+ // Helpers: Config + mock factories for IncomingTxMonitorService
104
+ // ---------------------------------------------------------------------------
105
+ function makeConfig(overrides = {}) {
106
+ return {
107
+ enabled: true,
108
+ pollIntervalSec: 30,
109
+ retentionDays: 90,
110
+ dustThresholdUsd: 0.01,
111
+ amountMultiplier: 10,
112
+ cooldownMinutes: 5,
113
+ ...overrides,
114
+ };
115
+ }
116
+ function createMockEventBus() {
117
+ return {
118
+ emit: vi.fn().mockReturnValue(true),
119
+ on: vi.fn(),
120
+ removeAllListeners: vi.fn(),
121
+ listenerCount: vi.fn().mockReturnValue(0),
122
+ };
123
+ }
124
+ function createMockWorkers() {
125
+ return {
126
+ register: vi.fn(),
127
+ startAll: vi.fn(),
128
+ stopAll: vi.fn(),
129
+ size: 0,
130
+ isRunning: vi.fn().mockReturnValue(false),
131
+ };
132
+ }
133
+ // ===========================================================================
134
+ // Section 1: C-01 - WebSocket Listener Leak
135
+ // ===========================================================================
136
+ describe('C-01: WebSocket Listener Leak', () => {
137
+ let mockSubscribers;
138
+ let subscriberFactory;
139
+ let onTransaction;
140
+ beforeEach(() => {
141
+ txCounter = 0;
142
+ idCounter = 0;
143
+ mockSubscribers = [];
144
+ subscriberFactory = vi.fn().mockImplementation((chain) => {
145
+ const sub = createMockSubscriber(chain);
146
+ mockSubscribers.push(sub);
147
+ return sub;
148
+ });
149
+ onTransaction = vi.fn();
150
+ });
151
+ function createMultiplexer(overrides = {}) {
152
+ return new SubscriptionMultiplexer({
153
+ subscriberFactory,
154
+ onTransaction,
155
+ reconnectConfig: {
156
+ initialDelayMs: 100,
157
+ maxDelayMs: 200,
158
+ maxAttempts: 3,
159
+ jitterFactor: 0,
160
+ pollingFallbackThreshold: 2,
161
+ },
162
+ ...overrides,
163
+ });
164
+ }
165
+ it('C-01: 10 wallets added then all removed -- subscriberFactory called once, 10 unsubscribes, 1 destroy, 0 active connections', async () => {
166
+ const mux = createMultiplexer();
167
+ // Add 10 wallets to the same chain:network
168
+ for (let i = 0; i < 10; i++) {
169
+ await mux.addWallet('solana', 'mainnet', `w${i}`, `addr${i}`);
170
+ }
171
+ // Only one subscriber should have been created for solana:mainnet
172
+ expect(subscriberFactory).toHaveBeenCalledTimes(1);
173
+ const sub = mockSubscribers[0];
174
+ // Remove all 10 wallets
175
+ for (let i = 0; i < 10; i++) {
176
+ await mux.removeWallet('solana', 'mainnet', `w${i}`);
177
+ }
178
+ // 10 unsubscribe calls (one per wallet)
179
+ expect(sub.unsubscribe).toHaveBeenCalledTimes(10);
180
+ // 1 destroy call (when last wallet removed)
181
+ expect(sub.destroy).toHaveBeenCalledTimes(1);
182
+ // No active connections remain
183
+ expect(mux.getActiveConnections()).toEqual([]);
184
+ });
185
+ it('C-01: 10 add/remove cycles create 10 subscribers, all previous destroyed', async () => {
186
+ const mux = createMultiplexer();
187
+ for (let cycle = 0; cycle < 10; cycle++) {
188
+ await mux.addWallet('solana', 'mainnet', 'w1', 'addr1');
189
+ await mux.removeWallet('solana', 'mainnet', 'w1');
190
+ }
191
+ // Each cycle creates a new subscriber (total 10)
192
+ expect(subscriberFactory).toHaveBeenCalledTimes(10);
193
+ // All 10 subscribers should have destroy() called
194
+ for (const sub of mockSubscribers) {
195
+ expect(sub.destroy).toHaveBeenCalledTimes(1);
196
+ }
197
+ // No active connections
198
+ expect(mux.getActiveConnections()).toHaveLength(0);
199
+ });
200
+ it('C-01: stopAll on 5 wallets triggers 5 unsubscribes + 1 destroy, re-add creates fresh subscriber', async () => {
201
+ const mux = createMultiplexer();
202
+ // Add 5 wallets to the same chain:network
203
+ for (let i = 0; i < 5; i++) {
204
+ await mux.addWallet('solana', 'mainnet', `w${i}`, `addr${i}`);
205
+ }
206
+ const firstSub = mockSubscribers[0];
207
+ await mux.stopAll();
208
+ // 5 unsubscribe calls + 1 destroy
209
+ expect(firstSub.unsubscribe).toHaveBeenCalledTimes(5);
210
+ expect(firstSub.destroy).toHaveBeenCalledTimes(1);
211
+ expect(mux.getActiveConnections()).toHaveLength(0);
212
+ // Re-add a wallet -- should create a fresh subscriber (not reuse destroyed one)
213
+ await mux.addWallet('solana', 'mainnet', 'w-new', 'addr-new');
214
+ expect(subscriberFactory).toHaveBeenCalledTimes(2); // original + new
215
+ expect(mockSubscribers).toHaveLength(2);
216
+ const secondSub = mockSubscribers[1];
217
+ expect(secondSub).not.toBe(firstSub);
218
+ expect(secondSub.connect).toHaveBeenCalledTimes(1);
219
+ await mux.stopAll();
220
+ });
221
+ });
222
+ // ===========================================================================
223
+ // Section 2: C-02 - SQLite Event Loop Contention
224
+ // ===========================================================================
225
+ describe('C-02: SQLite Event Loop Contention', () => {
226
+ let queue;
227
+ beforeEach(() => {
228
+ queue = new IncomingTxQueue();
229
+ txCounter = 0;
230
+ idCounter = 0;
231
+ });
232
+ it('C-02: 500 rapid pushes flush in MAX_BATCH=100 chunks (5 flush calls), no data lost', () => {
233
+ const mock = createMockDb();
234
+ // Simulate burst from WebSocket: 500 rapid pushes
235
+ for (let i = 0; i < 500; i++) {
236
+ queue.push(makeTx());
237
+ }
238
+ expect(queue.size).toBe(500);
239
+ // Flush repeatedly until empty, collecting results
240
+ const allInserted = [];
241
+ let flushCount = 0;
242
+ while (queue.size > 0) {
243
+ const batch = queue.flush(mock.db);
244
+ allInserted.push(...batch);
245
+ flushCount++;
246
+ }
247
+ // 500 / 100 = 5 flush cycles
248
+ expect(flushCount).toBe(5);
249
+ // All 500 items flushed with no data loss
250
+ expect(allInserted).toHaveLength(500);
251
+ // Queue is now empty
252
+ expect(queue.size).toBe(0);
253
+ // 500 stmt.run() calls total
254
+ expect(mock.getRunCalls()).toHaveLength(500);
255
+ });
256
+ it('C-02: batch atomicity -- 50 transactions processed within single transaction() call', () => {
257
+ let transactionCallCount = 0;
258
+ const mockStmt = {
259
+ run: (..._args) => ({ changes: 1 }),
260
+ };
261
+ const mockDb = {
262
+ prepare: () => mockStmt,
263
+ transaction: (fn) => {
264
+ // Each call to the returned function represents one transaction() invocation
265
+ return (batch) => {
266
+ transactionCallCount++;
267
+ return fn(batch);
268
+ };
269
+ },
270
+ };
271
+ for (let i = 0; i < 50; i++) {
272
+ queue.push(makeTx());
273
+ }
274
+ queue.flush(mockDb);
275
+ // All 50 items processed in a single transaction() call
276
+ expect(transactionCallCount).toBe(1);
277
+ expect(queue.size).toBe(0);
278
+ });
279
+ it('C-02: 100 rapid transactions via push+flush cycle all inserted without duplicates', () => {
280
+ const mock = createMockDb();
281
+ // Push 100 unique transactions
282
+ for (let i = 0; i < 100; i++) {
283
+ queue.push(makeTx());
284
+ }
285
+ const result = queue.flush(mock.db);
286
+ // Exactly 100 items flushed (fits in single MAX_BATCH)
287
+ expect(result).toHaveLength(100);
288
+ expect(queue.size).toBe(0);
289
+ // All IDs are unique
290
+ const ids = new Set(result.map((tx) => tx.id));
291
+ expect(ids.size).toBe(100);
292
+ });
293
+ });
294
+ // ===========================================================================
295
+ // Section 3: C-04 - Duplicate Event Prevention
296
+ // ===========================================================================
297
+ describe('C-04: Duplicate Event Prevention', () => {
298
+ let queue;
299
+ beforeEach(() => {
300
+ queue = new IncomingTxQueue();
301
+ txCounter = 0;
302
+ idCounter = 0;
303
+ });
304
+ it('C-04: same txHash:walletId pushed 10 times -- queue.size === 1, 1 DB insert', () => {
305
+ const mock = createMockDb();
306
+ // Push same transaction 10 times
307
+ for (let i = 0; i < 10; i++) {
308
+ queue.push(makeTx({ txHash: 'dup-hash', walletId: 'w1' }));
309
+ }
310
+ // Queue-level dedup: only 1 entry
311
+ expect(queue.size).toBe(1);
312
+ const result = queue.flush(mock.db);
313
+ // Only 1 DB insert
314
+ expect(mock.getRunCalls()).toHaveLength(1);
315
+ expect(result).toHaveLength(1);
316
+ });
317
+ it('C-04: 5 unique + 5 duplicates of first -- queue.size === 5, 5 DB inserts', () => {
318
+ const mock = createMockDb();
319
+ const baseTx = makeTx({ txHash: 'tx-0', walletId: 'w1' });
320
+ queue.push(baseTx);
321
+ // 4 more unique transactions
322
+ for (let i = 1; i < 5; i++) {
323
+ queue.push(makeTx({ txHash: `tx-${i}`, walletId: 'w1' }));
324
+ }
325
+ // 5 duplicates of the first
326
+ for (let i = 0; i < 5; i++) {
327
+ queue.push(makeTx({ txHash: 'tx-0', walletId: 'w1' }));
328
+ }
329
+ expect(queue.size).toBe(5);
330
+ const result = queue.flush(mock.db);
331
+ expect(result).toHaveLength(5);
332
+ expect(mock.getRunCalls()).toHaveLength(5);
333
+ });
334
+ it('C-04: DB-level dedup via ON CONFLICT -- changes=0 excludes from result', () => {
335
+ // Simulate DB ON CONFLICT skip: items 1 and 3 (0-indexed) return changes=0
336
+ const mock = createMockDb((idx) => (idx === 1 || idx === 3) ? 0 : 1);
337
+ for (let i = 0; i < 5; i++) {
338
+ queue.push(makeTx({ txHash: `unique-${i}`, walletId: 'w1' }));
339
+ }
340
+ const result = queue.flush(mock.db);
341
+ // 5 stmt.run() calls, but only 3 returned changes > 0
342
+ expect(mock.getRunCalls()).toHaveLength(5);
343
+ expect(result).toHaveLength(3);
344
+ });
345
+ it('C-04: end-to-end -- SubscriptionMultiplexer onTransaction routes to queue.push with dedup', async () => {
346
+ const mock = createMockDb();
347
+ const realQueue = new IncomingTxQueue();
348
+ const mux = new SubscriptionMultiplexer({
349
+ subscriberFactory: () => createMockSubscriber('solana'),
350
+ onTransaction: (tx) => {
351
+ realQueue.push(tx);
352
+ },
353
+ reconnectConfig: {
354
+ initialDelayMs: 100,
355
+ maxDelayMs: 200,
356
+ maxAttempts: 3,
357
+ jitterFactor: 0,
358
+ pollingFallbackThreshold: 2,
359
+ },
360
+ });
361
+ await mux.addWallet('solana', 'mainnet', 'w1', 'addr1');
362
+ // Simulate subscriber emitting transactions via the onTransaction callback
363
+ // The multiplexer wires onTransaction to subscriber.subscribe's 4th parameter.
364
+ // We manually invoke the callback that was passed to subscribe().
365
+ const subscriberMock = mux.getActiveConnections().length > 0 ? true : false;
366
+ expect(subscriberMock).toBe(true);
367
+ // Get the onTransaction callback from the subscribe mock call
368
+ // The multiplexer passes `this.deps.onTransaction` to subscriber.subscribe()
369
+ // Since our factory returns a mock, we can inspect what subscribe was called with
370
+ // Push duplicate transactions through the callback
371
+ const tx1 = makeTx({ txHash: 'dup-e2e', walletId: 'w1' });
372
+ const tx2 = makeTx({ txHash: 'dup-e2e', walletId: 'w1' });
373
+ // Call onTransaction directly (simulating subscriber callback)
374
+ realQueue.push(tx1);
375
+ realQueue.push(tx2);
376
+ // Map-level dedup: only 1 entry
377
+ expect(realQueue.size).toBe(1);
378
+ const result = realQueue.flush(mock.db);
379
+ expect(result).toHaveLength(1);
380
+ expect(result[0].txHash).toBe('dup-e2e');
381
+ await mux.stopAll();
382
+ });
383
+ });
384
+ // ===========================================================================
385
+ // Section 4: C-05 - Shutdown Data Loss Prevention
386
+ // ===========================================================================
387
+ describe('C-05: Shutdown Data Loss Prevention', () => {
388
+ let sqlite;
389
+ let eventBus;
390
+ let workers;
391
+ beforeEach(() => {
392
+ vi.clearAllMocks();
393
+ txCounter = 0;
394
+ idCounter = 0;
395
+ sqlite = createMockDb();
396
+ eventBus = createMockEventBus();
397
+ workers = createMockWorkers();
398
+ });
399
+ function createMonitorService(configOverrides = {}) {
400
+ return new IncomingTxMonitorService({
401
+ sqlite: sqlite.db,
402
+ db: {},
403
+ workers: workers,
404
+ eventBus: eventBus,
405
+ killSwitchService: null,
406
+ notificationService: null,
407
+ subscriberFactory: () => createMockSubscriber('solana'),
408
+ config: makeConfig(configOverrides),
409
+ });
410
+ }
411
+ it('C-05: 10 queued transactions all saved to DB on stop()', async () => {
412
+ // Return empty wallets list from DB for start()
413
+ sqlite.mockStmt.all.mockReturnValueOnce([]);
414
+ const service = createMonitorService();
415
+ await service.start();
416
+ // Access the real internal queue and push 10 transactions
417
+ const internalQueue = service.queue;
418
+ for (let i = 0; i < 10; i++) {
419
+ internalQueue.push(makeTx({ txHash: `stop-tx-${i}`, walletId: 'w1' }));
420
+ }
421
+ expect(internalQueue.size).toBe(10);
422
+ // stop() calls drain() which flushes all queued items
423
+ await service.stop();
424
+ // Verify all 10 transactions were inserted via stmt.run()
425
+ // Filter run calls to only INSERT calls (13 params: id + 12 data fields)
426
+ const insertCalls = sqlite.getRunCalls().filter((args) => args.length === 13);
427
+ expect(insertCalls).toHaveLength(10);
428
+ // Queue should be empty after drain
429
+ expect(internalQueue.size).toBe(0);
430
+ });
431
+ it('C-05: 250 queued transactions drain loops multiple flush cycles (100+100+50)', async () => {
432
+ sqlite.mockStmt.all.mockReturnValueOnce([]);
433
+ const service = createMonitorService();
434
+ await service.start();
435
+ const internalQueue = service.queue;
436
+ for (let i = 0; i < 250; i++) {
437
+ internalQueue.push(makeTx({ txHash: `drain-${i}`, walletId: 'w1' }));
438
+ }
439
+ expect(internalQueue.size).toBe(250);
440
+ await service.stop();
441
+ // All 250 should be flushed (in 3 cycles: 100 + 100 + 50)
442
+ const insertCalls = sqlite.getRunCalls().filter((args) => args.length === 13);
443
+ expect(insertCalls).toHaveLength(250);
444
+ expect(internalQueue.size).toBe(0);
445
+ });
446
+ it('C-05: 0 queued transactions -- empty drain is no-op, but stopAll() still called', async () => {
447
+ sqlite.mockStmt.all.mockReturnValueOnce([]);
448
+ const service = createMonitorService();
449
+ await service.start();
450
+ const internalQueue = service.queue;
451
+ expect(internalQueue.size).toBe(0);
452
+ // Spy on multiplexer stopAll to verify it is called
453
+ const multiplexer = service.multiplexer;
454
+ const stopAllSpy = vi.spyOn(multiplexer, 'stopAll');
455
+ await service.stop();
456
+ // No INSERT calls from drain (queue was empty)
457
+ const insertCalls = sqlite.getRunCalls().filter((args) => args.length === 13);
458
+ expect(insertCalls).toHaveLength(0);
459
+ // But multiplexer.stopAll() should still have been called
460
+ expect(stopAllSpy).toHaveBeenCalledTimes(1);
461
+ });
462
+ it('C-05: stop() with real queue wired to multiplexer.onTransaction -- end-to-end drain', async () => {
463
+ sqlite.mockStmt.all.mockReturnValueOnce([]);
464
+ const service = createMonitorService();
465
+ await service.start();
466
+ // The service wires multiplexer's onTransaction to queue.push internally.
467
+ // We simulate transactions arriving via the onTransaction callback.
468
+ const internalQueue = service.queue;
469
+ // Push directly to queue (same path as onTransaction callback)
470
+ for (let i = 0; i < 10; i++) {
471
+ internalQueue.push(makeTx({ txHash: `e2e-drain-${i}`, walletId: `w${i % 3}` }));
472
+ }
473
+ await service.stop();
474
+ const insertCalls = sqlite.getRunCalls().filter((args) => args.length === 13);
475
+ expect(insertCalls).toHaveLength(10);
476
+ // Verify walletId diversity in inserts (w0, w1, w2)
477
+ const walletIds = new Set(insertCalls.map((args) => args[1]));
478
+ expect(walletIds.size).toBe(3);
479
+ });
480
+ });
481
+ // ===========================================================================
482
+ // Section 5: C-06 - EVM Reorg Safety
483
+ // ===========================================================================
484
+ describe('C-06: EVM Reorg Safety', () => {
485
+ beforeEach(() => {
486
+ txCounter = 0;
487
+ idCounter = 0;
488
+ });
489
+ /**
490
+ * Create a mock SQLite for confirmation worker tests.
491
+ * Returns DETECTED rows from .all() and tracks UPDATE calls via .run().
492
+ */
493
+ function createConfirmationMockDb(detectedRows) {
494
+ const updateCalls = [];
495
+ let allCallCount = 0;
496
+ const mockDb = {
497
+ prepare: vi.fn().mockImplementation((sql) => {
498
+ if (sql.includes('SELECT')) {
499
+ return {
500
+ all: () => {
501
+ allCallCount++;
502
+ return detectedRows;
503
+ },
504
+ };
505
+ }
506
+ // UPDATE statement
507
+ return {
508
+ run: (...args) => {
509
+ updateCalls.push(args);
510
+ return { changes: 1 };
511
+ },
512
+ };
513
+ }),
514
+ };
515
+ return {
516
+ db: mockDb,
517
+ getUpdateCalls: () => updateCalls,
518
+ getAllCallCount: () => allCallCount,
519
+ };
520
+ }
521
+ it('C-06: EVM DETECTED tx at block 100, currentBlock=111 (11 confirms) -- NOT confirmed (mainnet threshold=12)', async () => {
522
+ const detectedRows = [
523
+ { id: 'tx-1', tx_hash: '0xabc', chain: 'ethereum', network: 'mainnet', block_number: 100 },
524
+ ];
525
+ const mockDb = createConfirmationMockDb(detectedRows);
526
+ const handler = createConfirmationWorkerHandler({
527
+ sqlite: mockDb.db,
528
+ getBlockNumber: async () => 111n,
529
+ checkSolanaFinalized: async () => false,
530
+ });
531
+ await handler();
532
+ // 111 - 100 = 11 confirmations < threshold 12 for mainnet
533
+ expect(mockDb.getUpdateCalls()).toHaveLength(0);
534
+ });
535
+ it('C-06: EVM DETECTED tx at block 100, currentBlock=112 (12 confirms) -- CONFIRMED (mainnet threshold=12)', async () => {
536
+ const detectedRows = [
537
+ { id: 'tx-1', tx_hash: '0xabc', chain: 'ethereum', network: 'mainnet', block_number: 100 },
538
+ ];
539
+ const mockDb = createConfirmationMockDb(detectedRows);
540
+ const handler = createConfirmationWorkerHandler({
541
+ sqlite: mockDb.db,
542
+ getBlockNumber: async () => 112n,
543
+ checkSolanaFinalized: async () => false,
544
+ });
545
+ await handler();
546
+ // 112 - 100 = 12 confirmations >= threshold 12 for mainnet
547
+ expect(mockDb.getUpdateCalls()).toHaveLength(1);
548
+ expect(mockDb.getUpdateCalls()[0][1]).toBe('tx-1');
549
+ });
550
+ it('C-06: two-cycle reorg simulation -- first tx confirmed, second tx waits for enough blocks', async () => {
551
+ // Cycle 1: tx-A at block 100, currentBlock=115 (15 confirms) -> CONFIRMED
552
+ const cycle1Rows = [
553
+ { id: 'tx-A', tx_hash: '0xA', chain: 'ethereum', network: 'mainnet', block_number: 100 },
554
+ ];
555
+ const mockDb1 = createConfirmationMockDb(cycle1Rows);
556
+ const handler1 = createConfirmationWorkerHandler({
557
+ sqlite: mockDb1.db,
558
+ getBlockNumber: async () => 115n,
559
+ });
560
+ await handler1();
561
+ expect(mockDb1.getUpdateCalls()).toHaveLength(1); // tx-A confirmed
562
+ // Cycle 2: tx-B at block 113, currentBlock=120 (7 confirms) -> NOT CONFIRMED (< 12)
563
+ const cycle2Rows = [
564
+ { id: 'tx-B', tx_hash: '0xB', chain: 'ethereum', network: 'mainnet', block_number: 113 },
565
+ ];
566
+ const mockDb2 = createConfirmationMockDb(cycle2Rows);
567
+ const handler2 = createConfirmationWorkerHandler({
568
+ sqlite: mockDb2.db,
569
+ getBlockNumber: async () => 120n,
570
+ });
571
+ await handler2();
572
+ // 120 - 113 = 7 < 12 -- tx-B NOT confirmed
573
+ expect(mockDb2.getUpdateCalls()).toHaveLength(0);
574
+ });
575
+ it('C-06: mixed Solana + EVM DETECTED transactions -- each chain uses its own confirmation method', async () => {
576
+ const detectedRows = [
577
+ { id: 'sol-tx', tx_hash: 'solSig123', chain: 'solana', network: 'mainnet', block_number: null },
578
+ { id: 'evm-tx', tx_hash: '0xevmHash', chain: 'ethereum', network: 'mainnet', block_number: 200 },
579
+ ];
580
+ const mockDb = createConfirmationMockDb(detectedRows);
581
+ let solanaCheckCalled = false;
582
+ let evmBlockNumberCalled = false;
583
+ const handler = createConfirmationWorkerHandler({
584
+ sqlite: mockDb.db,
585
+ getBlockNumber: async (chain, network) => {
586
+ evmBlockNumberCalled = true;
587
+ expect(chain).toBe('ethereum');
588
+ expect(network).toBe('mainnet');
589
+ return 220n; // 220 - 200 = 20 >= 12 -> CONFIRMED
590
+ },
591
+ checkSolanaFinalized: async (txHash) => {
592
+ solanaCheckCalled = true;
593
+ expect(txHash).toBe('solSig123');
594
+ return true; // finalized
595
+ },
596
+ });
597
+ await handler();
598
+ // Both chain-specific methods should have been called
599
+ expect(solanaCheckCalled).toBe(true);
600
+ expect(evmBlockNumberCalled).toBe(true);
601
+ // Both should be confirmed
602
+ const updates = mockDb.getUpdateCalls();
603
+ expect(updates).toHaveLength(2);
604
+ const confirmedIds = updates.map((args) => args[1]);
605
+ expect(confirmedIds).toContain('sol-tx');
606
+ expect(confirmedIds).toContain('evm-tx');
607
+ });
608
+ it('C-06: EVM block number cache prevents redundant RPC calls for same chain:network', async () => {
609
+ const detectedRows = [
610
+ { id: 'tx-1', tx_hash: '0x1', chain: 'ethereum', network: 'mainnet', block_number: 100 },
611
+ { id: 'tx-2', tx_hash: '0x2', chain: 'ethereum', network: 'mainnet', block_number: 105 },
612
+ { id: 'tx-3', tx_hash: '0x3', chain: 'ethereum', network: 'mainnet', block_number: 110 },
613
+ ];
614
+ const mockDb = createConfirmationMockDb(detectedRows);
615
+ let rpcCallCount = 0;
616
+ const handler = createConfirmationWorkerHandler({
617
+ sqlite: mockDb.db,
618
+ getBlockNumber: async () => {
619
+ rpcCallCount++;
620
+ return 130n; // All 3 txs will be confirmed (>= 12 confirmations for each)
621
+ },
622
+ });
623
+ await handler();
624
+ // Block number cache: should only call getBlockNumber once for ethereum:mainnet
625
+ expect(rpcCallCount).toBe(1);
626
+ // All 3 should be confirmed
627
+ expect(mockDb.getUpdateCalls()).toHaveLength(3);
628
+ });
629
+ it('C-06: EVM network-specific threshold -- polygon-mainnet uses 128, not default 12', async () => {
630
+ const detectedRows = [
631
+ { id: 'poly-tx', tx_hash: '0xpoly', chain: 'ethereum', network: 'polygon-mainnet', block_number: 1000 },
632
+ ];
633
+ const mockDb = createConfirmationMockDb(detectedRows);
634
+ // polygon-mainnet threshold is 128
635
+ expect(EVM_CONFIRMATION_THRESHOLDS['polygon-mainnet']).toBe(128);
636
+ // 1127 - 1000 = 127 < 128 -- NOT confirmed
637
+ const handler1 = createConfirmationWorkerHandler({
638
+ sqlite: mockDb.db,
639
+ getBlockNumber: async () => 1127n,
640
+ });
641
+ await handler1();
642
+ expect(mockDb.getUpdateCalls()).toHaveLength(0);
643
+ // 1128 - 1000 = 128 >= 128 -- CONFIRMED
644
+ const mockDb2 = createConfirmationMockDb(detectedRows);
645
+ const handler2 = createConfirmationWorkerHandler({
646
+ sqlite: mockDb2.db,
647
+ getBlockNumber: async () => 1128n,
648
+ });
649
+ await handler2();
650
+ expect(mockDb2.getUpdateCalls()).toHaveLength(1);
651
+ });
652
+ });
653
+ //# sourceMappingURL=integration-pitfall.test.js.map