walletpair-sdk 1.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.
Files changed (95) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +415 -0
  3. package/dist/ble/framing.d.ts +23 -0
  4. package/dist/ble/framing.d.ts.map +1 -0
  5. package/dist/ble/framing.js +83 -0
  6. package/dist/ble/framing.js.map +1 -0
  7. package/dist/ble/index.d.ts +9 -0
  8. package/dist/ble/index.d.ts.map +1 -0
  9. package/dist/ble/index.js +9 -0
  10. package/dist/ble/index.js.map +1 -0
  11. package/dist/ble/web-ble-transport.d.ts +29 -0
  12. package/dist/ble/web-ble-transport.d.ts.map +1 -0
  13. package/dist/ble/web-ble-transport.js +93 -0
  14. package/dist/ble/web-ble-transport.js.map +1 -0
  15. package/dist/crypto.d.ts +102 -0
  16. package/dist/crypto.d.ts.map +1 -0
  17. package/dist/crypto.js +279 -0
  18. package/dist/crypto.js.map +1 -0
  19. package/dist/dapp-session.d.ts +106 -0
  20. package/dist/dapp-session.d.ts.map +1 -0
  21. package/dist/dapp-session.js +918 -0
  22. package/dist/dapp-session.js.map +1 -0
  23. package/dist/emitter.d.ts +16 -0
  24. package/dist/emitter.d.ts.map +1 -0
  25. package/dist/emitter.js +41 -0
  26. package/dist/emitter.js.map +1 -0
  27. package/dist/evm/eip1193.d.ts +83 -0
  28. package/dist/evm/eip1193.d.ts.map +1 -0
  29. package/dist/evm/eip1193.js +270 -0
  30. package/dist/evm/eip1193.js.map +1 -0
  31. package/dist/evm/index.d.ts +8 -0
  32. package/dist/evm/index.d.ts.map +1 -0
  33. package/dist/evm/index.js +8 -0
  34. package/dist/evm/index.js.map +1 -0
  35. package/dist/evm/wagmi.d.ts +118 -0
  36. package/dist/evm/wagmi.d.ts.map +1 -0
  37. package/dist/evm/wagmi.js +205 -0
  38. package/dist/evm/wagmi.js.map +1 -0
  39. package/dist/index.d.ts +22 -0
  40. package/dist/index.d.ts.map +1 -0
  41. package/dist/index.js +24 -0
  42. package/dist/index.js.map +1 -0
  43. package/dist/types.d.ts +225 -0
  44. package/dist/types.d.ts.map +1 -0
  45. package/dist/types.js +31 -0
  46. package/dist/types.js.map +1 -0
  47. package/dist/wallet-session.d.ts +107 -0
  48. package/dist/wallet-session.d.ts.map +1 -0
  49. package/dist/wallet-session.js +794 -0
  50. package/dist/wallet-session.js.map +1 -0
  51. package/dist/ws-transport.d.ts +29 -0
  52. package/dist/ws-transport.d.ts.map +1 -0
  53. package/dist/ws-transport.js +79 -0
  54. package/dist/ws-transport.js.map +1 -0
  55. package/package.json +55 -0
  56. package/src/__tests__/adversarial/crypto-attacks.test.ts +557 -0
  57. package/src/__tests__/adversarial/malicious-dapp.test.ts +505 -0
  58. package/src/__tests__/adversarial/malicious-relay.test.ts +528 -0
  59. package/src/__tests__/adversarial/malicious-wallet.test.ts +467 -0
  60. package/src/__tests__/spec-compliance/canonical-json.test.ts +227 -0
  61. package/src/__tests__/spec-compliance/crypto-vectors.test.ts +321 -0
  62. package/src/__tests__/spec-compliance/message-format.test.ts +356 -0
  63. package/src/__tests__/spec-compliance/sequence-numbers.test.ts +300 -0
  64. package/src/__tests__/spec-compliance/state-machine.test.ts +364 -0
  65. package/src/ble/framing.test.ts +196 -0
  66. package/src/ble/framing.ts +100 -0
  67. package/src/ble/index.ts +18 -0
  68. package/src/ble/web-ble-transport.test.ts +192 -0
  69. package/src/ble/web-ble-transport.ts +116 -0
  70. package/src/ble/web-bluetooth.d.ts +47 -0
  71. package/src/canonical-json.test.ts +612 -0
  72. package/src/crypto-directional.test.ts +263 -0
  73. package/src/crypto-hardening.test.ts +529 -0
  74. package/src/crypto.test.ts +635 -0
  75. package/src/crypto.ts +405 -0
  76. package/src/dapp-session.test.ts +647 -0
  77. package/src/dapp-session.ts +1004 -0
  78. package/src/emitter.test.ts +169 -0
  79. package/src/emitter.ts +45 -0
  80. package/src/evm/eip1193.test.ts +365 -0
  81. package/src/evm/eip1193.ts +346 -0
  82. package/src/evm/index.ts +19 -0
  83. package/src/evm/wagmi.test.ts +396 -0
  84. package/src/evm/wagmi.ts +321 -0
  85. package/src/index.ts +86 -0
  86. package/src/integration.test.ts +385 -0
  87. package/src/security.test.ts +430 -0
  88. package/src/sequence-validation.test.ts +1185 -0
  89. package/src/test-helpers.ts +216 -0
  90. package/src/types.test.ts +82 -0
  91. package/src/types.ts +305 -0
  92. package/src/wallet-session.test.ts +683 -0
  93. package/src/wallet-session.ts +922 -0
  94. package/src/ws-transport.test.ts +231 -0
  95. package/src/ws-transport.ts +92 -0
