@meshconnect/uwc-core 1.10.4 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/events.d.ts +5 -1
- package/dist/events.d.ts.map +1 -1
- package/dist/events.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 +194 -92
- package/dist/observability/lifecycle-bridge.js.map +1 -1
- package/dist/services/connection-service.d.ts +86 -0
- package/dist/services/connection-service.d.ts.map +1 -1
- package/dist/services/connection-service.js +354 -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/services/signature-service.d.ts +4 -3
- package/dist/services/signature-service.d.ts.map +1 -1
- package/dist/services/signature-service.js +2 -1
- package/dist/services/signature-service.js.map +1 -1
- package/dist/universal-wallet-connector.d.ts +5 -3
- package/dist/universal-wallet-connector.d.ts.map +1 -1
- package/dist/universal-wallet-connector.js +3 -1
- package/dist/universal-wallet-connector.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.d.ts.map +1 -1
- package/dist/version.js +1 -1
- package/dist/version.js.map +1 -1
- package/package.json +5 -5
- package/src/events.ts +5 -2
- package/src/observability/lifecycle-bridge.test.ts +316 -0
- package/src/observability/lifecycle-bridge.ts +240 -99
- package/src/services/connection-service.test.ts +798 -1
- package/src/services/connection-service.ts +410 -10
- package/src/services/network-switch-service.test.ts +33 -0
- package/src/services/network-switch-service.ts +4 -1
- package/src/services/signature-service.test.ts +9 -2
- package/src/services/signature-service.ts +4 -3
- package/src/universal-wallet-connector.ts +5 -3
- package/src/version.ts +1 -1
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
|
2
|
-
import {
|
|
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
|
})
|