@kodelyth/nostr 2026.5.42 → 2026.6.2

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 (47) hide show
  1. package/klaw.plugin.json +185 -2
  2. package/package.json +19 -6
  3. package/api.ts +0 -10
  4. package/channel-plugin-api.ts +0 -1
  5. package/index.ts +0 -95
  6. package/runtime-api.ts +0 -6
  7. package/setup-api.ts +0 -1
  8. package/setup-entry.ts +0 -9
  9. package/setup-plugin-api.ts +0 -3
  10. package/src/channel-api.ts +0 -11
  11. package/src/channel.inbound.test.ts +0 -187
  12. package/src/channel.outbound.test.ts +0 -163
  13. package/src/channel.setup.ts +0 -234
  14. package/src/channel.test.ts +0 -526
  15. package/src/channel.ts +0 -215
  16. package/src/config-schema.ts +0 -98
  17. package/src/default-relays.ts +0 -1
  18. package/src/gateway.ts +0 -321
  19. package/src/inbound-direct-dm-runtime.ts +0 -1
  20. package/src/metrics.ts +0 -458
  21. package/src/nostr-bus.fuzz.test.ts +0 -382
  22. package/src/nostr-bus.inbound.test.ts +0 -526
  23. package/src/nostr-bus.integration.test.ts +0 -477
  24. package/src/nostr-bus.test.ts +0 -231
  25. package/src/nostr-bus.ts +0 -789
  26. package/src/nostr-key-utils.ts +0 -94
  27. package/src/nostr-profile-core.ts +0 -134
  28. package/src/nostr-profile-http-runtime.ts +0 -6
  29. package/src/nostr-profile-http.test.ts +0 -632
  30. package/src/nostr-profile-http.ts +0 -583
  31. package/src/nostr-profile-import.test.ts +0 -119
  32. package/src/nostr-profile-import.ts +0 -262
  33. package/src/nostr-profile-url-safety.ts +0 -21
  34. package/src/nostr-profile.fuzz.test.ts +0 -430
  35. package/src/nostr-profile.test.ts +0 -415
  36. package/src/nostr-profile.ts +0 -144
  37. package/src/nostr-state-store.test.ts +0 -237
  38. package/src/nostr-state-store.ts +0 -206
  39. package/src/runtime.ts +0 -9
  40. package/src/seen-tracker.ts +0 -289
  41. package/src/session-route.ts +0 -25
  42. package/src/setup-surface.ts +0 -264
  43. package/src/test-fixtures.ts +0 -45
  44. package/src/types.ts +0 -117
  45. package/test/setup.ts +0 -5
  46. package/test-api.ts +0 -1
  47. package/tsconfig.json +0 -16
