@meshconnect/uwc-core 1.10.4 → 2.1.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.
Files changed (51) hide show
  1. package/dist/events.d.ts +46 -2
  2. package/dist/events.d.ts.map +1 -1
  3. package/dist/events.js +2 -0
  4. package/dist/events.js.map +1 -1
  5. package/dist/observability/instrument-handoff.d.ts +8 -1
  6. package/dist/observability/instrument-handoff.d.ts.map +1 -1
  7. package/dist/observability/instrument-handoff.js +9 -2
  8. package/dist/observability/instrument-handoff.js.map +1 -1
  9. package/dist/observability/lifecycle-bridge.d.ts +31 -13
  10. package/dist/observability/lifecycle-bridge.d.ts.map +1 -1
  11. package/dist/observability/lifecycle-bridge.js +194 -92
  12. package/dist/observability/lifecycle-bridge.js.map +1 -1
  13. package/dist/observability/telemetry.d.ts +15 -1
  14. package/dist/observability/telemetry.d.ts.map +1 -1
  15. package/dist/observability/telemetry.js +15 -0
  16. package/dist/observability/telemetry.js.map +1 -1
  17. package/dist/services/connection-service.d.ts +109 -0
  18. package/dist/services/connection-service.d.ts.map +1 -1
  19. package/dist/services/connection-service.js +407 -22
  20. package/dist/services/connection-service.js.map +1 -1
  21. package/dist/services/network-switch-service.d.ts.map +1 -1
  22. package/dist/services/network-switch-service.js +4 -1
  23. package/dist/services/network-switch-service.js.map +1 -1
  24. package/dist/services/signature-service.d.ts +4 -3
  25. package/dist/services/signature-service.d.ts.map +1 -1
  26. package/dist/services/signature-service.js +2 -1
  27. package/dist/services/signature-service.js.map +1 -1
  28. package/dist/universal-wallet-connector.d.ts +15 -3
  29. package/dist/universal-wallet-connector.d.ts.map +1 -1
  30. package/dist/universal-wallet-connector.js +31 -2
  31. package/dist/universal-wallet-connector.js.map +1 -1
  32. package/dist/version.d.ts +1 -1
  33. package/dist/version.d.ts.map +1 -1
  34. package/dist/version.js +1 -1
  35. package/dist/version.js.map +1 -1
  36. package/package.json +5 -5
  37. package/src/events.ts +51 -3
  38. package/src/observability/instrument-handoff.ts +8 -2
  39. package/src/observability/lifecycle-bridge.test.ts +316 -0
  40. package/src/observability/lifecycle-bridge.ts +240 -99
  41. package/src/observability/telemetry.ts +32 -1
  42. package/src/services/connection-service.reentrancy.test.ts +87 -0
  43. package/src/services/connection-service.test.ts +798 -1
  44. package/src/services/connection-service.ts +492 -39
  45. package/src/services/network-switch-service.test.ts +33 -0
  46. package/src/services/network-switch-service.ts +4 -1
  47. package/src/services/signature-service.test.ts +9 -2
  48. package/src/services/signature-service.ts +4 -3
  49. package/src/universal-wallet-connector.observability.test.ts +126 -3
  50. package/src/universal-wallet-connector.ts +43 -4
  51. package/src/version.ts +1 -1
@@ -55,6 +55,82 @@ describe('attachLifecycle', () => {
55
55
  expect(got.walletSessionExpired).toEqual([routing])
56
56
  })
57
57
 
