@meshconnect/uwc-core 1.2.3-snapshot.bd9aae7 → 1.2.3-snapshot.dcfdbbf
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 +5 -42
- package/dist/events.d.ts.map +1 -1
- package/dist/events.js +0 -4
- package/dist/events.js.map +1 -1
- package/dist/observability/focus-tracker.d.ts +12 -25
- package/dist/observability/focus-tracker.d.ts.map +1 -1
- package/dist/observability/focus-tracker.js +19 -42
- 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 +6 -24
- package/dist/observability/instrument-handoff.js.map +1 -1
- package/dist/observability/lifecycle-bridge.d.ts +31 -13
- package/dist/observability/lifecycle-bridge.d.ts.map +1 -1
- package/dist/observability/lifecycle-bridge.js +167 -92
- package/dist/observability/lifecycle-bridge.js.map +1 -1
- package/dist/observability/telemetry.d.ts +0 -8
- package/dist/observability/telemetry.d.ts.map +1 -1
- package/dist/observability/telemetry.js +5 -49
- package/dist/observability/telemetry.js.map +1 -1
- package/dist/services/connection-service.d.ts +43 -0
- package/dist/services/connection-service.d.ts.map +1 -1
- package/dist/services/connection-service.js +163 -9
- 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 +4 -1
- 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 +1 -7
- package/dist/universal-wallet-connector.js.map +1 -1
- package/package.json +5 -5
- package/src/events.ts +6 -50
- package/src/observability/focus-tracker.test.ts +8 -60
- package/src/observability/focus-tracker.ts +23 -59
- package/src/observability/instrument-handoff.test.ts +0 -42
- package/src/observability/instrument-handoff.ts +6 -26
- package/src/observability/lifecycle-bridge.test.ts +197 -0
- package/src/observability/lifecycle-bridge.ts +211 -99
- package/src/observability/telemetry.test.ts +0 -35
- package/src/observability/telemetry.ts +6 -58
- package/src/services/connection-service.test.ts +272 -0
- package/src/services/connection-service.ts +190 -10
- package/src/services/network-switch-service.test.ts +33 -0
- package/src/services/network-switch-service.ts +4 -1
- package/src/universal-wallet-connector.ts +1 -11
|
@@ -863,5 +863,277 @@ 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
|
+
})
|
|
866
1138
|
})
|
|
867
1139
|
})
|
|
@@ -26,6 +26,12 @@ export class ConnectionService {
|
|
|
26
26
|
private activeConnector: Connector | null = null
|
|
27
27
|
/** Tears down the post-connect lifecycle subscription; set while connected. */
|
|
28
28
|
private lifecycleTeardown: (() => void) | undefined
|
|
29
|
+
/**
|
|
30
|
+
* Teardowns for the accounts-sync attachments of the session's SECONDARY
|
|
31
|
+
* namespaces (see `attachSecondaryAccountSync`); set while connected,
|
|
32
|
+
* cleared together with `lifecycleTeardown`.
|
|
33
|
+
*/
|
|
34
|
+
private secondaryAccountSyncTeardowns: Array<() => void> = []
|
|
29
35
|
/**
|
|
30
36
|
* Abort handle for the CURRENT in-flight connect attempt. A re-entrant
|
|
31
37
|
* connect() aborts this before starting, so a superseded attempt's URI poll
|
|
@@ -200,6 +206,11 @@ export class ConnectionService {
|
|
|
200
206
|
// disconnect, expiry) from the live provider — telemetry only, never changes
|
|
201
207
|
// connection behaviour. Torn down on disconnect.
|
|
202
208
|
this.attachLifecycle(connectionMode, wallet.id, network.namespace)
|
|
209
|
+
this.attachSecondaryAccountSync(
|
|
210
|
+
connectionMode,
|
|
211
|
+
wallet.id,
|
|
212
|
+
network.namespace
|
|
213
|
+
)
|
|
203
214
|
}
|
|
204
215
|
|
|
205
216
|
/** Wire the active connector's lifecycle stream onto the bus (or signal absence). */
|
|
@@ -207,7 +218,7 @@ export class ConnectionService {
|
|
|
207
218
|
connectionMode: ConnectionMode,
|
|
208
219
|
walletId: string,
|
|
209
220
|
namespace: Namespace,
|
|
210
|
-
initialSwitch?: { chainId: NetworkId; at: number }
|
|
221
|
+
initialSwitch?: { chainId: NetworkId; address?: string; at: number }
|
|
211
222
|
): void {
|
|
212
223
|
this.teardownLifecycle()
|
|
213
224
|
const connector = this.activeConnector
|
|
@@ -227,6 +238,16 @@ export class ConnectionService {
|
|
|
227
238
|
walletId
|
|
228
239
|
})
|
|
229
240
|
: undefined,
|
|
241
|
+
subscribeAccounts: connector?.onAccountsChanged
|
|
242
|
+
? listener =>
|
|
243
|
+
connector.onAccountsChanged!(listener, {
|
|
244
|
+
connectionMode,
|
|
245
|
+
namespace,
|
|
246
|
+
walletId
|
|
247
|
+
})
|
|
248
|
+
: undefined,
|
|
249
|
+
onAccountsChanged: accounts =>
|
|
250
|
+
this.handleAccountsChanged(accounts, namespace),
|
|
230
251
|
getRouting: () => {
|
|
231
252
|
const s = this.sessionManager.getSession()
|
|
232
253
|
return {
|
|
@@ -249,6 +270,63 @@ export class ConnectionService {
|
|
|
249
270
|
})
|
|
250
271
|
}
|
|
251
272
|
|
|
273
|
+
/**
|
|
274
|
+
* Apply a REAL account-list change (from `Connector.onAccountsChanged`, via
|
|
275
|
+
* the lifecycle bridge's second stream) to the session. `namespace` is the
|
|
276
|
+
* namespace of the provider that reported the change — `activeAddress`
|
|
277
|
+
* only moves when that matches the session's currently active network; an
|
|
278
|
+
* empty list is a full, wallet-initiated disconnect.
|
|
279
|
+
*/
|
|
280
|
+
private handleAccountsChanged(
|
|
281
|
+
accounts: string[],
|
|
282
|
+
namespace: Namespace
|
|
283
|
+
): void {
|
|
284
|
+
if (accounts.length === 0) {
|
|
285
|
+
// Idempotency guard: this runs synchronously FROM INSIDE the
|
|
286
|
+
// connector's own accountsChanged dispatch — finishDisconnect() below
|
|
287
|
+
// tears down (among other things) the very subscription that is
|
|
288
|
+
// currently delivering this callback. If a wallet double-fires the
|
|
289
|
+
// empty-array disconnect signal (or one slips through before teardown
|
|
290
|
+
// takes effect), `activeConnector` is already null from the first call
|
|
291
|
+
// — skip re-clearing an already-cleared session and re-emitting
|
|
292
|
+
// disconnected/sessionChanged a second time. Scoped to THIS wallet-
|
|
293
|
+
// initiated path only; the public disconnect() below still always
|
|
294
|
+
// runs finishDisconnect() defensively, even with no active connector.
|
|
295
|
+
if (this.activeConnector === null) return
|
|
296
|
+
// The wallet ended the session on its own side, but the connector's
|
|
297
|
+
// OWN local state (e.g. EthereumWalletService's cached account/
|
|
298
|
+
// provider/uuid) is never cleared by finishDisconnect() below — it
|
|
299
|
+
// only clears core's sessionManager. Left uncleared, a later connect
|
|
300
|
+
// to a DIFFERENT wallet reads that stale cached account via
|
|
301
|
+
// checkExistingConnections() and never rebinds to the new wallet's
|
|
302
|
+
// provider/uuid, silently keeping the old wallet's address live under
|
|
303
|
+
// the new wallet's session. disconnect() is fire-and-forget and
|
|
304
|
+
// error-swallowed here: for injected connectors it's local-only
|
|
305
|
+
// cleanup (safe to call any number of times), and for protocol-based
|
|
306
|
+
// connectors that might reject on an already-closed session, there is
|
|
307
|
+
// no caller to propagate that error to anyway.
|
|
308
|
+
this.activeConnector?.disconnect?.().catch(() => {})
|
|
309
|
+
this.finishDisconnect()
|
|
310
|
+
return
|
|
311
|
+
}
|
|
312
|
+
const newAddress = accounts[0]!
|
|
313
|
+
const session = this.sessionManager.getSession()
|
|
314
|
+
const availableAddresses = session.availableAddresses.map(addr => {
|
|
315
|
+
const network = this.networks.find(n => n.id === addr.networkId)
|
|
316
|
+
return network?.namespace === namespace
|
|
317
|
+
? { ...addr, address: newAddress }
|
|
318
|
+
: addr
|
|
319
|
+
})
|
|
320
|
+
const activeAddress =
|
|
321
|
+
session.activeNetwork?.namespace === namespace
|
|
322
|
+
? newAddress
|
|
323
|
+
: session.activeAddress
|
|
324
|
+
this.sessionManager.updateSession({ activeAddress, availableAddresses })
|
|
325
|
+
this.eventManager.emit('sessionChanged', {
|
|
326
|
+
session: this.sessionManager.getSession()
|
|
327
|
+
})
|
|
328
|
+
}
|
|
329
|
+
|
|
252
330
|
/**
|
|
253
331
|
* Remove the active provider-lifecycle subscription (EIP-1193 `.on` / wallet-
|
|
254
332
|
* adapter / SignClient listeners) WITHOUT disconnecting the wallet. Idempotent.
|
|
@@ -259,6 +337,8 @@ export class ConnectionService {
|
|
|
259
337
|
teardownLifecycle(): void {
|
|
260
338
|
this.lifecycleTeardown?.()
|
|
261
339
|
this.lifecycleTeardown = undefined
|
|
340
|
+
for (const teardown of this.secondaryAccountSyncTeardowns) teardown()
|
|
341
|
+
this.secondaryAccountSyncTeardowns = []
|
|
262
342
|
}
|
|
263
343
|
|
|
264
344
|
/**
|
|
@@ -283,11 +363,93 @@ export class ConnectionService {
|
|
|
283
363
|
session.connectionMode,
|
|
284
364
|
session.activeWallet.id,
|
|
285
365
|
session.activeNetwork.namespace,
|
|
286
|
-
// The reattach follows a JUST-completed switch to the now-active chain
|
|
287
|
-
// arm echo suppression for
|
|
288
|
-
// for a wallet-initiated change (one-shot, same as
|
|
289
|
-
|
|
366
|
+
// The reattach follows a JUST-completed switch to the now-active chain
|
|
367
|
+
// (and address) — arm echo suppression for both so the new provider's
|
|
368
|
+
// echo isn't mistaken for a wallet-initiated change (one-shot, same as
|
|
369
|
+
// bus-armed switches).
|
|
370
|
+
{
|
|
371
|
+
chainId: session.activeNetwork.id,
|
|
372
|
+
...(session.activeAddress && { address: session.activeAddress }),
|
|
373
|
+
at: Date.now()
|
|
374
|
+
}
|
|
290
375
|
)
|
|
376
|
+
// Re-derive the secondary set against the NOW-active namespace — a
|
|
377
|
+
// cross-namespace switch swaps which namespace is "secondary" (the
|
|
378
|
+
// attachLifecycle above already tore the previous set down via
|
|
379
|
+
// teardownLifecycle).
|
|
380
|
+
this.attachSecondaryAccountSync(
|
|
381
|
+
session.connectionMode,
|
|
382
|
+
session.activeWallet.id,
|
|
383
|
+
session.activeNetwork.namespace
|
|
384
|
+
)
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Attach account-sync for every OTHER namespace the session's
|
|
389
|
+
* `availableAddresses` span. `attachLifecycle` observes only the ACTIVE
|
|
390
|
+
* network's namespace, but the cross-namespace gather seeds addresses from
|
|
391
|
+
* other namespaces' live surfaces at connect time — without their own
|
|
392
|
+
* subscription, a wallet-side account switch on such a namespace is never
|
|
393
|
+
* observed and the session serves the connect-time addresses forever (e.g.
|
|
394
|
+
* a Solana-active MetaMask session whose eip155 addresses never follow the
|
|
395
|
+
* extension's account switch).
|
|
396
|
+
*
|
|
397
|
+
* Accounts-sync ONLY, no lifecycle-telemetry stream: the active namespace
|
|
398
|
+
* stays the single source of wallet* telemetry and of the wallet-initiated
|
|
399
|
+
* disconnect signal.
|
|
400
|
+
*/
|
|
401
|
+
private attachSecondaryAccountSync(
|
|
402
|
+
connectionMode: ConnectionMode,
|
|
403
|
+
walletId: string,
|
|
404
|
+
activeNamespace: Namespace
|
|
405
|
+
): void {
|
|
406
|
+
const connector = this.activeConnector
|
|
407
|
+
if (!connector?.onAccountsChanged) return
|
|
408
|
+
|
|
409
|
+
const session = this.sessionManager.getSession()
|
|
410
|
+
const secondaryNamespaces = new Set<Namespace>()
|
|
411
|
+
for (const addr of session.availableAddresses) {
|
|
412
|
+
const namespace = this.networks.find(
|
|
413
|
+
n => n.id === addr.networkId
|
|
414
|
+
)?.namespace
|
|
415
|
+
if (namespace && namespace !== activeNamespace) {
|
|
416
|
+
secondaryNamespaces.add(namespace)
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const runtime =
|
|
421
|
+
(globalThis as { UWCBridgeChildInitialized?: boolean })
|
|
422
|
+
.UWCBridgeChildInitialized === true
|
|
423
|
+
? ('iframe' as const)
|
|
424
|
+
: ('direct' as const)
|
|
425
|
+
|
|
426
|
+
for (const namespace of secondaryNamespaces) {
|
|
427
|
+
this.secondaryAccountSyncTeardowns.push(
|
|
428
|
+
attachLifecycle({
|
|
429
|
+
eventManager: this.eventManager,
|
|
430
|
+
subscribe: undefined,
|
|
431
|
+
subscribeAccounts: listener =>
|
|
432
|
+
connector.onAccountsChanged!(listener, {
|
|
433
|
+
connectionMode,
|
|
434
|
+
namespace,
|
|
435
|
+
walletId
|
|
436
|
+
}),
|
|
437
|
+
onAccountsChanged: accounts => {
|
|
438
|
+
// An empty list from a SECONDARY namespace means that namespace's
|
|
439
|
+
// own authorization ended — NOT the session. Only the active
|
|
440
|
+
// namespace's stream may signal the full wallet-initiated
|
|
441
|
+
// disconnect (handleAccountsChanged's empty-list path).
|
|
442
|
+
if (accounts.length === 0) return
|
|
443
|
+
this.handleAccountsChanged(accounts, namespace)
|
|
444
|
+
},
|
|
445
|
+
getRouting: () => ({ connectionMode, walletId, namespace }),
|
|
446
|
+
connectionMode,
|
|
447
|
+
walletId,
|
|
448
|
+
namespace,
|
|
449
|
+
runtime
|
|
450
|
+
})
|
|
451
|
+
)
|
|
452
|
+
}
|
|
291
453
|
}
|
|
292
454
|
|
|
293
455
|
private async connectWithURIPoll(
|
|
@@ -486,14 +648,32 @@ export class ConnectionService {
|
|
|
486
648
|
await this.activeConnector.disconnect()
|
|
487
649
|
}
|
|
488
650
|
} finally {
|
|
489
|
-
this.
|
|
490
|
-
this.sessionManager.clearSession()
|
|
491
|
-
const session = this.sessionManager.getSession()
|
|
492
|
-
this.eventManager.emit('disconnected', undefined)
|
|
493
|
-
this.eventManager.emit('sessionChanged', { session })
|
|
651
|
+
this.finishDisconnect()
|
|
494
652
|
}
|
|
495
653
|
}
|
|
496
654
|
|
|
655
|
+
/**
|
|
656
|
+
* Clears core's own local state (session, active connector reference,
|
|
657
|
+
* lifecycle subscription) and emits the disconnect events. Does NOT itself
|
|
658
|
+
* call the connector's `disconnect()` — that happens in each CALLER,
|
|
659
|
+
* because the two callers need different semantics: the public
|
|
660
|
+
* `disconnect()` above awaits it (the caller may care whether the wallet
|
|
661
|
+
* side actually tore down); the wallet-initiated path in
|
|
662
|
+
* `handleAccountsChanged` fires it and swallows the result (the wallet
|
|
663
|
+
* already ended the session on its own side, but the connector's own
|
|
664
|
+
* LOCAL state — e.g. a cached account/uuid — still needs clearing, or a
|
|
665
|
+
* later connect to a different wallet reads it as stale and never
|
|
666
|
+
* rebinds).
|
|
667
|
+
*/
|
|
668
|
+
private finishDisconnect(): void {
|
|
669
|
+
this.teardownLifecycle()
|
|
670
|
+
this.activeConnector = null
|
|
671
|
+
this.sessionManager.clearSession()
|
|
672
|
+
const session = this.sessionManager.getSession()
|
|
673
|
+
this.eventManager.emit('disconnected', undefined)
|
|
674
|
+
this.eventManager.emit('sessionChanged', { session })
|
|
675
|
+
}
|
|
676
|
+
|
|
497
677
|
getConnectionURI(): string | undefined {
|
|
498
678
|
if (
|
|
499
679
|
this.activeConnector &&
|
|
@@ -234,4 +234,37 @@ describe('NetworkSwitchService', () => {
|
|
|
234
234
|
expect(events[0].data.operation).toBe('switchNetwork')
|
|
235
235
|
expect(events[0].data.chainId).toBe('eip155:137')
|
|
236
236
|
})
|
|
237
|
+
|
|
238
|
+
it('includes the new address on the emitted networkSwitched event', async () => {
|
|
239
|
+
sessionManager.updateSession({
|
|
240
|
+
connectionMode: 'injected',
|
|
241
|
+
activeWallet: {
|
|
242
|
+
id: 'metamask',
|
|
243
|
+
name: 'MetaMask',
|
|
244
|
+
metadata: {
|
|
245
|
+
icon: 'test',
|
|
246
|
+
downloadUrl: 'test',
|
|
247
|
+
supportedChains: ['eip155:1', 'eip155:137']
|
|
248
|
+
},
|
|
249
|
+
extensionInjectedProvider: {
|
|
250
|
+
supportedNetworkIds: ['eip155:1', 'eip155:137']
|
|
251
|
+
}
|
|
252
|
+
} as any
|
|
253
|
+
})
|
|
254
|
+
mockConnector.switchNetwork = vi.fn().mockResolvedValue({
|
|
255
|
+
address: '0xabc',
|
|
256
|
+
availableAddresses: [{ address: '0xabc', networkId: 'eip155:137' }]
|
|
257
|
+
})
|
|
258
|
+
const switched: Array<{ network: unknown; address?: string }> = []
|
|
259
|
+
eventManager.on('networkSwitched', d => switched.push(d))
|
|
260
|
+
|
|
261
|
+
await networkSwitchService.switchNetwork('eip155:137')
|
|
262
|
+
|
|
263
|
+
expect(switched).toEqual([
|
|
264
|
+
{
|
|
265
|
+
network: expect.objectContaining({ id: 'eip155:137' }),
|
|
266
|
+
address: '0xabc'
|
|
267
|
+
}
|
|
268
|
+
])
|
|
269
|
+
})
|
|
237
270
|
})
|
|
@@ -188,7 +188,10 @@ export class NetworkSwitchService {
|
|
|
188
188
|
})
|
|
189
189
|
})
|
|
190
190
|
|
|
191
|
-
this.eventManager.emit('networkSwitched', {
|
|
191
|
+
this.eventManager.emit('networkSwitched', {
|
|
192
|
+
network,
|
|
193
|
+
address: result.address
|
|
194
|
+
})
|
|
192
195
|
this.eventManager.emit('sessionChanged', {
|
|
193
196
|
session: this.sessionManager.getSession()
|
|
194
197
|
})
|
|
@@ -325,17 +325,7 @@ export class UniversalWalletConnector {
|
|
|
325
325
|
(error: unknown) =>
|
|
326
326
|
this.eventManager.emit('walletConnectInitError', {
|
|
327
327
|
error: toWalletError(error)
|
|
328
|
-
})
|
|
329
|
-
// Relay transport dropped — capture the FACT (+ scrubbed reason if any) so a
|
|
330
|
-
// socket death during a wallet deep-link is visible, not silent (CARE-263).
|
|
331
|
-
(detail: unknown) =>
|
|
332
|
-
this.eventManager.emit(
|
|
333
|
-
'relayDisconnected',
|
|
334
|
-
detail ? { error: toWalletError(detail) } : {}
|
|
335
|
-
),
|
|
336
|
-
// Relay transport recovered — pair with `relayDisconnected` (same
|
|
337
|
-
// sdkSessionId) to measure the dead window.
|
|
338
|
-
() => this.eventManager.emit('relayReconnected', undefined)
|
|
328
|
+
})
|
|
339
329
|
)
|
|
340
330
|
)
|
|
341
331
|
}
|