@@ -1,477 +0,0 @@
1
- import { afterEach, describe, expect, it, vi } from "vitest";
2
- import { createMetrics, createNoopMetrics, type MetricEvent } from "./metrics.js";
3
- import { createSeenTracker } from "./seen-tracker.js";
4
- import { TEST_RELAY_URL } from "./test-fixtures.js";
5
-
6
- const TEST_RELAY_URL_1 = "wss://relay1.com";
7
- const TEST_RELAY_URL_2 = "wss://relay2.com";
8
- const TEST_RELAY_URL_PRIMARY = "wss://relay.com";
9
- const TEST_RELAY_URL_GOOD = "wss://good-relay.com";
10
- const TEST_RELAY_URL_BAD = "wss://bad-relay.com";
11
-
12
- afterEach(() => {
13
- vi.useRealTimers();
14
- });
15
-
16
- function createTracker(overrides?: Partial<Parameters<typeof createSeenTracker>[0]>) {
17
- return createSeenTracker({
18
- maxEntries: 100,
19
- ttlMs: 60000,
20
- ...overrides,
21
- });
22
- }
23
-
24
- function createCollectingMetrics() {
25
- const events: MetricEvent[] = [];
26
- return {
27
- events,
28
- metrics: createMetrics((event) => events.push(event)),
29
- };
30
- }
31
-
32
- function createPlainMetrics() {
33
- return createMetrics();
34
- }
35
-
36
- // ============================================================================
37
- // Seen Tracker Integration Tests
38
- // ============================================================================
39
-
40
- describe("SeenTracker", () => {
41
- describe("basic operations", () => {
42
- it("tracks seen IDs", () => {
43
- const tracker = createTracker();
44
-
45
- // First check returns false and adds
46
- expect(tracker.has("id1")).toBe(false);
47
- // Second check returns true (already seen)
48
- expect(tracker.has("id1")).toBe(true);
49
-
50
- tracker.stop();
51
- });
52
-
53
- it("peek does not add", () => {
54
- const tracker = createTracker();
55
-
56
- expect(tracker.peek("id1")).toBe(false);
57
- expect(tracker.peek("id1")).toBe(false); // Still false
58
-
59
- tracker.add("id1");
60
- expect(tracker.peek("id1")).toBe(true);
61
-
62
- tracker.stop();
63
- });
64
-
65
- it("delete removes entries", () => {
66
- const tracker = createTracker();
67
-
68
- tracker.add("id1");
69
- expect(tracker.peek("id1")).toBe(true);
70
-
71
- tracker.delete("id1");
72
- expect(tracker.peek("id1")).toBe(false);
73
-
74
- tracker.stop();
75
- });
76
-
77
- it("clear removes all entries", () => {
78
- const tracker = createTracker();
79
-
80
- tracker.add("id1");
81
- tracker.add("id2");
82
- tracker.add("id3");
83
- expect(tracker.size()).toBe(3);
84
-
85
- tracker.clear();
86
- expect(tracker.size()).toBe(0);
87
- expect(tracker.peek("id1")).toBe(false);
88
-
89
- tracker.stop();
90
- });
91
-
92
- it("seed pre-populates entries", () => {
93
- const tracker = createTracker();
94
-
95
- tracker.seed(["id1", "id2", "id3"]);
96
- expect(tracker.size()).toBe(3);
97
- expect(tracker.peek("id1")).toBe(true);
98
- expect(tracker.peek("id2")).toBe(true);
99
- expect(tracker.peek("id3")).toBe(true);
100
-
101
- tracker.stop();
102
- });
103
- });
104
-
105
- describe("LRU eviction", () => {
106
- it("evicts least recently used when at capacity", () => {
107
- const tracker = createTracker({ maxEntries: 3 });
108
-
109
- tracker.add("id1");
110
- tracker.add("id2");
111
- tracker.add("id3");
112
- expect(tracker.size()).toBe(3);
113
-
114
- // Adding fourth should evict oldest (id1)
115
- tracker.add("id4");
116
- expect(tracker.size()).toBe(3);
117
- expect(tracker.peek("id1")).toBe(false); // Evicted
118
- expect(tracker.peek("id2")).toBe(true);
119
- expect(tracker.peek("id3")).toBe(true);
120
- expect(tracker.peek("id4")).toBe(true);
121
-
122
- tracker.stop();
123
- });
124
-
125
- it("accessing an entry moves it to front (prevents eviction)", () => {
126
- const tracker = createTracker({ maxEntries: 3 });
127
-
128
- tracker.add("id1");
129
- tracker.add("id2");
130
- tracker.add("id3");
131
-
132
- // Access id1, moving it to front
133
- tracker.has("id1");
134
-
135
- // Add id4 - should evict id2 (now oldest)
136
- tracker.add("id4");
137
- expect(tracker.peek("id1")).toBe(true); // Not evicted, was accessed
138
- expect(tracker.peek("id2")).toBe(false); // Evicted
139
- expect(tracker.peek("id3")).toBe(true);
140
- expect(tracker.peek("id4")).toBe(true);
141
-
142
- tracker.stop();
143
- });
144
-
145
- it("handles capacity of 1", () => {
146
- const tracker = createTracker({ maxEntries: 1 });
147
-
148
- tracker.add("id1");
149
- expect(tracker.peek("id1")).toBe(true);
150
-
151
- tracker.add("id2");
152
- expect(tracker.peek("id1")).toBe(false);
153
- expect(tracker.peek("id2")).toBe(true);
154
-
155
- tracker.stop();
156
- });
157
-
158
- it("seed respects maxEntries", () => {
159
- const tracker = createTracker({ maxEntries: 2 });
160
-
161
- tracker.seed(["id1", "id2", "id3", "id4"]);
162
- expect(tracker.size()).toBe(2);
163
- // Seed stops when maxEntries reached, processing from end to start
164
- // So id4 and id3 get added first, then we're at capacity
165
- expect(tracker.peek("id3")).toBe(true);
166
- expect(tracker.peek("id4")).toBe(true);
167
-
168
- tracker.stop();
169
- });
170
- });
171
-
172
- describe("TTL expiration", () => {
173
- it("expires entries after TTL", () => {
174
- vi.useFakeTimers();
175
-
176
- const tracker = createTracker({
177
- maxEntries: 100,
178
- ttlMs: 100,
179
- pruneIntervalMs: 50,
180
- });
181
-
182
- tracker.add("id1");
183
- expect(tracker.peek("id1")).toBe(true);
184
-
185
- // Advance past TTL
186
- vi.advanceTimersByTime(150);
187
-
188
- // Entry should be expired
189
- expect(tracker.peek("id1")).toBe(false);
190
-
191
- tracker.stop();
192
- vi.useRealTimers();
193
- });
194
-
195
- it("has() refreshes TTL", () => {
196
- vi.useFakeTimers();
197
-
198
- const tracker = createTracker({
199
- maxEntries: 100,
200
- ttlMs: 100,
201
- pruneIntervalMs: 50,
202
- });
203
-
204
- tracker.add("id1");
205
-
206
- // Advance halfway
207
- vi.advanceTimersByTime(50);
208
-
209
- // Access to refresh
210
- expect(tracker.has("id1")).toBe(true);
211
-
212
- // Advance another 75ms (total 125ms from add, but only 75ms from last access)
213
- vi.advanceTimersByTime(75);
214
-
215
- // Should still be valid (refreshed at 50ms)
216
- expect(tracker.peek("id1")).toBe(true);
217
-
218
- tracker.stop();
219
- vi.useRealTimers();
220
- });
221
- });
222
- });
223
-
224
- // ============================================================================
225
- // Metrics Integration Tests
226
- // ============================================================================
227
-
228
- describe("Metrics", () => {
229
- describe("createMetrics", () => {
230
- it("emits metric events to callback", () => {
231
- const { events, metrics } = createCollectingMetrics();
232
-
233
- metrics.emit("event.received");
234
- metrics.emit("event.processed");
235
- metrics.emit("event.duplicate");
236
-
237
- expect(events).toHaveLength(3);
238
- expect(events[0].name).toBe("event.received");
239
- expect(events[1].name).toBe("event.processed");
240
- expect(events[2].name).toBe("event.duplicate");
241
- });
242
-
243
- it("includes labels in metric events", () => {
244
- const { events, metrics } = createCollectingMetrics();
245
-
246
- metrics.emit("relay.connect", 1, { relay: TEST_RELAY_URL });
247
-
248
- expect(events[0].labels).toEqual({ relay: TEST_RELAY_URL });
249
- });
250
-
251
- it("accumulates counters in snapshot", () => {
252
- const metrics = createPlainMetrics();
253
-
254
- metrics.emit("event.received");
255
- metrics.emit("event.received");
256
- metrics.emit("event.processed");
257
- metrics.emit("event.duplicate");
258
- metrics.emit("event.duplicate");
259
- metrics.emit("event.duplicate");
260
-
261
- const snapshot = metrics.getSnapshot();
262
- expect(snapshot.eventsReceived).toBe(2);
263
- expect(snapshot.eventsProcessed).toBe(1);
264
- expect(snapshot.eventsDuplicate).toBe(3);
265
- });
266
-
267
- it("tracks per-relay stats", () => {
268
- const metrics = createPlainMetrics();
269
-
270
- metrics.emit("relay.connect", 1, { relay: TEST_RELAY_URL_1 });
271
- metrics.emit("relay.connect", 1, { relay: TEST_RELAY_URL_2 });
272
- metrics.emit("relay.error", 1, { relay: TEST_RELAY_URL_1 });
273
- metrics.emit("relay.error", 1, { relay: TEST_RELAY_URL_1 });
274
-
275
- const snapshot = metrics.getSnapshot();
276
- const relayOne = snapshot.relays[TEST_RELAY_URL_1];
277
- if (!relayOne) {
278
- throw new Error("expected first relay metrics");
279
- }
280
- expect(relayOne.connects).toBe(1);
281
- expect(relayOne.errors).toBe(2);
282
- expect(snapshot.relays[TEST_RELAY_URL_2].connects).toBe(1);
283
- expect(snapshot.relays[TEST_RELAY_URL_2].errors).toBe(0);
284
- });
285
-
286
- it("tracks circuit breaker state changes", () => {
287
- const metrics = createPlainMetrics();
288
-
289
- metrics.emit("relay.circuit_breaker.open", 1, { relay: TEST_RELAY_URL_PRIMARY });
290
-
291
- let snapshot = metrics.getSnapshot();
292
- expect(snapshot.relays[TEST_RELAY_URL_PRIMARY].circuitBreakerState).toBe("open");
293
- expect(snapshot.relays[TEST_RELAY_URL_PRIMARY].circuitBreakerOpens).toBe(1);
294
-
295
- metrics.emit("relay.circuit_breaker.close", 1, { relay: TEST_RELAY_URL_PRIMARY });
296
-
297
- snapshot = metrics.getSnapshot();
298
- expect(snapshot.relays[TEST_RELAY_URL_PRIMARY].circuitBreakerState).toBe("closed");
299
- expect(snapshot.relays[TEST_RELAY_URL_PRIMARY].circuitBreakerCloses).toBe(1);
300
- });
301
-
302
- it("tracks all rejection reasons", () => {
303
- const metrics = createPlainMetrics();
304
-
305
- metrics.emit("event.rejected.invalid_shape");
306
- metrics.emit("event.rejected.wrong_kind");
307
- metrics.emit("event.rejected.stale");
308
- metrics.emit("event.rejected.future");
309
- metrics.emit("event.rejected.rate_limited");
310
- metrics.emit("event.rejected.invalid_signature");
311
- metrics.emit("event.rejected.oversized_ciphertext");
312
- metrics.emit("event.rejected.oversized_plaintext");
313
- metrics.emit("event.rejected.decrypt_failed");
314
- metrics.emit("event.rejected.self_message");
315
-
316
- const snapshot = metrics.getSnapshot();
317
- expect(snapshot.eventsRejected.invalidShape).toBe(1);
318
- expect(snapshot.eventsRejected.wrongKind).toBe(1);
319
- expect(snapshot.eventsRejected.stale).toBe(1);
320
- expect(snapshot.eventsRejected.future).toBe(1);
321
- expect(snapshot.eventsRejected.rateLimited).toBe(1);
322
- expect(snapshot.eventsRejected.invalidSignature).toBe(1);
323
- expect(snapshot.eventsRejected.oversizedCiphertext).toBe(1);
324
- expect(snapshot.eventsRejected.oversizedPlaintext).toBe(1);
325
- expect(snapshot.eventsRejected.decryptFailed).toBe(1);
326
- expect(snapshot.eventsRejected.selfMessage).toBe(1);
327
- });
328
-
329
- it("tracks relay message types", () => {
330
- const metrics = createPlainMetrics();
331
-
332
- metrics.emit("relay.message.event", 1, { relay: TEST_RELAY_URL_PRIMARY });
333
- metrics.emit("relay.message.eose", 1, { relay: TEST_RELAY_URL_PRIMARY });
334
- metrics.emit("relay.message.closed", 1, { relay: TEST_RELAY_URL_PRIMARY });
335
- metrics.emit("relay.message.notice", 1, { relay: TEST_RELAY_URL_PRIMARY });
336
- metrics.emit("relay.message.ok", 1, { relay: TEST_RELAY_URL_PRIMARY });
337
- metrics.emit("relay.message.auth", 1, { relay: TEST_RELAY_URL_PRIMARY });
338
-
339
- const snapshot = metrics.getSnapshot();
340
- const relay = snapshot.relays[TEST_RELAY_URL_PRIMARY];
341
- expect(relay.messagesReceived.event).toBe(1);
342
- expect(relay.messagesReceived.eose).toBe(1);
343
- expect(relay.messagesReceived.closed).toBe(1);
344
- expect(relay.messagesReceived.notice).toBe(1);
345
- expect(relay.messagesReceived.ok).toBe(1);
346
- expect(relay.messagesReceived.auth).toBe(1);
347
- });
348
-
349
- it("tracks decrypt success/failure", () => {
350
- const metrics = createPlainMetrics();
351
-
352
- metrics.emit("decrypt.success");
353
- metrics.emit("decrypt.success");
354
- metrics.emit("decrypt.failure");
355
-
356
- const snapshot = metrics.getSnapshot();
357
- expect(snapshot.decrypt.success).toBe(2);
358
- expect(snapshot.decrypt.failure).toBe(1);
359
- });
360
-
361
- it("tracks memory gauges (replaces rather than accumulates)", () => {
362
- const metrics = createPlainMetrics();
363
-
364
- metrics.emit("memory.seen_tracker_size", 100);
365
- metrics.emit("memory.seen_tracker_size", 150);
366
- metrics.emit("memory.seen_tracker_size", 125);
367
-
368
- const snapshot = metrics.getSnapshot();
369
- expect(snapshot.memory.seenTrackerSize).toBe(125); // Last value, not sum
370
- });
371
-
372
- it("reset clears all counters", () => {
373
- const metrics = createPlainMetrics();
374
-
375
- metrics.emit("event.received");
376
- metrics.emit("event.processed");
377
- metrics.emit("relay.connect", 1, { relay: TEST_RELAY_URL_PRIMARY });
378
-
379
- metrics.reset();
380
-
381
- const snapshot = metrics.getSnapshot();
382
- expect(snapshot.eventsReceived).toBe(0);
383
- expect(snapshot.eventsProcessed).toBe(0);
384
- expect(Object.keys(snapshot.relays)).toHaveLength(0);
385
- });
386
- });
387
-
388
- describe("createNoopMetrics", () => {
389
- it("ignores emitted metrics", () => {
390
- const metrics = createNoopMetrics();
391
-
392
- expect(metrics.emit("event.received")).toBeUndefined();
393
- expect(metrics.emit("relay.connect", 1, { relay: TEST_RELAY_URL_PRIMARY })).toBeUndefined();
394
- });
395
-
396
- it("returns empty snapshot", () => {
397
- const metrics = createNoopMetrics();
398
-
399
- const snapshot = metrics.getSnapshot();
400
- expect(snapshot.eventsReceived).toBe(0);
401
- expect(snapshot.eventsProcessed).toBe(0);
402
- });
403
- });
404
- });
405
-
406
- // ============================================================================
407
- // Circuit Breaker Behavior Tests
408
- // ============================================================================
409
-
410
- describe("Circuit Breaker Behavior", () => {
411
- // Test the circuit breaker logic through metrics emissions
412
- it("emits circuit breaker metrics in correct sequence", () => {
413
- const { events, metrics } = createCollectingMetrics();
414
-
415
- // Simulate 5 failures -> open
416
- for (let i = 0; i < 5; i++) {
417
- metrics.emit("relay.error", 1, { relay: TEST_RELAY_URL_PRIMARY });
418
- }
419
- metrics.emit("relay.circuit_breaker.open", 1, { relay: TEST_RELAY_URL_PRIMARY });
420
-
421
- // Simulate recovery
422
- metrics.emit("relay.circuit_breaker.half_open", 1, { relay: TEST_RELAY_URL_PRIMARY });
423
- metrics.emit("relay.circuit_breaker.close", 1, { relay: TEST_RELAY_URL_PRIMARY });
424
-
425
- const cbEvents = events.filter((e) => e.name.startsWith("relay.circuit_breaker"));
426
- expect(cbEvents).toHaveLength(3);
427
- expect(cbEvents[0].name).toBe("relay.circuit_breaker.open");
428
- expect(cbEvents[1].name).toBe("relay.circuit_breaker.half_open");
429
- expect(cbEvents[2].name).toBe("relay.circuit_breaker.close");
430
- });
431
- });
432
-
433
- // ============================================================================
434
- // Health Scoring Behavior Tests
435
- // ============================================================================
436
-
437
- describe("Health Scoring", () => {
438
- it("metrics track relay errors for health scoring", () => {
439
- const metrics = createPlainMetrics();
440
-
441
- // Simulate mixed success/failure pattern
442
- metrics.emit("relay.connect", 1, { relay: TEST_RELAY_URL_GOOD });
443
- metrics.emit("relay.connect", 1, { relay: TEST_RELAY_URL_BAD });
444
-
445
- metrics.emit("relay.error", 1, { relay: TEST_RELAY_URL_BAD });
446
- metrics.emit("relay.error", 1, { relay: TEST_RELAY_URL_BAD });
447
- metrics.emit("relay.error", 1, { relay: TEST_RELAY_URL_BAD });
448
-
449
- const snapshot = metrics.getSnapshot();
450
- expect(snapshot.relays[TEST_RELAY_URL_GOOD].errors).toBe(0);
451
- expect(snapshot.relays[TEST_RELAY_URL_BAD].errors).toBe(3);
452
- });
453
- });
454
-
455
- // ============================================================================
456
- // Reconnect Backoff Tests
457
- // ============================================================================
458
-
459
- describe("Reconnect Backoff", () => {
460
- it("computes delays within expected bounds", () => {
461
- // Compute expected delays (1s, 2s, 4s, 8s, 16s, 32s, 60s cap)
462
- const BASE = 1000;
463
- const MAX = 60000;
464
- const JITTER = 0.3;
465
-
466
- for (let attempt = 0; attempt < 10; attempt++) {
467
- const exponential = BASE * 2 ** attempt;
468
- const capped = Math.min(exponential, MAX);
469
- const minDelay = capped * (1 - JITTER);
470
- const maxDelay = capped * (1 + JITTER);
471
-
472
- // These are the expected bounds
473
- expect(minDelay).toBeGreaterThanOrEqual(BASE * 0.7);
474
- expect(maxDelay).toBeLessThanOrEqual(MAX * 1.3);
475
- }
476
- });
477
- });
@@ -1,231 +0,0 @@
1
- import { describe, expect, it } from "vitest";
2
- import {
3
- validatePrivateKey,
4
- getPublicKeyFromPrivate,
5
- isValidPubkey,
6
- normalizePubkey,
7
- pubkeyToNpub,
8
- } from "./nostr-key-utils.js";
9
- import { TEST_HEX_PRIVATE_KEY, TEST_NSEC } from "./test-fixtures.js";
10
-
11
- const UPPERCASE_HEX = "0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF";
12
- const INVALID_HEX = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdeg";
13
-
14
- function expectThrowsError(run: () => unknown): void {
15
- let error: unknown;
16
- try {
17
- run();
18
- } catch (caught) {
19
- error = caught;
20
- }
21
- expect(error).toBeInstanceOf(Error);
22
- }
23
-
24
- const uppercaseHexAcceptanceCases = [
25
- {
26
- name: "validatePrivateKey",
27
- assert: () => {
28
- const result = validatePrivateKey(TEST_HEX_PRIVATE_KEY.toUpperCase());
29
- expect(result).toBeInstanceOf(Uint8Array);
30
- },
31
- },
32
- {
33
- name: "isValidPubkey",
34
- assert: () => {
35
- expect(isValidPubkey(UPPERCASE_HEX)).toBe(true);
36
- },
37
- },
38
- ];
39
-
40
- const invalidHexRejectionCases = [
41
- {
42
- name: "validatePrivateKey",
43
- assert: (input: string) => {
44
- expect(() => validatePrivateKey(input)).toThrow("Private key must be 64 hex characters");
45
- },
46
- },
47
- {
48
- name: "isValidPubkey",
49
- assert: (input: string) => {
50
- expect(isValidPubkey(input)).toBe(false);
51
- },
52
- },
53
- ];
54
-
55
- const whitespaceNormalizationCases = [
56
- {
57
- name: "validatePrivateKey",
58
- assert: () => {
59
- const result = validatePrivateKey(` ${TEST_HEX_PRIVATE_KEY} `);
60
- expect(result).toBeInstanceOf(Uint8Array);
61
- },
62
- },
63
- {
64
- name: "normalizePubkey",
65
- assert: () => {
66
- expect(normalizePubkey(` ${TEST_HEX_PRIVATE_KEY} `)).toBe(TEST_HEX_PRIVATE_KEY);
67
- },
68
- },
69
- ];
70
-
71
- describe("hex key helper contracts", () => {
72
- it.each(uppercaseHexAcceptanceCases)("$name accepts uppercase hex", ({ assert }) => {
73
- assert();
74
- });
75
-
76
- it.each(invalidHexRejectionCases)("$name rejects non-hex characters", ({ assert }) => {
77
- assert(INVALID_HEX);
78
- });
79
-
80
- it.each(invalidHexRejectionCases)("$name rejects empty string", ({ assert }) => {
81
- assert("");
82
- });
83
-
84
- it.each(whitespaceNormalizationCases)("$name trims whitespace", ({ assert }) => {
85
- assert();
86
- });
87
- });
88
-
89
- describe("validatePrivateKey", () => {
90
- describe("validatePrivateKey hex format", () => {
91
- it("accepts valid 64-char hex key", () => {
92
- const result = validatePrivateKey(TEST_HEX_PRIVATE_KEY);
93
- expect(result).toBeInstanceOf(Uint8Array);
94
- expect(result.length).toBe(32);
95
- });
96
-
97
- it("accepts lowercase hex", () => {
98
- const result = validatePrivateKey(TEST_HEX_PRIVATE_KEY.toLowerCase());
99
- expect(result).toBeInstanceOf(Uint8Array);
100
- });
101
-
102
- it("accepts mixed case hex", () => {
103
- const mixed = "0123456789ABCdef0123456789abcDEF0123456789abcdef0123456789ABCDEF";
104
- const result = validatePrivateKey(mixed);
105
- expect(result).toBeInstanceOf(Uint8Array);
106
- });
107
-
108
- it("trims newlines", () => {
109
- const result = validatePrivateKey(`${TEST_HEX_PRIVATE_KEY}\n`);
110
- expect(result).toBeInstanceOf(Uint8Array);
111
- });
112
-
113
- it("rejects 63-char hex (too short)", () => {
114
- expect(() => validatePrivateKey(TEST_HEX_PRIVATE_KEY.slice(0, 63))).toThrow(
115
- "Private key must be 64 hex characters",
116
- );
117
- });
118
-
119
- it("rejects 65-char hex (too long)", () => {
120
- expect(() => validatePrivateKey(TEST_HEX_PRIVATE_KEY + "0")).toThrow(
121
- "Private key must be 64 hex characters",
122
- );
123
- });
124
-
125
- it("rejects whitespace-only string", () => {
126
- expect(() => validatePrivateKey(" ")).toThrow("Private key must be 64 hex characters");
127
- });
128
-
129
- it("rejects key with 0x prefix", () => {
130
- expect(() => validatePrivateKey("0x" + TEST_HEX_PRIVATE_KEY)).toThrow(
131
- "Private key must be 64 hex characters",
132
- );
133
- });
134
- });
135
-
136
- describe("nsec format", () => {
137
- it("rejects invalid nsec (wrong checksum)", () => {
138
- const badNsec = "nsec1invalidinvalidinvalidinvalidinvalidinvalidinvalidinvalid";
139
- expectThrowsError(() => validatePrivateKey(badNsec));
140
- });
141
-
142
- it("rejects npub (wrong type)", () => {
143
- const npub = "npub1qypqxpq9qtpqscx7peytzfwtdjmcv0mrz5rjpej8vjppfkqfqy8s5epk55";
144
- expectThrowsError(() => validatePrivateKey(npub));
145
- });
146
- });
147
- });
148
-
149
- describe("isValidPubkey", () => {
150
- describe("isValidPubkey hex format", () => {
151
- it("accepts valid 64-char hex pubkey", () => {
152
- expect(isValidPubkey(TEST_HEX_PRIVATE_KEY)).toBe(true);
153
- });
154
-
155
- it("rejects 63-char hex", () => {
156
- const shortHex = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcde";
157
- expect(isValidPubkey(shortHex)).toBe(false);
158
- });
159
-
160
- it("rejects 65-char hex", () => {
161
- const longHex = `${TEST_HEX_PRIVATE_KEY}0`;
162
- expect(isValidPubkey(longHex)).toBe(false);
163
- });
164
- });
165
-
166
- describe("npub format", () => {
167
- it("rejects invalid npub", () => {
168
- expect(isValidPubkey("npub1invalid")).toBe(false);
169
- });
170
-
171
- it("rejects nsec (wrong type)", () => {
172
- expect(isValidPubkey(TEST_NSEC)).toBe(false);
173
- });
174
- });
175
-
176
- describe("edge cases", () => {
177
- it("handles whitespace-padded input", () => {
178
- expect(isValidPubkey(` ${TEST_HEX_PRIVATE_KEY} `)).toBe(true);
179
- });
180
- });
181
- });
182
-
183
- describe("normalizePubkey", () => {
184
- describe("normalizePubkey hex format", () => {
185
- it("lowercases hex pubkey", () => {
186
- const upper = "0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF";
187
- const result = normalizePubkey(upper);
188
- expect(result).toBe(upper.toLowerCase());
189
- });
190
-
191
- it("rejects invalid hex", () => {
192
- expect(() => normalizePubkey("invalid")).toThrow("Pubkey must be 64 hex characters");
193
- });
194
- });
195
- });
196
-
197
- describe("getPublicKeyFromPrivate", () => {
198
- it("derives public key from hex private key", () => {
199
- const pubkey = getPublicKeyFromPrivate(TEST_HEX_PRIVATE_KEY);
200
- expect(pubkey).toMatch(/^[0-9a-f]{64}$/);
201
- expect(pubkey.length).toBe(64);
202
- });
203
-
204
- it("derives consistent public key", () => {
205
- const pubkey1 = getPublicKeyFromPrivate(TEST_HEX_PRIVATE_KEY);
206
- const pubkey2 = getPublicKeyFromPrivate(TEST_HEX_PRIVATE_KEY);
207
- expect(pubkey1).toBe(pubkey2);
208
- });
209
-
210
- it("throws for invalid private key", () => {
211
- expectThrowsError(() => getPublicKeyFromPrivate("invalid"));
212
- });
213
- });
214
-
215
- describe("pubkeyToNpub", () => {
216
- it("converts hex pubkey to npub format", () => {
217
- const npub = pubkeyToNpub(TEST_HEX_PRIVATE_KEY);
218
- expect(npub).toMatch(/^npub1[a-z0-9]+$/);
219
- });
220
-
221
- it("produces consistent output", () => {
222
- const npub1 = pubkeyToNpub(TEST_HEX_PRIVATE_KEY);
223
- const npub2 = pubkeyToNpub(TEST_HEX_PRIVATE_KEY);
224
- expect(npub1).toBe(npub2);
225
- });
226
-
227
- it("normalizes uppercase hex first", () => {
228
- const upper = TEST_HEX_PRIVATE_KEY.toUpperCase();
229
- expect(pubkeyToNpub(TEST_HEX_PRIVATE_KEY)).toBe(pubkeyToNpub(upper));
230
- });
231
- });