forked from Dshield-xyz/Dshield
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathnotes.test.ts
More file actions
410 lines (362 loc) · 14.1 KB
/
Copy pathnotes.test.ts
File metadata and controls
410 lines (362 loc) · 14.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
import { describe, it, expect } from "vitest";
import {
saveNote,
getNotes,
markNoteSpent,
getActiveNotes,
generateRandomField,
serializeNote,
serializeNotes,
parseNote,
saveNoteIfNew,
generateNoteLink,
type ShieldedNote,
} from "./notes";
function makeNote(overrides: Partial<ShieldedNote> = {}): ShieldedNote {
return {
nullifier: "00aabbcc",
secret: "00ddeeff",
commitment: "abcd1234",
leafIndex: 0,
amount: "1000000",
spent: false,
createdAt: Date.now(),
...overrides,
};
}
describe("generateRandomField", () => {
it("produces a 64-char hex string starting with 00", () => {
const field = generateRandomField();
expect(field).toHaveLength(64);
expect(field.slice(0, 2)).toBe("00");
expect(/^[0-9a-f]{64}$/.test(field)).toBe(true);
});
it("produces different values on successive calls", () => {
const a = generateRandomField();
const b = generateRandomField();
expect(a).not.toBe(b);
});
});
describe("serializeNote / parseNote", () => {
it("round-trips a note's withdrawable fields", () => {
const note = makeNote({
poolId: "CABC123",
leafIndex: 7,
amount: "10000000",
commitment: "deadbeef",
nullifier: "00aa",
secret: "00bb",
});
const restored = parseNote(serializeNote(note));
expect(restored).not.toBeNull();
expect(restored!.poolId).toBe("CABC123");
expect(restored!.leafIndex).toBe(7);
expect(restored!.amount).toBe("10000000");
expect(restored!.commitment).toBe("deadbeef");
expect(restored!.nullifier).toBe("00aa");
expect(restored!.secret).toBe("00bb");
expect(restored!.spent).toBe(false);
});
it("produces a dshield-v1 prefixed string", () => {
expect(serializeNote(makeNote())).toMatch(/^dshield-v1-/);
});
it("returns null for malformed or foreign strings", () => {
expect(parseNote("not-a-note")).toBeNull();
expect(parseNote("tornado-eth-0.1-1-0xabc")).toBeNull();
expect(parseNote("dshield-v2-a-0-1-c-n-s")).toBeNull();
expect(parseNote("")).toBeNull();
});
});
describe("serializeNotes", () => {
it("joins one dshield-v1 line per note, newline-terminated", () => {
const notes = [
makeNote({ commitment: "aaa" }),
makeNote({ commitment: "bbb" }),
makeNote({ commitment: "ccc" }),
];
const body = serializeNotes(notes);
expect(body.endsWith("\n")).toBe(true);
const lines = body.trim().split("\n");
expect(lines).toHaveLength(3);
for (const line of lines) expect(line).toMatch(/^dshield-v1-/);
});
it("round-trips every note's withdrawable fields through parseNote", () => {
const notes = [
makeNote({ commitment: "aaa", leafIndex: 1, amount: "1000" }),
makeNote({ commitment: "bbb", leafIndex: 2, amount: "2000" }),
];
const restored = serializeNotes(notes)
.trim()
.split("\n")
.map(parseNote);
expect(restored.map((n) => n?.commitment)).toEqual(["aaa", "bbb"]);
expect(restored.map((n) => n?.leafIndex)).toEqual([1, 2]);
expect(restored.map((n) => n?.amount)).toEqual(["1000", "2000"]);
});
it("produces output that NoteImport's whitespace-split parsing recovers cleanly", () => {
// NoteImport splits pasted/uploaded text on /[\n\r\s]+/ and keeps only
// tokens starting with "dshield-v1-" — mirror that here without
// importing a React component into a lib-level test.
const notes = [makeNote({ commitment: "aaa" }), makeNote({ commitment: "bbb" })];
const tokens = serializeNotes(notes)
.split(/[\n\r\s]+/)
.map((s) => s.trim())
.filter((s) => s.startsWith("dshield-v1-"));
expect(tokens).toHaveLength(2);
expect(tokens.map(parseNote).map((n) => n?.commitment)).toEqual(["aaa", "bbb"]);
});
it("returns an empty backup as a single trailing newline", () => {
expect(serializeNotes([])).toBe("\n");
});
});
describe("generateNoteLink (compact link encoding)", () => {
const HEX32_A = "1234567890abcdef".repeat(4);
const HEX32_B = "00aabbcc".repeat(8);
const HEX32_C = "deadbeef".repeat(8);
const VALID_POOL = "CBQ3EPNIMGLS53U4HHLT4V3HAGJJCLONVXAN2QEREGQZMFQOLK7VF6C7";
function fullNote(overrides: Partial<ShieldedNote> = {}): ShieldedNote {
return {
nullifier: HEX32_A,
secret: HEX32_B,
commitment: HEX32_C,
leafIndex: 42,
amount: "100000000",
spent: false,
createdAt: Date.now(),
poolId: VALID_POOL,
...overrides,
};
}
function hashPayload(link: string): string {
return decodeURIComponent(link.split("#note=")[1]);
}
it("round-trips every withdrawable field through the compact format", () => {
const note = fullNote();
const link = generateNoteLink(note);
expect(hashPayload(link)).toMatch(/^dS2\./);
const restored = parseNote(hashPayload(link));
expect(restored).not.toBeNull();
expect(restored!.poolId).toBe(VALID_POOL);
expect(restored!.leafIndex).toBe(42);
expect(restored!.amount).toBe("100000000");
expect(restored!.commitment).toBe(HEX32_C);
expect(restored!.nullifier).toBe(HEX32_A);
expect(restored!.secret).toBe(HEX32_B);
});
it("round-trips a note with no poolId", () => {
const note = fullNote({ poolId: undefined });
const restored = parseNote(hashPayload(generateNoteLink(note)));
expect(restored!.poolId).toBeUndefined();
});
it("produces a materially shorter payload than the dash-joined backup format", () => {
const note = fullNote();
const compactLen = hashPayload(generateNoteLink(note)).length;
const legacyLen = serializeNote(note).length;
expect(compactLen).toBeLessThan(legacyLen * 0.75);
});
it("still parses a pre-existing dshield-v1 link (backward compatibility)", () => {
const note = fullNote();
const legacyPayload = serializeNote(note);
const restored = parseNote(legacyPayload);
expect(restored).not.toBeNull();
expect(restored!.commitment).toBe(HEX32_C);
});
it("falls back to the legacy format for fields that don't fit the compact encoding", () => {
// The default short fixture (8-char hex) isn't a valid 32-byte field,
// so encodeNoteCompact should decline and generateNoteLink should fall
// back to serializeNote rather than produce a broken link.
const shortNote: ShieldedNote = {
nullifier: "00aabbcc",
secret: "00ddeeff",
commitment: "abcd1234",
leafIndex: 0,
amount: "1000000",
spent: false,
createdAt: Date.now(),
};
const payload = hashPayload(generateNoteLink(shortNote));
expect(payload).toMatch(/^dshield-v1-/);
const restored = parseNote(payload);
expect(restored).not.toBeNull();
expect(restored!.commitment).toBe("abcd1234");
});
});
describe("saveNote / getNotes", () => {
it("returns empty array when nothing saved", () => {
expect(getNotes()).toEqual([]);
});
it("saves and retrieves a note", async () => {
const note = makeNote();
await saveNote(note);
const notes = getNotes();
expect(notes).toHaveLength(1);
expect(notes[0].commitment).toBe("abcd1234");
expect(notes[0].amount).toBe("1000000");
});
it("appends multiple notes", async () => {
await saveNote(makeNote({ commitment: "aaa" }));
await saveNote(makeNote({ commitment: "bbb" }));
expect(getNotes()).toHaveLength(2);
});
});
describe("saveNoteIfNew", () => {
it("adds a note that isn't already stored", async () => {
expect(await saveNoteIfNew(makeNote({ commitment: "aaa" }))).toBe(true);
expect(getNotes()).toHaveLength(1);
});
it("does not duplicate a note with an existing commitment", async () => {
await saveNote(makeNote({ commitment: "aaa" }));
expect(await saveNoteIfNew(makeNote({ commitment: "aaa", secret: "00ff" }))).toBe(
false,
);
expect(getNotes()).toHaveLength(1);
});
});
describe("markNoteSpent", () => {
it("marks the correct note as spent", async () => {
await saveNote(makeNote({ commitment: "aaa" }));
await saveNote(makeNote({ commitment: "bbb" }));
await markNoteSpent("aaa");
const notes = getNotes();
expect(notes[0].spent).toBe(true);
expect(notes[1].spent).toBe(false);
});
it("does not modify notes with different commitment", async () => {
await saveNote(makeNote({ commitment: "aaa" }));
await markNoteSpent("zzz");
expect(getNotes()[0].spent).toBe(false);
});
});
describe("getActiveNotes", () => {
it("filters out spent notes", async () => {
await saveNote(makeNote({ commitment: "aaa" }));
await saveNote(makeNote({ commitment: "bbb" }));
await markNoteSpent("aaa");
const active = getActiveNotes();
expect(active).toHaveLength(1);
expect(active[0].commitment).toBe("bbb");
});
it("returns all notes when none are spent", async () => {
await saveNote(makeNote({ commitment: "aaa" }));
await saveNote(makeNote({ commitment: "bbb" }));
expect(getActiveNotes()).toHaveLength(2);
});
it("returns empty array when all are spent", async () => {
await saveNote(makeNote({ commitment: "aaa" }));
await markNoteSpent("aaa");
expect(getActiveNotes()).toHaveLength(0);
});
});
describe("generateNoteLink without a Buffer global", () => {
// Regression test for a real crash: the browser's `Buffer` only exists via
// a bundler polyfill. An earlier version of the compact link encoder used
// Buffer.alloc/writeUInt32BE/writeBigUInt64BE/copy/equals/
// toString("base64url") to pack the note — surface nothing else in this
// codebase exercises. That threw during render on the deposit
// success screen (a render-time throw unmounts the whole React tree,
// which is what actually crashed). Trying to fake a spec-compliant Buffer
// here to test the old behavior isn't safe either: swapping in anything
// that fails `instanceof Buffer` can crash unrelated code that assumes a
// real Buffer constructor exists (this was verified directly — even
// vitest's own error serializer does `instanceof Buffer` and hard-crashes
// the test worker on a fake one). So instead this removes `Buffer`
// entirely and asserts generateNoteLink degrades gracefully rather than
// throwing, and that the core packing (Uint8Array/DataView/btoa) needs no
// Buffer at all when no pool StrKey en/decoding is involved.
function withoutBuffer<T>(fn: () => T): T {
const RealBuffer = globalThis.Buffer;
// @ts-expect-error -- intentionally removing the global for this test
delete globalThis.Buffer;
try {
return fn();
} finally {
globalThis.Buffer = RealBuffer;
}
}
const note: ShieldedNote = {
nullifier: "1234567890abcdef".repeat(4),
secret: "00aabbcc".repeat(8),
commitment: "deadbeef".repeat(8),
leafIndex: 42,
amount: "100000000",
spent: false,
createdAt: Date.now(),
};
it("uses the compact format and round-trips with no poolId involved", () => {
const link = withoutBuffer(() => generateNoteLink(note));
const payload = decodeURIComponent(link.split("#note=")[1]);
expect(payload.startsWith("dS2.")).toBe(true);
const restored = withoutBuffer(() => parseNote(payload));
expect(restored).not.toBeNull();
expect(restored!.commitment).toBe(note.commitment);
expect(restored!.nullifier).toBe(note.nullifier);
expect(restored!.secret).toBe(note.secret);
expect(restored!.leafIndex).toBe(note.leafIndex);
expect(restored!.amount).toBe(note.amount);
});
it("degrades to the legacy format instead of throwing when poolId needs StrKey", () => {
const withPool = { ...note, poolId: "CBQ3EPNIMGLS53U4HHLT4V3HAGJJCLONVXAN2QEREGQZMFQOLK7VF6C7" };
let link = "";
expect(() => {
link = withoutBuffer(() => generateNoteLink(withPool));
}).not.toThrow();
const payload = decodeURIComponent(link.split("#note=")[1]);
expect(payload.startsWith("dshield-v1-")).toBe(true);
const restored = parseNote(payload);
expect(restored!.poolId).toBe(withPool.poolId);
});
});
describe("Cross-tab synchronization", () => {
it("concurrent saves do not clobber each other", async () => {
// Simulate two tabs saving notes concurrently
const note1 = makeNote({ commitment: "concurrent1" });
const note2 = makeNote({ commitment: "concurrent2" });
// Fire both saves simultaneously
await Promise.all([
saveNote(note1),
saveNote(note2),
]);
const notes = getNotes();
expect(notes).toHaveLength(2);
expect(notes.some((n) => n.commitment === "concurrent1")).toBe(true);
expect(notes.some((n) => n.commitment === "concurrent2")).toBe(true);
});
it("concurrent markNoteSpent operations serialize correctly", async () => {
await saveNote(makeNote({ commitment: "mark1" }));
await saveNote(makeNote({ commitment: "mark2" }));
// Mark both as spent concurrently
await Promise.all([
markNoteSpent("mark1"),
markNoteSpent("mark2"),
]);
const notes = getNotes();
expect(notes[0].spent).toBe(true);
expect(notes[1].spent).toBe(true);
});
it("concurrent save and markNoteSpent do not lose updates", async () => {
await saveNote(makeNote({ commitment: "existing" }));
// One tab saves a new note while another marks existing note as spent
await Promise.all([
saveNote(makeNote({ commitment: "new" })),
markNoteSpent("existing"),
]);
const notes = getNotes();
expect(notes).toHaveLength(2);
expect(notes.find((n) => n.commitment === "existing")?.spent).toBe(true);
expect(notes.find((n) => n.commitment === "new")).toBeDefined();
});
it("lock timeout throws error if lock cannot be acquired", async () => {
// Manually set a lock with a recent timestamp that won't be considered stale
const stubbornLock = JSON.stringify({
id: "stuck-lock",
timestamp: Date.now() // Fresh timestamp, won't be cleared as stale
});
localStorage.setItem("dshield_notes_lock", stubbornLock);
// Attempt to save should timeout and throw
await expect(
saveNote(makeNote({ commitment: "timeout-test" }))
).rejects.toThrow("Failed to acquire storage lock after timeout");
// Clean up
localStorage.removeItem("dshield_notes_lock");
}, 10000); // 10 second timeout for this test
});