@meshconnect/uwc-core 1.2.3-snapshot.dcfdbbf → 1.3.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/dist/events.d.ts +40 -5
- package/dist/events.d.ts.map +1 -1
- package/dist/events.js +4 -0
- package/dist/events.js.map +1 -1
- package/dist/observability/focus-tracker.d.ts +30 -11
- package/dist/observability/focus-tracker.d.ts.map +1 -1
- package/dist/observability/focus-tracker.js +54 -18
- package/dist/observability/focus-tracker.js.map +1 -1
- package/dist/observability/instrument-handoff.d.ts.map +1 -1
- package/dist/observability/instrument-handoff.js +25 -6
- package/dist/observability/instrument-handoff.js.map +1 -1
- package/dist/observability/lifecycle-bridge.d.ts +13 -31
- package/dist/observability/lifecycle-bridge.d.ts.map +1 -1
- package/dist/observability/lifecycle-bridge.js +92 -167
- package/dist/observability/lifecycle-bridge.js.map +1 -1
- package/dist/observability/relay-drop-tracker.d.ts +37 -0
- package/dist/observability/relay-drop-tracker.d.ts.map +1 -0
- package/dist/observability/relay-drop-tracker.js +30 -0
- package/dist/observability/relay-drop-tracker.js.map +1 -0
- package/dist/observability/telemetry.d.ts +8 -0
- package/dist/observability/telemetry.d.ts.map +1 -1
- package/dist/observability/telemetry.js +32 -5
- package/dist/observability/telemetry.js.map +1 -1
- package/dist/services/connection-service.d.ts +0 -43
- package/dist/services/connection-service.d.ts.map +1 -1
- package/dist/services/connection-service.js +9 -163
- package/dist/services/connection-service.js.map +1 -1
- package/dist/services/network-switch-service.d.ts.map +1 -1
- package/dist/services/network-switch-service.js +1 -4
- package/dist/services/network-switch-service.js.map +1 -1
- package/dist/universal-wallet-connector.d.ts.map +1 -1
- package/dist/universal-wallet-connector.js +18 -11
- package/dist/universal-wallet-connector.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +5 -5
- package/src/events.ts +50 -6
- package/src/observability/focus-tracker.test.ts +95 -8
- package/src/observability/focus-tracker.ts +72 -22
- package/src/observability/instrument-handoff.test.ts +62 -0
- package/src/observability/instrument-handoff.ts +37 -6
- package/src/observability/lifecycle-bridge.test.ts +0 -197
- package/src/observability/lifecycle-bridge.ts +99 -211
- package/src/observability/relay-drop-tracker.test.ts +84 -0
- package/src/observability/relay-drop-tracker.ts +53 -0
- package/src/observability/telemetry.test.ts +18 -0
- package/src/observability/telemetry.ts +41 -6
- package/src/services/connection-service.test.ts +0 -272
- package/src/services/connection-service.ts +10 -190
- package/src/services/network-switch-service.test.ts +0 -33
- package/src/services/network-switch-service.ts +1 -4
- package/src/universal-wallet-connector.observability.test.ts +54 -8
- package/src/universal-wallet-connector.ts +22 -12
- package/src/version.ts +1 -1
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { EventManager } from '../managers/event-manager'
|
|
3
|
+
import { trackRelayDropsDuringHandoff } from './relay-drop-tracker'
|
|
4
|
+
|
|
5
|
+
describe('trackRelayDropsDuringHandoff', () => {
|
|
6
|
+
it('counts each relay drop seen during a WalletConnect handoff', () => {
|
|
7
|
+
const em = new EventManager()
|
|
8
|
+
const tracker = trackRelayDropsDuringHandoff(em, 'walletConnect')
|
|
9
|
+
|
|
10
|
+
expect(tracker.summary()).toEqual({ relayDropCount: 0 })
|
|
11
|
+
|
|
12
|
+
em.emit('relayDisconnected', undefined)
|
|
13
|
+
em.emit('relayDisconnected', undefined)
|
|
14
|
+
expect(tracker.summary()).toEqual({ relayDropCount: 2 })
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('reports undefined (not 0) off WalletConnect so the terminal omits the field', () => {
|
|
18
|
+
// A `0` on an injected handoff would read as "a relay we watched, and it
|
|
19
|
+
// held" — but no relay is in play, so the field must be absent entirely.
|
|
20
|
+
const em = new EventManager()
|
|
21
|
+
for (const mode of ['injected', 'tonConnect'] as const) {
|
|
22
|
+
const tracker = trackRelayDropsDuringHandoff(em, mode)
|
|
23
|
+
expect(tracker.summary()).toEqual({ relayDropCount: undefined })
|
|
24
|
+
}
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('does NOT subscribe off WalletConnect — a relay flap on the shared bus is ignored', () => {
|
|
28
|
+
const em = new EventManager()
|
|
29
|
+
const tracker = trackRelayDropsDuringHandoff(em, 'injected')
|
|
30
|
+
|
|
31
|
+
// The bus is client-global; a WC relay drop firing while an injected handoff
|
|
32
|
+
// is in flight must not be attributed to it.
|
|
33
|
+
em.emit('relayDisconnected', undefined)
|
|
34
|
+
em.emit('relayDisconnected', undefined)
|
|
35
|
+
expect(tracker.summary()).toEqual({ relayDropCount: undefined })
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('stops counting after stop() — drops outside the blind window do not accrue', () => {
|
|
39
|
+
const em = new EventManager()
|
|
40
|
+
const tracker = trackRelayDropsDuringHandoff(em, 'walletConnect')
|
|
41
|
+
|
|
42
|
+
em.emit('relayDisconnected', undefined)
|
|
43
|
+
tracker.stop()
|
|
44
|
+
em.emit('relayDisconnected', undefined)
|
|
45
|
+
em.emit('relayDisconnected', undefined)
|
|
46
|
+
|
|
47
|
+
expect(tracker.summary()).toEqual({ relayDropCount: 1 })
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('only counts relayDisconnected — reconnects and other bus events are ignored', () => {
|
|
51
|
+
const em = new EventManager()
|
|
52
|
+
const tracker = trackRelayDropsDuringHandoff(em, 'walletConnect')
|
|
53
|
+
|
|
54
|
+
em.emit('relayDisconnected', undefined)
|
|
55
|
+
em.emit('relayReconnected', undefined)
|
|
56
|
+
em.emit('relayReconnected', undefined)
|
|
57
|
+
|
|
58
|
+
expect(tracker.summary()).toEqual({ relayDropCount: 1 })
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('stop() is a safe no-op off WalletConnect (nothing was subscribed)', () => {
|
|
62
|
+
const em = new EventManager()
|
|
63
|
+
const tracker = trackRelayDropsDuringHandoff(em, 'tonConnect')
|
|
64
|
+
expect(() => tracker.stop()).not.toThrow()
|
|
65
|
+
expect(tracker.summary()).toEqual({ relayDropCount: undefined })
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('two concurrent WalletConnect handoffs count independently and unsubscribe independently', () => {
|
|
69
|
+
// The bus is shared; each tracker owns its own listener, so stopping one
|
|
70
|
+
// must not silence the other (and both saw the same drop).
|
|
71
|
+
const em = new EventManager()
|
|
72
|
+
const a = trackRelayDropsDuringHandoff(em, 'walletConnect')
|
|
73
|
+
const b = trackRelayDropsDuringHandoff(em, 'walletConnect')
|
|
74
|
+
|
|
75
|
+
em.emit('relayDisconnected', undefined)
|
|
76
|
+
expect(a.summary()).toEqual({ relayDropCount: 1 })
|
|
77
|
+
expect(b.summary()).toEqual({ relayDropCount: 1 })
|
|
78
|
+
|
|
79
|
+
a.stop()
|
|
80
|
+
em.emit('relayDisconnected', undefined)
|
|
81
|
+
expect(a.summary()).toEqual({ relayDropCount: 1 })
|
|
82
|
+
expect(b.summary()).toEqual({ relayDropCount: 2 })
|
|
83
|
+
})
|
|
84
|
+
})
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { ConnectionMode } from '@meshconnect/uwc-types'
|
|
2
|
+
import type { EventManager } from '../managers/event-manager'
|
|
3
|
+
|
|
4
|
+
/** Handle returned by `trackRelayDropsDuringHandoff`. */
|
|
5
|
+
export interface RelayDropHandoffTracker {
|
|
6
|
+
/**
|
|
7
|
+
* Remove the relay-drop subscription. Always call on settle (no-op off
|
|
8
|
+
* WalletConnect, where nothing was subscribed).
|
|
9
|
+
*/
|
|
10
|
+
stop: () => void
|
|
11
|
+
/**
|
|
12
|
+
* Relay-drop count at call time, or `undefined` off WalletConnect. Undefined
|
|
13
|
+
* (not `0`) so the terminal's `compactUndefined` omits the field entirely — a
|
|
14
|
+
* `0` on an injected/TON handoff would falsely imply "a WC relay we watched,
|
|
15
|
+
* and it held", when in fact no relay was in play.
|
|
16
|
+
*/
|
|
17
|
+
summary: () => { relayDropCount: number | undefined }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* While a wallet handoff is pending, count relay-socket drops seen on the UWC
|
|
22
|
+
* event bus. The WalletConnect client emits `relayDisconnected` once per outage
|
|
23
|
+
* (see `attachRelayLifecycleListeners`); this is the CARE-263 signal — an
|
|
24
|
+
* iOS-suspended WebView drops the relay socket mid-handoff and strands the
|
|
25
|
+
* in-flight sign/connect response until WC core's slow heartbeat reconnects, so
|
|
26
|
+
* the drop count on a terminal explains a long/failed handoff that focus timing
|
|
27
|
+
* alone can't.
|
|
28
|
+
*
|
|
29
|
+
* WalletConnect ONLY. The relay bus is client-global, so an injected or TON
|
|
30
|
+
* handoff that happens to be in flight during a WC relay flap would otherwise
|
|
31
|
+
* misattribute the drop; off WalletConnect this is a no-op tracker whose
|
|
32
|
+
* `summary()` reports `undefined`. Mirrors `trackFocusDuringHandoff`: attach
|
|
33
|
+
* right after `awaitingWallet`, and `stop()` (called the instant the op settles)
|
|
34
|
+
* removes the subscription so drops are only counted during the blind window.
|
|
35
|
+
*/
|
|
36
|
+
export function trackRelayDropsDuringHandoff(
|
|
37
|
+
eventManager: EventManager,
|
|
38
|
+
connectionMode: ConnectionMode
|
|
39
|
+
): RelayDropHandoffTracker {
|
|
40
|
+
if (connectionMode !== 'walletConnect') {
|
|
41
|
+
return { stop: () => {}, summary: () => ({ relayDropCount: undefined }) }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
let relayDropCount = 0
|
|
45
|
+
const off = eventManager.on('relayDisconnected', () => {
|
|
46
|
+
relayDropCount++
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
stop: off,
|
|
51
|
+
summary: () => ({ relayDropCount })
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -520,6 +520,24 @@ describe('normalizeEvent', () => {
|
|
|
520
520
|
expect(JSON.stringify(out)).not.toContain('0xAbC123')
|
|
521
521
|
})
|
|
522
522
|
|
|
523
|
+
it('normalizes relayDisconnected as an unclassified walletConnect signal (void, no reason)', () => {
|
|
524
|
+
// The relayer carries no payload, so the event is void — never an error, and
|
|
525
|
+
// observational (no failureKind, so it can't inflate failure metrics).
|
|
526
|
+
const out = normalizeEvent('relayDisconnected', undefined, SDK_SESSION, NOW)
|
|
527
|
+
expect(out.connectionMode).toBe('walletConnect')
|
|
528
|
+
expect(out.source).toBe('provider')
|
|
529
|
+
expect(out.error).toBeUndefined()
|
|
530
|
+
expect(out.failureKind).toBeUndefined()
|
|
531
|
+
})
|
|
532
|
+
|
|
533
|
+
it('normalizes relayReconnected as an unclassified walletConnect signal', () => {
|
|
534
|
+
const out = normalizeEvent('relayReconnected', undefined, SDK_SESSION, NOW)
|
|
535
|
+
expect(out.name).toBe('relayReconnected')
|
|
536
|
+
expect(out.connectionMode).toBe('walletConnect')
|
|
537
|
+
expect(out.source).toBe('provider')
|
|
538
|
+
expect(out.failureKind).toBeUndefined()
|
|
539
|
+
})
|
|
540
|
+
|
|
523
541
|
it('normalizes walletConnectInitError as a walletConnect client_init_failed, message scrubbed', () => {
|
|
524
542
|
const out = normalizeEvent(
|
|
525
543
|
'walletConnectInitError',
|
|
@@ -154,6 +154,14 @@ export interface UWCTelemetryEvent {
|
|
|
154
154
|
* handoffCovered to avoid double-counting.
|
|
155
155
|
*/
|
|
156
156
|
handoffCovered?: boolean | undefined
|
|
157
|
+
/** `pageVisibleDuringHandoff` only: how long the page was hidden for that segment (ms). */
|
|
158
|
+
hiddenMs?: number | undefined
|
|
159
|
+
/** Handoff terminals: cumulative time the page was backgrounded during the handoff (ms). */
|
|
160
|
+
backgroundedMs?: number | undefined
|
|
161
|
+
/** Handoff terminals: how many times the page was backgrounded during the handoff. */
|
|
162
|
+
backgroundCount?: number | undefined
|
|
163
|
+
/** Handoff terminals, WalletConnect only: relay-socket drops seen while the handoff was pending. */
|
|
164
|
+
relayDropCount?: number | undefined
|
|
157
165
|
}
|
|
158
166
|
|
|
159
167
|
/**
|
|
@@ -362,7 +370,10 @@ function buildTelemetryEvent<K extends UWCEventName>(
|
|
|
362
370
|
durationMs: d.durationMs,
|
|
363
371
|
timedOut: d.timedOut,
|
|
364
372
|
timeoutMs: d.timeoutMs,
|
|
365
|
-
targetAlreadyActive: d.targetAlreadyActive
|
|
373
|
+
targetAlreadyActive: d.targetAlreadyActive,
|
|
374
|
+
backgroundedMs: d.backgroundedMs,
|
|
375
|
+
backgroundCount: d.backgroundCount,
|
|
376
|
+
relayDropCount: d.relayDropCount
|
|
366
377
|
}
|
|
367
378
|
}
|
|
368
379
|
case 'walletRejected': {
|
|
@@ -381,7 +392,10 @@ function buildTelemetryEvent<K extends UWCEventName>(
|
|
|
381
392
|
targetAlreadyActive: d.targetAlreadyActive,
|
|
382
393
|
errorCode: d.errorCode,
|
|
383
394
|
failureKind: d.failureKind,
|
|
384
|
-
source: d.source
|
|
395
|
+
source: d.source,
|
|
396
|
+
backgroundedMs: d.backgroundedMs,
|
|
397
|
+
backgroundCount: d.backgroundCount,
|
|
398
|
+
relayDropCount: d.relayDropCount
|
|
385
399
|
}
|
|
386
400
|
}
|
|
387
401
|
case 'walletTimedOut': {
|
|
@@ -396,7 +410,10 @@ function buildTelemetryEvent<K extends UWCEventName>(
|
|
|
396
410
|
handoffId: d.handoffId,
|
|
397
411
|
durationMs: d.durationMs,
|
|
398
412
|
timeoutMs: d.timeoutMs,
|
|
399
|
-
targetAlreadyActive: d.targetAlreadyActive
|
|
413
|
+
targetAlreadyActive: d.targetAlreadyActive,
|
|
414
|
+
backgroundedMs: d.backgroundedMs,
|
|
415
|
+
backgroundCount: d.backgroundCount,
|
|
416
|
+
relayDropCount: d.relayDropCount
|
|
400
417
|
}
|
|
401
418
|
}
|
|
402
419
|
case 'walletFailed': {
|
|
@@ -416,7 +433,10 @@ function buildTelemetryEvent<K extends UWCEventName>(
|
|
|
416
433
|
errorCode: d.errorCode,
|
|
417
434
|
failureKind: d.failureKind,
|
|
418
435
|
source: d.source,
|
|
419
|
-
error: { type: d.error.type, message: scrubMessage(d.error.message) }
|
|
436
|
+
error: { type: d.error.type, message: scrubMessage(d.error.message) },
|
|
437
|
+
backgroundedMs: d.backgroundedMs,
|
|
438
|
+
backgroundCount: d.backgroundCount,
|
|
439
|
+
relayDropCount: d.relayDropCount
|
|
420
440
|
}
|
|
421
441
|
}
|
|
422
442
|
case 'capabilitiesUpdated': {
|
|
@@ -448,8 +468,15 @@ function buildTelemetryEvent<K extends UWCEventName>(
|
|
|
448
468
|
}
|
|
449
469
|
case 'pageHiddenDuringHandoff':
|
|
450
470
|
case 'pageVisibleDuringHandoff': {
|
|
451
|
-
|
|
452
|
-
|
|
471
|
+
// Cast to the visible shape (superset). `hiddenMs` is absent on the hidden
|
|
472
|
+
// event → undefined → dropped by compactUndefined.
|
|
473
|
+
const d = data as UWCEventMap['pageVisibleDuringHandoff']
|
|
474
|
+
return {
|
|
475
|
+
...base,
|
|
476
|
+
handoffId: d.handoffId,
|
|
477
|
+
platform: d.platform,
|
|
478
|
+
hiddenMs: d.hiddenMs
|
|
479
|
+
}
|
|
453
480
|
}
|
|
454
481
|
case 'bridgeError': {
|
|
455
482
|
const d = data as UWCEventMap['bridgeError']
|
|
@@ -473,6 +500,14 @@ function buildTelemetryEvent<K extends UWCEventName>(
|
|
|
473
500
|
error: { type: d.error.type, message: scrubMessage(d.error.message) }
|
|
474
501
|
}
|
|
475
502
|
}
|
|
503
|
+
case 'relayDisconnected':
|
|
504
|
+
case 'relayReconnected': {
|
|
505
|
+
// Relay transport lifecycle (WalletConnect). Observational — NOT a failure,
|
|
506
|
+
// so deliberately no `failureKind` (never inflates failure metrics). The
|
|
507
|
+
// relayer carries no payload; pair the two by sdkSessionId + timestamp for
|
|
508
|
+
// the dead-window duration.
|
|
509
|
+
return { ...base, connectionMode: 'walletConnect', source: 'provider' }
|
|
510
|
+
}
|
|
476
511
|
case 'walletConnectInitError': {
|
|
477
512
|
const d = data as UWCEventMap['walletConnectInitError']
|
|
478
513
|
// SignClient.init failed — the WC client never came up. Distinct from
|
|
@@ -863,277 +863,5 @@ describe('ConnectionService', () => {
|
|
|
863
863
|
// No connection → no lifecycle evaluation, so no "unavailable" noise.
|
|
864
864
|
expect(unavail).toHaveLength(0)
|
|
865
865
|
})
|
|
866
|
-
|
|
867
|
-
it('a real accountsChanged from the connector updates activeAddress and emits sessionChanged', async () => {
|
|
868
|
-
let emitAccounts!: (accounts: string[]) => void
|
|
869
|
-
mockConnector.onAccountsChanged = (l: (accounts: string[]) => void) => {
|
|
870
|
-
emitAccounts = l
|
|
871
|
-
return () => {}
|
|
872
|
-
}
|
|
873
|
-
const sessions: Array<{ activeAddress: string | null }> = []
|
|
874
|
-
eventManager.on('sessionChanged', d =>
|
|
875
|
-
sessions.push({ activeAddress: d.session.activeAddress })
|
|
876
|
-
)
|
|
877
|
-
|
|
878
|
-
await connectionService.connect('injected', 'metamask', 'eip155:1')
|
|
879
|
-
emitAccounts(['0xNEW'])
|
|
880
|
-
|
|
881
|
-
expect(sessionManager.getSession().activeAddress).toBe('0xNEW')
|
|
882
|
-
expect(sessions[sessions.length - 1]?.activeAddress).toBe('0xNEW')
|
|
883
|
-
})
|
|
884
|
-
|
|
885
|
-
it('updates the matching availableAddresses entries for the changed namespace', async () => {
|
|
886
|
-
let emitAccounts!: (accounts: string[]) => void
|
|
887
|
-
mockConnector.onAccountsChanged = (l: (accounts: string[]) => void) => {
|
|
888
|
-
emitAccounts = l
|
|
889
|
-
return () => {}
|
|
890
|
-
}
|
|
891
|
-
|
|
892
|
-
await connectionService.connect('injected', 'metamask', 'eip155:1')
|
|
893
|
-
emitAccounts(['0xNEW'])
|
|
894
|
-
|
|
895
|
-
expect(sessionManager.getSession().availableAddresses).toEqual([
|
|
896
|
-
{ address: '0xNEW', networkId: 'eip155:1' }
|
|
897
|
-
])
|
|
898
|
-
})
|
|
899
|
-
|
|
900
|
-
it('an empty accounts list triggers a full disconnect AND calls connector.disconnect for local-state cleanup', async () => {
|
|
901
|
-
// The wallet already ended the session on its own side, but the
|
|
902
|
-
// connector's own LOCAL state (e.g. EthereumWalletService's cached
|
|
903
|
-
// account/uuid) still needs clearing — otherwise a later connect to a
|
|
904
|
-
// DIFFERENT wallet reads the stale cached account via
|
|
905
|
-
// checkExistingConnections() and never rebinds to the new wallet's
|
|
906
|
-
// provider/uuid. disconnect() must still be called here; it's a
|
|
907
|
-
// fire-and-forget, error-swallowed call (see the next test) so a
|
|
908
|
-
// protocol-based connector that rejects on an already-closed session
|
|
909
|
-
// can't break this path.
|
|
910
|
-
let emitAccounts!: (accounts: string[]) => void
|
|
911
|
-
mockConnector.onAccountsChanged = (l: (accounts: string[]) => void) => {
|
|
912
|
-
emitAccounts = l
|
|
913
|
-
return () => {}
|
|
914
|
-
}
|
|
915
|
-
const disconnectSpy = vi.fn().mockResolvedValue(undefined)
|
|
916
|
-
mockConnector.disconnect = disconnectSpy
|
|
917
|
-
|
|
918
|
-
await connectionService.connect('injected', 'metamask', 'eip155:1')
|
|
919
|
-
emitAccounts([])
|
|
920
|
-
|
|
921
|
-
expect(sessionManager.getSession().activeAddress).toBeNull()
|
|
922
|
-
expect(sessionManager.getSession().connectionMode).toBeNull()
|
|
923
|
-
expect(disconnectSpy).toHaveBeenCalledTimes(1)
|
|
924
|
-
})
|
|
925
|
-
|
|
926
|
-
it('does not let a rejecting connector.disconnect() break the wallet-initiated disconnect path', async () => {
|
|
927
|
-
let emitAccounts!: (accounts: string[]) => void
|
|
928
|
-
mockConnector.onAccountsChanged = (l: (accounts: string[]) => void) => {
|
|
929
|
-
emitAccounts = l
|
|
930
|
-
return () => {}
|
|
931
|
-
}
|
|
932
|
-
mockConnector.disconnect = vi
|
|
933
|
-
.fn()
|
|
934
|
-
.mockRejectedValue(new Error('already closed'))
|
|
935
|
-
|
|
936
|
-
await connectionService.connect('injected', 'metamask', 'eip155:1')
|
|
937
|
-
|
|
938
|
-
expect(() => emitAccounts([])).not.toThrow()
|
|
939
|
-
expect(sessionManager.getSession().activeAddress).toBeNull()
|
|
940
|
-
// Let the fire-and-forget rejection settle so it doesn't surface as an
|
|
941
|
-
// unhandled rejection in a later test.
|
|
942
|
-
await Promise.resolve()
|
|
943
|
-
await Promise.resolve()
|
|
944
|
-
})
|
|
945
|
-
|
|
946
|
-
it('a wallet that double-fires an empty accountsChanged does not double-emit disconnected/sessionChanged', async () => {
|
|
947
|
-
// handleAccountsChanged([]) -> finishDisconnect() runs synchronously
|
|
948
|
-
// FROM INSIDE the connector's own accountsChanged dispatch (the
|
|
949
|
-
// teardown it triggers removes that very listener re-entrantly). If a
|
|
950
|
-
// wallet emits the empty-array disconnect signal twice in quick
|
|
951
|
-
// succession — or the second emission slips through before teardown
|
|
952
|
-
// takes effect — finishDisconnect must be a no-op the second time
|
|
953
|
-
// rather than clearing an already-cleared session and re-emitting.
|
|
954
|
-
let emitAccounts!: (accounts: string[]) => void
|
|
955
|
-
mockConnector.onAccountsChanged = (l: (accounts: string[]) => void) => {
|
|
956
|
-
emitAccounts = l
|
|
957
|
-
return () => {}
|
|
958
|
-
}
|
|
959
|
-
mockConnector.disconnect = vi.fn().mockResolvedValue(undefined)
|
|
960
|
-
const disconnectedEvents: unknown[] = []
|
|
961
|
-
const sessionChangedEvents: unknown[] = []
|
|
962
|
-
eventManager.on('disconnected', d => disconnectedEvents.push(d))
|
|
963
|
-
eventManager.on('sessionChanged', d => sessionChangedEvents.push(d))
|
|
964
|
-
|
|
965
|
-
await connectionService.connect('injected', 'metamask', 'eip155:1')
|
|
966
|
-
sessionChangedEvents.length = 0 // drop the connect's own sessionChanged
|
|
967
|
-
|
|
968
|
-
emitAccounts([])
|
|
969
|
-
emitAccounts([]) // the wallet (or a race) delivers the same signal again
|
|
970
|
-
|
|
971
|
-
expect(disconnectedEvents).toHaveLength(1)
|
|
972
|
-
expect(sessionChangedEvents).toHaveLength(1)
|
|
973
|
-
expect(mockConnector.disconnect).toHaveBeenCalledTimes(1)
|
|
974
|
-
})
|
|
975
|
-
|
|
976
|
-
it('reattachLifecycle() seeds address-echo suppression with the just-active address', async () => {
|
|
977
|
-
const accountSubscribers: Array<(accounts: string[]) => void> = []
|
|
978
|
-
mockConnector.onAccountsChanged = (l: (accounts: string[]) => void) => {
|
|
979
|
-
accountSubscribers.push(l)
|
|
980
|
-
return () => {}
|
|
981
|
-
}
|
|
982
|
-
const sessions: unknown[] = []
|
|
983
|
-
eventManager.on('sessionChanged', d => sessions.push(d))
|
|
984
|
-
|
|
985
|
-
await connectionService.connect('injected', 'metamask', 'eip155:1')
|
|
986
|
-
sessions.length = 0
|
|
987
|
-
connectionService.reattachLifecycle()
|
|
988
|
-
|
|
989
|
-
// The reattached provider echoes the session's own current address →
|
|
990
|
-
// suppressed, no redundant sessionChanged.
|
|
991
|
-
accountSubscribers[1]!(['0x1234'])
|
|
992
|
-
expect(sessions).toHaveLength(0)
|
|
993
|
-
|
|
994
|
-
// A later, genuinely different address is a real change.
|
|
995
|
-
accountSubscribers[1]!(['0xDIFFERENT'])
|
|
996
|
-
expect(sessions).toHaveLength(1)
|
|
997
|
-
})
|
|
998
|
-
|
|
999
|
-
it("does not move activeAddress when the changed namespace no longer matches the session's active namespace (stale subscription after a cross-namespace switch)", async () => {
|
|
1000
|
-
let emitAccounts!: (accounts: string[]) => void
|
|
1001
|
-
mockConnector.onAccountsChanged = (l: (accounts: string[]) => void) => {
|
|
1002
|
-
emitAccounts = l
|
|
1003
|
-
return () => {}
|
|
1004
|
-
}
|
|
1005
|
-
|
|
1006
|
-
await connectionService.connect('injected', 'metamask', 'eip155:1')
|
|
1007
|
-
const connectTimeAddress = sessionManager.getSession().activeAddress
|
|
1008
|
-
|
|
1009
|
-
// Simulate the session's active network having already moved to a
|
|
1010
|
-
// different namespace (e.g. mid-cross-namespace-switch) WITHOUT this
|
|
1011
|
-
// stream having been reattached yet — it's still the eip155 one from
|
|
1012
|
-
// connect time.
|
|
1013
|
-
sessionManager.updateSession({
|
|
1014
|
-
activeNetwork: {
|
|
1015
|
-
...sessionManager.getSession().activeNetwork,
|
|
1016
|
-
namespace: 'solana'
|
|
1017
|
-
} as any
|
|
1018
|
-
})
|
|
1019
|
-
|
|
1020
|
-
emitAccounts(['0xNEW'])
|
|
1021
|
-
|
|
1022
|
-
// activeAddress belongs to the now-active (solana) namespace and must
|
|
1023
|
-
// not be clobbered by a stale eip155 subscription's account change.
|
|
1024
|
-
expect(sessionManager.getSession().activeAddress).toBe(connectTimeAddress)
|
|
1025
|
-
// availableAddresses for the eip155 entry IS still updated — the address
|
|
1026
|
-
// change is real, just not the currently-active one.
|
|
1027
|
-
expect(sessionManager.getSession().availableAddresses).toEqual([
|
|
1028
|
-
{ address: '0xNEW', networkId: 'eip155:1' }
|
|
1029
|
-
])
|
|
1030
|
-
})
|
|
1031
|
-
})
|
|
1032
|
-
|
|
1033
|
-
describe('secondary-namespace account sync', () => {
|
|
1034
|
-
// A multi-namespace wallet (e.g. MetaMask with Solana support) connected
|
|
1035
|
-
// on a SOLANA-active session: the cross-namespace gather seeds eip155
|
|
1036
|
-
// addresses into availableAddresses at connect time, but the lifecycle
|
|
1037
|
-
// attach used to observe only the ACTIVE namespace — an eip155 account
|
|
1038
|
-
// switch in the extension was never observed and the session served the
|
|
1039
|
-
// connect-time eip155 addresses forever.
|
|
1040
|
-
let accountSubs: Map<string, (accounts: string[]) => void>
|
|
1041
|
-
let teardownsByNamespace: Map<string, number>
|
|
1042
|
-
|
|
1043
|
-
beforeEach(() => {
|
|
1044
|
-
networks.push({
|
|
1045
|
-
id: 'solana:mainnet',
|
|
1046
|
-
internalId: 101,
|
|
1047
|
-
namespace: 'solana',
|
|
1048
|
-
name: 'Solana',
|
|
1049
|
-
logoUrl: '',
|
|
1050
|
-
nativeCurrency: { name: 'SOL', symbol: 'SOL', decimals: 9 },
|
|
1051
|
-
networkType: 'solana'
|
|
1052
|
-
} as never)
|
|
1053
|
-
wallets[0]!.extensionInjectedProvider = {
|
|
1054
|
-
supportedNetworkIds: ['solana:mainnet', 'eip155:1']
|
|
1055
|
-
} as never
|
|
1056
|
-
mockConnector.connect = vi.fn().mockResolvedValue({
|
|
1057
|
-
networkId: 'solana:mainnet',
|
|
1058
|
-
address: 'SoLAddr111',
|
|
1059
|
-
availableAddresses: [
|
|
1060
|
-
{ address: 'SoLAddr111', networkId: 'solana:mainnet' },
|
|
1061
|
-
{ address: '0x1234', networkId: 'eip155:1' }
|
|
1062
|
-
]
|
|
1063
|
-
})
|
|
1064
|
-
accountSubs = new Map()
|
|
1065
|
-
teardownsByNamespace = new Map()
|
|
1066
|
-
mockConnector.onAccountsChanged = ((
|
|
1067
|
-
listener: (accounts: string[]) => void,
|
|
1068
|
-
context: { namespace: string }
|
|
1069
|
-
) => {
|
|
1070
|
-
accountSubs.set(context.namespace, listener)
|
|
1071
|
-
return () => {
|
|
1072
|
-
teardownsByNamespace.set(
|
|
1073
|
-
context.namespace,
|
|
1074
|
-
(teardownsByNamespace.get(context.namespace) ?? 0) + 1
|
|
1075
|
-
)
|
|
1076
|
-
}
|
|
1077
|
-
}) as never
|
|
1078
|
-
})
|
|
1079
|
-
|
|
1080
|
-
it('attaches an accounts-sync subscription for every namespace present in availableAddresses, not only the active one', async () => {
|
|
1081
|
-
await connectionService.connect('injected', 'metamask', 'solana:mainnet')
|
|
1082
|
-
|
|
1083
|
-
expect([...accountSubs.keys()].sort()).toEqual(['eip155', 'solana'])
|
|
1084
|
-
})
|
|
1085
|
-
|
|
1086
|
-
it("a secondary namespace's accountsChanged updates its availableAddresses entries without touching activeAddress", async () => {
|
|
1087
|
-
await connectionService.connect('injected', 'metamask', 'solana:mainnet')
|
|
1088
|
-
|
|
1089
|
-
accountSubs.get('eip155')!(['0xSWITCHED'])
|
|
1090
|
-
|
|
1091
|
-
const session = sessionManager.getSession()
|
|
1092
|
-
expect(session.activeAddress).toBe('SoLAddr111')
|
|
1093
|
-
expect(session.availableAddresses).toEqual([
|
|
1094
|
-
{ address: 'SoLAddr111', networkId: 'solana:mainnet' },
|
|
1095
|
-
{ address: '0xSWITCHED', networkId: 'eip155:1' }
|
|
1096
|
-
])
|
|
1097
|
-
})
|
|
1098
|
-
|
|
1099
|
-
it('an empty accounts list from a SECONDARY namespace does not disconnect the session', async () => {
|
|
1100
|
-
// Only the ACTIVE namespace's stream may signal the full
|
|
1101
|
-
// wallet-initiated disconnect — a secondary namespace reporting []
|
|
1102
|
-
// means that namespace's own authorization ended, not the session.
|
|
1103
|
-
const disconnectSpy = vi.fn().mockResolvedValue(undefined)
|
|
1104
|
-
mockConnector.disconnect = disconnectSpy
|
|
1105
|
-
|
|
1106
|
-
await connectionService.connect('injected', 'metamask', 'solana:mainnet')
|
|
1107
|
-
accountSubs.get('eip155')!([])
|
|
1108
|
-
|
|
1109
|
-
const session = sessionManager.getSession()
|
|
1110
|
-
expect(session.connectionMode).toBe('injected')
|
|
1111
|
-
expect(session.activeAddress).toBe('SoLAddr111')
|
|
1112
|
-
expect(disconnectSpy).not.toHaveBeenCalled()
|
|
1113
|
-
})
|
|
1114
|
-
|
|
1115
|
-
it('disconnect() tears down the secondary subscriptions too', async () => {
|
|
1116
|
-
await connectionService.connect('injected', 'metamask', 'solana:mainnet')
|
|
1117
|
-
|
|
1118
|
-
await connectionService.disconnect()
|
|
1119
|
-
|
|
1120
|
-
expect(teardownsByNamespace.get('eip155')).toBe(1)
|
|
1121
|
-
expect(teardownsByNamespace.get('solana')).toBe(1)
|
|
1122
|
-
})
|
|
1123
|
-
|
|
1124
|
-
it('reattachLifecycle() re-derives the secondary set for the CURRENT session without stacking duplicates', async () => {
|
|
1125
|
-
await connectionService.connect('injected', 'metamask', 'solana:mainnet')
|
|
1126
|
-
|
|
1127
|
-
connectionService.reattachLifecycle()
|
|
1128
|
-
|
|
1129
|
-
// Old eip155 secondary attachment released, exactly one fresh one live.
|
|
1130
|
-
expect(teardownsByNamespace.get('eip155')).toBe(1)
|
|
1131
|
-
accountSubs.get('eip155')!(['0xAFTER'])
|
|
1132
|
-
expect(
|
|
1133
|
-
sessionManager
|
|
1134
|
-
.getSession()
|
|
1135
|
-
.availableAddresses.find(a => a.networkId === 'eip155:1')?.address
|
|
1136
|
-
).toBe('0xAFTER')
|
|
1137
|
-
})
|
|
1138
866
|
})
|
|
1139
867
|
})
|