@meshconnect/uwc-core 1.0.3 → 1.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 (121) hide show
  1. package/README.md +247 -76
  2. package/dist/events.d.ts +241 -5
  3. package/dist/events.d.ts.map +1 -1
  4. package/dist/events.js +77 -1
  5. package/dist/events.js.map +1 -1
  6. package/dist/index.d.ts +2 -0
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +2 -0
  9. package/dist/index.js.map +1 -1
  10. package/dist/logger/create-logger.d.ts +32 -0
  11. package/dist/logger/create-logger.d.ts.map +1 -0
  12. package/dist/logger/create-logger.js +79 -0
  13. package/dist/logger/create-logger.js.map +1 -0
  14. package/dist/managers/event-manager.d.ts.map +1 -1
  15. package/dist/managers/event-manager.js +5 -1
  16. package/dist/managers/event-manager.js.map +1 -1
  17. package/dist/observability/connect-observer.d.ts +33 -0
  18. package/dist/observability/connect-observer.d.ts.map +1 -0
  19. package/dist/observability/connect-observer.js +115 -0
  20. package/dist/observability/connect-observer.js.map +1 -0
  21. package/dist/observability/focus-tracker.d.ts +42 -0
  22. package/dist/observability/focus-tracker.d.ts.map +1 -0
  23. package/dist/observability/focus-tracker.js +57 -0
  24. package/dist/observability/focus-tracker.js.map +1 -0
  25. package/dist/observability/instrument-handoff.d.ts +54 -0
  26. package/dist/observability/instrument-handoff.d.ts.map +1 -0
  27. package/dist/observability/instrument-handoff.js +173 -0
  28. package/dist/observability/instrument-handoff.js.map +1 -0
  29. package/dist/observability/lifecycle-bridge.d.ts +69 -0
  30. package/dist/observability/lifecycle-bridge.d.ts.map +1 -0
  31. package/dist/observability/lifecycle-bridge.js +155 -0
  32. package/dist/observability/lifecycle-bridge.js.map +1 -0
  33. package/dist/observability/scrub.d.ts +31 -0
  34. package/dist/observability/scrub.d.ts.map +1 -0
  35. package/dist/observability/scrub.js +167 -0
  36. package/dist/observability/scrub.js.map +1 -0
  37. package/dist/observability/telemetry.d.ts +157 -0
  38. package/dist/observability/telemetry.d.ts.map +1 -0
  39. package/dist/observability/telemetry.js +373 -0
  40. package/dist/observability/telemetry.js.map +1 -0
  41. package/dist/services/connection-service.d.ts +23 -1
  42. package/dist/services/connection-service.d.ts.map +1 -1
  43. package/dist/services/connection-service.js +128 -19
  44. package/dist/services/connection-service.js.map +1 -1
  45. package/dist/services/network-switch-service.d.ts +2 -1
  46. package/dist/services/network-switch-service.d.ts.map +1 -1
  47. package/dist/services/network-switch-service.js +45 -16
  48. package/dist/services/network-switch-service.js.map +1 -1
  49. package/dist/services/signature-service.d.ts +14 -7
  50. package/dist/services/signature-service.d.ts.map +1 -1
  51. package/dist/services/signature-service.js +56 -58
  52. package/dist/services/signature-service.js.map +1 -1
  53. package/dist/services/transaction-service.d.ts +19 -5
  54. package/dist/services/transaction-service.d.ts.map +1 -1
  55. package/dist/services/transaction-service.js +55 -38
  56. package/dist/services/transaction-service.js.map +1 -1
  57. package/dist/universal-wallet-connector.d.ts +56 -1
  58. package/dist/universal-wallet-connector.d.ts.map +1 -1
  59. package/dist/universal-wallet-connector.js +207 -110
  60. package/dist/universal-wallet-connector.js.map +1 -1
  61. package/dist/utils/id.d.ts +2 -0
  62. package/dist/utils/id.d.ts.map +1 -0
  63. package/dist/utils/id.js +41 -0
  64. package/dist/utils/id.js.map +1 -0
  65. package/dist/utils/resolve-session-provider.d.ts +10 -0
  66. package/dist/utils/resolve-session-provider.d.ts.map +1 -0
  67. package/dist/utils/resolve-session-provider.js +21 -0
  68. package/dist/utils/resolve-session-provider.js.map +1 -0
  69. package/dist/utils/to-wallet-error.d.ts +35 -0
  70. package/dist/utils/to-wallet-error.d.ts.map +1 -0
  71. package/dist/utils/to-wallet-error.js +130 -0
  72. package/dist/utils/to-wallet-error.js.map +1 -0
  73. package/dist/utils/validate-wallet-session.d.ts +15 -0
  74. package/dist/utils/validate-wallet-session.d.ts.map +1 -0
  75. package/dist/utils/validate-wallet-session.js +29 -0
  76. package/dist/utils/validate-wallet-session.js.map +1 -0
  77. package/dist/version.d.ts +2 -0
  78. package/dist/version.d.ts.map +1 -0
  79. package/dist/version.js +3 -0
  80. package/dist/version.js.map +1 -0
  81. package/package.json +6 -6
  82. package/src/events.ts +326 -14
  83. package/src/index.ts +10 -0
  84. package/src/logger/create-logger.test.ts +126 -0
  85. package/src/logger/create-logger.ts +112 -0
  86. package/src/managers/event-manager.test.ts +55 -0
  87. package/src/managers/event-manager.ts +5 -1
  88. package/src/observability/connect-observer.test.ts +214 -0
  89. package/src/observability/connect-observer.ts +128 -0
  90. package/src/observability/focus-tracker.test.ts +191 -0
  91. package/src/observability/focus-tracker.ts +96 -0
  92. package/src/observability/instrument-handoff.test.ts +397 -0
  93. package/src/observability/instrument-handoff.ts +231 -0
  94. package/src/observability/lifecycle-bridge.test.ts +398 -0
  95. package/src/observability/lifecycle-bridge.ts +216 -0
  96. package/src/observability/scrub.test.ts +183 -0
  97. package/src/observability/scrub.ts +174 -0
  98. package/src/observability/telemetry.test.ts +549 -0
  99. package/src/observability/telemetry.ts +546 -0
  100. package/src/services/connection-service.test.ts +261 -1
  101. package/src/services/connection-service.ts +167 -38
  102. package/src/services/network-switch-service.test.ts +51 -1
  103. package/src/services/network-switch-service.ts +56 -24
  104. package/src/services/signature-service.test.ts +181 -76
  105. package/src/services/signature-service.ts +75 -85
  106. package/src/services/transaction-service.test.ts +270 -69
  107. package/src/services/transaction-service.ts +74 -51
  108. package/src/universal-wallet-connector.instrumentation-guard.test.ts +199 -0
  109. package/src/universal-wallet-connector.observability.test.ts +610 -0
  110. package/src/universal-wallet-connector.test.ts +27 -146
  111. package/src/universal-wallet-connector.ts +279 -142
  112. package/src/utils/id.test.ts +15 -0
  113. package/src/utils/id.ts +48 -0
  114. package/src/utils/resolve-session-provider.test.ts +54 -0
  115. package/src/utils/resolve-session-provider.ts +25 -0
  116. package/src/utils/to-wallet-error.test.ts +181 -0
  117. package/src/utils/to-wallet-error.ts +152 -0
  118. package/src/utils/validate-wallet-session.test.ts +77 -0
  119. package/src/utils/validate-wallet-session.ts +40 -0
  120. package/src/version.test.ts +11 -0
  121. package/src/version.ts +2 -0