@@ -0,0 +1,1185 @@
1
+ /**
2
+ * Sequence number validation tests for DAppSession and WalletSession.
3
+ *
4
+ * Covers: replay rejection, sequence gaps, persistence of recvSeq,
5
+ * send sequence overflow, pending accept timeout, and capabilities validation.
6
+ */
7
+
8
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
9
+ import type { AadHeader } from './crypto.js'
10
+ import {
11
+ b64urlDecode,
12
+ b64urlEncode,
13
+ buildPairingUri,
14
+ computeSharedSecret,
15
+ deriveSessionKey,
16
+ generateChannelId,
17
+ generateX25519KeyPair,
18
+ sealPayload,
19
+ unsealPayload,
20
+ } from './crypto.js'
21
+ import { DAppSession } from './dapp-session.js'
22
+ import { MockRelay, MockTransport, makeJoinBody, parseSnapshot } from './test-helpers.js'
23
+ import type { ProtocolMessage, SessionPersistence } from './types.js'
24
+ import { WalletSession } from './wallet-session.js'
25
+
26
+ function wait(ms = 50): Promise<void> {
27
+ return new Promise((r) => setTimeout(r, ms))
28
+ }
29
+
30
+ class ControlledPersistence implements SessionPersistence {
31
+ snapshots: string[] = []
32
+ hold = false
33
+ private pending: Array<() => void> = []
34
+
35
+ save(snapshot: string): void | Promise<void> {
36
+ this.snapshots.push(snapshot)
37
+ if (!this.hold) return
38
+ return new Promise((resolve) => {
39
+ this.pending.push(resolve)
40
+ })
41
+ }
42
+
43
+ load(): string | null {
44
+ return this.snapshots.length ? this.snapshots[this.snapshots.length - 1]! : null
45
+ }
46
+
47
+ resolveNext(): void {
48
+ this.pending.shift()?.()
49
+ }
50
+
51
+ latest(): Record<string, unknown> {
52
+ return parseSnapshot(this.load() ?? '{}') as Record<string, unknown>
53
+ }
54
+ }
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // Helpers to set up connected DApp + Wallet sessions
58
+ // ---------------------------------------------------------------------------
59
+
60
+ interface ConnectedPair {
61
+ dappSession: DAppSession
62
+ walletSession: WalletSession
63
+ dappTransport: MockTransport
64
+ walletTransport: MockTransport
65
+ /** Session key derived from the dApp side (same key both sides share). */
66
+ sessionKey: Uint8Array
67
+ /** The wallet's key pair (for crafting raw messages from wallet side). */
68
+ walletKp: ReturnType<typeof generateX25519KeyPair>
69
+ /** The dApp's key pair (for crafting raw messages from dApp side). */
70
+ dappKp: { publicKeyB64: string }
71
+ channelId: string
72
+ }
73
+
74
+ async function setupConnectedPair(): Promise<ConnectedPair> {
75
+ const dappTransport = new MockTransport()
76
+ const walletTransport = new MockTransport()
77
+ const _relay = new MockRelay(dappTransport, walletTransport)
78
+
79
+ const dappSession = new DAppSession({
80
+ transport: dappTransport,
81
+ meta: {
82
+ name: 'Test dApp',
83
+ description: 'Test',
84
+ url: 'https://test.com',
85
+ icon: 'https://test.com/icon.png',
86
+ },
87
+ })
88
+ const walletSession = new WalletSession({
89
+ transport: walletTransport,
90
+ capabilities: {
91
+ methods: ['wallet_getAccounts', 'wallet_signMessage'],
92
+ events: ['accountsChanged'],
93
+ chains: ['eip155:1'],
94
+ },
95
+ meta: {
96
+ name: 'Test Wallet',
97
+ description: 'Test',
98
+ url: 'https://test.com',
99
+ icon: 'https://test.com/icon.png',
100
+ },
101
+ })
102
+
103
+ const uri = await dappSession.createPairing()
104
+ await walletSession.joinFromUri(uri)
105
+ await wait()
106
+ await wait()
107
+
108
+ // Derive session key from the dApp side perspective.
109
+ // We need the wallet's pubkey and dApp's private key — but those are internal.
110
+ // Instead, we extract from what the relay forwarded.
111
+ // The walletTransport.sent has the join message with from = wallet pubkey.
112
+ const walletJoinMsg = walletTransport.sent.find((m) => m.t === 'join')!
113
+ const walletPubB64 = walletJoinMsg.from!
114
+ // The dappTransport.sent has the create message with from = dApp pubkey.
115
+ const dappCreateMsg = dappTransport.sent.find((m) => m.t === 'create')!
116
+ const dappPubB64 = dappCreateMsg.from!
117
+
118
+ return {
119
+ dappSession,
120
+ walletSession,
121
+ dappTransport,
122
+ walletTransport,
123
+ sessionKey: null as any, // We use sessions directly; raw key only needed for manual message tests
124
+ walletKp: null as any,
125
+ dappKp: { publicKeyB64: dappPubB64 },
126
+ channelId: dappSession.channelId,
127
+ }
128
+ }
129
+
130
+ /**
131
+ * Set up a DAppSession with a direct MockTransport (no relay), manually
132
+ * driving the handshake so we can craft raw messages with specific seq numbers.
133
+ */
134
+ function setupDAppWithManualWallet(persistence?: SessionPersistence) {
135
+ const transport = new MockTransport()
136
+ const session = new DAppSession({
137
+ transport,
138
+ meta: {
139
+ name: 'Test dApp',
140
+ description: 'Test',
141
+ url: 'https://test.com',
142
+ icon: 'https://test.com/icon.png',
143
+ },
144
+ persistence,
145
+ })
146
+ const walletKp = generateX25519KeyPair()
147
+
148
+ return { transport, session, walletKp }
149
+ }
150
+
151
+ async function connectDAppManually(ctx: ReturnType<typeof setupDAppWithManualWallet>) {
152
+ const { transport, session, walletKp } = ctx
153
+ await session.createPairing()
154
+
155
+ // Simulate wallet join
156
+ transport.receive({
157
+ v: 1,
158
+ t: 'join',
159
+ ch: session.channelId,
160
+ ts: Date.now(),
161
+ from: walletKp.publicKeyB64,
162
+ body: makeJoinBody(session.channelId, transport.sent[0]!.from!, walletKp),
163
+ } as ProtocolMessage)
164
+
165
+ // Derive root key from wallet side. Responses/events use wallet->dApp key,
166
+ // which is DAppSession.recvKey after the join transcript is processed.
167
+ const dappPubB64 = transport.sent[0]!.from!
168
+ const dappPub = b64urlDecode(dappPubB64)
169
+ const shared = computeSharedSecret(walletKp.privateKey, dappPub)
170
+ deriveSessionKey(shared, session.channelId)
171
+
172
+ // Auto-accepted; simulate relay ready.connected
173
+ transport.receive({
174
+ v: 1,
175
+ t: 'ready',
176
+ ch: session.channelId,
177
+ ts: Date.now(),
178
+ from: '_adapter',
179
+ body: { state: 'connected', reconnect: false, remote: walletKp.publicKeyB64 },
180
+ } as ProtocolMessage)
181
+
182
+ return { sessionKey: (session as any).recvKey as Uint8Array, dappPubB64 }
183
+ }
184
+
185
+ /**
186
+ * Set up a WalletSession with a direct MockTransport, manually driving the handshake.
187
+ */
188
+ function setupWalletWithManualDApp(persistence?: SessionPersistence) {
189
+ const transport = new MockTransport()
190
+ const dappKp = generateX25519KeyPair()
191
+ const channelId = generateChannelId()
192
+
193
+ const session = new WalletSession({
194
+ transport,
195
+ capabilities: {
196
+ methods: ['wallet_getAccounts'],
197
+ events: [],
198
+ chains: ['eip155:1'],
199
+ },
200
+ meta: {
201
+ name: 'Test Wallet',
202
+ description: 'Test',
203
+ url: 'https://test.com',
204
+ icon: 'https://test.com/icon.png',
205
+ },
206
+ persistence,
207
+ })
208
+
209
+ return { transport, session, dappKp, channelId }
210
+ }
211
+
212
+ async function connectWalletManually(ctx: ReturnType<typeof setupWalletWithManualDApp>) {
213
+ const { transport, session, dappKp, channelId } = ctx
214
+
215
+ const uri = buildPairingUri({
216
+ channelId,
217
+ pubkeyB64: dappKp.publicKeyB64,
218
+ relayUrl: 'ws://localhost:8080/v1',
219
+ name: 'Test dApp',
220
+ url: 'https://test.com',
221
+ icon: 'https://test.com/icon.png',
222
+ })
223
+
224
+ await session.joinFromUri(uri)
225
+
226
+ // Derive root key from dApp side. Requests use dApp->wallet key,
227
+ // which is WalletSession.recvKey after prepareJoin().
228
+ const walletPubB64 = transport.sent.find((m) => m.t === 'join')!.from!
229
+ const walletPub = b64urlDecode(walletPubB64)
230
+ const shared = computeSharedSecret(dappKp.privateKey, walletPub)
231
+ deriveSessionKey(shared, channelId)
232
+
233
+ // Connect
234
+ transport.receive({
235
+ v: 1,
236
+ t: 'ready',
237
+ ch: channelId,
238
+ ts: Date.now(),
239
+ from: '_adapter',
240
+ body: { state: 'connected', reconnect: false, remote: dappKp.publicKeyB64 },
241
+ } as ProtocolMessage)
242
+
243
+ return { sessionKey: (session as any).recvKey as Uint8Array, walletPubB64 }
244
+ }
245
+
246
+ // ---------------------------------------------------------------------------
247
+ // Tests
248
+ // ---------------------------------------------------------------------------
249
+
250
+ describe('Sequence validation', () => {
251
+ // -----------------------------------------------------------------------
252
+ // 1. Replay rejection on DAppSession
253
+ // -----------------------------------------------------------------------
254
+ describe('replay rejection on DAppSession', () => {
255
+ it('accepts seq=0, rejects replay seq=0, accepts seq=1', async () => {
256
+ const ctx = setupDAppWithManualWallet()
257
+ const { transport, session, walletKp } = ctx
258
+ const { sessionKey } = await connectDAppManually(ctx)
259
+
260
+ // Send request so we have a pending request for seq=0 response
261
+ const p0 = session.request('wallet_getAccounts')
262
+ await wait(20)
263
+ const req0 = transport.sent.find((m) => m.t === 'req') as any
264
+
265
+ // Wallet responds with seq=0 -> should be accepted
266
+ transport.receive({
267
+ v: 1,
268
+ t: 'res',
269
+ ch: session.channelId,
270
+ ts: Date.now(),
271
+ from: walletKp.publicKeyB64,
272
+ body: {
273
+ id: req0.body.id,
274
+ sealed: sealPayload(
275
+ sessionKey,
276
+ session.channelId,
277
+ 0,
278
+ { _ok: true, _result: ['0xabc'] },
279
+ { type: 'res', from: walletKp.publicKeyB64, id: req0.body.id },
280
+ ),
281
+ },
282
+ } as ProtocolMessage)
283
+
284
+ const result0 = await p0
285
+ expect(result0).toEqual(['0xabc'])
286
+
287
+ // Send another request for seq=0 replay test
288
+ const p1 = session.request('wallet_getAccounts')
289
+ await wait(20)
290
+ const req1 = transport.sent.filter((m) => m.t === 'req')[1] as any
291
+
292
+ // Wallet responds with seq=0 again (replay) -> should be rejected
293
+ transport.receive({
294
+ v: 1,
295
+ t: 'res',
296
+ ch: session.channelId,
297
+ ts: Date.now(),
298
+ from: walletKp.publicKeyB64,
299
+ body: {
300
+ id: req1.body.id,
301
+ sealed: sealPayload(
302
+ sessionKey,
303
+ session.channelId,
304
+ 0,
305
+ { _ok: true, _result: ['0xreplay'] },
306
+ { type: 'res', from: walletKp.publicKeyB64, id: req1.body.id },
307
+ ),
308
+ },
309
+ } as ProtocolMessage)
310
+
311
+ await expect(p1).rejects.toThrow('Replay detected')
312
+
313
+ // Send another request for seq=1 test
314
+ const p2 = session.request('wallet_getAccounts')
315
+ await wait(20)
316
+ const req2 = transport.sent.filter((m) => m.t === 'req')[2] as any
317
+
318
+ // Wallet responds with seq=1 -> should be accepted
319
+ transport.receive({
320
+ v: 1,
321
+ t: 'res',
322
+ ch: session.channelId,
323
+ ts: Date.now(),
324
+ from: walletKp.publicKeyB64,
325
+ body: {
326
+ id: req2.body.id,
327
+ sealed: sealPayload(
328
+ sessionKey,
329
+ session.channelId,
330
+ 1,
331
+ { _ok: true, _result: ['0xdef'] },
332
+ { type: 'res', from: walletKp.publicKeyB64, id: req2.body.id },
333
+ ),
334
+ },
335
+ } as ProtocolMessage)
336
+
337
+ const result2 = await p2
338
+ expect(result2).toEqual(['0xdef'])
339
+ })
340
+
341
+ it('silently drops replayed events', async () => {
342
+ const ctx = setupDAppWithManualWallet()
343
+ const { transport, session, walletKp } = ctx
344
+ const { sessionKey } = await connectDAppManually(ctx)
345
+
346
+ const eventHandler = vi.fn()
347
+ session.on('event', eventHandler)
348
+
349
+ // First event with seq=0 -> accepted
350
+ transport.receive({
351
+ v: 1,
352
+ t: 'evt',
353
+ ch: session.channelId,
354
+ ts: Date.now(),
355
+ from: walletKp.publicKeyB64,
356
+ body: {
357
+ id: 'evt-test',
358
+ sealed: sealPayload(
359
+ sessionKey,
360
+ session.channelId,
361
+ 0,
362
+ { _event: 'accountsChanged', accounts: ['0xa'] },
363
+ { type: 'evt', from: walletKp.publicKeyB64, id: 'evt-test' },
364
+ ),
365
+ },
366
+ } as ProtocolMessage)
367
+
368
+ expect(eventHandler).toHaveBeenCalledTimes(1)
369
+
370
+ // Replay same event with seq=0 -> silently dropped
371
+ transport.receive({
372
+ v: 1,
373
+ t: 'evt',
374
+ ch: session.channelId,
375
+ ts: Date.now(),
376
+ from: walletKp.publicKeyB64,
377
+ body: {
378
+ id: 'evt-test',
379
+ sealed: sealPayload(
380
+ sessionKey,
381
+ session.channelId,
382
+ 0,
383
+ { _event: 'accountsChanged', accounts: ['0xa'] },
384
+ { type: 'evt', from: walletKp.publicKeyB64, id: 'evt-test' },
385
+ ),
386
+ },
387
+ } as ProtocolMessage)
388
+
389
+ expect(eventHandler).toHaveBeenCalledTimes(1) // still 1
390
+
391
+ // New event with seq=1 -> accepted
392
+ transport.receive({
393
+ v: 1,
394
+ t: 'evt',
395
+ ch: session.channelId,
396
+ ts: Date.now(),
397
+ from: walletKp.publicKeyB64,
398
+ body: {
399
+ id: 'evt-test',
400
+ sealed: sealPayload(
401
+ sessionKey,
402
+ session.channelId,
403
+ 1,
404
+ { _event: 'accountsChanged', accounts: ['0xb'] },
405
+ { type: 'evt', from: walletKp.publicKeyB64, id: 'evt-test' },
406
+ ),
407
+ },
408
+ } as ProtocolMessage)
409
+
410
+ expect(eventHandler).toHaveBeenCalledTimes(2)
411
+ })
412
+ })
413
+
414
+ // -----------------------------------------------------------------------
415
+ // 2. Replay rejection on WalletSession
416
+ // -----------------------------------------------------------------------
417
+ describe('replay rejection on WalletSession', () => {
418
+ it('accepts seq=0, drops replay seq=0, accepts seq=1', async () => {
419
+ const ctx = setupWalletWithManualDApp()
420
+ const { transport, session, dappKp, channelId } = ctx
421
+ const { sessionKey } = await connectWalletManually(ctx)
422
+
423
+ const requestHandler = vi.fn()
424
+ session.on('request', requestHandler)
425
+
426
+ // Request with seq=0 -> accepted
427
+ transport.receive({
428
+ v: 1,
429
+ t: 'req',
430
+ ch: channelId,
431
+ ts: Date.now(),
432
+ from: dappKp.publicKeyB64,
433
+ body: {
434
+ id: 'req-1',
435
+ sealed: sealPayload(
436
+ sessionKey,
437
+ channelId,
438
+ 0,
439
+ { _method: 'wallet_getAccounts', foo: 'bar' },
440
+ { type: 'req', from: dappKp.publicKeyB64, id: 'req-1' },
441
+ ),
442
+ },
443
+ } as ProtocolMessage)
444
+
445
+ expect(requestHandler).toHaveBeenCalledTimes(1)
446
+ expect(requestHandler).toHaveBeenCalledWith({
447
+ id: 'req-1',
448
+ method: 'wallet_getAccounts',
449
+ params: { foo: 'bar' },
450
+ })
451
+
452
+ // Replay same request with seq=0 -> silently dropped
453
+ transport.receive({
454
+ v: 1,
455
+ t: 'req',
456
+ ch: channelId,
457
+ ts: Date.now(),
458
+ from: dappKp.publicKeyB64,
459
+ body: {
460
+ id: 'req-1-replay',
461
+ sealed: sealPayload(
462
+ sessionKey,
463
+ channelId,
464
+ 0,
465
+ { _method: 'wallet_getAccounts', foo: 'bar' },
466
+ { type: 'req', from: dappKp.publicKeyB64, id: 'req-1-replay' },
467
+ ),
468
+ },
469
+ } as ProtocolMessage)
470
+
471
+ expect(requestHandler).toHaveBeenCalledTimes(1) // still 1
472
+
473
+ // Request with seq=1 -> accepted
474
+ transport.receive({
475
+ v: 1,
476
+ t: 'req',
477
+ ch: channelId,
478
+ ts: Date.now(),
479
+ from: dappKp.publicKeyB64,
480
+ body: {
481
+ id: 'req-2',
482
+ sealed: sealPayload(
483
+ sessionKey,
484
+ channelId,
485
+ 1,
486
+ { _method: 'wallet_getAccounts', message: 'hello' },
487
+ { type: 'req', from: dappKp.publicKeyB64, id: 'req-2' },
488
+ ),
489
+ },
490
+ } as ProtocolMessage)
491
+
492
+ expect(requestHandler).toHaveBeenCalledTimes(2)
493
+ expect(requestHandler).toHaveBeenLastCalledWith({
494
+ id: 'req-2',
495
+ method: 'wallet_getAccounts',
496
+ params: { message: 'hello' },
497
+ })
498
+ })
499
+ })
500
+
501
+ // -----------------------------------------------------------------------
502
+ // 3. Sequence gaps accepted
503
+ // -----------------------------------------------------------------------
504
+ describe('sequence gaps accepted', () => {
505
+ it('accepts seq=0 then seq=5 (gap), rejects seq=3 (below high watermark)', async () => {
506
+ const ctx = setupDAppWithManualWallet()
507
+ const { transport, session, walletKp } = ctx
508
+ const { sessionKey } = await connectDAppManually(ctx)
509
+
510
+ // Request 1: seq=0
511
+ const p0 = session.request('wallet_getAccounts')
512
+ await wait(20)
513
+ const req0 = transport.sent.find((m) => m.t === 'req') as any
514
+
515
+ transport.receive({
516
+ v: 1,
517
+ t: 'res',
518
+ ch: session.channelId,
519
+ ts: Date.now(),
520
+ from: walletKp.publicKeyB64,
521
+ body: {
522
+ id: req0.body.id,
523
+ sealed: sealPayload(
524
+ sessionKey,
525
+ session.channelId,
526
+ 0,
527
+ { _ok: true, _result: 'first' },
528
+ { type: 'res', from: walletKp.publicKeyB64, id: req0.body.id },
529
+ ),
530
+ },
531
+ } as ProtocolMessage)
532
+
533
+ expect(await p0).toBe('first')
534
+
535
+ // Request 2: seq=5 (gap of 4) -> should be accepted
536
+ const p1 = session.request('wallet_getAccounts')
537
+ await wait(20)
538
+ const req1 = transport.sent.filter((m) => m.t === 'req')[1] as any
539
+
540
+ transport.receive({
541
+ v: 1,
542
+ t: 'res',
543
+ ch: session.channelId,
544
+ ts: Date.now(),
545
+ from: walletKp.publicKeyB64,
546
+ body: {
547
+ id: req1.body.id,
548
+ sealed: sealPayload(
549
+ sessionKey,
550
+ session.channelId,
551
+ 5,
552
+ { _ok: true, _result: 'second' },
553
+ { type: 'res', from: walletKp.publicKeyB64, id: req1.body.id },
554
+ ),
555
+ },
556
+ } as ProtocolMessage)
557
+
558
+ expect(await p1).toBe('second')
559
+
560
+ // Request 3: seq=3 (less than current high watermark of 5) -> rejected
561
+ const p2 = session.request('wallet_getAccounts')
562
+ await wait(20)
563
+ const req2 = transport.sent.filter((m) => m.t === 'req')[2] as any
564
+
565
+ transport.receive({
566
+ v: 1,
567
+ t: 'res',
568
+ ch: session.channelId,
569
+ ts: Date.now(),
570
+ from: walletKp.publicKeyB64,
571
+ body: {
572
+ id: req2.body.id,
573
+ sealed: sealPayload(
574
+ sessionKey,
575
+ session.channelId,
576
+ 3,
577
+ { _ok: true, _result: 'replay-attempt' },
578
+ { type: 'res', from: walletKp.publicKeyB64, id: req2.body.id },
579
+ ),
580
+ },
581
+ } as ProtocolMessage)
582
+
583
+ await expect(p2).rejects.toThrow('Replay detected')
584
+ })
585
+
586
+ it('wallet session also accepts gaps and rejects below watermark', async () => {
587
+ const ctx = setupWalletWithManualDApp()
588
+ const { transport, session, dappKp, channelId } = ctx
589
+ const { sessionKey } = await connectWalletManually(ctx)
590
+
591
+ const requestHandler = vi.fn()
592
+ session.on('request', requestHandler)
593
+
594
+ // seq=0 -> accepted
595
+ transport.receive({
596
+ v: 1,
597
+ t: 'req',
598
+ ch: channelId,
599
+ ts: Date.now(),
600
+ from: dappKp.publicKeyB64,
601
+ body: {
602
+ id: 'r1',
603
+ sealed: sealPayload(
604
+ sessionKey,
605
+ channelId,
606
+ 0,
607
+ { _method: 'wallet_getAccounts' },
608
+ { type: 'req', from: dappKp.publicKeyB64, id: 'r1' },
609
+ ),
610
+ },
611
+ } as ProtocolMessage)
612
+ expect(requestHandler).toHaveBeenCalledTimes(1)
613
+
614
+ // seq=5 (gap) -> accepted
615
+ transport.receive({
616
+ v: 1,
617
+ t: 'req',
618
+ ch: channelId,
619
+ ts: Date.now(),
620
+ from: dappKp.publicKeyB64,
621
+ body: {
622
+ id: 'r2',
623
+ sealed: sealPayload(
624
+ sessionKey,
625
+ channelId,
626
+ 5,
627
+ { _method: 'wallet_getAccounts' },
628
+ { type: 'req', from: dappKp.publicKeyB64, id: 'r2' },
629
+ ),
630
+ },
631
+ } as ProtocolMessage)
632
+ expect(requestHandler).toHaveBeenCalledTimes(2)
633
+
634
+ // seq=3 (below 5) -> dropped
635
+ transport.receive({
636
+ v: 1,
637
+ t: 'req',
638
+ ch: channelId,
639
+ ts: Date.now(),
640
+ from: dappKp.publicKeyB64,
641
+ body: {
642
+ id: 'r3',
643
+ sealed: sealPayload(
644
+ sessionKey,
645
+ channelId,
646
+ 3,
647
+ { _method: 'wallet_getAccounts' },
648
+ { type: 'req', from: dappKp.publicKeyB64, id: 'r3' },
649
+ ),
650
+ },
651
+ } as ProtocolMessage)
652
+ expect(requestHandler).toHaveBeenCalledTimes(2) // still 2
653
+ })
654
+ })
655
+
656
+ // -----------------------------------------------------------------------
657
+ // 4. Sequence persistence through serialize/restore
658
+ // -----------------------------------------------------------------------
659
+ describe('sequence persistence', () => {
660
+ it('DAppSession: restored session rejects replayed seq numbers', async () => {
661
+ const ctx = setupDAppWithManualWallet()
662
+ const { transport, session, walletKp } = ctx
663
+ const { sessionKey } = await connectDAppManually(ctx)
664
+
665
+ // Exchange messages to advance recvSeq to 2
666
+ for (let seq = 0; seq <= 2; seq++) {
667
+ const p = session.request('wallet_getAccounts')
668
+ await wait(20)
669
+ const reqs = transport.sent.filter((m) => m.t === 'req')
670
+ const req = reqs[reqs.length - 1] as any
671
+
672
+ transport.receive({
673
+ v: 1,
674
+ t: 'res',
675
+ ch: session.channelId,
676
+ ts: Date.now(),
677
+ from: walletKp.publicKeyB64,
678
+ body: {
679
+ id: req.body.id,
680
+ sealed: sealPayload(
681
+ sessionKey,
682
+ session.channelId,
683
+ seq,
684
+ { _ok: true, _result: `result-${seq}` },
685
+ { type: 'res', from: walletKp.publicKeyB64, id: req.body.id },
686
+ ),
687
+ },
688
+ } as ProtocolMessage)
689
+
690
+ await p
691
+ }
692
+
693
+ // Serialize and restore
694
+ const json = session.serialize()
695
+ const newTransport = new MockTransport()
696
+ const restored = new DAppSession({
697
+ transport: newTransport,
698
+ meta: {
699
+ name: 'Test dApp',
700
+ description: 'Test',
701
+ url: 'https://test.com',
702
+ icon: 'https://test.com/icon.png',
703
+ },
704
+ })
705
+ expect(restored.restore(json)).toBe(true)
706
+
707
+ // Manually set phase to connected so we can send requests
708
+ ;(restored as any).phase = 'connected'
709
+
710
+ // Try sending a request and responding with old seq=1 -> should be rejected
711
+ const p = restored.request('wallet_getAccounts')
712
+ await wait(20)
713
+ const reqMsg = newTransport.sent.find((m) => m.t === 'req') as any
714
+
715
+ newTransport.receive({
716
+ v: 1,
717
+ t: 'res',
718
+ ch: restored.channelId,
719
+ ts: Date.now(),
720
+ from: walletKp.publicKeyB64,
721
+ body: {
722
+ id: reqMsg.body.id,
723
+ sealed: sealPayload(
724
+ sessionKey,
725
+ restored.channelId,
726
+ 1,
727
+ { _ok: true, _result: 'stale' },
728
+ { type: 'res', from: walletKp.publicKeyB64, id: reqMsg.body.id },
729
+ ),
730
+ },
731
+ } as ProtocolMessage)
732
+
733
+ await expect(p).rejects.toThrow('Replay detected')
734
+
735
+ // seq=3 should be accepted
736
+ const p2 = restored.request('wallet_getAccounts')
737
+ await wait(20)
738
+ const reqMsg2 = newTransport.sent.filter((m) => m.t === 'req')[1] as any
739
+
740
+ newTransport.receive({
741
+ v: 1,
742
+ t: 'res',
743
+ ch: restored.channelId,
744
+ ts: Date.now(),
745
+ from: walletKp.publicKeyB64,
746
+ body: {
747
+ id: reqMsg2.body.id,
748
+ sealed: sealPayload(
749
+ sessionKey,
750
+ restored.channelId,
751
+ 3,
752
+ { _ok: true, _result: 'fresh' },
753
+ { type: 'res', from: walletKp.publicKeyB64, id: reqMsg2.body.id },
754
+ ),
755
+ },
756
+ } as ProtocolMessage)
757
+
758
+ expect(await p2).toBe('fresh')
759
+ })
760
+
761
+ it('WalletSession: restored session rejects replayed seq numbers', async () => {
762
+ const ctx = setupWalletWithManualDApp()
763
+ const { transport, session, dappKp, channelId } = ctx
764
+ const { sessionKey } = await connectWalletManually(ctx)
765
+
766
+ const handler = vi.fn()
767
+ session.on('request', handler)
768
+
769
+ // Advance recvSeq to 2
770
+ for (let seq = 0; seq <= 2; seq++) {
771
+ transport.receive({
772
+ v: 1,
773
+ t: 'req',
774
+ ch: channelId,
775
+ ts: Date.now(),
776
+ from: dappKp.publicKeyB64,
777
+ body: {
778
+ id: `req-${seq}`,
779
+ sealed: sealPayload(
780
+ sessionKey,
781
+ channelId,
782
+ seq,
783
+ { _method: 'wallet_getAccounts' },
784
+ { type: 'req', from: dappKp.publicKeyB64, id: `req-${seq}` },
785
+ ),
786
+ },
787
+ } as ProtocolMessage)
788
+ }
789
+ expect(handler).toHaveBeenCalledTimes(3)
790
+
791
+ // Serialize and restore
792
+ const json = session.serialize()
793
+ const newTransport = new MockTransport()
794
+ const restored = new WalletSession({
795
+ transport: newTransport,
796
+ capabilities: { methods: ['wallet_getAccounts'], events: [], chains: ['eip155:1'] },
797
+ meta: {
798
+ name: 'Test Wallet',
799
+ description: 'Test',
800
+ url: 'https://test.com',
801
+ icon: 'https://test.com/icon.png',
802
+ },
803
+ })
804
+ expect(restored.restore(json)).toBe(true)
805
+
806
+ const handler2 = vi.fn()
807
+ restored.on('request', handler2)
808
+
809
+ // Old seq=1 -> dropped
810
+ newTransport.receive({
811
+ v: 1,
812
+ t: 'req',
813
+ ch: channelId,
814
+ ts: Date.now(),
815
+ from: dappKp.publicKeyB64,
816
+ body: {
817
+ id: 'replay-1',
818
+ sealed: sealPayload(
819
+ sessionKey,
820
+ channelId,
821
+ 1,
822
+ { _method: 'wallet_getAccounts' },
823
+ { type: 'req', from: dappKp.publicKeyB64, id: 'replay-1' },
824
+ ),
825
+ },
826
+ } as ProtocolMessage)
827
+ expect(handler2).toHaveBeenCalledTimes(0)
828
+
829
+ // seq=3 -> accepted
830
+ newTransport.receive({
831
+ v: 1,
832
+ t: 'req',
833
+ ch: channelId,
834
+ ts: Date.now(),
835
+ from: dappKp.publicKeyB64,
836
+ body: {
837
+ id: 'fresh-3',
838
+ sealed: sealPayload(
839
+ sessionKey,
840
+ channelId,
841
+ 3,
842
+ { _method: 'wallet_getAccounts' },
843
+ { type: 'req', from: dappKp.publicKeyB64, id: 'fresh-3' },
844
+ ),
845
+ },
846
+ } as ProtocolMessage)
847
+ expect(handler2).toHaveBeenCalledTimes(1)
848
+ })
849
+
850
+ it('DAppSession persists next sendSeq before sending encrypted requests', async () => {
851
+ const persistence = new ControlledPersistence()
852
+ const ctx = setupDAppWithManualWallet(persistence)
853
+ const { transport, session, walletKp } = ctx
854
+ const { sessionKey } = await connectDAppManually(ctx)
855
+ await wait(20)
856
+
857
+ persistence.hold = true
858
+ const result = session.request('wallet_getAccounts')
859
+ await wait(20)
860
+
861
+ expect(transport.sent.filter((m) => m.t === 'req')).toHaveLength(0)
862
+ expect(persistence.latest().sendSeq).toBe(1)
863
+
864
+ persistence.resolveNext()
865
+ await wait(20)
866
+
867
+ const reqMsg = transport.sent.find((m) => m.t === 'req') as any
868
+ expect(reqMsg).toBeTruthy()
869
+
870
+ persistence.hold = false
871
+ transport.receive({
872
+ v: 1,
873
+ t: 'res',
874
+ ch: session.channelId,
875
+ ts: Date.now(),
876
+ from: walletKp.publicKeyB64,
877
+ body: {
878
+ id: reqMsg.body.id,
879
+ sealed: sealPayload(
880
+ sessionKey,
881
+ session.channelId,
882
+ 0,
883
+ { _ok: true, _result: 'ok' },
884
+ { type: 'res', from: walletKp.publicKeyB64, id: reqMsg.body.id },
885
+ ),
886
+ },
887
+ } as ProtocolMessage)
888
+
889
+ expect(await result).toBe('ok')
890
+ })
891
+
892
+ it('WalletSession persists recvSeq before emitting decrypted requests', async () => {
893
+ const persistence = new ControlledPersistence()
894
+ const ctx = setupWalletWithManualDApp(persistence)
895
+ const { transport, session, dappKp, channelId } = ctx
896
+ const { sessionKey } = await connectWalletManually(ctx)
897
+ await wait(20)
898
+
899
+ const handler = vi.fn()
900
+ session.on('request', handler)
901
+
902
+ persistence.hold = true
903
+ transport.receive({
904
+ v: 1,
905
+ t: 'req',
906
+ ch: channelId,
907
+ ts: Date.now(),
908
+ from: dappKp.publicKeyB64,
909
+ body: {
910
+ id: 'r1',
911
+ sealed: sealPayload(
912
+ sessionKey,
913
+ channelId,
914
+ 0,
915
+ { _method: 'wallet_getAccounts' },
916
+ { type: 'req', from: dappKp.publicKeyB64, id: 'r1' },
917
+ ),
918
+ },
919
+ } as ProtocolMessage)
920
+ await wait(20)
921
+
922
+ expect(handler).not.toHaveBeenCalled()
923
+ expect(persistence.latest().recvSeq).toBe(0)
924
+
925
+ persistence.resolveNext()
926
+ await wait(20)
927
+
928
+ expect(handler).toHaveBeenCalledWith({
929
+ id: 'r1',
930
+ method: 'wallet_getAccounts',
931
+ params: {},
932
+ })
933
+ })
934
+
935
+ it('WalletSession persists next sendSeq before sending encrypted responses', async () => {
936
+ const persistence = new ControlledPersistence()
937
+ const ctx = setupWalletWithManualDApp(persistence)
938
+ const { transport, session, dappKp, channelId } = ctx
939
+ const { sessionKey } = await connectWalletManually(ctx)
940
+ await wait(20)
941
+
942
+ transport.receive({
943
+ v: 1,
944
+ t: 'req',
945
+ ch: channelId,
946
+ ts: Date.now(),
947
+ from: dappKp.publicKeyB64,
948
+ body: {
949
+ id: 'r1',
950
+ sealed: sealPayload(
951
+ sessionKey,
952
+ channelId,
953
+ 0,
954
+ { _method: 'wallet_getAccounts' },
955
+ { type: 'req', from: dappKp.publicKeyB64, id: 'r1' },
956
+ ),
957
+ },
958
+ } as ProtocolMessage)
959
+ await wait(20)
960
+
961
+ persistence.hold = true
962
+ const sent = session.approve('r1', ['0x123'])
963
+ await wait(20)
964
+
965
+ expect(transport.sent.filter((m) => m.t === 'res')).toHaveLength(0)
966
+ expect(persistence.latest().sendSeq).toBe(1)
967
+
968
+ persistence.resolveNext()
969
+ expect(await sent).toBe(true)
970
+ await wait(20)
971
+
972
+ expect(transport.sent.find((m) => m.t === 'res')).toBeTruthy()
973
+ })
974
+ })
975
+
976
+ // -----------------------------------------------------------------------
977
+ // 5. Send sequence overflow
978
+ // -----------------------------------------------------------------------
979
+ describe('send sequence overflow', () => {
980
+ it('DAppSession closes on send sequence overflow', async () => {
981
+ const ctx = setupDAppWithManualWallet()
982
+ const { transport, session } = ctx
983
+ await connectDAppManually(ctx)
984
+
985
+ // Set sendSeq to the last allowed sealed message.
986
+ ;(session as any).sendSeq = 2 ** 31 - 1
987
+
988
+ const errorHandler = vi.fn()
989
+ session.on('error', errorHandler)
990
+
991
+ // First request with params uses the last allowed sequence number.
992
+ // This will be left pending and rejected when session closes, so catch it.
993
+ const p1 = session.request('wallet_getAccounts', { test: true }).catch(() => {})
994
+ await wait(20)
995
+ // p1 was sent successfully; sendSeq is now at the protocol limit.
996
+
997
+ // Second request would exceed the protocol limit.
998
+ const p2 = session.request('wallet_getAccounts', { test: true })
999
+ await expect(p2).rejects.toThrow('Send sequence overflow')
1000
+ expect(errorHandler).toHaveBeenCalled()
1001
+ expect(session.phase).toBe('closed')
1002
+ await p1 // ensure the suppressed rejection is settled
1003
+ })
1004
+
1005
+ it('WalletSession closes on send sequence overflow via approve', async () => {
1006
+ const ctx = setupWalletWithManualDApp()
1007
+ const { transport, session, dappKp, channelId } = ctx
1008
+ await connectWalletManually(ctx)
1009
+
1010
+ // Set sendSeq to the last allowed sealed message.
1011
+ ;(session as any).sendSeq = 2 ** 31 - 1
1012
+
1013
+ const errorHandler = vi.fn()
1014
+ session.on('error', errorHandler)
1015
+
1016
+ // First approve uses the last allowed sequence number.
1017
+ session.approve('r1', ['0x123'])
1018
+
1019
+ // Second approve exceeds the protocol limit and closes the session.
1020
+ session.approve('r2', ['0x456'])
1021
+
1022
+ expect(errorHandler).toHaveBeenCalledWith(
1023
+ expect.objectContaining({
1024
+ message: expect.stringContaining('Send sequence overflow'),
1025
+ }),
1026
+ )
1027
+ expect(session.phase).toBe('closed')
1028
+ })
1029
+
1030
+ it('WalletSession closes on send sequence overflow via pushEvent', async () => {
1031
+ const ctx = setupWalletWithManualDApp()
1032
+ const { session } = ctx
1033
+ await connectWalletManually(ctx)
1034
+
1035
+ ;(session as any).sendSeq = 2 ** 31 - 1
1036
+
1037
+ const errorHandler = vi.fn()
1038
+ session.on('error', errorHandler)
1039
+
1040
+ // First push: ok
1041
+ session.pushEvent('accountsChanged', { accounts: ['0xa'] })
1042
+ expect(session.phase).toBe('connected')
1043
+
1044
+ // Second push: overflow
1045
+ session.pushEvent('accountsChanged', { accounts: ['0xb'] })
1046
+ expect(errorHandler).toHaveBeenCalled()
1047
+ expect(session.phase).toBe('closed')
1048
+ })
1049
+ })
1050
+
1051
+ // -----------------------------------------------------------------------
1052
+ // 6. Pending accept timeout
1053
+ // -----------------------------------------------------------------------
1054
+ describe('pending accept timeout', () => {
1055
+ beforeEach(() => {
1056
+ vi.useFakeTimers()
1057
+ })
1058
+
1059
+ afterEach(() => {
1060
+ vi.useRealTimers()
1061
+ })
1062
+
1063
+ it('first-time join is auto-accepted (pending_accept phase is brief)', async () => {
1064
+ const transport = new MockTransport()
1065
+ const session = new DAppSession({
1066
+ transport,
1067
+ meta: {
1068
+ name: 'Test dApp',
1069
+ description: 'Test',
1070
+ url: 'https://test.com',
1071
+ icon: 'https://test.com/icon.png',
1072
+ },
1073
+ })
1074
+ const walletKp = generateX25519KeyPair()
1075
+
1076
+ await session.createPairing()
1077
+
1078
+ const phases: string[] = []
1079
+ session.on('phase', (p) => phases.push(p))
1080
+
1081
+ // Simulate wallet join with valid sealed_join
1082
+ transport.receive({
1083
+ v: 1,
1084
+ t: 'join',
1085
+ ch: session.channelId,
1086
+ ts: Date.now(),
1087
+ from: walletKp.publicKeyB64,
1088
+ body: makeJoinBody(session.channelId, transport.sent[0]!.from!, walletKp),
1089
+ } as ProtocolMessage)
1090
+
1091
+ // Session enters pending_accept briefly, then auto-accept sends accept
1092
+ expect(phases).toContain('pending_accept')
1093
+
1094
+ // Should have sent accept immediately
1095
+ const acceptMsg = transport.sent.find((m) => m.t === 'accept')
1096
+ expect(acceptMsg).toBeTruthy()
1097
+
1098
+ // Advance time — should not timeout or close
1099
+ vi.advanceTimersByTime(61_000)
1100
+ expect(session.phase).not.toBe('closed')
1101
+ })
1102
+ })
1103
+
1104
+ // -----------------------------------------------------------------------
1105
+ // 7. Capabilities validation
1106
+ // -----------------------------------------------------------------------
1107
+ describe('capabilities validation', () => {
1108
+ it('rejects initial join with no sealed_join', async () => {
1109
+ const transport = new MockTransport()
1110
+ const session = new DAppSession({
1111
+ transport,
1112
+ meta: {
1113
+ name: 'Test dApp',
1114
+ description: 'Test',
1115
+ url: 'https://test.com',
1116
+ icon: 'https://test.com/icon.png',
1117
+ },
1118
+ })
1119
+ const walletKp = generateX25519KeyPair()
1120
+
1121
+ await session.createPairing()
1122
+ const errorHandler = vi.fn()
1123
+ session.on('error', errorHandler)
1124
+
1125
+ transport.receive({
1126
+ v: 1,
1127
+ t: 'join',
1128
+ ch: session.channelId,
1129
+ ts: Date.now(),
1130
+ from: walletKp.publicKeyB64,
1131
+ body: { sealed_join: null },
1132
+ } as ProtocolMessage)
1133
+
1134
+ expect(errorHandler).toHaveBeenCalledWith(
1135
+ expect.objectContaining({
1136
+ message: expect.stringContaining('missing sealed_join'),
1137
+ }),
1138
+ )
1139
+ const closeMsg = transport.sent.find((m) => m.t === 'close')
1140
+ expect((closeMsg as any).body.reason).toBe('protocol_error')
1141
+ })
1142
+
1143
+ it('rejects join with invalid sealed_join (decryption failure)', async () => {
1144
+ const transport = new MockTransport()
1145
+ const session = new DAppSession({
1146
+ transport,
1147
+ meta: {
1148
+ name: 'Test dApp',
1149
+ description: 'Test',
1150
+ url: 'https://test.com',
1151
+ icon: 'https://test.com/icon.png',
1152
+ },
1153
+ })
1154
+ const walletKp = generateX25519KeyPair()
1155
+
1156
+ await session.createPairing()
1157
+
1158
+ const errorHandler = vi.fn()
1159
+ session.on('error', errorHandler)
1160
+
1161
+ transport.receive({
1162
+ v: 1,
1163
+ t: 'join',
1164
+ ch: session.channelId,
1165
+ ts: Date.now(),
1166
+ from: walletKp.publicKeyB64,
1167
+ body: { sealed_join: 'invalid-ciphertext' },
1168
+ } as ProtocolMessage)
1169
+
1170
+ expect(errorHandler).toHaveBeenCalledWith(
1171
+ expect.objectContaining({
1172
+ message: expect.stringContaining('Failed to decrypt sealed_join'),
1173
+ }),
1174
+ )
1175
+
1176
+ // Should have sent a close with decryption_failed
1177
+ const closeMsg = transport.sent.find((m) => m.t === 'close')
1178
+ expect(closeMsg).toBeTruthy()
1179
+ expect((closeMsg as any).body.reason).toBe('decryption_failed')
1180
+
1181
+ // Should NOT have transitioned to pending_accept
1182
+ expect(session.phase).not.toBe('pending_accept')
1183
+ })
1184
+ })
1185
+ })