@meshconnect/uwc-core 1.4.0 → 1.4.1-snapshot.1a75c56

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 (36) hide show
  1. package/dist/events.d.ts +5 -1
  2. package/dist/events.d.ts.map +1 -1
  3. package/dist/events.js.map +1 -1
  4. package/dist/observability/lifecycle-bridge.d.ts +31 -13
  5. package/dist/observability/lifecycle-bridge.d.ts.map +1 -1
  6. package/dist/observability/lifecycle-bridge.js +167 -92
  7. package/dist/observability/lifecycle-bridge.js.map +1 -1
  8. package/dist/services/connection-service.d.ts +73 -0
  9. package/dist/services/connection-service.d.ts.map +1 -1
  10. package/dist/services/connection-service.js +283 -9
  11. package/dist/services/connection-service.js.map +1 -1
  12. package/dist/services/network-switch-service.d.ts.map +1 -1
  13. package/dist/services/network-switch-service.js +4 -1
  14. package/dist/services/network-switch-service.js.map +1 -1
  15. package/dist/services/signature-service.d.ts +4 -3
  16. package/dist/services/signature-service.d.ts.map +1 -1
  17. package/dist/services/signature-service.js +2 -1
  18. package/dist/services/signature-service.js.map +1 -1
  19. package/dist/universal-wallet-connector.d.ts +5 -3
  20. package/dist/universal-wallet-connector.d.ts.map +1 -1
  21. package/dist/universal-wallet-connector.js +3 -1
  22. package/dist/universal-wallet-connector.js.map +1 -1
  23. package/dist/version.d.ts +1 -1
  24. package/dist/version.js +1 -1
  25. package/package.json +5 -5
  26. package/src/events.ts +5 -2
  27. package/src/observability/lifecycle-bridge.test.ts +197 -0
  28. package/src/observability/lifecycle-bridge.ts +211 -99
  29. package/src/services/connection-service.test.ts +750 -1
  30. package/src/services/connection-service.ts +331 -10
  31. package/src/services/network-switch-service.test.ts +33 -0
  32. package/src/services/network-switch-service.ts +4 -1
  33. package/src/services/signature-service.test.ts +9 -2
  34. package/src/services/signature-service.ts +4 -3
  35. package/src/universal-wallet-connector.ts +5 -3
  36. 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,750 @@ 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