@@ -3,86 +3,83 @@ import type {
3
3
  Connector,
4
4
  ExtensionInjectedProvider,
5
5
  IntegratedBrowserInjectedProvider,
6
- WalletConnectProvider,
7
6
  SupportedProvider,
8
7
  SignatureType,
9
8
  EIP712TypedData
10
9
  } from '@meshconnect/uwc-types'
11
10
  import type { SessionManager } from '../managers/session-manager'
11
+ import type { EventManager } from '../managers/event-manager'
12
12
  import type { ServiceOptions } from './connection-service'
13
+ import {
14
+ instrumentHandoff,
15
+ sessionHandoffContext
16
+ } from '../observability/instrument-handoff'
17
+ import { resolveSessionProvider } from '../utils/resolve-session-provider'
18
+ import {
19
+ validateWalletSession,
20
+ getSessionConnector
21
+ } from '../utils/validate-wallet-session'
13
22
 
14
23
  /**
15
- * Service for handling signature operations across different connector types
24
+ * Service for handling signature operations across different connector types.
25
+ *
26
+ * Owns the WHOLE wallet-boundary op — validation (session guards, connector
27
+ * capability, provider resolution) BEFORE the funnel, then the instrumented
28
+ * connector call — the same layering as ConnectionService / NetworkSwitchService,
29
+ * so a pre-wallet failure never emits an `awaitingWallet`/terminal pair.
16
30
  */
