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,300 @@
1
+ /**
2
+ * WalletPair Protocol v1 — Section 6.6.1 sequence number rules.
3
+ *
4
+ * Verifies sequence number validation logic independent of the session
5
+ * classes, using pure functions that any implementation can replicate.
6
+ */
7
+
8
+ import { describe, expect, it } from 'vitest';
9
+ import {
10
+ b64urlDecode,
11
+ bytesToHex,
12
+ hexToBytes,
13
+ sealPayload,
14
+ unsealPayload,
15
+ generateChannelId,
16
+ } from '../../crypto.js';
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // Sequence number validator (pure model per Section 6.6.1)
20
+ // ---------------------------------------------------------------------------
21
+
22
+ /**
23
+ * Tracks the highest accepted receive sequence number.
24
+ * Per spec: initial value is -1 (no messages accepted yet).
25
+ * A message MUST be rejected if its seq is not strictly greater than lastAccepted.
26
+ */
27
+ class SequenceValidator {
28
+ /** Highest accepted sequence number. -1 means none accepted yet. */
29
+ private lastAccepted = -1;
30
+
31
+ /**
32
+ * Attempt to accept a sequence number.
33
+ * Returns true if accepted, false if rejected (replay/non-increasing).
34
+ */
35
+ accept(seq: number): boolean {
36
+ if (seq <= this.lastAccepted) return false;
37
+ this.lastAccepted = seq;
38
+ return true;
39
+ }
40
+
41
+ /** Current high watermark. */
42
+ get highWatermark(): number {
43
+ return this.lastAccepted;
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Tracks the send sequence counter.
49
+ * Per spec: starts at 0, increments by 1 per sealed message.
50
+ * Limit at 2^31.
51
+ */
52
+ class SendSequence {
53
+ private seq = 0;
54
+
55
+ /** Get the next sequence number for sending, or null if limit reached. */
56
+ next(): number | null {
57
+ if (this.seq >= 2 ** 31) return null;
58
+ return this.seq++;
59
+ }
60
+
61
+ /** Current value (next seq that will be used). */
62
+ get current(): number {
63
+ return this.seq;
64
+ }
65
+
66
+ /** Set the counter to a specific value (for testing). */
67
+ setTo(n: number): void {
68
+ this.seq = n;
69
+ }
70
+ }
71
+
72
+ // ---------------------------------------------------------------------------
73
+ // Section 6.6.1 — Basic sequence number rules
74
+ // ---------------------------------------------------------------------------
75
+
76
+ describe('Section 6.6.1 — Sequence number starts at 0', () => {
77
+ it('first send sequence is 0', () => {
78
+ const send = new SendSequence();
79
+ expect(send.next()).toBe(0);
80
+ });
81
+
82
+ it('first accepted receive sequence can be 0', () => {
83
+ const recv = new SequenceValidator();
84
+ expect(recv.accept(0)).toBe(true);
85
+ expect(recv.highWatermark).toBe(0);
86
+ });
87
+ });
88
+
89
+ describe('Section 6.6.1 — Increments by 1', () => {
90
+ it('send counter increments by 1 per message', () => {
91
+ const send = new SendSequence();
92
+ expect(send.next()).toBe(0);
93
+ expect(send.next()).toBe(1);
94
+ expect(send.next()).toBe(2);
95
+ expect(send.next()).toBe(3);
96
+ });
97
+
98
+ it('receive validator accepts strictly increasing sequence', () => {
99
+ const recv = new SequenceValidator();
100
+ expect(recv.accept(0)).toBe(true);
101
+ expect(recv.accept(1)).toBe(true);
102
+ expect(recv.accept(2)).toBe(true);
103
+ expect(recv.accept(3)).toBe(true);
104
+ });
105
+ });
106
+
107
+ // ---------------------------------------------------------------------------
108
+ // Replay rejection (non-increasing)
109
+ // ---------------------------------------------------------------------------
110
+
111
+ describe('Section 6.6.1 — Reject non-increasing (replay)', () => {
112
+ it('rejects the same sequence number twice', () => {
113
+ const recv = new SequenceValidator();
114
+ expect(recv.accept(0)).toBe(true);
115
+ expect(recv.accept(0)).toBe(false); // replay
116
+ });
117
+
118
+ it('rejects a lower sequence number after a higher one', () => {
119
+ const recv = new SequenceValidator();
120
+ expect(recv.accept(5)).toBe(true);
121
+ expect(recv.accept(3)).toBe(false); // lower than watermark
122
+ expect(recv.accept(4)).toBe(false); // still lower
123
+ expect(recv.accept(5)).toBe(false); // equal to watermark
124
+ });
125
+
126
+ it('rejects seq=0 replay after initial acceptance', () => {
127
+ const recv = new SequenceValidator();
128
+ recv.accept(0);
129
+ recv.accept(1);
130
+ expect(recv.accept(0)).toBe(false);
131
+ });
132
+
133
+ it('multiple replays are all rejected', () => {
134
+ const recv = new SequenceValidator();
135
+ recv.accept(0);
136
+ for (let i = 0; i < 10; i++) {
137
+ expect(recv.accept(0)).toBe(false);
138
+ }
139
+ });
140
+ });
141
+
142
+ // ---------------------------------------------------------------------------
143
+ // Gaps are valid (after reconnect)
144
+ // ---------------------------------------------------------------------------
145
+
146
+ describe('Section 6.6.1 — Gaps valid (expected after reconnect)', () => {
147
+ it('accepts seq=0 then seq=5 (gap of 4)', () => {
148
+ const recv = new SequenceValidator();
149
+ expect(recv.accept(0)).toBe(true);
150
+ expect(recv.accept(5)).toBe(true);
151
+ expect(recv.highWatermark).toBe(5);
152
+ });
153
+
154
+ it('accepts large gaps', () => {
155
+ const recv = new SequenceValidator();
156
+ expect(recv.accept(0)).toBe(true);
157
+ expect(recv.accept(1000)).toBe(true);
158
+ expect(recv.accept(500000)).toBe(true);
159
+ });
160
+
161
+ it('rejects values within a gap after the gap is established', () => {
162
+ const recv = new SequenceValidator();
163
+ recv.accept(0);
164
+ recv.accept(10);
165
+ // All values 0-10 are now below the watermark
166
+ for (let i = 0; i <= 10; i++) {
167
+ expect(recv.accept(i)).toBe(false);
168
+ }
169
+ // 11 and above are accepted
170
+ expect(recv.accept(11)).toBe(true);
171
+ });
172
+
173
+ it('gap from initial state (first message is not 0)', () => {
174
+ const recv = new SequenceValidator();
175
+ // After reconnect, the first message might not be seq=0
176
+ expect(recv.accept(42)).toBe(true);
177
+ expect(recv.highWatermark).toBe(42);
178
+ });
179
+ });
180
+
181
+ // ---------------------------------------------------------------------------
182
+ // Limit at 2^31
183
+ // ---------------------------------------------------------------------------
184
+
185
+ describe('Section 6.6.1 — Limit at 2^31', () => {
186
+ const LIMIT = 2 ** 31; // 2,147,483,648
187
+
188
+ it('send counter allows 2^31 - 1 as the last valid sequence', () => {
189
+ const send = new SendSequence();
190
+ send.setTo(LIMIT - 1);
191
+ expect(send.next()).toBe(LIMIT - 1);
192
+ });
193
+
194
+ it('send counter returns null (overflow) at 2^31', () => {
195
+ const send = new SendSequence();
196
+ send.setTo(LIMIT);
197
+ expect(send.next()).toBeNull();
198
+ });
199
+
200
+ it('receive validator accepts up to 2^31 - 1', () => {
201
+ const recv = new SequenceValidator();
202
+ expect(recv.accept(LIMIT - 1)).toBe(true);
203
+ });
204
+
205
+ it('send counter reaches limit after 2^31 messages', () => {
206
+ // Verify the math: starting at 0, after LIMIT sends, next() returns null
207
+ const send = new SendSequence();
208
+ send.setTo(LIMIT - 1);
209
+ const lastValid = send.next();
210
+ expect(lastValid).toBe(LIMIT - 1);
211
+ expect(send.next()).toBeNull(); // overflow
212
+ });
213
+
214
+ it('the limit is 2^31 not 2^32-1 (signed integer safety)', () => {
215
+ // Section 6.6.1: "The limit is 2^31 rather than 2^32 - 1 to avoid
216
+ // signed integer overflow in languages where 32-bit integers are signed."
217
+ expect(LIMIT).toBe(2147483648);
218
+ expect(LIMIT).toBeLessThan(2 ** 32 - 1);
219
+ });
220
+ });
221
+
222
+ // ---------------------------------------------------------------------------
223
+ // Sequence persistence across reconnects
224
+ // ---------------------------------------------------------------------------
225
+
226
+ describe('Section 6.6.1 — Counters persist across reconnects', () => {
227
+ it('simulated reconnect preserves send counter', () => {
228
+ const send = new SendSequence();
229
+ send.next(); // 0
230
+ send.next(); // 1
231
+ send.next(); // 2
232
+ const savedSeq = send.current; // 3
233
+
234
+ // Simulate reconnect: new SendSequence initialized from persisted state
235
+ const restored = new SendSequence();
236
+ restored.setTo(savedSeq);
237
+ expect(restored.next()).toBe(3); // continues from where we left off
238
+ expect(restored.next()).toBe(4);
239
+ });
240
+
241
+ it('simulated reconnect preserves receive watermark', () => {
242
+ const recv = new SequenceValidator();
243
+ recv.accept(0);
244
+ recv.accept(1);
245
+ recv.accept(2);
246
+ const savedWatermark = recv.highWatermark; // 2
247
+
248
+ // Simulate reconnect: new SequenceValidator initialized from persisted state
249
+ const restored = new SequenceValidator();
250
+ // Set watermark by accepting the saved value
251
+ restored.accept(savedWatermark);
252
+ // Old sequences are rejected
253
+ expect(restored.accept(0)).toBe(false);
254
+ expect(restored.accept(1)).toBe(false);
255
+ expect(restored.accept(2)).toBe(false);
256
+ // New sequences are accepted
257
+ expect(restored.accept(3)).toBe(true);
258
+ });
259
+ });
260
+
261
+ // ---------------------------------------------------------------------------
262
+ // seq_bytes encoding in sealed messages
263
+ // ---------------------------------------------------------------------------
264
+
265
+ describe('Section 6.6 — seq_bytes is 4-byte big-endian in sealed messages', () => {
266
+ const key = new Uint8Array(32).fill(0xaa);
267
+ const ch = 'bb'.repeat(32);
268
+
269
+ it('seq=0 encodes as 00000000', () => {
270
+ const sealed = sealPayload(key, ch, 0, { test: true });
271
+ const bytes = b64urlDecode(sealed);
272
+ expect(bytesToHex(bytes.slice(0, 4))).toBe('00000000');
273
+ });
274
+
275
+ it('seq=1 encodes as 00000001', () => {
276
+ const sealed = sealPayload(key, ch, 1, { test: true });
277
+ const bytes = b64urlDecode(sealed);
278
+ expect(bytesToHex(bytes.slice(0, 4))).toBe('00000001');
279
+ });
280
+
281
+ it('seq=256 encodes as 00000100', () => {
282
+ const sealed = sealPayload(key, ch, 256, { test: true });
283
+ const bytes = b64urlDecode(sealed);
284
+ expect(bytesToHex(bytes.slice(0, 4))).toBe('00000100');
285
+ });
286
+
287
+ it('seq=2^31-1 encodes as 7fffffff', () => {
288
+ const sealed = sealPayload(key, ch, 2 ** 31 - 1, { test: true });
289
+ const bytes = b64urlDecode(sealed);
290
+ expect(bytesToHex(bytes.slice(0, 4))).toBe('7fffffff');
291
+ });
292
+
293
+ it('round-trip: seq is correctly extracted from sealed payload', () => {
294
+ for (const seq of [0, 1, 42, 1000, 65535, 2 ** 31 - 1]) {
295
+ const sealed = sealPayload(key, ch, seq, { n: seq });
296
+ const result = unsealPayload(key, ch, sealed);
297
+ expect(result.seq).toBe(seq);
298
+ }
299
+ });
300
+ });
@@ -0,0 +1,364 @@
1
+ /**
2
+ * WalletPair Protocol v1 — Section 14 state machine transitions.
3
+ *
4
+ * Verifies that the dApp and wallet state machines accept valid
5
+ * transitions, reject invalid ones, and handle reconnect and race
6
+ * conditions correctly per the specification.
7
+ */
8
+
9
+ import { describe, expect, it } from 'vitest';
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // State machine model (pure functions, no SDK dependency)
13
+ // ---------------------------------------------------------------------------
14
+
15
+ type DAppState = 'idle' | 'waiting' | 'pending_accept' | 'connected' | 'disconnected' | 'closed';
16
+ type WalletState = 'idle' | 'waiting_accept' | 'connected' | 'disconnected' | 'closed';
17
+
18
+ type DAppEvent =
19
+ | 'send_create'
20
+ | 'receive_join'
21
+ | 'sealed_join_verified_send_accept'
22
+ | 'user_rejects_send_close'
23
+ | 'receive_ready_connected'
24
+ | 'receive_close'
25
+ | 'receive_terminate'
26
+ | 'timeout'
27
+ | 'transport_disconnected'
28
+ | 'send_create_reconnect'
29
+ | 'receive_channel_exists'
30
+ | 'session_expired'
31
+ | 'give_up';
32
+
33
+ type WalletEvent =
34
+ | 'send_join'
35
+ | 'receive_ready_connected'
36
+ | 'receive_close'
37
+ | 'receive_terminate'
38
+ | 'timeout'
39
+ | 'transport_disconnected'
40
+ | 'send_join_reconnect'
41
+ | 'receive_channel_not_found'
42
+ | 'session_expired'
43
+ | 'give_up';
44
+
45
+ /** DApp state machine per Section 14. */
46
+ function dappTransition(state: DAppState, event: DAppEvent): DAppState | null {
47
+ switch (state) {
48
+ case 'idle':
49
+ if (event === 'send_create') return 'waiting';
50
+ return null;
51
+ case 'waiting':
52
+ if (event === 'receive_join') return 'pending_accept';
53
+ if (event === 'receive_close' || event === 'receive_terminate') return 'closed';
54
+ if (event === 'timeout') return 'closed';
55
+ return null;
56
+ case 'pending_accept':
57
+ if (event === 'sealed_join_verified_send_accept') return 'connected';
58
+ if (event === 'user_rejects_send_close') return 'closed';
59
+ if (event === 'receive_terminate') return 'closed';
60
+ if (event === 'timeout') return 'closed';
61
+ return null;
62
+ case 'connected':
63
+ if (event === 'receive_close' || event === 'receive_terminate') return 'closed';
64
+ if (event === 'transport_disconnected') return 'disconnected';
65
+ if (event === 'session_expired') return 'closed';
66
+ return null;
67
+ case 'disconnected':
68
+ if (event === 'send_create_reconnect') return 'waiting';
69
+ if (event === 'receive_channel_exists') return 'disconnected';
70
+ if (event === 'session_expired') return 'closed';
71
+ if (event === 'give_up') return 'closed';
72
+ return null;
73
+ case 'closed':
74
+ return null; // terminal state
75
+ }
76
+ }
77
+
78
+ /** Wallet state machine per Section 14. */
79
+ function walletTransition(state: WalletState, event: WalletEvent): WalletState | null {
80
+ switch (state) {
81
+ case 'idle':
82
+ if (event === 'send_join') return 'waiting_accept';
83
+ return null;
84
+ case 'waiting_accept':
85
+ if (event === 'receive_ready_connected') return 'connected';
86
+ if (event === 'receive_close' || event === 'receive_terminate') return 'closed';
87
+ if (event === 'timeout') return 'closed';
88
+ return null;
89
+ case 'connected':
90
+ if (event === 'receive_close' || event === 'receive_terminate') return 'closed';
91
+ if (event === 'transport_disconnected') return 'disconnected';
92
+ if (event === 'session_expired') return 'closed';
93
+ return null;
94
+ case 'disconnected':
95
+ if (event === 'send_join_reconnect') return 'waiting_accept';
96
+ if (event === 'receive_channel_not_found') return 'disconnected';
97
+ if (event === 'session_expired') return 'closed';
98
+ if (event === 'give_up') return 'closed';
99
+ return null;
100
+ case 'closed':
101
+ return null; // terminal state
102
+ }
103
+ }
104
+
105
+ // ---------------------------------------------------------------------------
106
+ // DApp state machine tests
107
+ // ---------------------------------------------------------------------------
108
+
109
+ describe('Section 14 — DApp state machine', () => {
110
+ describe('valid transitions: idle -> waiting -> pending_accept -> connected -> closed', () => {
111
+ it('full happy path', () => {
112
+ let state: DAppState = 'idle';
113
+ state = dappTransition(state, 'send_create')!;
114
+ expect(state).toBe('waiting');
115
+ state = dappTransition(state, 'receive_join')!;
116
+ expect(state).toBe('pending_accept');
117
+ state = dappTransition(state, 'sealed_join_verified_send_accept')!;
118
+ expect(state).toBe('connected');
119
+ });
120
+
121
+ it('close from connected', () => {
122
+ expect(dappTransition('connected', 'receive_close')).toBe('closed');
123
+ });
124
+
125
+ it('terminate from connected', () => {
126
+ expect(dappTransition('connected', 'receive_terminate')).toBe('closed');
127
+ });
128
+
129
+ it('session expiry from connected', () => {
130
+ expect(dappTransition('connected', 'session_expired')).toBe('closed');
131
+ });
132
+ });
133
+
134
+ describe('invalid transitions are rejected', () => {
135
+ it('cannot receive join in idle', () => {
136
+ expect(dappTransition('idle', 'receive_join')).toBeNull();
137
+ });
138
+
139
+ it('cannot send create in waiting', () => {
140
+ expect(dappTransition('waiting', 'send_create')).toBeNull();
141
+ });
142
+
143
+ it('cannot send create in pending_accept', () => {
144
+ expect(dappTransition('pending_accept', 'send_create')).toBeNull();
145
+ });
146
+
147
+ it('cannot receive join in pending_accept', () => {
148
+ expect(dappTransition('pending_accept', 'receive_join')).toBeNull();
149
+ });
150
+
151
+ it('cannot send create in connected', () => {
152
+ expect(dappTransition('connected', 'send_create')).toBeNull();
153
+ });
154
+
155
+ it('closed is terminal — all events rejected', () => {
156
+ const events: DAppEvent[] = [
157
+ 'send_create', 'receive_join', 'sealed_join_verified_send_accept',
158
+ 'receive_close', 'receive_terminate', 'timeout',
159
+ 'transport_disconnected', 'send_create_reconnect',
160
+ ];
161
+ for (const event of events) {
162
+ expect(dappTransition('closed', event)).toBeNull();
163
+ }
164
+ });
165
+ });
166
+
167
+ describe('close and timeout paths', () => {
168
+ it('waiting -> close on receive_close', () => {
169
+ expect(dappTransition('waiting', 'receive_close')).toBe('closed');
170
+ });
171
+
172
+ it('waiting -> closed on timeout', () => {
173
+ expect(dappTransition('waiting', 'timeout')).toBe('closed');
174
+ });
175
+
176
+ it('pending_accept -> closed on user_rejects_send_close', () => {
177
+ expect(dappTransition('pending_accept', 'user_rejects_send_close')).toBe('closed');
178
+ });
179
+
180
+ it('pending_accept -> closed on timeout', () => {
181
+ expect(dappTransition('pending_accept', 'timeout')).toBe('closed');
182
+ });
183
+ });
184
+
185
+ describe('reconnect: disconnected -> waiting with same ch', () => {
186
+ it('transport disconnect moves to disconnected', () => {
187
+ expect(dappTransition('connected', 'transport_disconnected')).toBe('disconnected');
188
+ });
189
+
190
+ it('send_create_reconnect from disconnected goes to waiting', () => {
191
+ expect(dappTransition('disconnected', 'send_create_reconnect')).toBe('waiting');
192
+ });
193
+
194
+ it('session expiry from disconnected goes to closed', () => {
195
+ expect(dappTransition('disconnected', 'session_expired')).toBe('closed');
196
+ });
197
+
198
+ it('give up from disconnected goes to closed', () => {
199
+ expect(dappTransition('disconnected', 'give_up')).toBe('closed');
200
+ });
201
+ });
202
+
203
+ describe('race condition: channel_exists handling', () => {
204
+ it('receive_channel_exists stays in disconnected (transient failure)', () => {
205
+ expect(dappTransition('disconnected', 'receive_channel_exists')).toBe('disconnected');
206
+ });
207
+ });
208
+ });
209
+
210
+ // ---------------------------------------------------------------------------
211
+ // Wallet state machine tests
212
+ // ---------------------------------------------------------------------------
213
+
214
+ describe('Section 14 — Wallet state machine', () => {
215
+ describe('valid transitions: idle -> waiting_accept -> connected -> closed', () => {
216
+ it('full happy path', () => {
217
+ let state: WalletState = 'idle';
218
+ state = walletTransition(state, 'send_join')!;
219
+ expect(state).toBe('waiting_accept');
220
+ state = walletTransition(state, 'receive_ready_connected')!;
221
+ expect(state).toBe('connected');
222
+ });
223
+
224
+ it('close from connected', () => {
225
+ expect(walletTransition('connected', 'receive_close')).toBe('closed');
226
+ });
227
+
228
+ it('terminate from connected', () => {
229
+ expect(walletTransition('connected', 'receive_terminate')).toBe('closed');
230
+ });
231
+ });
232
+
233
+ describe('invalid transitions are rejected', () => {
234
+ it('cannot receive_ready_connected in idle', () => {
235
+ expect(walletTransition('idle', 'receive_ready_connected')).toBeNull();
236
+ });
237
+
238
+ it('cannot send_join in waiting_accept', () => {
239
+ expect(walletTransition('waiting_accept', 'send_join')).toBeNull();
240
+ });
241
+
242
+ it('cannot send_join in connected', () => {
243
+ expect(walletTransition('connected', 'send_join')).toBeNull();
244
+ });
245
+
246
+ it('closed is terminal — all events rejected', () => {
247
+ const events: WalletEvent[] = [
248
+ 'send_join', 'receive_ready_connected', 'receive_close',
249
+ 'receive_terminate', 'timeout', 'transport_disconnected',
250
+ 'send_join_reconnect',
251
+ ];
252
+ for (const event of events) {
253
+ expect(walletTransition('closed', event)).toBeNull();
254
+ }
255
+ });
256
+ });
257
+
258
+ describe('close and timeout paths', () => {
259
+ it('waiting_accept -> closed on receive_close', () => {
260
+ expect(walletTransition('waiting_accept', 'receive_close')).toBe('closed');
261
+ });
262
+
263
+ it('waiting_accept -> closed on timeout', () => {
264
+ expect(walletTransition('waiting_accept', 'timeout')).toBe('closed');
265
+ });
266
+ });
267
+
268
+ describe('reconnect: disconnected -> waiting_accept with same ch', () => {
269
+ it('transport disconnect moves to disconnected', () => {
270
+ expect(walletTransition('connected', 'transport_disconnected')).toBe('disconnected');
271
+ });
272
+
273
+ it('send_join_reconnect from disconnected goes to waiting_accept', () => {
274
+ expect(walletTransition('disconnected', 'send_join_reconnect')).toBe('waiting_accept');
275
+ });
276
+
277
+ it('session expiry from disconnected goes to closed', () => {
278
+ expect(walletTransition('disconnected', 'session_expired')).toBe('closed');
279
+ });
280
+
281
+ it('give up from disconnected goes to closed', () => {
282
+ expect(walletTransition('disconnected', 'give_up')).toBe('closed');
283
+ });
284
+ });
285
+
286
+ describe('race condition: channel_not_found handling', () => {
287
+ it('receive_channel_not_found stays in disconnected (transient failure)', () => {
288
+ expect(walletTransition('disconnected', 'receive_channel_not_found')).toBe('disconnected');
289
+ });
290
+ });
291
+ });
292
+
293
+ // ---------------------------------------------------------------------------
294
+ // Cross-cutting state machine properties
295
+ // ---------------------------------------------------------------------------
296
+
297
+ describe('Section 14 — Cross-cutting properties', () => {
298
+ it('closed is terminal for dApp — no outgoing transitions', () => {
299
+ const allDappEvents: DAppEvent[] = [
300
+ 'send_create', 'receive_join', 'sealed_join_verified_send_accept',
301
+ 'user_rejects_send_close', 'receive_ready_connected', 'receive_close',
302
+ 'receive_terminate', 'timeout', 'transport_disconnected',
303
+ 'send_create_reconnect', 'receive_channel_exists', 'session_expired',
304
+ 'give_up',
305
+ ];
306
+ for (const event of allDappEvents) {
307
+ expect(dappTransition('closed', event)).toBeNull();
308
+ }
309
+ });
310
+
311
+ it('closed is terminal for wallet — no outgoing transitions', () => {
312
+ const allWalletEvents: WalletEvent[] = [
313
+ 'send_join', 'receive_ready_connected', 'receive_close',
314
+ 'receive_terminate', 'timeout', 'transport_disconnected',
315
+ 'send_join_reconnect', 'receive_channel_not_found', 'session_expired',
316
+ 'give_up',
317
+ ];
318
+ for (const event of allWalletEvents) {
319
+ expect(walletTransition('closed', event)).toBeNull();
320
+ }
321
+ });
322
+
323
+ it('reconnect path reuses the full handshake flow (create/join/accept)', () => {
324
+ // DApp: disconnected -> waiting (via create) -> pending_accept (via join) -> connected
325
+ let dapp: DAppState = 'disconnected';
326
+ dapp = dappTransition(dapp, 'send_create_reconnect')!;
327
+ expect(dapp).toBe('waiting');
328
+ dapp = dappTransition(dapp, 'receive_join')!;
329
+ expect(dapp).toBe('pending_accept');
330
+ dapp = dappTransition(dapp, 'sealed_join_verified_send_accept')!;
331
+ expect(dapp).toBe('connected');
332
+
333
+ // Wallet: disconnected -> waiting_accept (via join) -> connected
334
+ let wallet: WalletState = 'disconnected';
335
+ wallet = walletTransition(wallet, 'send_join_reconnect')!;
336
+ expect(wallet).toBe('waiting_accept');
337
+ wallet = walletTransition(wallet, 'receive_ready_connected')!;
338
+ expect(wallet).toBe('connected');
339
+ });
340
+
341
+ it('race condition recovery: multiple channel_exists before successful reconnect', () => {
342
+ let state: DAppState = 'disconnected';
343
+ // Multiple transient failures
344
+ state = dappTransition(state, 'receive_channel_exists')!;
345
+ expect(state).toBe('disconnected');
346
+ state = dappTransition(state, 'receive_channel_exists')!;
347
+ expect(state).toBe('disconnected');
348
+ // Eventually succeeds
349
+ state = dappTransition(state, 'send_create_reconnect')!;
350
+ expect(state).toBe('waiting');
351
+ });
352
+
353
+ it('race condition recovery: multiple channel_not_found before wallet reconnects', () => {
354
+ let state: WalletState = 'disconnected';
355
+ // Multiple transient failures
356
+ state = walletTransition(state, 'receive_channel_not_found')!;
357
+ expect(state).toBe('disconnected');
358
+ state = walletTransition(state, 'receive_channel_not_found')!;
359
+ expect(state).toBe('disconnected');
360
+ // Eventually succeeds
361
+ state = walletTransition(state, 'send_join_reconnect')!;
362
+ expect(state).toBe('waiting_accept');
363
+ });
364
+ });