+ await connectionService.connect('injected', 'metamask', 'solana:mainnet')
1092
+
1093
+ accountSubs.get('eip155')!(['0xSWITCHED'])
1094
+
1095
+ const session = sessionManager.getSession()
1096
+ expect(session.activeAddress).toBe('SoLAddr111')
1097
+ expect(session.availableAddresses).toEqual([
1098
+ { address: 'SoLAddr111', networkId: 'solana:mainnet' },
1099
+ { address: '0xSWITCHED', networkId: 'eip155:1' }
1100
+ ])
1101
+ })
1102
+
1103
+ it('an empty accounts list from a SECONDARY namespace does not disconnect the session', async () => {
1104
+ // Only the ACTIVE namespace's stream may signal the full
1105
+ // wallet-initiated disconnect — a secondary namespace reporting []
1106
+ // means that namespace's own authorization ended, not the session.
1107
+ const disconnectSpy = vi.fn().mockResolvedValue(undefined)
1108
+ mockConnector.disconnect = disconnectSpy
1109
+
1110
+ await connectionService.connect('injected', 'metamask', 'solana:mainnet')
1111
+ accountSubs.get('eip155')!([])
1112
+
1113
+ const session = sessionManager.getSession()
1114
+ expect(session.connectionMode).toBe('injected')
1115
+ expect(session.activeAddress).toBe('SoLAddr111')
1116
+ expect(disconnectSpy).not.toHaveBeenCalled()
1117
+ })
1118
+
1119
+ it('disconnect() tears down the secondary subscriptions too', async () => {
1120
+ await connectionService.connect('injected', 'metamask', 'solana:mainnet')
1121
+
1122
+ await connectionService.disconnect()
1123
+
1124
+ expect(teardownsByNamespace.get('eip155')).toBe(1)
1125
+ expect(teardownsByNamespace.get('solana')).toBe(1)
1126
+ })
1127
+
1128
+ it('reattachLifecycle() re-derives the secondary set for the CURRENT session without stacking duplicates', async () => {
1129
+ await connectionService.connect('injected', 'metamask', 'solana:mainnet')
1130
+
1131
+ connectionService.reattachLifecycle()
1132
+
1133
+ // Old eip155 secondary attachment released, exactly one fresh one live.
1134
+ expect(teardownsByNamespace.get('eip155')).toBe(1)
1135
+ accountSubs.get('eip155')!(['0xAFTER'])
1136
+ expect(
1137
+ sessionManager
1138
+ .getSession()
1139
+ .availableAddresses.find(a => a.networkId === 'eip155:1')?.address
1140
+ ).toBe('0xAFTER')
1141
+ })
1142
+ })
1143
+
1144
+ describe('cross-namespace account refresh on switch', () => {
1145
+ // Multi-namespace injected wallets (MetaMask/Trust/Phantom) derive every
1146
+ // namespace's address from ONE underlying account. The wallet fires
1147
+ // accountsChanged for a SINGLE namespace on a switch; the sibling
1148
+ // namespaces' addresses change too but emit no event of their own, so
1149
+ // `refreshSiblingNamespaces` re-reads each connected sibling's LIVE
1150
+ // account and folds any real change into the session. Session is seeded
1151
+ // SOLANA-active (the real-bug shape: an eip155 switch must still move the
1152
+ // active solana address).
1153
+ let accountSubs: Map<string, (accounts: string[]) => void>
1154
+
1155
+ beforeEach(() => {
1156
+ networks.push({
1157
+ id: 'solana:mainnet',
1158
+ internalId: 101,
1159
+ namespace: 'solana',
1160
+ name: 'Solana',
1161
+ logoUrl: '',
1162
+ nativeCurrency: { name: 'SOL', symbol: 'SOL', decimals: 9 },
1163
+ networkType: 'solana'
1164
+ } as never)
1165
+ wallets[0]!.extensionInjectedProvider = {
1166
+ supportedNetworkIds: ['solana:mainnet', 'eip155:1']
1167
+ } as never
1168
+ mockConnector.connect = vi.fn().mockResolvedValue({
1169
+ networkId: 'solana:mainnet',
1170
+ address: 'SoLAddr111',
1171
+ availableAddresses: [
1172
+ { address: 'SoLAddr111', networkId: 'solana:mainnet' },
1173
+ { address: '0x1234', networkId: 'eip155:1' }
1174
+ ]
1175
+ })
1176
+ accountSubs = new Map()
1177
+ mockConnector.onAccountsChanged = ((
1178
+ listener: (accounts: string[]) => void,
1179
+ context: { namespace: string }
1180
+ ) => {
1181
+ accountSubs.set(context.namespace, listener)
1182
+ return () => {}
1183
+ }) as never
1184
+ })
1185
+
1186
+ it('a Solana-active session follows an eip155 switch: the solana sibling is refreshed and activeAddress moves with it', async () => {
1187
+ const liveAccount = vi.fn(async (ns: string) =>
1188
+ ns === 'solana' ? 'SoLAddr_NEW' : null
1189
+ )
1190
+ ;(mockConnector as any).getLiveAccount = liveAccount
1191
+
1192
+ const sessions: unknown[] = []
1193
+ eventManager.on('sessionChanged', d => sessions.push(d))
1194
+
1195
+ await connectionService.connect('injected', 'metamask', 'solana:mainnet')
1196
+ sessions.length = 0 // drop the connect's own sessionChanged
1197
+
1198
+ accountSubs.get('eip155')!(['0xSWITCHED'])
1199
+
1200
+ await vi.waitFor(() => {
1201
+ expect(
1202
+ sessionManager
1203
+ .getSession()
1204
+ .availableAddresses.find(a => a.networkId === 'solana:mainnet')
1205
+ ?.address
1206
+ ).toBe('SoLAddr_NEW')
1207
+ })
1208
+
1209
+ const session = sessionManager.getSession()
1210
+ expect(session.activeAddress).toBe('SoLAddr_NEW')
1211
+ expect(session.availableAddresses).toEqual([
1212
+ { address: 'SoLAddr_NEW', networkId: 'solana:mainnet' },
1213
+ { address: '0xSWITCHED', networkId: 'eip155:1' }
1214
+ ])
1215
+ expect(sessions.length).toBeGreaterThan(0)
1216
+ })
1217
+
1218
+ it('polls a lagging sibling until its live address differs from the stored one', async () => {
1219
+ vi.useFakeTimers()
1220
+ try {
1221
+ let call = 0
1222
+ const liveAccount = vi.fn(async (ns: string) => {
1223
+ if (ns !== 'solana') return null
1224
+ call++
1225
+ // First read still reflects the OLD (stored) address — the wallet
1226
+ // hasn't finished re-deriving the sibling yet.
1227
+ return call === 1 ? 'SoLAddr111' : 'SoLAddr_NEW'
1228
+ })
1229
+ ;(mockConnector as any).getLiveAccount = liveAccount
1230
+
1231
+ await connectionService.connect(
1232
+ 'injected',
1233
+ 'metamask',
1234
+ 'solana:mainnet'
1235
+ )
1236
+
1237
+ accountSubs.get('eip155')!(['0xSWITCHED'])
1238
+
1239
+ await vi.advanceTimersByTimeAsync(SIBLING_REFRESH_DELAY_MS)
1240
+
1241
+ const session = sessionManager.getSession()
1242
+ expect(
1243
+ session.availableAddresses.find(a => a.networkId === 'solana:mainnet')
1244
+ ?.address
1245
+ ).toBe('SoLAddr_NEW')
1246
+ expect(session.activeAddress).toBe('SoLAddr_NEW')
1247
+ } finally {
1248
+ vi.useRealTimers()
1249
+ }
1250
+ })
1251
+
1252
+ it('gives up after the poll cap when the sibling never changes, without an extra update or emit', async () => {
1253
+ vi.useFakeTimers()
1254
+ try {
1255
+ const liveAccount = vi.fn(async (ns: string) =>
1256
+ ns === 'solana' ? 'SoLAddr111' : null
1257
+ )
1258
+ ;(mockConnector as any).getLiveAccount = liveAccount
1259
+
1260
+ const sessions: unknown[] = []
1261
+ eventManager.on('sessionChanged', d => sessions.push(d))
1262
+
1263
+ await connectionService.connect(
1264
+ 'injected',
1265
+ 'metamask',
1266
+ 'solana:mainnet'
1267
+ )
1268
+ sessions.length = 0 // drop the connect's own sessionChanged
1269
+
1270
+ accountSubs.get('eip155')!(['0xSWITCHED'])
1271
+ // The eip155 switch's own sessionChanged fires synchronously above.
1272
+ expect(sessions.length).toBe(1)
1273
+
1274
+ await vi.advanceTimersByTimeAsync(
1275
+ SIBLING_REFRESH_MAX_ATTEMPTS * SIBLING_REFRESH_DELAY_MS
1276
+ )
1277
+
1278
+ const session = sessionManager.getSession()
1279
+ expect(
1280
+ session.availableAddresses.find(a => a.networkId === 'solana:mainnet')
1281
+ ?.address
1282
+ ).toBe('SoLAddr111')
1283
+ expect(session.activeAddress).toBe('SoLAddr111')
1284
+ // No additional sessionChanged beyond the firing namespace's own one.
1285
+ expect(sessions.length).toBe(1)
1286
+ } finally {
1287
+ vi.useRealTimers()
1288
+ }
1289
+ })
1290
+
1291
+ it('an empty accounts list (disconnect) does not trigger a sibling refresh', async () => {
1292
+ const liveAccount = vi.fn(async () => 'SoLAddr_NEW')
1293
+ ;(mockConnector as any).getLiveAccount = liveAccount
1294
+ mockConnector.disconnect = vi.fn().mockResolvedValue(undefined)
1295
+
1296
+ await connectionService.connect('injected', 'metamask', 'solana:mainnet')
1297
+
1298
+ accountSubs.get('solana')!([]) // active namespace's own disconnect signal
1299
+
1300
+ expect(liveAccount).not.toHaveBeenCalled()
1301
+ const session = sessionManager.getSession()
1302
+ expect(session.activeAddress).toBeNull()
1303
+ expect(session.connectionMode).toBeNull()
1304
+ })
1305
+
1306
+ it('cancels an in-flight poll when a newer switch supersedes it — the later switch wins', async () => {
1307
+ vi.useFakeTimers()
1308
+ try {
1309
+ // Keyed by CALL ORDER, not by a single shared "current live value"
1310
+ // variable — a shared variable would let the superseded refresh's
1311
+ // later read silently converge with whatever the second refresh just
1312
+ // wrote, passing even with the generation guard deleted. Instead:
1313
+ // call #1 = refresh #1 (superseded), attempt 0 → equals the stored
1314
+ // address, so refresh #1 does NOT resolve yet and keeps polling.
1315
+ // call #2 = refresh #2 (superseding), attempt 0 → differs from the
1316
+ // stored address, so refresh #2 resolves immediately and writes
1317
+ // the correct value.
1318
+ // call #3 = refresh #1, attempt 1 — reached ONLY if refresh #1 is
1319
+ // allowed to keep going after being superseded (i.e. the
1320
+ // generation guard is missing). Returns a THIRD address that is
1321
+ // neither the original stored value nor refresh #2's correct
1322
+ // value, so if refresh #1 is ever allowed to write here, it
1323
+ // visibly clobbers the correct value with this stale one.
1324
+ let solanaCallCount = 0
1325
+ const liveAccount = vi.fn(async (ns: string) => {
1326
+ if (ns !== 'solana') return null
1327
+ solanaCallCount++
1328
+ if (solanaCallCount === 1) return 'SoLAddr111'
1329
+ if (solanaCallCount === 2) return 'SoLAddr_FROM_SECOND'
1330
+ return 'SoLAddr_STALE_FROM_FIRST'
1331
+ })
1332
+ ;(mockConnector as any).getLiveAccount = liveAccount
1333
+
1334
+ await connectionService.connect(
1335
+ 'injected',
1336
+ 'metamask',
1337
+ 'solana:mainnet'
1338
+ )
1339
+
1340
+ accountSubs.get('eip155')!(['0xSWITCHED1'])
1341
+ // Let the first refresh's attempt-0 read (call #1) resolve and enter
1342
+ // its poll wait.
1343
+ await vi.advanceTimersByTimeAsync(0)
1344
+
1345
+ // A newer switch fires before the first refresh's next attempt — it
1346
+ // supersedes the generation token. Its own attempt-0 read (call #2)
1347
+ // resolves synchronously below, since the stored address hasn't
1348
+ // moved yet.
1349
+ accountSubs.get('eip155')!(['0xSWITCHED2'])
1350
+
1351
+ // Drain everything: the first refresh's remaining attempts must bail
1352
+ // out on the generation check rather than issuing call #3 and writing
1353
+ // its stale value over the second refresh's correct one.
1354
+ await vi.advanceTimersByTimeAsync(
1355
+ SIBLING_REFRESH_MAX_ATTEMPTS * SIBLING_REFRESH_DELAY_MS
1356
+ )
1357
+
1358
+ const session = sessionManager.getSession()
1359
+ expect(
1360
+ session.availableAddresses.find(a => a.networkId === 'solana:mainnet')
1361
+ ?.address
1362
+ ).toBe('SoLAddr_FROM_SECOND')
1363
+ expect(session.activeAddress).toBe('SoLAddr_FROM_SECOND')
1364
+ // Never the superseded refresh's later, stale read (call #3) — this
1365
+ // is the write the generation guard exists to prevent.
1366
+ expect(session.availableAddresses.map(a => a.address)).not.toContain(
1367
+ 'SoLAddr_STALE_FROM_FIRST'
1368
+ )
1369
+ } finally {
1370
+ vi.useRealTimers()
1371
+ }
1372
+ })
1373
+
1374
+ it('is a no-op (no throw, no sibling change) when the connector has no getLiveAccount', async () => {
1375
+ // Backward compat: connectors that predate `getLiveAccount` (Task A)
1376
+ // must not break the switch path.
1377
+ expect((mockConnector as any).getLiveAccount).toBeUndefined()
1378
+
1379
+ await connectionService.connect('injected', 'metamask', 'solana:mainnet')
1380
+
1381
+ expect(() => accountSubs.get('eip155')!(['0xSWITCHED'])).not.toThrow()
1382
+
1383
+ const session = sessionManager.getSession()
1384
+ expect(
1385
+ session.availableAddresses.find(a => a.networkId === 'solana:mainnet')
1386
+ ?.address
1387
+ ).toBe('SoLAddr111')
1388
+ })
1389
+
1390
+ it('treats an eip155 sibling address as unchanged when it differs from stored only by case', async () => {
1391
+ vi.useFakeTimers()
1392
+ try {
1393
+ // Changed namespace is SOLANA here; eip155 is the sibling under test.
1394
+ const liveAccount = vi.fn(async (ns: string) =>
1395
+ ns === 'eip155' ? '0X1234' : null
1396
+ )
1397
+ ;(mockConnector as any).getLiveAccount = liveAccount
1398
+
1399
+ await connectionService.connect(
1400
+ 'injected',
1401
+ 'metamask',
1402
+ 'solana:mainnet'
1403
+ )
1404
+
1405
+ accountSubs.get('solana')!(['SoLAddr_NEW'])
1406
+
1407
+ await vi.advanceTimersByTimeAsync(
1408
+ SIBLING_REFRESH_MAX_ATTEMPTS * SIBLING_REFRESH_DELAY_MS
1409
+ )
1410
+
1411
+ const session = sessionManager.getSession()
1412
+ expect(
1413
+ session.availableAddresses.find(a => a.networkId === 'eip155:1')
1414
+ ?.address
1415
+ ).toBe('0x1234')
1416
+ } finally {
1417
+ vi.useRealTimers()
1418
+ }
1419
+ })
1420
+ })
1421
+
1422
+ describe('Solana account-change event + sibling-poll coexistence (Task 3.3)', () => {
1423
+ // 3.1/3.2 wire a REAL solana `onAccountsChanged` into core — active-
1424
+ // namespace path via `attachLifecycle`, secondary-namespace path via
1425
+ // `attachSecondaryAccountSync`. Separately, `refreshSiblingNamespaces`
1426
+ // polls every OTHER connected namespace's live account after a sibling's
1427
+ // switch. These tests confirm the two mechanisms coexist for a solana
1428
+ // session: no double `sessionChanged` emit, no re-entrant loop, no stale
1429
+ // clobber of a fresher value, and an empty solana event on a SECONDARY
1430
+ // namespace never ends the session.
1431
+ let accountSubs: Map<string, (accounts: string[]) => void>
1432
+
1433
+ beforeEach(() => {
1434
+ networks.push({
1435
+ id: 'solana:mainnet',
1436
+ internalId: 101,
1437
+ namespace: 'solana',
1438
+ name: 'Solana',
1439
+ logoUrl: '',
1440
+ nativeCurrency: { name: 'SOL', symbol: 'SOL', decimals: 9 },
1441
+ networkType: 'solana'
1442
+ } as never)
1443
+ wallets[0]!.extensionInjectedProvider = {
1444
+ supportedNetworkIds: ['solana:mainnet', 'eip155:1']
1445
+ } as never
1446
+ accountSubs = new Map()
1447
+ mockConnector.onAccountsChanged = ((
1448
+ listener: (accounts: string[]) => void,
1449
+ context: { namespace: string }
1450
+ ) => {
1451
+ accountSubs.set(context.namespace, listener)
1452
+ return () => {}
1453
+ }) as never
1454
+ })
1455
+
1456
+ it('a solana accountsChanged on a SOLANA-active session updates the address + activeAddress with exactly ONE sessionChanged (no double-emit)', async () => {
1457
+ mockConnector.connect = vi.fn().mockResolvedValue({
1458
+ networkId: 'solana:mainnet',
1459
+ address: 'SoLAddr111',
1460
+ availableAddresses: [
1461
+ { address: 'SoLAddr111', networkId: 'solana:mainnet' },
1462
+ { address: '0x1234', networkId: 'eip155:1' }
1463
+ ]
1464
+ })
1465
+ // No getLiveAccount on this connector: isolates the direct-event path
1466
+ // from the sibling-poll path for this assertion.
1467
+ expect((mockConnector as any).getLiveAccount).toBeUndefined()
1468
+
1469
+ await connectionService.connect('injected', 'metamask', 'solana:mainnet')
1470
+
1471
+ const sessions: unknown[] = []
1472
+ eventManager.on('sessionChanged', d => sessions.push(d))
1473
+
1474
+ accountSubs.get('solana')!(['SoLAddr_NEW'])
1475
+
1476
+ const session = sessionManager.getSession()
1477
+ expect(session.activeAddress).toBe('SoLAddr_NEW')
1478
+ expect(session.availableAddresses).toEqual([
1479
+ { address: 'SoLAddr_NEW', networkId: 'solana:mainnet' },
1480
+ { address: '0x1234', networkId: 'eip155:1' }
1481
+ ])
1482
+ expect(sessions.length).toBe(1)
1483
+ })
1484
+
1485
+ 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 () => {
1486
+ mockConnector.connect = vi.fn().mockResolvedValue({
1487
+ networkId: 'eip155:1',
1488
+ address: '0x1234',
1489
+ availableAddresses: [
1490
+ { address: 'SoLAddr111', networkId: 'solana:mainnet' },
1491
+ { address: '0x1234', networkId: 'eip155:1' }
1492
+ ]
1493
+ })
1494
+
1495
+ await connectionService.connect('injected', 'metamask', 'eip155:1')
1496
+
1497
+ accountSubs.get('solana')!(['SoLAddr_NEW'])
1498
+
1499
+ const session = sessionManager.getSession()
1500
+ expect(session.connectionMode).toBe('injected')
1501
+ expect(session.activeAddress).toBe('0x1234')
1502
+ expect(session.availableAddresses).toEqual([
1503
+ { address: 'SoLAddr_NEW', networkId: 'solana:mainnet' },
1504
+ { address: '0x1234', networkId: 'eip155:1' }
1505
+ ])
1506
+ })
1507
+
1508
+ it('an empty solana accountsChanged on the SECONDARY (non-active) namespace is a no-op — it does not end the session', async () => {
1509
+ const disconnectSpy = vi.fn().mockResolvedValue(undefined)
1510
+ mockConnector.disconnect = disconnectSpy
1511
+ mockConnector.connect = vi.fn().mockResolvedValue({
1512
+ networkId: 'eip155:1',
1513
+ address: '0x1234',
1514
+ availableAddresses: [
1515
+ { address: 'SoLAddr111', networkId: 'solana:mainnet' },
1516
+ { address: '0x1234', networkId: 'eip155:1' }
1517
+ ]
1518
+ })
1519
+
1520
+ await connectionService.connect('injected', 'metamask', 'eip155:1')
1521
+
1522
+ accountSubs.get('solana')!([])
1523
+
1524
+ const session = sessionManager.getSession()
1525
+ expect(session.connectionMode).toBe('injected')
1526
+ expect(session.activeAddress).toBe('0x1234')
1527
+ expect(session.availableAddresses).toEqual([
1528
+ { address: 'SoLAddr111', networkId: 'solana:mainnet' },
1529
+ { address: '0x1234', networkId: 'eip155:1' }
1530
+ ])
1531
+ expect(disconnectSpy).not.toHaveBeenCalled()
1532
+ })
1533
+
1534
+ it('a direct solana event and an eip155-triggered sibling poll converge on the same address without a stale clobber (generation guard)', async () => {
1535
+ vi.useFakeTimers()
1536
+ try {
1537
+ mockConnector.connect = vi.fn().mockResolvedValue({
1538
+ networkId: 'solana:mainnet',
1539
+ address: 'SoLAddr111',
1540
+ availableAddresses: [
1541
+ { address: 'SoLAddr111', networkId: 'solana:mainnet' },
1542
+ { address: '0x1234', networkId: 'eip155:1' }
1543
+ ]
1544
+ })
1545
+ let solanaLiveCalls = 0
1546
+ const liveAccount = vi.fn(async (ns: string) => {
1547
+ if (ns === 'solana') {
1548
+ solanaLiveCalls++
1549
+ // First read still reflects the OLD (stored) address — unchanged,
1550
+ // so the poll keeps going. A SECOND read is reached only if the
1551
+ // superseded refresh is wrongly allowed to continue; it returns a
1552
+ // value that is neither the original nor the direct event's
1553
+ // value, so any write from it is a visible clobber.
1554
+ return solanaLiveCalls === 1
1555
+ ? 'SoLAddr111'
1556
+ : 'SoLAddr_STALE_FROM_POLL'
1557
+ }
1558
+ // eip155 sibling (queried by the solana-event-triggered refresh):
1559
+ // never resolves — the direct event's own change already landed
1560
+ // through the non-polling path.
1561
+ return null
1562
+ })
1563
+ ;(mockConnector as any).getLiveAccount = liveAccount
1564
+
1565
+ const sessions: unknown[] = []
1566
+ eventManager.on('sessionChanged', d => sessions.push(d))
1567
+
1568
+ await connectionService.connect(
1569
+ 'injected',
1570
+ 'metamask',
1571
+ 'solana:mainnet'
1572
+ )
1573
+ sessions.length = 0 // drop the connect's own sessionChanged
1574
+
1575
+ // eip155 fires first: updates its own entry and kicks off a sibling
1576
+ // poll of solana (generation N).
1577
+ accountSubs.get('eip155')!(['0xSWITCHED'])
1578
+ await vi.advanceTimersByTimeAsync(0) // let attempt-0's read land (unchanged, keeps polling)
1579
+
1580
+ // A REAL solana accounts-change event lands next (the 3.1/3.2 direct
1581
+ // path) carrying the same new address the poll is chasing. It writes
1582
+ // directly and starts its OWN sibling refresh (generation N+1),
1583
+ // superseding the still-polling eip155-triggered one.
1584
+ accountSubs.get('solana')!(['SoLAddr_NEW'])
1585
+
1586
+ // Drain both: the superseded refresh must bail on the generation
1587
+ // check instead of issuing a second solana read and clobbering.
1588
+ await vi.advanceTimersByTimeAsync(
1589
+ SIBLING_REFRESH_MAX_ATTEMPTS * SIBLING_REFRESH_DELAY_MS
1590
+ )
1591
+
1592
+ const session = sessionManager.getSession()
1593
+ expect(
1594
+ session.availableAddresses.find(a => a.networkId === 'solana:mainnet')
1595
+ ?.address
1596
+ ).toBe('SoLAddr_NEW')
1597
+ expect(session.activeAddress).toBe('SoLAddr_NEW')
1598
+ expect(
1599
+ session.availableAddresses.find(a => a.networkId === 'eip155:1')
1600
+ ?.address
1601
+ ).toBe('0xSWITCHED')
1602
+ expect(session.availableAddresses.map(a => a.address)).not.toContain(
1603
+ 'SoLAddr_STALE_FROM_POLL'
1604
+ )
1605
+ // The superseded refresh never got a second read in.
1606
+ expect(solanaLiveCalls).toBe(1)
1607
+ // Exactly two emits: the eip155 event's own, and the solana event's
1608
+ // own. Neither sibling-refresh resolves a sibling, so neither adds an
1609
+ // extra emit — confirming no double-emit / no loop.
1610
+ expect(sessions.length).toBe(2)
1611
+ } finally {
1612
+ vi.useRealTimers()
1613
+ }
1614
+ })
866
1615
  })
867
1616
  })