17
31
  export class SignatureService {
18
32
  constructor(
19
33
  private sessionManager: SessionManager,
20
- private connectors: Map<ConnectionMode, Connector>
34
+ private connectors: Map<ConnectionMode, Connector>,
35
+ private usingIntegratedBrowser: boolean,
36
+ private eventManager: EventManager,
37
+ // Observational wallet-response deadline for the handoff timeout marker.
38
+ private walletResponseTimeoutMs: number
21
39
  ) {}
22
40
 
23
41
  /**
24
42
  * Sign a message with the currently connected wallet
25
43
  * @param message The message to sign
26
- * @param provider The wallet provider to use for signing (required for injected connector)
27
44
  * @param options Optional cancellation signal
28
45
  * @returns A promise that resolves to the signature
29
46
  */
30
47
  async signMessage(
31
48
  message: string,
32
- provider?: SupportedProvider,
33
49
  options?: ServiceOptions
34
50
  ): Promise<SignatureType> {
35
51
  options?.signal?.throwIfAborted()
36
- const session = this.sessionManager.getSession()
52
+ const session = validateWalletSession(this.sessionManager, {
53
+ requireAddress: true
54
+ })
37
55
 
38
- if (!session.connectionMode) {
39
- throw new Error('No active connection')
40
- }
41
-
42
- if (!session.activeAddress) {
43
- throw new Error('No active wallet address')
44
- }
45
-
46
- // Get the appropriate connector
47
- const connector = this.connectors.get(session.connectionMode)
48
- if (!connector) {
49
- throw new Error(
50
- `No connector found for connection mode: ${session.connectionMode}`
51
- )
52
- }
53
-
54
- // Check if the connector supports signing
56
+ const connector = getSessionConnector(this.connectors, session)
55
57
  if (!connector.signMessage) {
56
58
  throw new Error(`Connector does not support message signing`)
57
59
  }
58
60
 
59
- // Call the connector's signMessage method with the appropriate provider
60
- let signature: SignatureType
61
- if (session.connectionMode === 'injected') {
62
- if (!provider) {
63
- throw new Error('Provider is required for injected connector')
64
- }
65
- signature = await connector.signMessage(
66
- message,
67
- provider as
68
- | ExtensionInjectedProvider
69
- | IntegratedBrowserInjectedProvider
70
- )
71
- } else if (session.connectionMode === 'tonConnect') {
72
- signature = await connector.signMessage(message)
73
- } else if (session.connectionMode === 'walletConnect') {
74
- if (!provider) {
75
- throw new Error('Provider is required for WalletConnect connector')
76
- }
77
- signature = await connector.signMessage(
78
- message,
79
- provider as WalletConnectProvider
80
- )
81
- } else {
82
- // For other connectors that might not need the provider
83
- signature = await connector.signMessage(message)
84
- }
61
+ // Validation, not wallet work a missing provider must not enter the funnel.
62
+ // undefined only for tonConnect, whose connector resolves its own provider.
63
+ const provider = resolveSessionProvider(
64
+ session,
65
+ this.usingIntegratedBrowser
66
+ )
85
67
 
68
+ const signature = await instrumentHandoff(
69
+ {
70
+ eventManager: this.eventManager,
71
+ timeoutMs: this.walletResponseTimeoutMs,
72
+ signal: options?.signal
73
+ },
74
+ sessionHandoffContext('signMessage', session),
75
+ // tonConnect resolves undefined and keeps its message-only call shape.
76
+ () =>
77
+ provider === undefined
78
+ ? connector.signMessage!(message)
79
+ : connector.signMessage!(message, provider)
80
+ )
81
+ // AFTER the funnel: the wallet settled, so its truthful terminal is already
82
+ // recorded; an abort that raced completion still rejects the caller here.
86
83
  options?.signal?.throwIfAborted()
87
84
  return signature
88
85
  }
@@ -91,52 +88,45 @@ export class SignatureService {
91
88
  * Sign EIP-712 typed structured data with the currently connected wallet.
92
89
  * EVM-only (eip155); routes by connection mode. Throws for tonConnect.
93
90
  * @param typedData The EIP-712 typed data to sign
94
- * @param provider The wallet provider (required for the injected connector)
95
91
  * @returns A promise that resolves to the 65-byte hex signature
96
92
  */
97
- async signTypedData(
98
- typedData: EIP712TypedData,
99
- provider?: SupportedProvider
100
- ): Promise<string> {
101
- const session = this.sessionManager.getSession()
102
-
103
- if (!session.connectionMode) {
104
- throw new Error('No active connection')
93
+ async signTypedData(typedData: EIP712TypedData): Promise<string> {
94
+ const session = validateWalletSession(this.sessionManager, {
95
+ requireAddress: true
96
+ })
97
+
98
+ const connector = getSessionConnector(this.connectors, session)
99
+ // TON first: TonConnectConnector has no signTypedData, so the capability
100
+ // guard would otherwise shadow this branch with the generic message.
101
+ if (session.connectionMode === 'tonConnect') {
102
+ throw new Error('signTypedData is not supported for TON wallets')
105
103
  }
106
-
107
- if (!session.activeAddress) {
108
- throw new Error('No active wallet address')
109
- }
110
-
111
- const connector = this.connectors.get(session.connectionMode)
112
- if (!connector) {
113
- throw new Error(
114
- `No connector found for connection mode: ${session.connectionMode}`
115
- )
116
- }
117
-
118
104
  if (!connector.signTypedData) {
119
105
  throw new Error(`Connector does not support typed data signing`)
120
106
  }
121
107
 
108
+ // Only the injected path resolves (and validates) a provider — WalletConnect
109
+ // resolves its own inside the connector.
110
+ let provider: SupportedProvider | undefined
122
111
  if (session.connectionMode === 'injected') {
123
- if (!provider) {
124
- throw new Error('Provider is required for injected connector')
125
- }
126
- return await connector.signTypedData(
127
- typedData,
128
- provider as
129
- | ExtensionInjectedProvider
130
- | IntegratedBrowserInjectedProvider
131
- )
132
- }
133
-
134
- if (session.connectionMode === 'walletConnect') {
135
- return await connector.signTypedData(typedData)
112
+ provider = resolveSessionProvider(session, this.usingIntegratedBrowser)
136
113
  }
137
114
 
138
- throw new Error(
139
- `signTypedData is not supported for connection mode: ${session.connectionMode}`
115
+ return await instrumentHandoff(
116
+ {
117
+ eventManager: this.eventManager,
118
+ timeoutMs: this.walletResponseTimeoutMs
119
+ },
120
+ sessionHandoffContext('signTypedData', session),
121
+ () =>
122
+ session.connectionMode === 'injected'
123
+ ? connector.signTypedData!(
124
+ typedData,
125
+ provider as
126
+ | ExtensionInjectedProvider
127
+ | IntegratedBrowserInjectedProvider
128
+ )
129
+ : connector.signTypedData!(typedData)
140
130
  )
141
131
  }
142
132
  }
@@ -1,20 +1,65 @@
1
1
  import { describe, it, expect, beforeEach, vi } from 'vitest'
2
2
  import { TransactionService } from './transaction-service'
3
3
  import { SessionManager } from '../managers/session-manager'
4
+ import { EventManager } from '../managers/event-manager'
4
5
  import type {
5
6
  ConnectionMode,
6
7
  Connector,
7
- TransactionRequest
8
+ TransactionRequest,
9
+ WalletMetadata
8
10
  } from '../../../types/src'
9
11
 
10
- describe('TransactionService', () => {
12
+ // Same layering contract as SignatureService: the service owns validation
13
+ // (session guards, connector capability, provider resolution) BEFORE the
14
+ // funnel, then the instrumented connector call.
15
+
16
+ const injectedWallet = {
17
+ id: 'metamask',
18
+ name: 'MetaMask',
19
+ extensionInjectedProvider: { supportedNetworkIds: ['eip155:1'] }
20
+ } as unknown as WalletMetadata
21
+
22
+ const walletWithoutProviders = {
23
+ id: 'metamask',
24
+ name: 'MetaMask'
25
+ } as unknown as WalletMetadata
26
+
27
+ const request: TransactionRequest = {
28
+ to: '0x456',
29
+ value: BigInt('1000000000000000000'),
30
+ from: '0x123'
31
+ } as unknown as TransactionRequest
32
+
33
+ describe('TransactionService.sendTransaction', () => {
11
34
  let transactionService: TransactionService
12
35
  let sessionManager: SessionManager
36
+ let eventManager: EventManager
13
37
  let mockConnector: Connector
14
38
  let connectors: Map<ConnectionMode, Connector>
39
+ let events: string[]
40
+
41
+ const connectSession = (
42
+ mode: ConnectionMode = 'injected',
43
+ wallet: WalletMetadata = injectedWallet
44
+ ) =>
45
+ sessionManager.updateSession({
46
+ connectionMode: mode,
47
+ activeWallet: wallet,
48
+ activeAddress: '0x123'
49
+ })
15
50
 
16
51
  beforeEach(() => {
17
52
  sessionManager = new SessionManager()
53
+ eventManager = new EventManager()
54
+ events = []
55
+ for (const name of [
56
+ 'awaitingWallet',
57
+ 'walletSucceeded',
58
+ 'walletRejected',
59
+ 'walletFailed'
60
+ ] as const) {
61
+ eventManager.on(name, () => events.push(name))
62
+ }
18
63
 
19
64
  mockConnector = {
20
65
  connect: vi.fn(),
@@ -32,59 +77,82 @@ describe('TransactionService', () => {
32
77
  connectors = new Map()
33
78
  connectors.set('injected', mockConnector)
34
79
 
35
- transactionService = new TransactionService(sessionManager, connectors)
80
+ transactionService = new TransactionService(
81
+ sessionManager,
82
+ connectors,
83
+ false,
84
+ eventManager,
85
+ 60_000
86
+ )
36
87
  })
37
88
 
38
- it('should initialize with provided parameters', () => {
39
- expect(transactionService).toBeDefined()
89
+ it('throws "No active wallet" first on an empty session (public façade message)', async () => {
90
+ await expect(transactionService.sendTransaction(request)).rejects.toThrow(
91
+ 'No active wallet'
92
+ )
93
+ expect(events).toEqual([])
40
94
  })
41
95
 
42
- it('should throw error when no active connection', async () => {
43
- const request: TransactionRequest = {
44
- to: '0x456',
45
- value: BigInt('1000000000000000000'),
46
- from: '0x123'
47
- }
96
+ it('records walletSucceeded when an abort races completion (the tx already broadcast)', async () => {
97
+ // The transaction went OUT — suppressing the terminal would under-count
98
+ // real sends; the abort still rejects the caller, after the terminal.
99
+ connectSession()
100
+ const controller = new AbortController()
101
+ vi.mocked(mockConnector.sendTransaction!).mockImplementation(async () => {
102
+ controller.abort()
103
+ return { hash: '0xtxhash' } as never
104
+ })
48
105
 
49
- await expect(transactionService.sendTransaction(request)).rejects.toThrow(
50
- 'No active connection'
51
- )
106
+ await expect(
107
+ transactionService.sendTransaction(request, {
108
+ signal: controller.signal
109
+ })
110
+ ).rejects.toMatchObject({ name: 'AbortError' })
111
+ expect(events).toEqual(['awaitingWallet', 'walletSucceeded'])
52
112
  })
53
113
 
54
- it('should throw error when no active address', async () => {
55
- sessionManager.updateSession({
56
- connectionMode: 'injected'
114
+ it('rejects with a CUSTOM abort reason after broadcast — success terminal, no spurious failure', async () => {
115
+ connectSession()
116
+ const controller = new AbortController()
117
+ vi.mocked(mockConnector.sendTransaction!).mockImplementation(async () => {
118
+ controller.abort(new Error('nav away'))
119
+ return { hash: '0xtxhash' } as never
57
120
  })
58
121
 
59
- const request: TransactionRequest = {
60
- to: '0x456',
61
- value: BigInt('1000000000000000000'),
62
- from: '0x123'
63
- }
122
+ await expect(
123
+ transactionService.sendTransaction(request, {
124
+ signal: controller.signal
125
+ })
126
+ ).rejects.toThrow('nav away')
127
+ expect(events).toEqual(['awaitingWallet', 'walletSucceeded'])
128
+ })
129
+
130
+ it('throws when no active address', async () => {
131
+ sessionManager.updateSession({
132
+ connectionMode: 'injected',
133
+ activeWallet: injectedWallet
134
+ })
64
135
 
65
136
  await expect(transactionService.sendTransaction(request)).rejects.toThrow(
66
137
  'No active wallet address'
67
138
  )
139
+ expect(events).toEqual([])
68
140
  })
69
141
 
70
- it('should throw error when connector not found', async () => {
142
+ it('throws when connector not found', async () => {
71
143
  sessionManager.updateSession({
72
144
  connectionMode: 'unknown' as ConnectionMode,
145
+ activeWallet: injectedWallet,
73
146
  activeAddress: '0x123'
74
147
  })
75
148
 
76
- const request: TransactionRequest = {
77
- to: '0x456',
78
- value: BigInt('1000000000000000000'),
79
- from: '0x123'
80
- }
81
-
82
149
  await expect(transactionService.sendTransaction(request)).rejects.toThrow(
83
150
  'No connector found for connection mode: unknown'
84
151
  )
152
+ expect(events).toEqual([])
85
153
  })
86
154
 
87
- it('should throw error when connector does not support transactions', async () => {
155
+ it('throws when connector does not support transactions', async () => {
88
156
  const connectorWithoutTx = {
89
157
  connect: vi.fn(),
90
158
  disconnect: vi.fn(),
@@ -92,37 +160,29 @@ describe('TransactionService', () => {
92
160
  switchNetwork: vi.fn()
93
161
  }
94
162
  connectors.set('injected', connectorWithoutTx)
163
+ connectSession()
95
164
 
96
- sessionManager.updateSession({
97
- connectionMode: 'injected',
98
- activeAddress: '0x123'
99
- })
165
+ await expect(transactionService.sendTransaction(request)).rejects.toThrow(
166
+ 'Connector does not support transactions'
167
+ )
168
+ expect(events).toEqual([])
169
+ })
100
170
 
101
- const request: TransactionRequest = {
102
- to: '0x456',
103
- value: BigInt('1000000000000000000'),
104
- from: '0x123'
105
- }
171
+ it('a missing provider fails BEFORE the funnel (validation, not a wallet outcome)', async () => {
172
+ connectSession('injected', walletWithoutProviders)
106
173
 
107
174
  await expect(transactionService.sendTransaction(request)).rejects.toThrow(
108
- 'Connector does not support transactions'
175
+ 'No provider available for the active wallet'
109
176
  )
177
+ expect(events).toEqual([])
178
+ expect(mockConnector.sendTransaction).not.toHaveBeenCalled()
110
179
  })
111
180
 
112
- it('should send transaction successfully', async () => {
113
- sessionManager.updateSession({
114
- connectionMode: 'injected',
115
- activeAddress: '0x123'
116
- })
181
+ it('sends and emits awaitingWallet → walletSucceeded around the wallet call', async () => {
182
+ connectSession()
117
183
 
118
- const request: TransactionRequest = {
119
- to: '0x456',
120
- value: BigInt('1000000000000000000'),
121
- from: '0x123'
122
- }
184
+ const result = await transactionService.sendTransaction(request)
123
185
 
124
- const provider = {} as any
125
- const result = await transactionService.sendTransaction(request, provider)
126
186
  expect(result).toEqual({
127
187
  hash: '0xtxhash',
128
188
  from: '0x123',
@@ -131,33 +191,174 @@ describe('TransactionService', () => {
131
191
  })
132
192
  expect(mockConnector.sendTransaction).toHaveBeenCalledWith(
133
193
  request,
134
- provider
194
+ injectedWallet.extensionInjectedProvider
135
195
  )
196
+ expect(events).toEqual(['awaitingWallet', 'walletSucceeded'])
136
197
  })
137
198
 
138
- it('should send transaction with provider', async () => {
199
+ it('a user rejection closes the handoff as walletRejected', async () => {
200
+ connectSession()
201
+ vi.mocked(mockConnector.sendTransaction!).mockRejectedValueOnce({
202
+ code: 4001
203
+ })
204
+
205
+ await expect(transactionService.sendTransaction(request)).rejects.toEqual({
206
+ code: 4001
207
+ })
208
+ expect(events).toEqual(['awaitingWallet', 'walletRejected'])
209
+ })
210
+
211
+ it('tonConnect enters the funnel with the connector resolving its own provider', async () => {
212
+ connectors.set('tonConnect', mockConnector)
213
+ connectSession('tonConnect', walletWithoutProviders)
214
+
215
+ await transactionService.sendTransaction(request)
216
+
217
+ expect(mockConnector.sendTransaction).toHaveBeenCalledWith(request)
218
+ expect(events).toEqual(['awaitingWallet', 'walletSucceeded'])
219
+ })
220
+ })
221
+
222
+ describe('TransactionService.signSolanaTransactionBytes', () => {
223
+ let transactionService: TransactionService
224
+ let sessionManager: SessionManager
225
+ let eventManager: EventManager
226
+ let mockConnector: Connector
227
+ let connectors: Map<ConnectionMode, Connector>
228
+ let events: string[]
229
+
230
+ beforeEach(() => {
231
+ sessionManager = new SessionManager()
232
+ eventManager = new EventManager()
233
+ events = []
234
+ for (const name of [
235
+ 'awaitingWallet',
236
+ 'walletSucceeded',
237
+ 'walletFailed'
238
+ ] as const) {
239
+ eventManager.on(name, () => events.push(name))
240
+ }
241
+
242
+ mockConnector = {
243
+ connect: vi.fn(),
244
+ disconnect: vi.fn(),
245
+ signMessage: vi.fn(),
246
+ sendTransaction: vi.fn(),
247
+ switchNetwork: vi.fn(),
248
+ signSolanaTransactionBytes: vi.fn().mockResolvedValue(new Uint8Array([9]))
249
+ }
250
+
251
+ connectors = new Map()
252
+ connectors.set('injected', mockConnector)
253
+
254
+ transactionService = new TransactionService(
255
+ sessionManager,
256
+ connectors,
257
+ false,
258
+ eventManager,
259
+ 60_000
260
+ )
261
+ })
262
+
263
+ it('throws "No active wallet" on an empty session', async () => {
264
+ await expect(
265
+ transactionService.signSolanaTransactionBytes(new Uint8Array([1]))
266
+ ).rejects.toThrow('No active wallet')
267
+ expect(events).toEqual([])
268
+ })
269
+
270
+ it('throws "No active connection" when the session has no connectionMode', async () => {
271
+ sessionManager.updateSession({ activeWallet: injectedWallet })
272
+
273
+ await expect(
274
+ transactionService.signSolanaTransactionBytes(new Uint8Array([1]))
275
+ ).rejects.toThrow('No active connection')
276
+ expect(events).toEqual([])
277
+ })
278
+
279
+ it('throws "not supported" when connectionMode maps to no registered connector', async () => {
139
280
  sessionManager.updateSession({
140
- connectionMode: 'injected',
281
+ connectionMode: 'walletConnect',
282
+ activeWallet: injectedWallet,
141
283
  activeAddress: '0x123'
142
284
  })
143
285
 
144
- const request: TransactionRequest = {
145
- to: '0x456',
146
- value: BigInt('1000000000000000000'),
147
- from: '0x123'
286
+ await expect(
287
+ transactionService.signSolanaTransactionBytes(new Uint8Array([1]))
288
+ ).rejects.toThrow(
289
+ 'signSolanaTransaction is not supported for connection mode "walletConnect"'
290
+ )
291
+ expect(events).toEqual([])
292
+ })
293
+
294
+ it('routes serializedTx to the walletConnect connector when active', async () => {
295
+ const SIGNED_TX = new Uint8Array([6, 7, 8])
296
+ const wcConnector = {
297
+ connect: vi.fn(),
298
+ disconnect: vi.fn(),
299
+ switchNetwork: vi.fn(),
300
+ signSolanaTransactionBytes: vi.fn().mockResolvedValue(SIGNED_TX)
148
301
  }
302
+ connectors.set('walletConnect', wcConnector)
303
+ sessionManager.updateSession({
304
+ connectionMode: 'walletConnect',
305
+ activeWallet: injectedWallet,
306
+ activeAddress: '0x123'
307
+ })
308
+ const bytes = new Uint8Array([1, 2, 3])
149
309
 
150
- const provider = {} as any
151
- const result = await transactionService.sendTransaction(request, provider)
152
- expect(result).toEqual({
153
- hash: '0xtxhash',
154
- from: '0x123',
155
- to: '0x456',
156
- value: '1000000000000000000'
310
+ const result = await transactionService.signSolanaTransactionBytes(bytes)
311
+
312
+ expect(wcConnector.signSolanaTransactionBytes).toHaveBeenCalledWith(bytes)
313
+ expect(result).toBe(SIGNED_TX)
314
+ expect(events).toEqual(['awaitingWallet', 'walletSucceeded'])
315
+ })
316
+
317
+ it('INVARIANT: connector.sendTransaction is never called on the sign-only path', async () => {
318
+ sessionManager.updateSession({
319
+ connectionMode: 'injected',
320
+ activeWallet: injectedWallet,
321
+ activeAddress: '0x123'
157
322
  })
158
- expect(mockConnector.sendTransaction).toHaveBeenCalledWith(
159
- request,
160
- provider
323
+
324
+ await transactionService.signSolanaTransactionBytes(new Uint8Array([1]))
325
+
326
+ expect(mockConnector.sendTransaction).not.toHaveBeenCalled()
327
+ })
328
+
329
+ it('throws the façade message when the connector lacks the capability, outside the funnel', async () => {
330
+ const connectorWithoutSolana = {
331
+ connect: vi.fn(),
332
+ disconnect: vi.fn(),
333
+ switchNetwork: vi.fn()
334
+ }
335
+ connectors.set('injected', connectorWithoutSolana)
336
+ sessionManager.updateSession({
337
+ connectionMode: 'injected',
338
+ activeWallet: injectedWallet,
339
+ activeAddress: '0x123'
340
+ })
341
+
342
+ await expect(
343
+ transactionService.signSolanaTransactionBytes(new Uint8Array([1]))
344
+ ).rejects.toThrow(
345
+ 'signSolanaTransaction is not supported for connection mode "injected"'
161
346
  )
347
+ expect(events).toEqual([])
348
+ })
349
+
350
+ it('signs (never broadcasts) inside the funnel', async () => {
351
+ sessionManager.updateSession({
352
+ connectionMode: 'injected',
353
+ activeWallet: injectedWallet,
354
+ activeAddress: '0x123'
355
+ })
356
+ const bytes = new Uint8Array([1, 2, 3])
357
+
358
+ const signed = await transactionService.signSolanaTransactionBytes(bytes)
359
+
360
+ expect(signed).toEqual(new Uint8Array([9]))
361
+ expect(mockConnector.signSolanaTransactionBytes).toHaveBeenCalledWith(bytes)
362
+ expect(events).toEqual(['awaitingWallet', 'walletSucceeded'])
162
363
  })
163
364
  })