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,467 @@
1
+ /**
2
+ * Adversarial tests: Malicious Wallet
3
+ *
4
+ * Simulates a malicious wallet attempting to abuse the dApp through
5
+ * protocol violations: wrong response IDs, role violations, sequence
6
+ * manipulation, and out-of-state messages.
7
+ *
8
+ * These tests verify that the DAppSession enforces protocol rules
9
+ * correctly, protecting the dApp from a compromised or malicious wallet.
10
+ */
11
+
12
+ import { describe, it, expect, vi } from 'vitest';
13
+ import { DAppSession } from '../../dapp-session.js';
14
+ import { MockTransport, makeJoinBody } from '../../test-helpers.js';
15
+ import {
16
+ generateX25519KeyPair,
17
+ sealPayload,
18
+ b64urlEncode,
19
+ } from '../../crypto.js';
20
+ import type { ProtocolMessage } from '../../types.js';
21
+
22
+ function wait(ms = 50): Promise<void> {
23
+ return new Promise((r) => setTimeout(r, ms));
24
+ }
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // Helpers
28
+ // ---------------------------------------------------------------------------
29
+
30
+ function setupDAppManual() {
31
+ const transport = new MockTransport();
32
+ const session = new DAppSession({
33
+ transport,
34
+ meta: { name: 'Test', description: 'Test dApp', url: 'https://test.com', icon: 'https://test.com/icon.png' },
35
+ });
36
+ const walletKp = generateX25519KeyPair();
37
+ return { transport, session, walletKp };
38
+ }
39
+
40
+ async function connectDAppManual(ctx: ReturnType<typeof setupDAppManual>) {
41
+ const { transport, session, walletKp } = ctx;
42
+ await session.createPairing();
43
+
44
+ transport.receive({
45
+ v: 1, t: 'join', ch: session.channelId,
46
+ ts: Date.now(), from: walletKp.publicKeyB64,
47
+ body: makeJoinBody(session.channelId, transport.sent[0]!.from!, walletKp),
48
+ } as ProtocolMessage);
49
+
50
+ transport.receive({
51
+ v: 1, t: 'ready', ch: session.channelId,
52
+ ts: Date.now(), from: '_adapter',
53
+ body: { state: 'connected', reconnect: false, remote: walletKp.publicKeyB64 },
54
+ } as ProtocolMessage);
55
+
56
+ const recvKey = (session as any).recvKey as Uint8Array;
57
+ const dappPubB64 = transport.sent[0]!.from!;
58
+ return { recvKey, dappPubB64 };
59
+ }
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // Attack 1: Wallet sends res with wrong req.id
63
+ // ---------------------------------------------------------------------------
64
+
65
+ describe('Malicious Wallet: Wrong response ID', () => {
66
+ it('response with unknown req.id is ignored by dApp', async () => {
67
+ // ATTACK: A malicious wallet sends a response with an ID that does
68
+ // not match any pending request. This could be an attempt to inject
69
+ // fake results or confuse request/response matching.
70
+ //
71
+ // PREVENTS: Response injection for non-existent requests.
72
+ // The dApp should silently ignore responses with unknown IDs.
73
+
74
+ const ctx = setupDAppManual();
75
+ const { transport, session, walletKp } = ctx;
76
+ const { recvKey } = await connectDAppManual(ctx);
77
+
78
+ const responseHandler = vi.fn();
79
+ session.on('response', responseHandler);
80
+
81
+ // Wallet sends a response with a fabricated request ID
82
+ transport.receive({
83
+ v: 1, t: 'res', ch: session.channelId,
84
+ ts: Date.now(), from: walletKp.publicKeyB64,
85
+ body: {
86
+ id: 'fabricated-req-id',
87
+ sealed: sealPayload(
88
+ recvKey, session.channelId, 0,
89
+ { _ok: true, _result: 'injected-data' },
90
+ { type: 'res', from: walletKp.publicKeyB64, id: 'fabricated-req-id' },
91
+ ),
92
+ },
93
+ } as ProtocolMessage);
94
+
95
+ await wait();
96
+
97
+ // Response handler should NOT have been called (no matching pending request)
98
+ expect(responseHandler).not.toHaveBeenCalled();
99
+ // Session should remain healthy
100
+ expect(session.phase).toBe('connected');
101
+ });
102
+
103
+ it('response to wrong req.id does not resolve a different pending request', async () => {
104
+ // ATTACK: Wallet sends a response with a different request's ID,
105
+ // trying to resolve the wrong request with attacker-chosen data.
106
+ //
107
+ // PREVENTS: Cross-request response substitution.
108
+
109
+ const ctx = setupDAppManual();
110
+ const { transport, session, walletKp } = ctx;
111
+ const { recvKey } = await connectDAppManual(ctx);
112
+
113
+ // Send two requests
114
+ const p1 = session.request('wallet_getAccounts');
115
+ const p2 = session.request('wallet_signMessage', { message: 'test' });
116
+ await wait(20);
117
+
118
+ const reqs = transport.sent.filter(m => m.t === 'req') as any[];
119
+ const req1Id = reqs[0]!.body.id;
120
+ const req2Id = reqs[1]!.body.id;
121
+
122
+ // Wallet responds to req2 with req1's ID (cross-wired)
123
+ // The AAD includes the id field, so if the id doesn't match,
124
+ // AEAD decryption will fail (or the wrong request will be resolved
125
+ // with potentially confusing data).
126
+ transport.receive({
127
+ v: 1, t: 'res', ch: session.channelId,
128
+ ts: Date.now(), from: walletKp.publicKeyB64,
129
+ body: {
130
+ id: req1Id,
131
+ sealed: sealPayload(
132
+ recvKey, session.channelId, 0,
133
+ { _ok: true, _result: 'correct-for-req1' },
134
+ { type: 'res', from: walletKp.publicKeyB64, id: req1Id },
135
+ ),
136
+ },
137
+ } as ProtocolMessage);
138
+
139
+ // req1 should resolve correctly
140
+ expect(await p1).toBe('correct-for-req1');
141
+
142
+ // Now respond to req2 normally
143
+ transport.receive({
144
+ v: 1, t: 'res', ch: session.channelId,
145
+ ts: Date.now(), from: walletKp.publicKeyB64,
146
+ body: {
147
+ id: req2Id,
148
+ sealed: sealPayload(
149
+ recvKey, session.channelId, 1,
150
+ { _ok: true, _result: 'correct-for-req2' },
151
+ { type: 'res', from: walletKp.publicKeyB64, id: req2Id },
152
+ ),
153
+ },
154
+ } as ProtocolMessage);
155
+
156
+ expect(await p2).toBe('correct-for-req2');
157
+ });
158
+ });
159
+
160
+ // ---------------------------------------------------------------------------
161
+ // Attack 2: Wallet sends req (role violation)
162
+ // ---------------------------------------------------------------------------
163
+
164
+ describe('Malicious Wallet: Sends req (role violation)', () => {
165
+ it('dApp ignores req from wallet because only dApp sends req', async () => {
166
+ // ATTACK: Wallet sends a req message to the dApp. Per Section 5,
167
+ // only the dApp sends req. The wallet should never do this.
168
+ // The dApp should either ignore it or treat it as a protocol error.
169
+ //
170
+ // PREVENTS: Role reversal attack where wallet tries to command the dApp.
171
+
172
+ const ctx = setupDAppManual();
173
+ const { transport, session, walletKp } = ctx;
174
+ const { recvKey } = await connectDAppManual(ctx);
175
+
176
+ const errorHandler = vi.fn();
177
+ session.on('error', errorHandler);
178
+
179
+ // Wallet sends a req (which it should never do)
180
+ transport.receive({
181
+ v: 1, t: 'req', ch: session.channelId,
182
+ ts: Date.now(), from: walletKp.publicKeyB64,
183
+ body: {
184
+ id: 'evil-req-1',
185
+ sealed: sealPayload(
186
+ recvKey, session.channelId, 0,
187
+ { _method: 'dapp_executeTransaction' },
188
+ { type: 'req', from: walletKp.publicKeyB64, id: 'evil-req-1' },
189
+ ),
190
+ },
191
+ } as ProtocolMessage);
192
+
193
+ await wait();
194
+
195
+ // DApp does not have a request handler (it only sends requests).
196
+ // The message should be silently ignored or cause no state change.
197
+ // Key insight: DAppSession.handleMessage() has no case for 'req'
198
+ // messages from the wallet, so it falls through to the default
199
+ // case (no-op).
200
+ expect(session.phase).toBe('connected');
201
+ });
202
+ });
203
+
204
+ // ---------------------------------------------------------------------------
205
+ // Attack 3: Wallet manipulates sequence numbers
206
+ // ---------------------------------------------------------------------------
207
+
208
+ describe('Malicious Wallet: Sequence number manipulation', () => {
209
+ it('skipped sequence numbers are accepted (gaps are valid per spec)', async () => {
210
+ // Per Section 6.6.1: "Gaps are valid (expected after reconnect)."
211
+ // This is NOT an attack — verifying correct behavior.
212
+
213
+ const ctx = setupDAppManual();
214
+ const { transport, session, walletKp } = ctx;
215
+ const { recvKey } = await connectDAppManual(ctx);
216
+
217
+ const p = session.request('wallet_getAccounts');
218
+ await wait(20);
219
+ const req = transport.sent.find(m => m.t === 'req') as any;
220
+ const reqId = req.body.id;
221
+
222
+ // Wallet responds with seq=5 (skipping 0-4) — should be accepted
223
+ transport.receive({
224
+ v: 1, t: 'res', ch: session.channelId,
225
+ ts: Date.now(), from: walletKp.publicKeyB64,
226
+ body: {
227
+ id: reqId,
228
+ sealed: sealPayload(
229
+ recvKey, session.channelId, 5,
230
+ { _ok: true, _result: 'gap-ok' },
231
+ { type: 'res', from: walletKp.publicKeyB64, id: reqId },
232
+ ),
233
+ },
234
+ } as ProtocolMessage);
235
+
236
+ expect(await p).toBe('gap-ok');
237
+ });
238
+
239
+ it('reset sequence to 0 after receiving higher seq is rejected', async () => {
240
+ // ATTACK: Wallet sends seq=10, then tries to reset to seq=0.
241
+ // This is a replay/reset attack. Section 6.6.1: "A message MUST
242
+ // be rejected if its sequence number is not strictly greater than
243
+ // the last accepted value."
244
+ //
245
+ // PREVENTS: Sequence counter reset allowing message replay.
246
+
247
+ const ctx = setupDAppManual();
248
+ const { transport, session, walletKp } = ctx;
249
+ const { recvKey } = await connectDAppManual(ctx);
250
+
251
+ // First response at seq=10 (accepted)
252
+ const p0 = session.request('wallet_getAccounts');
253
+ await wait(20);
254
+ const req0 = transport.sent.find(m => m.t === 'req') as any;
255
+ const r0id = req0.body.id;
256
+ transport.receive({
257
+ v: 1, t: 'res', ch: session.channelId,
258
+ ts: Date.now(), from: walletKp.publicKeyB64,
259
+ body: {
260
+ id: r0id,
261
+ sealed: sealPayload(
262
+ recvKey, session.channelId, 10,
263
+ { _ok: true, _result: 'first' },
264
+ { type: 'res', from: walletKp.publicKeyB64, id: r0id },
265
+ ),
266
+ },
267
+ } as ProtocolMessage);
268
+ expect(await p0).toBe('first');
269
+
270
+ // Second response at seq=0 (reset attempt — MUST be rejected)
271
+ const p1 = session.request('wallet_getAccounts');
272
+ await wait(20);
273
+ const req1 = transport.sent.filter(m => m.t === 'req')[1] as any;
274
+ const r1id = req1.body.id;
275
+ transport.receive({
276
+ v: 1, t: 'res', ch: session.channelId,
277
+ ts: Date.now(), from: walletKp.publicKeyB64,
278
+ body: {
279
+ id: r1id,
280
+ sealed: sealPayload(
281
+ recvKey, session.channelId, 0, // reset to 0!
282
+ { _ok: true, _result: 'replayed' },
283
+ { type: 'res', from: walletKp.publicKeyB64, id: r1id },
284
+ ),
285
+ },
286
+ } as ProtocolMessage);
287
+ await expect(p1).rejects.toThrow('Replay detected');
288
+ });
289
+
290
+ it('reused sequence number is rejected', async () => {
291
+ // ATTACK: Wallet sends the same sequence number twice.
292
+ //
293
+ // PREVENTS: Nonce reuse in AEAD encryption.
294
+
295
+ const ctx = setupDAppManual();
296
+ const { transport, session, walletKp } = ctx;
297
+ const { recvKey } = await connectDAppManual(ctx);
298
+
299
+ // First at seq=3 (accepted)
300
+ const p0 = session.request('wallet_getAccounts');
301
+ await wait(20);
302
+ const req0 = transport.sent.find(m => m.t === 'req') as any;
303
+ const r0id = req0.body.id;
304
+ transport.receive({
305
+ v: 1, t: 'res', ch: session.channelId,
306
+ ts: Date.now(), from: walletKp.publicKeyB64,
307
+ body: {
308
+ id: r0id,
309
+ sealed: sealPayload(
310
+ recvKey, session.channelId, 3,
311
+ { _ok: true, _result: 'ok' },
312
+ { type: 'res', from: walletKp.publicKeyB64, id: r0id },
313
+ ),
314
+ },
315
+ } as ProtocolMessage);
316
+ expect(await p0).toBe('ok');
317
+
318
+ // Second at seq=3 (reuse — MUST be rejected)
319
+ const p1 = session.request('wallet_getAccounts');
320
+ await wait(20);
321
+ const req1 = transport.sent.filter(m => m.t === 'req')[1] as any;
322
+ const r1id = req1.body.id;
323
+ transport.receive({
324
+ v: 1, t: 'res', ch: session.channelId,
325
+ ts: Date.now(), from: walletKp.publicKeyB64,
326
+ body: {
327
+ id: r1id,
328
+ sealed: sealPayload(
329
+ recvKey, session.channelId, 3, // same seq!
330
+ { _ok: true, _result: 'reused' },
331
+ { type: 'res', from: walletKp.publicKeyB64, id: r1id },
332
+ ),
333
+ },
334
+ } as ProtocolMessage);
335
+ await expect(p1).rejects.toThrow('Replay detected');
336
+ });
337
+ });
338
+
339
+ // ---------------------------------------------------------------------------
340
+ // Attack 4: Wallet sends evt before ready.connected
341
+ // ---------------------------------------------------------------------------
342
+
343
+ describe('Malicious Wallet: Event before connected', () => {
344
+ it('dApp ignores evt received before ready.connected', async () => {
345
+ // ATTACK: Wallet sends an event before the channel reaches
346
+ // connected state. The dApp should not process events until
347
+ // ready.connected is received (Section 15 rule 7).
348
+ //
349
+ // PREVENTS: Pre-connection event injection.
350
+
351
+ const transport = new MockTransport();
352
+ const session = new DAppSession({
353
+ transport,
354
+ meta: { name: 'T', description: 'T', url: 'https://t.test', icon: 'https://t.test/i.png' },
355
+ autoAccept: false, // manual accept to control timing
356
+ });
357
+
358
+ const eventHandler = vi.fn();
359
+ session.on('event', eventHandler);
360
+
361
+ await session.createPairing();
362
+ const dappPubB64 = transport.sent[0]!.from!;
363
+
364
+ const walletKp = generateX25519KeyPair();
365
+
366
+ // Wallet joins
367
+ transport.receive({
368
+ v: 1, t: 'join', ch: session.channelId,
369
+ ts: Date.now(), from: walletKp.publicKeyB64,
370
+ body: makeJoinBody(session.channelId, dappPubB64, walletKp),
371
+ } as ProtocolMessage);
372
+
373
+ await wait();
374
+ // Session is now in pending_accept, NOT connected
375
+
376
+ // Wallet tries to send an event before connected
377
+ transport.receive({
378
+ v: 1, t: 'evt', ch: session.channelId,
379
+ ts: Date.now(), from: walletKp.publicKeyB64,
380
+ body: { id: 'premature-evt', sealed: 'fake-sealed' },
381
+ } as ProtocolMessage);
382
+
383
+ await wait();
384
+
385
+ // Event should NOT have been processed (recvKey exists but
386
+ // the sealed data is invalid, or the event is silently dropped
387
+ // due to decryption failure)
388
+ expect(eventHandler).not.toHaveBeenCalled();
389
+ });
390
+ });
391
+
392
+ // ---------------------------------------------------------------------------
393
+ // Attack 5: Wallet sends response from a different key
394
+ // ---------------------------------------------------------------------------
395
+
396
+ describe('Malicious Wallet: Response from wrong peer', () => {
397
+ it('dApp ignores response from a key that does not match paired wallet', async () => {
398
+ // ATTACK: A different entity (or the relay itself) sends a response
399
+ // with a different "from" key. The dApp checks that from matches
400
+ // the paired wallet's public key.
401
+ //
402
+ // PREVENTS: Third-party response injection.
403
+
404
+ const ctx = setupDAppManual();
405
+ const { transport, session, walletKp } = ctx;
406
+ const { recvKey } = await connectDAppManual(ctx);
407
+
408
+ const p = session.request('wallet_getAccounts');
409
+ await wait(20);
410
+ const req = transport.sent.find(m => m.t === 'req') as any;
411
+ const reqId = req.body.id;
412
+
413
+ // Impersonator uses a different key
414
+ const impersonatorKp = generateX25519KeyPair();
415
+ transport.receive({
416
+ v: 1, t: 'res', ch: session.channelId,
417
+ ts: Date.now(), from: impersonatorKp.publicKeyB64, // wrong key!
418
+ body: {
419
+ id: reqId,
420
+ sealed: sealPayload(
421
+ recvKey, session.channelId, 0,
422
+ { _ok: true, _result: 'evil' },
423
+ { type: 'res', from: impersonatorKp.publicKeyB64, id: reqId },
424
+ ),
425
+ },
426
+ } as ProtocolMessage);
427
+
428
+ await wait(20);
429
+
430
+ // The response should have been silently dropped (from mismatch)
431
+ // The request should still be pending (not resolved)
432
+ // Clean up by closing
433
+ session.close();
434
+ await expect(p).rejects.toThrow('Session closed');
435
+ });
436
+ });
437
+
438
+ // ---------------------------------------------------------------------------
439
+ // Attack 6: Wallet sends unsupported protocol version
440
+ // ---------------------------------------------------------------------------
441
+
442
+ describe('Malicious Wallet: Unsupported protocol version', () => {
443
+ it('dApp closes with unsupported_version on receiving v!=1', async () => {
444
+ // ATTACK: Wallet sends messages with a different protocol version
445
+ // to confuse parsing or exploit version-specific vulnerabilities.
446
+ //
447
+ // PREVENTS: Version confusion attacks. Section 15 rule 12.
448
+
449
+ const ctx = setupDAppManual();
450
+ const { transport, session, walletKp } = ctx;
451
+ await connectDAppManual(ctx);
452
+
453
+ transport.receive({
454
+ v: 99 as any, t: 'res', ch: session.channelId,
455
+ ts: Date.now(), from: walletKp.publicKeyB64,
456
+ body: { id: 'req-1', sealed: 'whatever' },
457
+ } as ProtocolMessage);
458
+
459
+ await wait();
460
+
461
+ // DApp should close with unsupported_version
462
+ expect(session.phase).toBe('closed');
463
+ const closeMsg = transport.sent.find(m => m.t === 'close') as any;
464
+ expect(closeMsg).toBeTruthy();
465
+ expect(closeMsg.body.reason).toBe('unsupported_version');
466
+ });
467
+ });
@@ -0,0 +1,227 @@
1
+ /**
2
+ * WalletPair Protocol v1 — Section 6.3 canonical JSON test vectors.
3
+ *
4
+ * Verifies that the SDK's canonicalJson implementation matches every
5
+ * test vector in the specification, including SHA-256 hashes, and
6
+ * handles all documented edge cases.
7
+ */
8
+
9
+ import { describe, expect, it } from 'vitest';
10
+ import { canonicalJson, sha256Hex } from '../../crypto.js';
11
+
12
+ function hash(s: string): string {
13
+ return sha256Hex(new TextEncoder().encode(s));
14
+ }
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Section 6.3 — Normative test vectors
18
+ // ---------------------------------------------------------------------------
19
+
20
+ describe('Section 6.3 — Vector 1: capabilities (key sorting, nested objects)', () => {
21
+ const input = {
22
+ methods: ['wallet_signTransaction', 'wallet_signMessage'],
23
+ events: ['accountsChanged', 'chainChanged'],
24
+ chains: ['eip155:1', 'eip155:137'],
25
+ };
26
+ const expected =
27
+ '{"chains":["eip155:1","eip155:137"],"events":["accountsChanged","chainChanged"],"methods":["wallet_signTransaction","wallet_signMessage"]}';
28
+ const expectedHash = '4da366e2aae26b47b3d90fff52410752348733350ce2525dce7d64510f571333';
29
+
30
+ it('produces the correct canonical output', () => {
31
+ expect(canonicalJson(input)).toBe(expected);
32
+ });
33
+
34
+ it('SHA-256 of output matches the spec', () => {
35
+ expect(hash(canonicalJson(input))).toBe(expectedHash);
36
+ });
37
+ });
38
+
39
+ describe('Section 6.3 — Vector 2: join plaintext (nested + meta)', () => {
40
+ const input = {
41
+ capabilities: {
42
+ methods: ['wallet_signTransaction', 'wallet_signMessage'],
43
+ events: ['accountsChanged', 'chainChanged'],
44
+ chains: ['eip155:1', 'eip155:137'],
45
+ },
46
+ meta: {
47
+ name: 'MyWallet',
48
+ description: 'A multi-chain wallet',
49
+ url: 'https://mywallet.app',
50
+ icon: 'https://mywallet.app/icon.png',
51
+ },
52
+ };
53
+ const expected =
54
+ '{"capabilities":{"chains":["eip155:1","eip155:137"],"events":["accountsChanged","chainChanged"],"methods":["wallet_signTransaction","wallet_signMessage"]},"meta":{"description":"A multi-chain wallet","icon":"https://mywallet.app/icon.png","name":"MyWallet","url":"https://mywallet.app"}}';
55
+ const expectedHash = '9f4f3b71b0db39ba8b86173b8c78182799d0a745c68b6e89e5d8f0d3def52594';
56
+
57
+ it('produces the correct canonical output', () => {
58
+ expect(canonicalJson(input)).toBe(expected);
59
+ });
60
+
61
+ it('SHA-256 of output matches the spec', () => {
62
+ expect(hash(canonicalJson(input))).toBe(expectedHash);
63
+ });
64
+ });
65
+
66
+ describe('Section 6.3 — Vector 3: primitives', () => {
67
+ it('null -> "null"', () => {
68
+ const output = canonicalJson(null);
69
+ expect(output).toBe('null');
70
+ expect(hash(output)).toBe('74234e98afe7498fb5daf1f36ac2d78acc339464f950703b8c019892f982b90b');
71
+ });
72
+
73
+ it('true -> "true"', () => {
74
+ const output = canonicalJson(true);
75
+ expect(output).toBe('true');
76
+ expect(hash(output)).toBe('b5bea41b6c623f7c09f1bf24dcae58ebab3c0cdd90ad966bc43a45b44867e12b');
77
+ });
78
+
79
+ it('42 -> "42"', () => {
80
+ const output = canonicalJson(42);
81
+ expect(output).toBe('42');
82
+ expect(hash(output)).toBe('73475cb40a568e8da8a045ced110137e159f890ac4da883b6b17dc651b3a8049');
83
+ });
84
+
85
+ it('"hello" -> \'"hello"\'', () => {
86
+ const output = canonicalJson('hello');
87
+ expect(output).toBe('"hello"');
88
+ expect(hash(output)).toBe('5aa762ae383fbb727af3c7a36d4940a5b8c40a989452d2304fc958ff3f354e7a');
89
+ });
90
+ });
91
+
92
+ describe('Section 6.3 — Vector 4: empty containers', () => {
93
+ it('{} -> "{}"', () => {
94
+ const output = canonicalJson({});
95
+ expect(output).toBe('{}');
96
+ expect(hash(output)).toBe('44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a');
97
+ });
98
+
99
+ it('[] -> "[]"', () => {
100
+ const output = canonicalJson([]);
101
+ expect(output).toBe('[]');
102
+ expect(hash(output)).toBe('4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945');
103
+ });
104
+ });
105
+
106
+ describe('Section 6.3 — Vector 5: negative zero', () => {
107
+ it('-0 -> "0"', () => {
108
+ const output = canonicalJson(-0);
109
+ expect(output).toBe('0');
110
+ expect(hash(output)).toBe('5feceb66ffc86f38d952786c6d696c79c2dbc239dd4e91b46729d73a27fb57e9');
111
+ });
112
+ });
113
+
114
+ describe('Section 6.3 — Vector 6: escaped control character', () => {
115
+ it('U+0001 -> "\\u0001" (lowercase hex)', () => {
116
+ const output = canonicalJson('\u0001');
117
+ expect(output).toBe('"\\u0001"');
118
+ expect(hash(output)).toBe('b81cfb0a6715e53b373345b49e8ad94eb55fd777519dc539373d0634973c186e');
119
+ });
120
+ });
121
+
122
+ // ---------------------------------------------------------------------------
123
+ // Edge cases from Section 6.3 rules
124
+ // ---------------------------------------------------------------------------
125
+
126
+ describe('Section 6.3 — Edge cases', () => {
127
+ it('negative zero inside an object serializes as 0', () => {
128
+ expect(canonicalJson({ value: -0 })).toBe('{"value":0}');
129
+ });
130
+
131
+ it('negative zero inside an array serializes as 0', () => {
132
+ expect(canonicalJson([-0])).toBe('[0]');
133
+ });
134
+
135
+ it('NaN is rejected', () => {
136
+ expect(() => canonicalJson(NaN)).toThrow();
137
+ });
138
+
139
+ it('Infinity is rejected', () => {
140
+ expect(() => canonicalJson(Infinity)).toThrow();
141
+ expect(() => canonicalJson(-Infinity)).toThrow();
142
+ });
143
+
144
+ it('forward slash is NOT escaped', () => {
145
+ const output = canonicalJson('/');
146
+ expect(output).toBe('"/"');
147
+ });
148
+
149
+ it('non-ASCII Unicode is output as literal UTF-8', () => {
150
+ expect(canonicalJson('中文')).toBe('"中文"');
151
+ });
152
+
153
+ it('control characters U+0000-U+001F use correct escape forms', () => {
154
+ // Short forms
155
+ expect(canonicalJson('\b')).toBe('"\\b"');
156
+ expect(canonicalJson('\t')).toBe('"\\t"');
157
+ expect(canonicalJson('\n')).toBe('"\\n"');
158
+ expect(canonicalJson('\f')).toBe('"\\f"');
159
+ expect(canonicalJson('\r')).toBe('"\\r"');
160
+
161
+ // All other C0 controls use lowercase \\uXXXX
162
+ expect(canonicalJson('\x00')).toBe('"\\u0000"');
163
+ expect(canonicalJson('\x01')).toBe('"\\u0001"');
164
+ expect(canonicalJson('\x1f')).toBe('"\\u001f"');
165
+ });
166
+
167
+ it('\\uXXXX escapes use lowercase hex digits only', () => {
168
+ for (let cp = 0; cp <= 0x1f; cp++) {
169
+ const result = canonicalJson(String.fromCharCode(cp));
170
+ // Verify no uppercase hex digits in any \u escape
171
+ const matches = result.match(/\\u[0-9a-fA-F]{4}/g);
172
+ if (matches) {
173
+ for (const m of matches) {
174
+ expect(m).toBe(m.toLowerCase());
175
+ }
176
+ }
177
+ }
178
+ });
179
+
180
+ it('no whitespace in structural output', () => {
181
+ const complex = {
182
+ z: [3, 1, 2],
183
+ a: { y: 'value', x: true },
184
+ m: null,
185
+ };
186
+ const output = canonicalJson(complex);
187
+ // Remove string content, then check structural chars only
188
+ const structural = output.replace(/"[^"]*"/g, '""');
189
+ expect(structural).not.toMatch(/\s/);
190
+ });
191
+
192
+ it('array order is preserved (NOT sorted)', () => {
193
+ expect(canonicalJson([3, 1, 2])).toBe('[3,1,2]');
194
+ });
195
+
196
+ it('object keys are sorted alphabetically at every nesting level', () => {
197
+ const input = { z: { b: 2, a: 1 }, a: 0 };
198
+ expect(canonicalJson(input)).toBe('{"a":0,"z":{"a":1,"b":2}}');
199
+ });
200
+
201
+ it('no trailing .0 on whole-number floats', () => {
202
+ expect(canonicalJson(100.0)).toBe('100');
203
+ });
204
+
205
+ it('numbers have no leading zeroes or + prefix', () => {
206
+ expect(canonicalJson(42)).toBe('42');
207
+ expect(canonicalJson(0)).toBe('0');
208
+ expect(canonicalJson(-1)).toBe('-1');
209
+ });
210
+ });
211
+
212
+ // ---------------------------------------------------------------------------
213
+ // Idempotency
214
+ // ---------------------------------------------------------------------------
215
+
216
+ describe('Section 6.3 — Idempotency', () => {
217
+ it('parsing the canonical output and re-canonicalizing produces identical bytes', () => {
218
+ const input = {
219
+ methods: ['wallet_signTransaction', 'wallet_signMessage'],
220
+ events: ['accountsChanged', 'chainChanged'],
221
+ chains: ['eip155:1', 'eip155:137'],
222
+ };
223
+ const first = canonicalJson(input);
224
+ const reparsed = JSON.parse(first);
225
+ expect(canonicalJson(reparsed)).toBe(first);
226
+ });
227
+ });