58
+ it('forwards real accounts from subscribeAccounts to onAccountsChanged (a separate stream from telemetry)', () => {
59
+ const em = new EventManager()
60
+ let emitAccounts!: (accounts: string[]) => void
61
+ const seen: string[][] = []
62
+
63
+ attachLifecycle({
64
+ eventManager: em,
65
+ subscribe: () => () => {},
66
+ subscribeAccounts: l => {
67
+ emitAccounts = l
68
+ return () => {}
69
+ },
70
+ onAccountsChanged: accounts => seen.push(accounts),
71
+ getRouting: () => routing,
72
+ connectionMode: 'injected',
73
+ namespace: 'eip155'
74
+ })
75
+
76
+ emitAccounts(['0xNEW'])
77
+ expect(seen).toEqual([['0xNEW']])
78
+ })
79
+
80
+ it('attaches subscribeAccounts even when the connector has NO telemetry subscribe (the Tron shape today)', () => {
81
+ // TronConnector implements onAccountsChanged but not onLifecycleEvent —
82
+ // the two streams must be independently attachable.
83
+ const em = new EventManager()
84
+ const unavail: unknown[] = []
85
+ em.on('lifecycleTelemetryUnavailable', d => unavail.push(d))
86
+ let emitAccounts!: (accounts: string[]) => void
87
+ const seen: string[][] = []
88
+
89
+ attachLifecycle({
90
+ eventManager: em,
91
+ subscribe: undefined,
92
+ subscribeAccounts: l => {
93
+ emitAccounts = l
94
+ return () => {}
95
+ },
96
+ onAccountsChanged: accounts => seen.push(accounts),
97
+ getRouting: () => routing,
98
+ connectionMode: 'injected',
99
+ namespace: 'tron'
100
+ })
101
+
102
+ emitAccounts(['TSwitched'])
103
+ expect(seen).toEqual([['TSwitched']])
104
+ // No telemetry surface exists for this connector, but that's a SEPARATE
105
+ // concern from account-sync succeeding — no unavailable signal fires
106
+ // just because the account stream alone is present.
107
+ expect(unavail).toHaveLength(0)
108
+ })
109
+
110
+ it('teardown unsubscribes both streams', () => {
111
+ const em = new EventManager()
112
+ let lifecycleUnsubbed = false
113
+ let accountsUnsubbed = false
114
+
115
+ const teardown = attachLifecycle({
116
+ eventManager: em,
117
+ subscribe: () => () => {
118
+ lifecycleUnsubbed = true
119
+ },
120
+ subscribeAccounts: () => () => {
121
+ accountsUnsubbed = true
122
+ },
123
+ onAccountsChanged: () => {},
124
+ getRouting: () => routing,
125
+ connectionMode: 'injected',
126
+ namespace: 'eip155'
127
+ })
128
+
129
+ teardown()
130
+ expect(lifecycleUnsubbed).toBe(true)
131
+ expect(accountsUnsubbed).toBe(true)
132
+ })
133
+
58
134
  it('a throwing getRouting never escapes into the wallet emitter dispatch', () => {
59
135
  // Wallet SDKs dispatch listeners unguarded (e.g. TonConnect's bare
60
136
  // forEach) — a throw from our listener would abort delivery to the
@@ -152,6 +228,210 @@ describe('attachLifecycle', () => {
152
228
  expect(seen).toHaveLength(1)
153
229
  })
154
230
 
231
+ it('suppresses an accountsChanged echo that matches the address a UWC switch just applied', () => {
232
+ let t = 1000
233
+ const em = new EventManager()
234
+ const seen: string[][] = []
235
+ let emitAccounts!: (accounts: string[]) => void
236
+
237
+ attachLifecycle({
238
+ eventManager: em,
239
+ subscribe: () => () => {},
240
+ subscribeAccounts: l => {
241
+ emitAccounts = l
242
+ return () => {}
243
+ },
244
+ onAccountsChanged: accounts => seen.push(accounts),
245
+ getRouting: () => routing,
246
+ connectionMode: 'injected',
247
+ namespace: 'eip155',
248
+ now: () => t
249
+ })
250
+
251
+ em.emit('networkSwitched', {
252
+ network: { id: 'eip155:8453' } as any,
253
+ address: '0xNEW'
254
+ })
255
+ emitAccounts(['0xNEW'])
256
+ expect(seen).toHaveLength(0)
257
+
258
+ // Past the echo window, the pending-switch entry is gone — but the session
259
+ // already holds 0xNEW (the UWC switch applied it), so a re-fire of the SAME
260
+ // address is deduped (no redundant session write). A genuinely different
261
+ // address still propagates, proving the listener is live.
262
+ t = 1000 + 5000
263
+ emitAccounts(['0xNEW'])
264
+ expect(seen).toHaveLength(0)
265
+ emitAccounts(['0xOTHER'])
266
+ expect(seen).toEqual([['0xOTHER']])
267
+ })
268
+
269
+ it('does NOT cap account-sync at the telemetry ceiling (a focus-refiring wallet must not re-serve a stale signer)', () => {
270
+ const em = new EventManager()
271
+ const seen: string[][] = []
272
+ let emitAccounts!: (accounts: string[]) => void
273
+ const unavail: Array<{ reason?: string }> = []
274
+ em.on('lifecycleTelemetryUnavailable', d =>
275
+ unavail.push(d as { reason?: string })
276
+ )
277
+
278
+ attachLifecycle({
279
+ eventManager: em,
280
+ subscribeAccounts: l => {
281
+ emitAccounts = l
282
+ return () => {}
283
+ },
284
+ onAccountsChanged: accounts => seen.push(accounts),
285
+ getRouting: () => routing,
286
+ connectionMode: 'injected',
287
+ namespace: 'eip155'
288
+ })
289
+
290
+ // Far more than the telemetry cap, each a DISTINCT address (a genuine
291
+ // change) — every one must propagate; none may be silenced by a cap.
292
+ const total = MAX_LIFECYCLE_EVENTS_PER_ATTACHMENT + 50
293
+ for (let i = 0; i < total; i++) emitAccounts([`0xacct${i}`])
294
+ expect(seen).toHaveLength(total)
295
+ // Account-sync never trips the emission-cap signal.
296
+ expect(unavail.some(u => u.reason === 'emission_cap')).toBe(false)
297
+ })
298
+
299
+ it('dedupes an unchanged account re-fire (same address on focus) but propagates a real change', () => {
300
+ const em = new EventManager()
301
+ const seen: string[][] = []
302
+ let emitAccounts!: (accounts: string[]) => void
303
+
304
+ attachLifecycle({
305
+ eventManager: em,
306
+ subscribeAccounts: l => {
307
+ emitAccounts = l
308
+ return () => {}
309
+ },
310
+ onAccountsChanged: accounts => seen.push(accounts),
311
+ getRouting: () => routing,
312
+ connectionMode: 'injected',
313
+ namespace: 'eip155'
314
+ })
315
+
316
+ emitAccounts(['0xAAA'])
317
+ emitAccounts(['0xAAA']) // focus re-fire → deduped
318
+ emitAccounts(['0xAAA'])
319
+ expect(seen).toEqual([['0xAAA']])
320
+
321
+ emitAccounts(['0xBBB']) // real change → propagates
322
+ emitAccounts(['0xAAA']) // switched back → propagates (not consecutive-same)
323
+ expect(seen).toEqual([['0xAAA'], ['0xBBB'], ['0xAAA']])
324
+ })
325
+
326
+ it('suppresses an eip155 accountsChanged echo despite address CASE differing from the switch (checksum vs lowercase)', () => {
327
+ const em = new EventManager()
328
+ const seen: string[][] = []
329
+ let emitAccounts!: (accounts: string[]) => void
330
+
331
+ attachLifecycle({
332
+ eventManager: em,
333
+ subscribe: () => () => {},
334
+ subscribeAccounts: l => {
335
+ emitAccounts = l
336
+ return () => {}
337
+ },
338
+ onAccountsChanged: accounts => seen.push(accounts),
339
+ getRouting: () => routing,
340
+ connectionMode: 'injected',
341
+ namespace: 'eip155'
342
+ })
343
+
344
+ // UWC switch applied a checksummed address; the wallet echoes the SAME
345
+ // account lowercased. Case-insensitive on eip155 → still suppressed, so the
346
+ // echo isn't forwarded as a wallet-initiated account change.
347
+ em.emit('networkSwitched', {
348
+ network: { id: 'eip155:8453' } as any,
349
+ address: '0xAbC123'
350
+ })
351
+ emitAccounts(['0xabc123'])
352
+ expect(seen).toHaveLength(0)
353
+ })
354
+
355
+ it('does NOT case-fold a non-eip155 (solana) address echo — case-sensitive namespaces stay exact', () => {
356
+ const em = new EventManager()
357
+ const seen: string[][] = []
358
+ let emitAccounts!: (accounts: string[]) => void
359
+
360
+ attachLifecycle({
361
+ eventManager: em,
362
+ subscribe: () => () => {},
363
+ subscribeAccounts: l => {
364
+ emitAccounts = l
365
+ return () => {}
366
+ },
367
+ onAccountsChanged: accounts => seen.push(accounts),
368
+ getRouting: () => routing,
369
+ connectionMode: 'injected',
370
+ namespace: 'solana'
371
+ })
372
+
373
+ // Solana addresses are case-sensitive: a different-case string is a
374
+ // DIFFERENT address, so it must NOT be suppressed as an echo.
375
+ em.emit('networkSwitched', {
376
+ network: { id: 'solana:mainnet' } as any,
377
+ address: 'SoLAddr'
378
+ })
379
+ emitAccounts(['soladdr'])
380
+ expect(seen).toEqual([['soladdr']])
381
+ })
382
+
383
+ it('forwards an accountsChanged inside the echo window whose address does NOT match the switch (a real, different, user-initiated change)', () => {
384
+ const em = new EventManager()
385
+ const seen: string[][] = []
386
+ let emitAccounts!: (accounts: string[]) => void
387
+
388
+ attachLifecycle({
389
+ eventManager: em,
390
+ subscribe: () => () => {},
391
+ subscribeAccounts: l => {
392
+ emitAccounts = l
393
+ return () => {}
394
+ },
395
+ onAccountsChanged: accounts => seen.push(accounts),
396
+ getRouting: () => routing,
397
+ connectionMode: 'injected',
398
+ namespace: 'eip155'
399
+ })
400
+
401
+ em.emit('networkSwitched', {
402
+ network: { id: 'eip155:8453' } as any,
403
+ address: '0xNEW'
404
+ })
405
+ emitAccounts(['0xSOMETHING_ELSE'])
406
+ expect(seen).toEqual([['0xSOMETHING_ELSE']])
407
+ })
408
+
409
+ it('never suppresses an empty accounts list (the disconnect signal), even inside the echo window', () => {
410
+ const em = new EventManager()
411
+ const seen: string[][] = []
412
+ let emitAccounts!: (accounts: string[]) => void
413
+
414
+ attachLifecycle({
415
+ eventManager: em,
416
+ subscribe: () => () => {},
417
+ subscribeAccounts: l => {
418
+ emitAccounts = l
419
+ return () => {}
420
+ },
421
+ onAccountsChanged: accounts => seen.push(accounts),
422
+ getRouting: () => routing,
423
+ connectionMode: 'injected',
424
+ namespace: 'eip155'
425
+ })
426
+
427
+ em.emit('networkSwitched', {
428
+ network: { id: 'eip155:8453' } as any,
429
+ address: '0xNEW'
430
+ })
431
+ emitAccounts([])
432
+ expect(seen).toEqual([[]])
433
+ })
434
+
155
435
  it('caps lifecycle emissions per attachment (a storming provider cannot drive unbounded egress)', () => {
156
436
  // Every other telemetry sink added by this layer is bounded (relay / bridge /
157
437
  // observer-error all cap at 100); a wallet looping chainChanged must not be
@@ -192,6 +472,42 @@ describe('attachLifecycle', () => {
192
472
  expect(unavail[0]?.reason).toBe('emission_cap')
193
473
  })
194
474
 
475
+ it('does not drop a still-pending echo suppression when a SECOND switchNetwork arms before the FIRST wallet echo arrives', () => {
476
+ // Two rapid, back-to-back switches (A then B) each arm their OWN echo
477
+ // window. A's wallet echo can legitimately arrive AFTER B's switch has
478
+ // already completed — B arming/re-arming must not erase A's still-valid,
479
+ // not-yet-consumed suppression window.
480
+ let t = 1000
481
+ const em = new EventManager()
482
+ const seen: unknown[] = []
483
+ em.on('walletChainChanged', d => seen.push(d))
484
+
485
+ let emit!: (e: WalletLifecycleEvent) => void
486
+ attachLifecycle({
487
+ eventManager: em,
488
+ subscribe: l => {
489
+ emit = l
490
+ return () => {}
491
+ },
492
+ getRouting: () => routing,
493
+ connectionMode: 'injected',
494
+ namespace: 'eip155',
495
+ now: () => t
496
+ })
497
+
498
+ em.emit('networkSwitched', switchedTo('eip155:8453')) // switch to A
499
+ t = 1010
500
+ em.emit('networkSwitched', switchedTo('eip155:137')) // switch to B, A's echo still pending
501
+
502
+ t = 1020
503
+ emit({ type: 'chainChanged', chainId: 'eip155:8453' }) // A's delayed echo
504
+ expect(seen).toHaveLength(0)
505
+
506
+ t = 1030
507
+ emit({ type: 'chainChanged', chainId: 'eip155:137' }) // B's delayed echo
508
+ expect(seen).toHaveLength(0)
509
+ })
510
+
195
511
  it('seeds echo suppression from initialSwitch (the reattach-after-switch path)', () => {
196
512
  // A cross-namespace switchNetwork re-attaches the subscription AFTER the
197
513
  // switch completed — the fresh closure never saw that switch's bus events,