@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
@@ -1,5 +1,9 @@
1
1
  import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
2
- import { ConnectionService } from './connection-service'
2
+ import {
3
+ ConnectionService,
4
+ SIBLING_REFRESH_MAX_ATTEMPTS,
5
+ SIBLING_REFRESH_DELAY_MS
6
+ } from './connection-service'
3
7
  import { SessionManager } from '../managers/session-manager'
4
8
  import { EventManager } from '../managers/event-manager'
5
9
  import type {
@@ -863,5 +867,798 @@ describe('ConnectionService', () => {
863
867
  // No connection → no lifecycle evaluation, so no "unavailable" noise.
864
868
  expect(unavail).toHaveLength(0)
865
869
  })
870
+
871
+ it('a real accountsChanged from the connector updates activeAddress and emits sessionChanged', async () => {
872
+ let emitAccounts!: (accounts: string[]) => void
873
+ mockConnector.onAccountsChanged = (l: (accounts: string[]) => void) => {
874
+ emitAccounts = l
875
+ return () => {}
876
+ }
877
+ const sessions: Array<{ activeAddress: string | null }> = []
878
+ eventManager.on('sessionChanged', d =>
879
+ sessions.push({ activeAddress: d.session.activeAddress })
880
+ )
881
+
882
+ await connectionService.connect('injected', 'metamask', 'eip155:1')
883
+ emitAccounts(['0xNEW'])
884
+
885
+ expect(sessionManager.getSession().activeAddress).toBe('0xNEW')
886
+ expect(sessions[sessions.length - 1]?.activeAddress).toBe('0xNEW')
887
+ })
888
+
889
+ it('updates the matching availableAddresses entries for the changed namespace', async () => {
890
+ let emitAccounts!: (accounts: string[]) => void
891
+ mockConnector.onAccountsChanged = (l: (accounts: string[]) => void) => {
892
+ emitAccounts = l
893
+ return () => {}
894
+ }
895
+
896
+ await connectionService.connect('injected', 'metamask', 'eip155:1')
897
+ emitAccounts(['0xNEW'])
898
+
899
+ expect(sessionManager.getSession().availableAddresses).toEqual([
900
+ { address: '0xNEW', networkId: 'eip155:1' }
901
+ ])
902
+ })
903
+
904
+ it('an empty accounts list triggers a full disconnect AND calls connector.disconnect for local-state cleanup', async () => {
905
+ // The wallet already ended the session on its own side, but the
906
+ // connector's own LOCAL state (e.g. EthereumWalletService's cached
907
+ // account/uuid) still needs clearing — otherwise a later connect to a
908
+ // DIFFERENT wallet reads the stale cached account via
909
+ // checkExistingConnections() and never rebinds to the new wallet's
910
+ // provider/uuid. disconnect() must still be called here; it's a
911
+ // fire-and-forget, error-swallowed call (see the next test) so a
912
+ // protocol-based connector that rejects on an already-closed session
913
+ // can't break this path.
914
+ let emitAccounts!: (accounts: string[]) => void
915
+ mockConnector.onAccountsChanged = (l: (accounts: string[]) => void) => {
916
+ emitAccounts = l
917
+ return () => {}
918
+ }
919
+ const disconnectSpy = vi.fn().mockResolvedValue(undefined)
920
+ mockConnector.disconnect = disconnectSpy
921
+
922
+ await connectionService.connect('injected', 'metamask', 'eip155:1')
923
+ emitAccounts([])
924
+
925
+ expect(sessionManager.getSession().activeAddress).toBeNull()
926
+ expect(sessionManager.getSession().connectionMode).toBeNull()
927
+ expect(disconnectSpy).toHaveBeenCalledTimes(1)
928
+ })
929
+
930
+ it('does not let a rejecting connector.disconnect() break the wallet-initiated disconnect path', async () => {
931
+ let emitAccounts!: (accounts: string[]) => void
932
+ mockConnector.onAccountsChanged = (l: (accounts: string[]) => void) => {
933
+ emitAccounts = l
934
+ return () => {}
935
+ }
936
+ mockConnector.disconnect = vi
937
+ .fn()
938
+ .mockRejectedValue(new Error('already closed'))
939
+
940
+ await connectionService.connect('injected', 'metamask', 'eip155:1')
941
+
942
+ expect(() => emitAccounts([])).not.toThrow()
943
+ expect(sessionManager.getSession().activeAddress).toBeNull()
944
+ // Let the fire-and-forget rejection settle so it doesn't surface as an
945
+ // unhandled rejection in a later test.
946
+ await Promise.resolve()
947
+ await Promise.resolve()
948
+ })
949
+
950
+ it('a wallet that double-fires an empty accountsChanged does not double-emit disconnected/sessionChanged', async () => {
951
+ // handleAccountsChanged([]) -> finishDisconnect() runs synchronously
952
+ // FROM INSIDE the connector's own accountsChanged dispatch (the
953
+ // teardown it triggers removes that very listener re-entrantly). If a
954
+ // wallet emits the empty-array disconnect signal twice in quick
955
+ // succession — or the second emission slips through before teardown
956
+ // takes effect — finishDisconnect must be a no-op the second time
957
+ // rather than clearing an already-cleared session and re-emitting.
958
+ let emitAccounts!: (accounts: string[]) => void
959
+ mockConnector.onAccountsChanged = (l: (accounts: string[]) => void) => {
960
+ emitAccounts = l
961
+ return () => {}
962
+ }
963
+ mockConnector.disconnect = vi.fn().mockResolvedValue(undefined)
964
+ const disconnectedEvents: unknown[] = []
965
+ const sessionChangedEvents: unknown[] = []
966
+ eventManager.on('disconnected', d => disconnectedEvents.push(d))
967
+ eventManager.on('sessionChanged', d => sessionChangedEvents.push(d))
968
+
969
+ await connectionService.connect('injected', 'metamask', 'eip155:1')
970
+ sessionChangedEvents.length = 0 // drop the connect's own sessionChanged
971
+
972
+ emitAccounts([])
973
+ emitAccounts([]) // the wallet (or a race) delivers the same signal again
974
+
975
+ expect(disconnectedEvents).toHaveLength(1)
976
+ expect(sessionChangedEvents).toHaveLength(1)
977
+ expect(mockConnector.disconnect).toHaveBeenCalledTimes(1)
978
+ })
979
+
980
+ it('reattachLifecycle() seeds address-echo suppression with the just-active address', async () => {
981
+ const accountSubscribers: Array<(accounts: string[]) => void> = []
982
+ mockConnector.onAccountsChanged = (l: (accounts: string[]) => void) => {
983
+ accountSubscribers.push(l)
984
+ return () => {}
985
+ }
986
+ const sessions: unknown[] = []
987
+ eventManager.on('sessionChanged', d => sessions.push(d))
988
+
989
+ await connectionService.connect('injected', 'metamask', 'eip155:1')
990
+ sessions.length = 0
991
+ connectionService.reattachLifecycle()
992
+
993
+ // The reattached provider echoes the session's own current address →
994
+ // suppressed, no redundant sessionChanged.
995
+ accountSubscribers[1]!(['0x1234'])
996
+ expect(sessions).toHaveLength(0)
997
+
998
+ // A later, genuinely different address is a real change.
999
+ accountSubscribers[1]!(['0xDIFFERENT'])
1000
+ expect(sessions).toHaveLength(1)
1001
+ })
1002
+
1003
+ 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 () => {
1004
+ let emitAccounts!: (accounts: string[]) => void
1005
+ mockConnector.onAccountsChanged = (l: (accounts: string[]) => void) => {
1006
+ emitAccounts = l
1007
+ return () => {}
1008
+ }
1009
+
1010
+ await connectionService.connect('injected', 'metamask', 'eip155:1')
1011
+ const connectTimeAddress = sessionManager.getSession().activeAddress
1012
+
1013
+ // Simulate the session's active network having already moved to a
1014
+ // different namespace (e.g. mid-cross-namespace-switch) WITHOUT this
1015
+ // stream having been reattached yet — it's still the eip155 one from
1016
+ // connect time.
1017
+ sessionManager.updateSession({
1018
+ activeNetwork: {
1019
+ ...sessionManager.getSession().activeNetwork,
1020
+ namespace: 'solana'
1021
+ } as any
1022
+ })
1023
+
1024
+ emitAccounts(['0xNEW'])
1025
+
1026
+ // activeAddress belongs to the now-active (solana) namespace and must
1027
+ // not be clobbered by a stale eip155 subscription's account change.
1028
+ expect(sessionManager.getSession().activeAddress).toBe(connectTimeAddress)
1029
+ // availableAddresses for the eip155 entry IS still updated — the address
1030
+ // change is real, just not the currently-active one.
1031
+ expect(sessionManager.getSession().availableAddresses).toEqual([
1032
+ { address: '0xNEW', networkId: 'eip155:1' }
1033
+ ])
1034
+ })
1035
+ })
1036
+
1037
+ describe('secondary-namespace account sync', () => {
1038
+ // A multi-namespace wallet (e.g. MetaMask with Solana support) connected
1039
+ // on a SOLANA-active session: the cross-namespace gather seeds eip155
1040
+ // addresses into availableAddresses at connect time, but the lifecycle
1041
+ // attach used to observe only the ACTIVE namespace — an eip155 account
1042
+ // switch in the extension was never observed and the session served the
1043
+ // connect-time eip155 addresses forever.
1044
+ let accountSubs: Map<string, (accounts: string[]) => void>
1045
+ let teardownsByNamespace: Map<string, number>
1046
+
1047
+ beforeEach(() => {
1048
+ networks.push({
1049
+ id: 'solana:mainnet',
1050
+ internalId: 101,
1051
+ namespace: 'solana',
1052
+ name: 'Solana',
1053
+ logoUrl: '',
1054
+ nativeCurrency: { name: 'SOL', symbol: 'SOL', decimals: 9 },
1055
+ networkType: 'solana'
1056
+ } as never)
1057
+ wallets[0]!.extensionInjectedProvider = {
1058
+ supportedNetworkIds: ['solana:mainnet', 'eip155:1']
1059
+ } as never
1060
+ mockConnector.connect = vi.fn().mockResolvedValue({
1061
+ networkId: 'solana:mainnet',
1062
+ address: 'SoLAddr111',
1063
+ availableAddresses: [
1064
+ { address: 'SoLAddr111', networkId: 'solana:mainnet' },
1065
+ { address: '0x1234', networkId: 'eip155:1' }
1066
+ ]
1067
+ })
1068
+ accountSubs = new Map()
1069
+ teardownsByNamespace = new Map()
1070
+ mockConnector.onAccountsChanged = ((
1071
+ listener: (accounts: string[]) => void,
1072
+ context: { namespace: string }
1073
+ ) => {
1074
+ accountSubs.set(context.namespace, listener)
1075
+ return () => {
1076
+ teardownsByNamespace.set(
1077
+ context.namespace,
1078
+ (teardownsByNamespace.get(context.namespace) ?? 0) + 1
1079
+ )
1080
+ }
1081
+ }) as never
1082
+ })
1083
+
1084
+ it('attaches an accounts-sync subscription for every namespace present in availableAddresses, not only the active one', async () => {
1085
+ await connectionService.connect('injected', 'metamask', 'solana:mainnet')
1086
+
1087
+ expect([...accountSubs.keys()].sort()).toEqual(['eip155', 'solana'])
1088
+ })
1089
+
1090
+ it("a secondary namespace's accountsChanged updates its availableAddresses entries without touching activeAddress", async () => {
1091
+ const walletAccountChangedSpy = vi.fn()
1092
+ eventManager.on('walletAccountChanged', walletAccountChangedSpy)
1093
+
1094
+ await connectionService.connect('injected', 'metamask', 'solana:mainnet')
1095
+
1096
+ accountSubs.get('eip155')!(['0xSWITCHED'])
1097
+
1098
+ const session = sessionManager.getSession()
1099
+ expect(session.activeAddress).toBe('SoLAddr111')
1100
+ expect(session.availableAddresses).toEqual([
1101
+ { address: 'SoLAddr111', networkId: 'solana:mainnet' },
1102
+ { address: '0xSWITCHED', networkId: 'eip155:1' }
1103
+ ])
1104
+ // Surfaced for observability (uwcObserver → `uwc.walletAccountChanged`).
1105
+ expect(walletAccountChangedSpy).toHaveBeenCalledWith(
1106
+ expect.objectContaining({ namespace: 'eip155' })
1107
+ )
1108
+ })
1109
+
1110
+ it('a secondary empty is a conservative no-op when the connector cannot report a live account', async () => {
1111
+ // No getLiveAccount on the connector → we cannot tell a partial de-auth
1112
+ // from a full disconnect, so keep the session (never risk a spurious
1113
+ // disconnect).
1114
+ const disconnectSpy = vi.fn().mockResolvedValue(undefined)
1115
+ mockConnector.disconnect = disconnectSpy
1116
+
1117
+ await connectionService.connect('injected', 'metamask', 'solana:mainnet')
1118
+ accountSubs.get('eip155')!([])
1119
+ await new Promise(resolve => setTimeout(resolve, 0))
1120
+
1121
+ const session = sessionManager.getSession()
1122
+ expect(session.connectionMode).toBe('injected')
1123
+ expect(session.activeAddress).toBe('SoLAddr111')
1124
+ expect(disconnectSpy).not.toHaveBeenCalled()
1125
+ })
1126
+
1127
+ it('a secondary empty does NOT disconnect when the ACTIVE namespace still has a live account (genuine partial change)', async () => {
1128
+ const disconnectSpy = vi.fn().mockResolvedValue(undefined)
1129
+ mockConnector.disconnect = disconnectSpy
1130
+ // Active (solana) still connected → the eip155 empty is only that leg's
1131
+ // de-auth.
1132
+ mockConnector.getLiveAccount = vi.fn().mockResolvedValue('SoLAddr111')
1133
+
1134
+ await connectionService.connect('injected', 'metamask', 'solana:mainnet')
1135
+ accountSubs.get('eip155')!([])
1136
+ await new Promise(resolve => setTimeout(resolve, 0))
1137
+
1138
+ const session = sessionManager.getSession()
1139
+ expect(mockConnector.getLiveAccount).toHaveBeenCalledWith('solana')
1140
+ expect(session.activeAddress).toBe('SoLAddr111')
1141
+ expect(disconnectSpy).not.toHaveBeenCalled()
1142
+ })
1143
+
1144
+ it('a secondary empty CLEARS the session when the ACTIVE namespace is also gone (full wallet disconnect, e.g. MetaMask whose Solana-Snap active namespace emits no disconnect)', async () => {
1145
+ const disconnectSpy = vi.fn().mockResolvedValue(undefined)
1146
+ mockConnector.disconnect = disconnectSpy
1147
+ // Active (solana) also gone → the whole wallet disconnected.
1148
+ mockConnector.getLiveAccount = vi.fn().mockResolvedValue(null)
1149
+ const walletDisconnectedSpy = vi.fn()
1150
+ eventManager.on('walletDisconnected', walletDisconnectedSpy)
1151
+
1152
+ await connectionService.connect('injected', 'metamask', 'solana:mainnet')
1153
+ accountSubs.get('eip155')!([])
1154
+ await new Promise(resolve => setTimeout(resolve, 0))
1155
+
1156
+ const session = sessionManager.getSession()
1157
+ expect(mockConnector.getLiveAccount).toHaveBeenCalledWith('solana')
1158
+ expect(disconnectSpy).toHaveBeenCalled()
1159
+ expect(session.activeAddress).toBeFalsy()
1160
+ // Surfaced as a wallet-initiated disconnect for observability — uwcObserver
1161
+ // forwards this to RUM as `uwc.walletDisconnected`.
1162
+ expect(walletDisconnectedSpy).toHaveBeenCalledWith(
1163
+ expect.objectContaining({ namespace: 'solana' })
1164
+ )
1165
+ })
1166
+
1167
+ it('disconnect() tears down the secondary subscriptions too', async () => {
1168
+ await connectionService.connect('injected', 'metamask', 'solana:mainnet')
1169
+
1170
+ await connectionService.disconnect()
1171
+
1172
+ expect(teardownsByNamespace.get('eip155')).toBe(1)
1173
+ expect(teardownsByNamespace.get('solana')).toBe(1)
1174
+ })
1175
+
1176
+ it('reattachLifecycle() re-derives the secondary set for the CURRENT session without stacking duplicates', async () => {
1177
+ await connectionService.connect('injected', 'metamask', 'solana:mainnet')
1178
+
1179
+ connectionService.reattachLifecycle()
1180
+
1181
+ // Old eip155 secondary attachment released, exactly one fresh one live.
1182
+ expect(teardownsByNamespace.get('eip155')).toBe(1)
1183
+ accountSubs.get('eip155')!(['0xAFTER'])
1184
+ expect(
1185
+ sessionManager
1186
+ .getSession()
1187
+ .availableAddresses.find(a => a.networkId === 'eip155:1')?.address
1188
+ ).toBe('0xAFTER')
1189
+ })
1190
+ })
1191
+
1192
+ describe('cross-namespace account refresh on switch', () => {
1193
+ // Multi-namespace injected wallets (MetaMask/Trust/Phantom) derive every
1194
+ // namespace's address from ONE underlying account. The wallet fires
1195
+ // accountsChanged for a SINGLE namespace on a switch; the sibling
1196
+ // namespaces' addresses change too but emit no event of their own, so
1197
+ // `refreshSiblingNamespaces` re-reads each connected sibling's LIVE
1198
+ // account and folds any real change into the session. Session is seeded
1199
+ // SOLANA-active (the real-bug shape: an eip155 switch must still move the
1200
+ // active solana address).
1201
+ let accountSubs: Map<string, (accounts: string[]) => void>
1202
+
1203
+ beforeEach(() => {
1204
+ networks.push({
1205
+ id: 'solana:mainnet',
1206
+ internalId: 101,
1207
+ namespace: 'solana',
1208
+ name: 'Solana',
1209
+ logoUrl: '',
1210
+ nativeCurrency: { name: 'SOL', symbol: 'SOL', decimals: 9 },
1211
+ networkType: 'solana'
1212
+ } as never)
1213
+ wallets[0]!.extensionInjectedProvider = {
1214
+ supportedNetworkIds: ['solana:mainnet', 'eip155:1']
1215
+ } as never
1216
+ mockConnector.connect = vi.fn().mockResolvedValue({
1217
+ networkId: 'solana:mainnet',
1218
+ address: 'SoLAddr111',
1219
+ availableAddresses: [
1220
+ { address: 'SoLAddr111', networkId: 'solana:mainnet' },
1221
+ { address: '0x1234', networkId: 'eip155:1' }
1222
+ ]
1223
+ })
1224
+ accountSubs = new Map()
1225
+ mockConnector.onAccountsChanged = ((
1226
+ listener: (accounts: string[]) => void,
1227
+ context: { namespace: string }
1228
+ ) => {
1229
+ accountSubs.set(context.namespace, listener)
1230
+ return () => {}
1231
+ }) as never
1232
+ })
1233
+
1234
+ it('a Solana-active session follows an eip155 switch: the solana sibling is refreshed and activeAddress moves with it', async () => {
1235
+ const liveAccount = vi.fn(async (ns: string) =>
1236
+ ns === 'solana' ? 'SoLAddr_NEW' : null
1237
+ )
1238
+ ;(mockConnector as any).getLiveAccount = liveAccount
1239
+
1240
+ const sessions: unknown[] = []
1241
+ eventManager.on('sessionChanged', d => sessions.push(d))
1242
+
1243
+ await connectionService.connect('injected', 'metamask', 'solana:mainnet')
1244
+ sessions.length = 0 // drop the connect's own sessionChanged
1245
+
1246
+ accountSubs.get('eip155')!(['0xSWITCHED'])
1247
+
1248
+ await vi.waitFor(() => {
1249
+ expect(
1250
+ sessionManager
1251
+ .getSession()
1252
+ .availableAddresses.find(a => a.networkId === 'solana:mainnet')
1253
+ ?.address
1254
+ ).toBe('SoLAddr_NEW')
1255
+ })
1256
+
1257
+ const session = sessionManager.getSession()
1258
+ expect(session.activeAddress).toBe('SoLAddr_NEW')
1259
+ expect(session.availableAddresses).toEqual([
1260
+ { address: 'SoLAddr_NEW', networkId: 'solana:mainnet' },
1261
+ { address: '0xSWITCHED', networkId: 'eip155:1' }
1262
+ ])
1263
+ expect(sessions.length).toBeGreaterThan(0)
1264
+ })
1265
+
1266
+ it('polls a lagging sibling until its live address differs from the stored one', async () => {
1267
+ vi.useFakeTimers()
1268
+ try {
1269
+ let call = 0
1270
+ const liveAccount = vi.fn(async (ns: string) => {
1271
+ if (ns !== 'solana') return null
1272
+ call++
1273
+ // First read still reflects the OLD (stored) address — the wallet
1274
+ // hasn't finished re-deriving the sibling yet.
1275
+ return call === 1 ? 'SoLAddr111' : 'SoLAddr_NEW'
1276
+ })
1277
+ ;(mockConnector as any).getLiveAccount = liveAccount
1278
+
1279
+ await connectionService.connect(
1280
+ 'injected',
1281
+ 'metamask',
1282
+ 'solana:mainnet'
1283
+ )
1284
+
1285
+ accountSubs.get('eip155')!(['0xSWITCHED'])
1286
+
1287
+ await vi.advanceTimersByTimeAsync(SIBLING_REFRESH_DELAY_MS)
1288
+
1289
+ const session = sessionManager.getSession()
1290
+ expect(
1291
+ session.availableAddresses.find(a => a.networkId === 'solana:mainnet')
1292
+ ?.address
1293
+ ).toBe('SoLAddr_NEW')
1294
+ expect(session.activeAddress).toBe('SoLAddr_NEW')
1295
+ } finally {
1296
+ vi.useRealTimers()
1297
+ }
1298
+ })
1299
+
1300
+ it('gives up after the poll cap when the sibling never changes, without an extra update or emit', async () => {
1301
+ vi.useFakeTimers()
1302
+ try {
1303
+ const liveAccount = vi.fn(async (ns: string) =>
1304
+ ns === 'solana' ? 'SoLAddr111' : null
1305
+ )
1306
+ ;(mockConnector as any).getLiveAccount = liveAccount
1307
+
1308
+ const sessions: unknown[] = []
1309
+ eventManager.on('sessionChanged', d => sessions.push(d))
1310
+
1311
+ await connectionService.connect(
1312
+ 'injected',
1313
+ 'metamask',
1314
+ 'solana:mainnet'
1315
+ )
1316
+ sessions.length = 0 // drop the connect's own sessionChanged
1317
+
1318
+ accountSubs.get('eip155')!(['0xSWITCHED'])
1319
+ // The eip155 switch's own sessionChanged fires synchronously above.
1320
+ expect(sessions.length).toBe(1)
1321
+
1322
+ await vi.advanceTimersByTimeAsync(
1323
+ SIBLING_REFRESH_MAX_ATTEMPTS * SIBLING_REFRESH_DELAY_MS
1324
+ )
1325
+
1326
+ const session = sessionManager.getSession()
1327
+ expect(
1328
+ session.availableAddresses.find(a => a.networkId === 'solana:mainnet')
1329
+ ?.address
1330
+ ).toBe('SoLAddr111')
1331
+ expect(session.activeAddress).toBe('SoLAddr111')
1332
+ // No additional sessionChanged beyond the firing namespace's own one.
1333
+ expect(sessions.length).toBe(1)
1334
+ } finally {
1335
+ vi.useRealTimers()
1336
+ }
1337
+ })
1338
+
1339
+ it('an empty accounts list (disconnect) does not trigger a sibling refresh', async () => {
1340
+ const liveAccount = vi.fn(async () => 'SoLAddr_NEW')
1341
+ ;(mockConnector as any).getLiveAccount = liveAccount
1342
+ mockConnector.disconnect = vi.fn().mockResolvedValue(undefined)
1343
+
1344
+ await connectionService.connect('injected', 'metamask', 'solana:mainnet')
1345
+
1346
+ accountSubs.get('solana')!([]) // active namespace's own disconnect signal
1347
+
1348
+ expect(liveAccount).not.toHaveBeenCalled()
1349
+ const session = sessionManager.getSession()
1350
+ expect(session.activeAddress).toBeNull()
1351
+ expect(session.connectionMode).toBeNull()
1352
+ })
1353
+
1354
+ it('cancels an in-flight poll when a newer switch supersedes it — the later switch wins', async () => {
1355
+ vi.useFakeTimers()
1356
+ try {
1357
+ // Keyed by CALL ORDER, not by a single shared "current live value"
1358
+ // variable — a shared variable would let the superseded refresh's
1359
+ // later read silently converge with whatever the second refresh just
1360
+ // wrote, passing even with the generation guard deleted. Instead:
1361
+ // call #1 = refresh #1 (superseded), attempt 0 → equals the stored
1362
+ // address, so refresh #1 does NOT resolve yet and keeps polling.
1363
+ // call #2 = refresh #2 (superseding), attempt 0 → differs from the
1364
+ // stored address, so refresh #2 resolves immediately and writes
1365
+ // the correct value.
1366
+ // call #3 = refresh #1, attempt 1 — reached ONLY if refresh #1 is
1367
+ // allowed to keep going after being superseded (i.e. the
1368
+ // generation guard is missing). Returns a THIRD address that is
1369
+ // neither the original stored value nor refresh #2's correct
1370
+ // value, so if refresh #1 is ever allowed to write here, it
1371
+ // visibly clobbers the correct value with this stale one.
1372
+ let solanaCallCount = 0
1373
+ const liveAccount = vi.fn(async (ns: string) => {
1374
+ if (ns !== 'solana') return null
1375
+ solanaCallCount++
1376
+ if (solanaCallCount === 1) return 'SoLAddr111'
1377
+ if (solanaCallCount === 2) return 'SoLAddr_FROM_SECOND'
1378
+ return 'SoLAddr_STALE_FROM_FIRST'
1379
+ })
1380
+ ;(mockConnector as any).getLiveAccount = liveAccount
1381
+
1382
+ await connectionService.connect(
1383
+ 'injected',
1384
+ 'metamask',
1385
+ 'solana:mainnet'
1386
+ )
1387
+
1388
+ accountSubs.get('eip155')!(['0xSWITCHED1'])
1389
+ // Let the first refresh's attempt-0 read (call #1) resolve and enter
1390
+ // its poll wait.
1391
+ await vi.advanceTimersByTimeAsync(0)
1392
+
1393
+ // A newer switch fires before the first refresh's next attempt — it
1394
+ // supersedes the generation token. Its own attempt-0 read (call #2)
1395
+ // resolves synchronously below, since the stored address hasn't
1396
+ // moved yet.
1397
+ accountSubs.get('eip155')!(['0xSWITCHED2'])
1398
+
1399
+ // Drain everything: the first refresh's remaining attempts must bail
1400
+ // out on the generation check rather than issuing call #3 and writing
1401
+ // its stale value over the second refresh's correct one.
1402
+ await vi.advanceTimersByTimeAsync(
1403
+ SIBLING_REFRESH_MAX_ATTEMPTS * SIBLING_REFRESH_DELAY_MS
1404
+ )
1405
+
1406
+ const session = sessionManager.getSession()
1407
+ expect(
1408
+ session.availableAddresses.find(a => a.networkId === 'solana:mainnet')
1409
+ ?.address
1410
+ ).toBe('SoLAddr_FROM_SECOND')
1411
+ expect(session.activeAddress).toBe('SoLAddr_FROM_SECOND')
1412
+ // Never the superseded refresh's later, stale read (call #3) — this
1413
+ // is the write the generation guard exists to prevent.
1414
+ expect(session.availableAddresses.map(a => a.address)).not.toContain(
1415
+ 'SoLAddr_STALE_FROM_FIRST'
1416
+ )
1417
+ } finally {
1418
+ vi.useRealTimers()
1419
+ }
1420
+ })
1421
+
1422
+ it('is a no-op (no throw, no sibling change) when the connector has no getLiveAccount', async () => {
1423
+ // Backward compat: connectors that predate `getLiveAccount` (Task A)
1424
+ // must not break the switch path.
1425
+ expect((mockConnector as any).getLiveAccount).toBeUndefined()
1426
+
1427
+ await connectionService.connect('injected', 'metamask', 'solana:mainnet')
1428
+
1429
+ expect(() => accountSubs.get('eip155')!(['0xSWITCHED'])).not.toThrow()
1430
+
1431
+ const session = sessionManager.getSession()
1432
+ expect(
1433
+ session.availableAddresses.find(a => a.networkId === 'solana:mainnet')
1434
+ ?.address
1435
+ ).toBe('SoLAddr111')
1436
+ })
1437
+
1438
+ it('treats an eip155 sibling address as unchanged when it differs from stored only by case', async () => {
1439
+ vi.useFakeTimers()
1440
+ try {
1441
+ // Changed namespace is SOLANA here; eip155 is the sibling under test.
1442
+ const liveAccount = vi.fn(async (ns: string) =>
1443
+ ns === 'eip155' ? '0X1234' : null
1444
+ )
1445
+ ;(mockConnector as any).getLiveAccount = liveAccount
1446
+
1447
+ await connectionService.connect(
1448
+ 'injected',
1449
+ 'metamask',
1450
+ 'solana:mainnet'
1451
+ )
1452
+
1453
+ accountSubs.get('solana')!(['SoLAddr_NEW'])
1454
+
1455
+ await vi.advanceTimersByTimeAsync(
1456
+ SIBLING_REFRESH_MAX_ATTEMPTS * SIBLING_REFRESH_DELAY_MS
1457
+ )
1458
+
1459
+ const session = sessionManager.getSession()
1460
+ expect(
1461
+ session.availableAddresses.find(a => a.networkId === 'eip155:1')
1462
+ ?.address
1463
+ ).toBe('0x1234')
1464
+ } finally {
1465
+ vi.useRealTimers()
1466
+ }
1467
+ })
1468
+ })
1469
+
1470
+ describe('Solana account-change event + sibling-poll coexistence (Task 3.3)', () => {
1471
+ // 3.1/3.2 wire a REAL solana `onAccountsChanged` into core — active-
1472
+ // namespace path via `attachLifecycle`, secondary-namespace path via
1473
+ // `attachSecondaryAccountSync`. Separately, `refreshSiblingNamespaces`
1474
+ // polls every OTHER connected namespace's live account after a sibling's
1475
+ // switch. These tests confirm the two mechanisms coexist for a solana
1476
+ // session: no double `sessionChanged` emit, no re-entrant loop, no stale
1477
+ // clobber of a fresher value, and an empty solana event on a SECONDARY
1478
+ // namespace never ends the session.
1479
+ let accountSubs: Map<string, (accounts: string[]) => void>
1480
+
1481
+ beforeEach(() => {
1482
+ networks.push({
1483
+ id: 'solana:mainnet',
1484
+ internalId: 101,
1485
+ namespace: 'solana',
1486
+ name: 'Solana',
1487
+ logoUrl: '',
1488
+ nativeCurrency: { name: 'SOL', symbol: 'SOL', decimals: 9 },
1489
+ networkType: 'solana'
1490
+ } as never)
1491
+ wallets[0]!.extensionInjectedProvider = {
1492
+ supportedNetworkIds: ['solana:mainnet', 'eip155:1']
1493
+ } as never
1494
+ accountSubs = new Map()
1495
+ mockConnector.onAccountsChanged = ((
1496
+ listener: (accounts: string[]) => void,
1497
+ context: { namespace: string }
1498
+ ) => {
1499
+ accountSubs.set(context.namespace, listener)
1500
+ return () => {}
1501
+ }) as never
1502
+ })
1503
+
1504
+ it('a solana accountsChanged on a SOLANA-active session updates the address + activeAddress with exactly ONE sessionChanged (no double-emit)', async () => {
1505
+ mockConnector.connect = vi.fn().mockResolvedValue({
1506
+ networkId: 'solana:mainnet',
1507
+ address: 'SoLAddr111',
1508
+ availableAddresses: [
1509
+ { address: 'SoLAddr111', networkId: 'solana:mainnet' },
1510
+ { address: '0x1234', networkId: 'eip155:1' }
1511
+ ]
1512
+ })
1513
+ // No getLiveAccount on this connector: isolates the direct-event path
1514
+ // from the sibling-poll path for this assertion.
1515
+ expect((mockConnector as any).getLiveAccount).toBeUndefined()
1516
+
1517
+ await connectionService.connect('injected', 'metamask', 'solana:mainnet')
1518
+
1519
+ const sessions: unknown[] = []
1520
+ eventManager.on('sessionChanged', d => sessions.push(d))
1521
+
1522
+ accountSubs.get('solana')!(['SoLAddr_NEW'])
1523
+
1524
+ const session = sessionManager.getSession()
1525
+ expect(session.activeAddress).toBe('SoLAddr_NEW')
1526
+ expect(session.availableAddresses).toEqual([
1527
+ { address: 'SoLAddr_NEW', networkId: 'solana:mainnet' },
1528
+ { address: '0x1234', networkId: 'eip155:1' }
1529
+ ])
1530
+ expect(sessions.length).toBe(1)
1531
+ })
1532
+
1533
+ it('a solana accountsChanged on an EIP155-active session updates only the solana sibling entry, leaves activeAddress alone, and does not end the session', async () => {
1534
+ mockConnector.connect = vi.fn().mockResolvedValue({
1535
+ networkId: 'eip155:1',
1536
+ address: '0x1234',
1537
+ availableAddresses: [
1538
+ { address: 'SoLAddr111', networkId: 'solana:mainnet' },
1539
+ { address: '0x1234', networkId: 'eip155:1' }
1540
+ ]
1541
+ })
1542
+
1543
+ await connectionService.connect('injected', 'metamask', 'eip155:1')
1544
+
1545
+ accountSubs.get('solana')!(['SoLAddr_NEW'])
1546
+
1547
+ const session = sessionManager.getSession()
1548
+ expect(session.connectionMode).toBe('injected')
1549
+ expect(session.activeAddress).toBe('0x1234')
1550
+ expect(session.availableAddresses).toEqual([
1551
+ { address: 'SoLAddr_NEW', networkId: 'solana:mainnet' },
1552
+ { address: '0x1234', networkId: 'eip155:1' }
1553
+ ])
1554
+ })
1555
+
1556
+ it('an empty solana accountsChanged on the SECONDARY (non-active) namespace is a no-op — it does not end the session', async () => {
1557
+ const disconnectSpy = vi.fn().mockResolvedValue(undefined)
1558
+ mockConnector.disconnect = disconnectSpy
1559
+ mockConnector.connect = vi.fn().mockResolvedValue({
1560
+ networkId: 'eip155:1',
1561
+ address: '0x1234',
1562
+ availableAddresses: [
1563
+ { address: 'SoLAddr111', networkId: 'solana:mainnet' },
1564
+ { address: '0x1234', networkId: 'eip155:1' }
1565
+ ]
1566
+ })
1567
+
1568
+ await connectionService.connect('injected', 'metamask', 'eip155:1')
1569
+
1570
+ accountSubs.get('solana')!([])
1571
+
1572
+ const session = sessionManager.getSession()
1573
+ expect(session.connectionMode).toBe('injected')
1574
+ expect(session.activeAddress).toBe('0x1234')
1575
+ expect(session.availableAddresses).toEqual([
1576
+ { address: 'SoLAddr111', networkId: 'solana:mainnet' },
1577
+ { address: '0x1234', networkId: 'eip155:1' }
1578
+ ])
1579
+ expect(disconnectSpy).not.toHaveBeenCalled()
1580
+ })
1581
+
1582
+ it('a direct solana event and an eip155-triggered sibling poll converge on the same address without a stale clobber (generation guard)', async () => {
1583
+ vi.useFakeTimers()
1584
+ try {
1585
+ mockConnector.connect = vi.fn().mockResolvedValue({
1586
+ networkId: 'solana:mainnet',
1587
+ address: 'SoLAddr111',
1588
+ availableAddresses: [
1589
+ { address: 'SoLAddr111', networkId: 'solana:mainnet' },
1590
+ { address: '0x1234', networkId: 'eip155:1' }
1591
+ ]
1592
+ })
1593
+ let solanaLiveCalls = 0
1594
+ const liveAccount = vi.fn(async (ns: string) => {
1595
+ if (ns === 'solana') {
1596
+ solanaLiveCalls++
1597
+ // First read still reflects the OLD (stored) address — unchanged,
1598
+ // so the poll keeps going. A SECOND read is reached only if the
1599
+ // superseded refresh is wrongly allowed to continue; it returns a
1600
+ // value that is neither the original nor the direct event's
1601
+ // value, so any write from it is a visible clobber.
1602
+ return solanaLiveCalls === 1
1603
+ ? 'SoLAddr111'
1604
+ : 'SoLAddr_STALE_FROM_POLL'
1605
+ }
1606
+ // eip155 sibling (queried by the solana-event-triggered refresh):
1607
+ // never resolves — the direct event's own change already landed
1608
+ // through the non-polling path.
1609
+ return null
1610
+ })
1611
+ ;(mockConnector as any).getLiveAccount = liveAccount
1612
+
1613
+ const sessions: unknown[] = []
1614
+ eventManager.on('sessionChanged', d => sessions.push(d))
1615
+
1616
+ await connectionService.connect(
1617
+ 'injected',
1618
+ 'metamask',
1619
+ 'solana:mainnet'
1620
+ )
1621
+ sessions.length = 0 // drop the connect's own sessionChanged
1622
+
1623
+ // eip155 fires first: updates its own entry and kicks off a sibling
1624
+ // poll of solana (generation N).
1625
+ accountSubs.get('eip155')!(['0xSWITCHED'])
1626
+ await vi.advanceTimersByTimeAsync(0) // let attempt-0's read land (unchanged, keeps polling)
1627
+
1628
+ // A REAL solana accounts-change event lands next (the 3.1/3.2 direct
1629
+ // path) carrying the same new address the poll is chasing. It writes
1630
+ // directly and starts its OWN sibling refresh (generation N+1),
1631
+ // superseding the still-polling eip155-triggered one.
1632
+ accountSubs.get('solana')!(['SoLAddr_NEW'])
1633
+
1634
+ // Drain both: the superseded refresh must bail on the generation
1635
+ // check instead of issuing a second solana read and clobbering.
1636
+ await vi.advanceTimersByTimeAsync(
1637
+ SIBLING_REFRESH_MAX_ATTEMPTS * SIBLING_REFRESH_DELAY_MS
1638
+ )
1639
+
1640
+ const session = sessionManager.getSession()
1641
+ expect(
1642
+ session.availableAddresses.find(a => a.networkId === 'solana:mainnet')
1643
+ ?.address
1644
+ ).toBe('SoLAddr_NEW')
1645
+ expect(session.activeAddress).toBe('SoLAddr_NEW')
1646
+ expect(
1647
+ session.availableAddresses.find(a => a.networkId === 'eip155:1')
1648
+ ?.address
1649
+ ).toBe('0xSWITCHED')
1650
+ expect(session.availableAddresses.map(a => a.address)).not.toContain(
1651
+ 'SoLAddr_STALE_FROM_POLL'
1652
+ )
1653
+ // The superseded refresh never got a second read in.
1654
+ expect(solanaLiveCalls).toBe(1)
1655
+ // Exactly two emits: the eip155 event's own, and the solana event's
1656
+ // own. Neither sibling-refresh resolves a sibling, so neither adds an
1657
+ // extra emit — confirming no double-emit / no loop.
1658
+ expect(sessions.length).toBe(2)
1659
+ } finally {
1660
+ vi.useRealTimers()
1661
+ }
1662
+ })
866
1663
  })
867
1664
  })