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.
- package/LICENSE +21 -0
- package/README.md +415 -0
- package/dist/ble/framing.d.ts +23 -0
- package/dist/ble/framing.d.ts.map +1 -0
- package/dist/ble/framing.js +83 -0
- package/dist/ble/framing.js.map +1 -0
- package/dist/ble/index.d.ts +9 -0
- package/dist/ble/index.d.ts.map +1 -0
- package/dist/ble/index.js +9 -0
- package/dist/ble/index.js.map +1 -0
- package/dist/ble/web-ble-transport.d.ts +29 -0
- package/dist/ble/web-ble-transport.d.ts.map +1 -0
- package/dist/ble/web-ble-transport.js +93 -0
- package/dist/ble/web-ble-transport.js.map +1 -0
- package/dist/crypto.d.ts +102 -0
- package/dist/crypto.d.ts.map +1 -0
- package/dist/crypto.js +279 -0
- package/dist/crypto.js.map +1 -0
- package/dist/dapp-session.d.ts +106 -0
- package/dist/dapp-session.d.ts.map +1 -0
- package/dist/dapp-session.js +918 -0
- package/dist/dapp-session.js.map +1 -0
- package/dist/emitter.d.ts +16 -0
- package/dist/emitter.d.ts.map +1 -0
- package/dist/emitter.js +41 -0
- package/dist/emitter.js.map +1 -0
- package/dist/evm/eip1193.d.ts +83 -0
- package/dist/evm/eip1193.d.ts.map +1 -0
- package/dist/evm/eip1193.js +270 -0
- package/dist/evm/eip1193.js.map +1 -0
- package/dist/evm/index.d.ts +8 -0
- package/dist/evm/index.d.ts.map +1 -0
- package/dist/evm/index.js +8 -0
- package/dist/evm/index.js.map +1 -0
- package/dist/evm/wagmi.d.ts +118 -0
- package/dist/evm/wagmi.d.ts.map +1 -0
- package/dist/evm/wagmi.js +205 -0
- package/dist/evm/wagmi.js.map +1 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +24 -0
- package/dist/index.js.map +1 -0
- package/dist/types.d.ts +225 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +31 -0
- package/dist/types.js.map +1 -0
- package/dist/wallet-session.d.ts +107 -0
- package/dist/wallet-session.d.ts.map +1 -0
- package/dist/wallet-session.js +794 -0
- package/dist/wallet-session.js.map +1 -0
- package/dist/ws-transport.d.ts +29 -0
- package/dist/ws-transport.d.ts.map +1 -0
- package/dist/ws-transport.js +79 -0
- package/dist/ws-transport.js.map +1 -0
- package/package.json +55 -0
- package/src/__tests__/adversarial/crypto-attacks.test.ts +557 -0
- package/src/__tests__/adversarial/malicious-dapp.test.ts +505 -0
- package/src/__tests__/adversarial/malicious-relay.test.ts +528 -0
- package/src/__tests__/adversarial/malicious-wallet.test.ts +467 -0
- package/src/__tests__/spec-compliance/canonical-json.test.ts +227 -0
- package/src/__tests__/spec-compliance/crypto-vectors.test.ts +321 -0
- package/src/__tests__/spec-compliance/message-format.test.ts +356 -0
- package/src/__tests__/spec-compliance/sequence-numbers.test.ts +300 -0
- package/src/__tests__/spec-compliance/state-machine.test.ts +364 -0
- package/src/ble/framing.test.ts +196 -0
- package/src/ble/framing.ts +100 -0
- package/src/ble/index.ts +18 -0
- package/src/ble/web-ble-transport.test.ts +192 -0
- package/src/ble/web-ble-transport.ts +116 -0
- package/src/ble/web-bluetooth.d.ts +47 -0
- package/src/canonical-json.test.ts +612 -0
- package/src/crypto-directional.test.ts +263 -0
- package/src/crypto-hardening.test.ts +529 -0
- package/src/crypto.test.ts +635 -0
- package/src/crypto.ts +405 -0
- package/src/dapp-session.test.ts +647 -0
- package/src/dapp-session.ts +1004 -0
- package/src/emitter.test.ts +169 -0
- package/src/emitter.ts +45 -0
- package/src/evm/eip1193.test.ts +365 -0
- package/src/evm/eip1193.ts +346 -0
- package/src/evm/index.ts +19 -0
- package/src/evm/wagmi.test.ts +396 -0
- package/src/evm/wagmi.ts +321 -0
- package/src/index.ts +86 -0
- package/src/integration.test.ts +385 -0
- package/src/security.test.ts +430 -0
- package/src/sequence-validation.test.ts +1185 -0
- package/src/test-helpers.ts +216 -0
- package/src/types.test.ts +82 -0
- package/src/types.ts +305 -0
- package/src/wallet-session.test.ts +683 -0
- package/src/wallet-session.ts +922 -0
- package/src/ws-transport.test.ts +231 -0
- package/src/ws-transport.ts +92 -0
|
@@ -0,0 +1,612 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Exhaustive canonical JSON tests.
|
|
3
|
+
*
|
|
4
|
+
* Tests every edge case, boundary condition, and cross-implementation
|
|
5
|
+
* compatibility concern for the canonicalJson function per protocol §6.2.
|
|
6
|
+
*
|
|
7
|
+
* Categories:
|
|
8
|
+
* A. Key sorting (lexicographic UTF-16 / UTF-8)
|
|
9
|
+
* B. Value types (null, boolean, number, string, array, object)
|
|
10
|
+
* C. Number edge cases
|
|
11
|
+
* D. String edge cases (unicode, escaping, surrogates)
|
|
12
|
+
* E. Structural edge cases (nesting, empty, mixed)
|
|
13
|
+
* F. undefined handling
|
|
14
|
+
* G. Determinism and idempotency
|
|
15
|
+
* H. Cross-implementation compatibility vectors
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { describe, it, expect } from 'vitest';
|
|
19
|
+
import { canonicalJson, sha256Hex } from './crypto.js';
|
|
20
|
+
|
|
21
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
22
|
+
// A. Key sorting
|
|
23
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
24
|
+
|
|
25
|
+
describe('canonicalJson — key sorting', () => {
|
|
26
|
+
it('sorts ASCII keys lexicographically', () => {
|
|
27
|
+
expect(canonicalJson({ z: 1, a: 2, m: 3 })).toBe('{"a":2,"m":3,"z":1}');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('uppercase sorts before lowercase (UTF-16 order)', () => {
|
|
31
|
+
// 'A' = 0x41, 'a' = 0x61 → 'A' < 'a'
|
|
32
|
+
expect(canonicalJson({ a: 1, A: 2 })).toBe('{"A":2,"a":1}');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('digits sort before letters', () => {
|
|
36
|
+
// '0' = 0x30, 'A' = 0x41
|
|
37
|
+
expect(canonicalJson({ a: 1, '0': 2 })).toBe('{"0":2,"a":1}');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('empty string key sorts first', () => {
|
|
41
|
+
expect(canonicalJson({ b: 2, '': 0, a: 1 })).toBe('{"":0,"a":1,"b":2}');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('underscore sorts between uppercase Z and lowercase a', () => {
|
|
45
|
+
// '_' = 0x5F, 'Z' = 0x5A, 'a' = 0x61
|
|
46
|
+
expect(canonicalJson({ a: 1, _: 2, Z: 3 })).toBe('{"Z":3,"_":2,"a":1}');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('numeric string keys sort lexicographically not numerically', () => {
|
|
50
|
+
// "10" < "2" lexicographically because '1' < '2'
|
|
51
|
+
expect(canonicalJson({ '2': 'b', '10': 'a', '1': 'c' })).toBe('{"1":"c","10":"a","2":"b"}');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('sorts keys with common prefixes correctly', () => {
|
|
55
|
+
expect(canonicalJson({ ab: 1, abc: 2, a: 3 })).toBe('{"a":3,"ab":1,"abc":2}');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('handles single-key object', () => {
|
|
59
|
+
expect(canonicalJson({ x: 1 })).toBe('{"x":1}');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('already-sorted keys produce same output', () => {
|
|
63
|
+
expect(canonicalJson({ a: 1, b: 2, c: 3 })).toBe('{"a":1,"b":2,"c":3}');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('reverse-sorted keys are reordered', () => {
|
|
67
|
+
expect(canonicalJson({ c: 3, b: 2, a: 1 })).toBe('{"a":1,"b":2,"c":3}');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('sorts unicode keys by UTF-16 code unit order', () => {
|
|
71
|
+
// For BMP characters, UTF-16 and UTF-8 sort order match for ASCII
|
|
72
|
+
// But for non-ASCII: é (U+00E9) vs ê (U+00EA) → é < ê
|
|
73
|
+
expect(canonicalJson({ 'ê': 2, 'é': 1 })).toBe('{"é":1,"ê":2}');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('handles keys with special JSON characters', () => {
|
|
77
|
+
expect(canonicalJson({ 'a"b': 1, 'a\\c': 2 })).toBe('{"a\\"b":1,"a\\\\c":2}');
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
82
|
+
// B. Value types
|
|
83
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
84
|
+
|
|
85
|
+
describe('canonicalJson — value types', () => {
|
|
86
|
+
it('null → "null"', () => {
|
|
87
|
+
expect(canonicalJson(null)).toBe('null');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('true → "true"', () => {
|
|
91
|
+
expect(canonicalJson(true)).toBe('true');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('false → "false"', () => {
|
|
95
|
+
expect(canonicalJson(false)).toBe('false');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('integer → decimal string', () => {
|
|
99
|
+
expect(canonicalJson(42)).toBe('42');
|
|
100
|
+
expect(canonicalJson(0)).toBe('0');
|
|
101
|
+
expect(canonicalJson(-1)).toBe('-1');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('string → quoted string', () => {
|
|
105
|
+
expect(canonicalJson('hello')).toBe('"hello"');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('empty string → \'""\'', () => {
|
|
109
|
+
expect(canonicalJson('')).toBe('""');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('array → ordered elements', () => {
|
|
113
|
+
expect(canonicalJson([1, 'two', null, true])).toBe('[1,"two",null,true]');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('empty array → "[]"', () => {
|
|
117
|
+
expect(canonicalJson([])).toBe('[]');
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('empty object → "{}"', () => {
|
|
121
|
+
expect(canonicalJson({})).toBe('{}');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('nested null in object', () => {
|
|
125
|
+
expect(canonicalJson({ a: null })).toBe('{"a":null}');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('nested null in array', () => {
|
|
129
|
+
expect(canonicalJson([null, null])).toBe('[null,null]');
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
134
|
+
// C. Number edge cases
|
|
135
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
136
|
+
|
|
137
|
+
describe('canonicalJson — numbers', () => {
|
|
138
|
+
it('negative zero → "0"', () => {
|
|
139
|
+
expect(canonicalJson(-0)).toBe('0');
|
|
140
|
+
// Also in object context
|
|
141
|
+
expect(canonicalJson({ v: -0 })).toBe('{"v":0}');
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('floats use shortest representation', () => {
|
|
145
|
+
expect(canonicalJson(1.5)).toBe('1.5');
|
|
146
|
+
expect(canonicalJson(0.1)).toBe('0.1');
|
|
147
|
+
expect(canonicalJson(100.0)).toBe('100'); // no trailing .0
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('large integers', () => {
|
|
151
|
+
expect(canonicalJson(1000000)).toBe('1000000');
|
|
152
|
+
expect(canonicalJson(Number.MAX_SAFE_INTEGER)).toBe('9007199254740991');
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('very small floats', () => {
|
|
156
|
+
// JS serializes 5e-7 as "5e-7" and 0.000001 as "0.000001"
|
|
157
|
+
expect(canonicalJson(0.000001)).toBe('0.000001');
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('scientific notation when JS uses it', () => {
|
|
161
|
+
// JS uses scientific notation for very large/small numbers
|
|
162
|
+
const result = canonicalJson(1e20);
|
|
163
|
+
expect(result).toBe('100000000000000000000');
|
|
164
|
+
// 1e21 triggers scientific notation in JS
|
|
165
|
+
expect(canonicalJson(1e21)).toBe('1e+21');
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('NaN → throws (RFC 8785 rejects non-finite numbers)', () => {
|
|
169
|
+
expect(() => canonicalJson(NaN)).toThrow();
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('Infinity → throws (RFC 8785 rejects non-finite numbers)', () => {
|
|
173
|
+
expect(() => canonicalJson(Infinity)).toThrow();
|
|
174
|
+
expect(() => canonicalJson(-Infinity)).toThrow();
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('NaN and Infinity in objects → throws', () => {
|
|
178
|
+
expect(() => canonicalJson({ a: NaN, b: Infinity })).toThrow();
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
183
|
+
// D. String edge cases
|
|
184
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
185
|
+
|
|
186
|
+
describe('canonicalJson — strings', () => {
|
|
187
|
+
// --- Control characters ---
|
|
188
|
+
it('escapes all C0 control characters (U+0000–U+001F)', () => {
|
|
189
|
+
// Tab, newline, carriage return use short form
|
|
190
|
+
expect(canonicalJson('\t')).toBe('"\\t"');
|
|
191
|
+
expect(canonicalJson('\n')).toBe('"\\n"');
|
|
192
|
+
expect(canonicalJson('\r')).toBe('"\\r"');
|
|
193
|
+
expect(canonicalJson('\b')).toBe('"\\b"');
|
|
194
|
+
expect(canonicalJson('\f')).toBe('"\\f"');
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('escapes NUL character', () => {
|
|
198
|
+
const result = canonicalJson('\x00');
|
|
199
|
+
expect(result).toBe('"\\u0000"');
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('escapes other C0 controls with \\uXXXX', () => {
|
|
203
|
+
// U+0001 through U+001F (excluding those with short forms)
|
|
204
|
+
const result = canonicalJson('\x01');
|
|
205
|
+
expect(result).toBe('"\\u0001"');
|
|
206
|
+
expect(canonicalJson('\x1f')).toBe('"\\u001f"');
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
// --- Mandatory escapes ---
|
|
210
|
+
it('escapes backslash', () => {
|
|
211
|
+
expect(canonicalJson('\\')).toBe('"\\\\"');
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('escapes double quote', () => {
|
|
215
|
+
expect(canonicalJson('"')).toBe('"\\""');
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('does NOT escape forward slash', () => {
|
|
219
|
+
expect(canonicalJson('/')).toBe('"/"');
|
|
220
|
+
expect(canonicalJson('a/b/c')).toBe('"a/b/c"');
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
// --- Unicode ---
|
|
224
|
+
it('preserves non-ASCII Unicode as literal UTF-8', () => {
|
|
225
|
+
expect(canonicalJson('中文')).toBe('"中文"');
|
|
226
|
+
expect(canonicalJson('éàü')).toBe('"éàü"');
|
|
227
|
+
expect(canonicalJson('日本語')).toBe('"日本語"');
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it('handles emoji (supplementary plane)', () => {
|
|
231
|
+
expect(canonicalJson('🔑')).toBe('"🔑"');
|
|
232
|
+
expect(canonicalJson('👨👩👧')).toBe('"👨👩👧"');
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('handles mixed ASCII and non-ASCII', () => {
|
|
236
|
+
expect(canonicalJson('hello 世界 🌍')).toBe('"hello 世界 🌍"');
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
// --- Long strings ---
|
|
240
|
+
it('handles very long strings', () => {
|
|
241
|
+
const long = 'x'.repeat(10000);
|
|
242
|
+
const result = canonicalJson(long);
|
|
243
|
+
expect(result).toBe(`"${long}"`);
|
|
244
|
+
expect(result.length).toBe(10002); // quotes
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
// --- Strings that look like other types ---
|
|
248
|
+
it('does not confuse string "null" with null', () => {
|
|
249
|
+
expect(canonicalJson('null')).toBe('"null"');
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it('does not confuse string "true" with true', () => {
|
|
253
|
+
expect(canonicalJson('true')).toBe('"true"');
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it('does not confuse string "123" with number', () => {
|
|
257
|
+
expect(canonicalJson('123')).toBe('"123"');
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
262
|
+
// E. Structural edge cases
|
|
263
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
264
|
+
|
|
265
|
+
describe('canonicalJson — structural', () => {
|
|
266
|
+
it('deeply nested objects (5 levels)', () => {
|
|
267
|
+
const input = { a: { b: { c: { d: { e: 1 } } } } };
|
|
268
|
+
expect(canonicalJson(input)).toBe('{"a":{"b":{"c":{"d":{"e":1}}}}}');
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it('deeply nested arrays', () => {
|
|
272
|
+
expect(canonicalJson([[[1]]])).toBe('[[[1]]]');
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it('mixed array/object nesting with key sorting at each level', () => {
|
|
276
|
+
const input = { z: [{ b: 2, a: 1 }], a: { y: [3], x: [1, 2] } };
|
|
277
|
+
expect(canonicalJson(input)).toBe('{"a":{"x":[1,2],"y":[3]},"z":[{"a":1,"b":2}]}');
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it('array of objects preserves array order but sorts each object', () => {
|
|
281
|
+
const input = [{ z: 1, a: 2 }, { y: 3, b: 4 }];
|
|
282
|
+
expect(canonicalJson(input)).toBe('[{"a":2,"z":1},{"b":4,"y":3}]');
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it('object with many keys', () => {
|
|
286
|
+
const obj: Record<string, number> = {};
|
|
287
|
+
for (let i = 0; i < 26; i++) {
|
|
288
|
+
obj[String.fromCharCode(122 - i)] = i; // z=0, y=1, ..., a=25
|
|
289
|
+
}
|
|
290
|
+
const result = canonicalJson(obj);
|
|
291
|
+
// Should start with "a" and end with "z"
|
|
292
|
+
expect(result.startsWith('{"a":25')).toBe(true);
|
|
293
|
+
expect(result.endsWith('"z":0}')).toBe(true);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it('handles object with boolean, null, number, string, array, object values', () => {
|
|
297
|
+
const input = { str: 'hello', num: 42, bool: true, nil: null, arr: [1], obj: { x: 1 } };
|
|
298
|
+
expect(canonicalJson(input)).toBe(
|
|
299
|
+
'{"arr":[1],"bool":true,"nil":null,"num":42,"obj":{"x":1},"str":"hello"}',
|
|
300
|
+
);
|
|
301
|
+
});
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
305
|
+
// F. undefined handling
|
|
306
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
307
|
+
|
|
308
|
+
describe('canonicalJson — undefined behavior', () => {
|
|
309
|
+
it('top-level undefined → "null"', () => {
|
|
310
|
+
expect(canonicalJson(undefined)).toBe('null');
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it('undefined object values are omitted (matches JSON.stringify)', () => {
|
|
314
|
+
// Keys with undefined values are omitted, matching JSON.stringify behavior
|
|
315
|
+
// and RFC 8785 / I-JSON which have no concept of undefined
|
|
316
|
+
const result = canonicalJson({ a: 1, b: undefined, c: 3 });
|
|
317
|
+
expect(result).toBe('{"a":1,"c":3}');
|
|
318
|
+
expect(result).not.toContain('"b"');
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it('undefined in array → "null"', () => {
|
|
322
|
+
// JSON.stringify([undefined]) → "[null]"
|
|
323
|
+
expect(canonicalJson([undefined])).toBe('[null]');
|
|
324
|
+
expect(canonicalJson([1, undefined, 3])).toBe('[1,null,3]');
|
|
325
|
+
});
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
329
|
+
// G. No whitespace
|
|
330
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
331
|
+
|
|
332
|
+
describe('canonicalJson — no whitespace', () => {
|
|
333
|
+
it('never contains spaces, tabs, or newlines in output', () => {
|
|
334
|
+
const complex = {
|
|
335
|
+
methods: ['wallet_signTransaction', 'wallet_signMessage'],
|
|
336
|
+
events: ['accountsChanged', 'chainChanged'],
|
|
337
|
+
chains: ['eip155:1', 'eip155:137'],
|
|
338
|
+
meta: { name: 'My Wallet', description: 'A test wallet' },
|
|
339
|
+
};
|
|
340
|
+
const result = canonicalJson(complex);
|
|
341
|
+
// Content strings may contain spaces, but structural whitespace should not exist
|
|
342
|
+
// Remove string content to check structural whitespace
|
|
343
|
+
const structural = result.replace(/"[^"]*"/g, '""');
|
|
344
|
+
expect(structural).not.toMatch(/[\s]/);
|
|
345
|
+
});
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
349
|
+
// H. Determinism and idempotency
|
|
350
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
351
|
+
|
|
352
|
+
describe('canonicalJson — determinism', () => {
|
|
353
|
+
it('output is idempotent (parsing output and re-serializing yields same result)', () => {
|
|
354
|
+
const input = { z: [3, 1], a: { y: 'hello', x: true } };
|
|
355
|
+
const first = canonicalJson(input);
|
|
356
|
+
const reparsed = JSON.parse(first);
|
|
357
|
+
const second = canonicalJson(reparsed);
|
|
358
|
+
expect(second).toBe(first);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it('different insertion order produces same output', () => {
|
|
362
|
+
const a: Record<string, number> = {};
|
|
363
|
+
a.x = 1; a.a = 2; a.m = 3;
|
|
364
|
+
|
|
365
|
+
const b: Record<string, number> = {};
|
|
366
|
+
b.a = 2; b.m = 3; b.x = 1;
|
|
367
|
+
|
|
368
|
+
expect(canonicalJson(a)).toBe(canonicalJson(b));
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
it('100 runs produce identical output', () => {
|
|
372
|
+
const input = { z: 1, a: [{ c: 3, b: 2 }], m: null };
|
|
373
|
+
const expected = canonicalJson(input);
|
|
374
|
+
for (let i = 0; i < 100; i++) {
|
|
375
|
+
expect(canonicalJson(input)).toBe(expected);
|
|
376
|
+
}
|
|
377
|
+
});
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
381
|
+
// I. Cross-implementation compatibility vectors
|
|
382
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
383
|
+
|
|
384
|
+
describe('canonicalJson — protocol spec test vectors (all SHA-256 verified)', () => {
|
|
385
|
+
function hash(s: string): string {
|
|
386
|
+
return sha256Hex(new TextEncoder().encode(s));
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
it('vector 1: capabilities (key sorting, nested)', () => {
|
|
390
|
+
const output = canonicalJson({
|
|
391
|
+
methods: ['wallet_signTransaction', 'wallet_signMessage'],
|
|
392
|
+
events: ['accountsChanged', 'chainChanged'],
|
|
393
|
+
chains: ['eip155:1', 'eip155:137'],
|
|
394
|
+
});
|
|
395
|
+
const expected = '{"chains":["eip155:1","eip155:137"],"events":["accountsChanged","chainChanged"],"methods":["wallet_signTransaction","wallet_signMessage"]}';
|
|
396
|
+
expect(output).toBe(expected);
|
|
397
|
+
expect(hash(output)).toBe('4da366e2aae26b47b3d90fff52410752348733350ce2525dce7d64510f571333');
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
it('vector 2: join plaintext (nested objects + meta, all fields required)', () => {
|
|
401
|
+
const output = canonicalJson({
|
|
402
|
+
capabilities: {
|
|
403
|
+
methods: ['wallet_signTransaction', 'wallet_signMessage'],
|
|
404
|
+
events: ['accountsChanged', 'chainChanged'],
|
|
405
|
+
chains: ['eip155:1', 'eip155:137'],
|
|
406
|
+
},
|
|
407
|
+
meta: {
|
|
408
|
+
name: 'MyWallet',
|
|
409
|
+
description: 'A multi-chain wallet',
|
|
410
|
+
url: 'https://mywallet.app',
|
|
411
|
+
icon: 'https://mywallet.app/icon.png',
|
|
412
|
+
},
|
|
413
|
+
});
|
|
414
|
+
const expected = '{"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"}}';
|
|
415
|
+
expect(output).toBe(expected);
|
|
416
|
+
expect(hash(output)).toBe('9f4f3b71b0db39ba8b86173b8c78182799d0a745c68b6e89e5d8f0d3def52594');
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
it('vector 3a: null', () => {
|
|
420
|
+
const output = canonicalJson(null);
|
|
421
|
+
expect(output).toBe('null');
|
|
422
|
+
expect(hash(output)).toBe('74234e98afe7498fb5daf1f36ac2d78acc339464f950703b8c019892f982b90b');
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
it('vector 3b: true', () => {
|
|
426
|
+
const output = canonicalJson(true);
|
|
427
|
+
expect(output).toBe('true');
|
|
428
|
+
expect(hash(output)).toBe('b5bea41b6c623f7c09f1bf24dcae58ebab3c0cdd90ad966bc43a45b44867e12b');
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
it('vector 3c: 42', () => {
|
|
432
|
+
const output = canonicalJson(42);
|
|
433
|
+
expect(output).toBe('42');
|
|
434
|
+
expect(hash(output)).toBe('73475cb40a568e8da8a045ced110137e159f890ac4da883b6b17dc651b3a8049');
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
it('vector 3d: "hello" (string)', () => {
|
|
438
|
+
const output = canonicalJson('hello');
|
|
439
|
+
expect(output).toBe('"hello"');
|
|
440
|
+
expect(hash(output)).toBe('5aa762ae383fbb727af3c7a36d4940a5b8c40a989452d2304fc958ff3f354e7a');
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
it('vector 4a: empty object {}', () => {
|
|
444
|
+
const output = canonicalJson({});
|
|
445
|
+
expect(output).toBe('{}');
|
|
446
|
+
expect(hash(output)).toBe('44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a');
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
it('vector 4b: empty array []', () => {
|
|
450
|
+
const output = canonicalJson([]);
|
|
451
|
+
expect(output).toBe('[]');
|
|
452
|
+
expect(hash(output)).toBe('4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945');
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
it('vector 5: negative zero → 0', () => {
|
|
456
|
+
const output = canonicalJson(-0);
|
|
457
|
+
expect(output).toBe('0');
|
|
458
|
+
expect(hash(output)).toBe('5feceb66ffc86f38d952786c6d696c79c2dbc239dd4e91b46729d73a27fb57e9');
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
it('vector 6: escaped control character (lowercase hex)', () => {
|
|
462
|
+
const output = canonicalJson('\u0001');
|
|
463
|
+
expect(output).toBe('"\\u0001"');
|
|
464
|
+
expect(hash(output)).toBe('b81cfb0a6715e53b373345b49e8ad94eb55fd777519dc539373d0634973c186e');
|
|
465
|
+
});
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
describe('canonicalJson — additional cross-implementation vectors', () => {
|
|
469
|
+
it('empty capabilities with null meta', () => {
|
|
470
|
+
const output = canonicalJson({ capabilities: { methods: [], events: [], chains: [] }, meta: null });
|
|
471
|
+
expect(output).toBe('{"capabilities":{"chains":[],"events":[],"methods":[]},"meta":null}');
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
it('unicode key and value', () => {
|
|
475
|
+
const output = canonicalJson({ name: '钱包', chains: ['eip155:1'] });
|
|
476
|
+
expect(output).toBe('{"chains":["eip155:1"],"name":"钱包"}');
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
it('boolean and null mix with sorting', () => {
|
|
480
|
+
const output = canonicalJson({ z: null, a: true, m: false });
|
|
481
|
+
expect(output).toBe('{"a":true,"m":false,"z":null}');
|
|
482
|
+
});
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
486
|
+
// J. RFC 8785 compliance — specific requirements
|
|
487
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
488
|
+
|
|
489
|
+
describe('canonicalJson — RFC 8785 specific compliance', () => {
|
|
490
|
+
// --- \uXXXX must be lowercase hex (RFC 8785 §3.2.2.2) ---
|
|
491
|
+
it('\\u escapes use lowercase hex digits', () => {
|
|
492
|
+
// U+0001 → \u0001 (not \u0001 with uppercase)
|
|
493
|
+
const result = canonicalJson('\u0001');
|
|
494
|
+
expect(result).toBe('"\\u0001"');
|
|
495
|
+
expect(result).not.toMatch(/\\u[0-9a-f]*[A-F]/); // no uppercase hex digits
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
it('\\u001f uses lowercase f', () => {
|
|
499
|
+
const result = canonicalJson('\u001f');
|
|
500
|
+
expect(result).toBe('"\\u001f"');
|
|
501
|
+
// Verify the 'f' is lowercase (0x66) not uppercase 'F' (0x46)
|
|
502
|
+
expect(result.charAt(6)).toBe('f');
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
it('all C0 control characters (U+0000–U+001F) are properly escaped', () => {
|
|
506
|
+
const shortForms: Record<number, string> = {
|
|
507
|
+
0x08: '\\b', 0x09: '\\t', 0x0a: '\\n', 0x0c: '\\f', 0x0d: '\\r',
|
|
508
|
+
};
|
|
509
|
+
for (let cp = 0; cp <= 0x1f; cp++) {
|
|
510
|
+
const result = canonicalJson(String.fromCharCode(cp));
|
|
511
|
+
if (shortForms[cp]) {
|
|
512
|
+
expect(result).toBe(`"${shortForms[cp]}"`);
|
|
513
|
+
} else {
|
|
514
|
+
const hex = cp.toString(16).padStart(4, '0');
|
|
515
|
+
expect(result).toBe(`"\\u${hex}"`);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
// --- Lone surrogates (ES2019 well-formed JSON.stringify) ---
|
|
521
|
+
it('lone high surrogate is escaped as \\udXXX', () => {
|
|
522
|
+
const result = canonicalJson('\uD800');
|
|
523
|
+
expect(result).toBe('"\\ud800"');
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
it('lone low surrogate is escaped as \\udcXX', () => {
|
|
527
|
+
const result = canonicalJson('\uDC00');
|
|
528
|
+
expect(result).toBe('"\\udc00"');
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
it('valid surrogate pair (emoji) is NOT escaped', () => {
|
|
532
|
+
// U+1F511 = 🔑 = \uD83D\uDD11 (surrogate pair)
|
|
533
|
+
const result = canonicalJson('🔑');
|
|
534
|
+
expect(result).toBe('"🔑"');
|
|
535
|
+
expect(result).not.toContain('\\u');
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
// --- Number serialization matches ECMAScript Number.toString() ---
|
|
539
|
+
it('1e20 outputs as full decimal (no scientific notation)', () => {
|
|
540
|
+
expect(canonicalJson(1e20)).toBe('100000000000000000000');
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
it('1e21 uses scientific notation (JS threshold)', () => {
|
|
544
|
+
expect(canonicalJson(1e21)).toBe('1e+21');
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
it('5e-7 uses scientific notation', () => {
|
|
548
|
+
expect(canonicalJson(5e-7)).toBe('5e-7');
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
it('0.000001 uses decimal notation', () => {
|
|
552
|
+
expect(canonicalJson(0.000001)).toBe('0.000001');
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
it('Number.MIN_VALUE in scientific notation', () => {
|
|
556
|
+
expect(canonicalJson(Number.MIN_VALUE)).toBe('5e-324');
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
it('Number.MAX_SAFE_INTEGER preserves precision', () => {
|
|
560
|
+
expect(canonicalJson(9007199254740991)).toBe('9007199254740991');
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
// --- Duplicate key handling ---
|
|
564
|
+
it('JSON.parse deduplicates keys (last value wins)', () => {
|
|
565
|
+
// This tests that our implementation handles the JS-level dedup correctly.
|
|
566
|
+
// True duplicate rejection requires a custom JSON parser, which is out of scope
|
|
567
|
+
// for the SDK (implementers using other languages must reject at parse time).
|
|
568
|
+
const input = JSON.parse('{"a":1,"b":2,"a":3}');
|
|
569
|
+
expect(canonicalJson(input)).toBe('{"a":3,"b":2}');
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
// --- Output encoding ---
|
|
573
|
+
it('output bytes are valid UTF-8', () => {
|
|
574
|
+
const output = canonicalJson({ name: '钱包', emoji: '🔑' });
|
|
575
|
+
const bytes = new TextEncoder().encode(output);
|
|
576
|
+
// TextEncoder always produces UTF-8
|
|
577
|
+
// Verify round-trip
|
|
578
|
+
const decoded = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
|
579
|
+
expect(decoded).toBe(output);
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
it('output has no BOM', () => {
|
|
583
|
+
const output = canonicalJson({ test: true });
|
|
584
|
+
const bytes = new TextEncoder().encode(output);
|
|
585
|
+
// UTF-8 BOM = EF BB BF
|
|
586
|
+
expect(bytes[0]).not.toBe(0xef);
|
|
587
|
+
});
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
591
|
+
// K. Protocol-specific: sealed payload uses canonical JSON
|
|
592
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
593
|
+
|
|
594
|
+
describe('canonicalJson — seal/unseal round-trip proves canonical encoding', () => {
|
|
595
|
+
it('object with unsorted keys round-trips correctly through seal/unseal', async () => {
|
|
596
|
+
// This proves that sealPayload uses canonicalJson internally,
|
|
597
|
+
// and unsealPayload returns the canonical form
|
|
598
|
+
const { sealPayload, unsealPayload } = await import('./crypto.js');
|
|
599
|
+
const key = new Uint8Array(32).fill(0xdd);
|
|
600
|
+
const ch = 'ee'.repeat(32);
|
|
601
|
+
|
|
602
|
+
// Input has keys in reverse order
|
|
603
|
+
const input = { z: 1, a: 2, m: 3 };
|
|
604
|
+
const sealed = sealPayload(key, ch, 0, input);
|
|
605
|
+
const { data, plaintextJson } = unsealPayload(key, ch, sealed);
|
|
606
|
+
|
|
607
|
+
// Data round-trips correctly
|
|
608
|
+
expect(data).toEqual(input);
|
|
609
|
+
// The plaintext inside the ciphertext is canonical JSON
|
|
610
|
+
expect(plaintextJson).toBe('{"a":2,"m":3,"z":1}');
|
|
611
|
+
});
|
|
612
|
+
});
|