@shroud-fi/scanning 0.1.2 → 0.1.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shroud-fi/scanning",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "Receiver-side stealth payment detection for ShroudFi. Scans EIP-5564 announcements with view-tag pre-filtering (~99.6% reduction).",
5
5
  "keywords": [
6
6
  "shroudfi",
@@ -26,11 +26,14 @@
26
26
  }
27
27
  },
28
28
  "files": [
29
- "dist"
29
+ "dist",
30
+ "src",
31
+ "tsconfig.json",
32
+ "README.md"
30
33
  ],
31
34
  "dependencies": {
32
- "@shroud-fi/core": "0.1.2",
33
- "@shroud-fi/transport": "0.1.3"
35
+ "@shroud-fi/core": "0.1.3",
36
+ "@shroud-fi/transport": "0.1.4"
34
37
  },
35
38
  "peerDependencies": {
36
39
  "viem": "^2.21.0"
package/src/backend.ts ADDED
@@ -0,0 +1,12 @@
1
+ // Thin re-export module that exposes the ScannerBackend interface without
2
+ // pulling the whole index.ts barrel. Lets backend implementations import the
3
+ // minimum surface they need.
4
+
5
+ export type {
6
+ ScannerBackend,
7
+ RawAnnouncement,
8
+ AnnouncementHandler,
9
+ UnsubscribeFn,
10
+ BackendWatchOptions,
11
+ FinalityLevel,
12
+ } from './types.js';
@@ -0,0 +1,21 @@
1
+ // Defaults for @shroud-fi/scanning. All values are tunable via ScannerConfig.
2
+
3
+ import type { FinalityLevel } from './types.js';
4
+
5
+ /** Default ERC-5564 SCHEME_ID — secp256k1 with view tags. */
6
+ export const DEFAULT_SCHEME_ID = 1n;
7
+
8
+ /** Reconciliation tick interval — 5 minutes. */
9
+ export const DEFAULT_RECONCILE_INTERVAL_MS = 300_000;
10
+
11
+ /** LRU dedup capacity — chosen to fit ~30 minutes of Base block production. */
12
+ export const DEFAULT_DEDUP_CAPACITY = 10_000;
13
+
14
+ /** Max blocks per getLogs call — Alchemy/QuickNode default-safe. */
15
+ export const DEFAULT_GETLOGS_CHUNK_SIZE = 10_000n;
16
+
17
+ /** Default finality threshold an event must satisfy before yielding. */
18
+ export const DEFAULT_FINALITY: FinalityLevel = 'safe';
19
+
20
+ /** Minimum metadata length before a view tag can even be read (byte 0). */
21
+ export const MIN_METADATA_LENGTH = 1;
package/src/cursor.ts ADDED
@@ -0,0 +1,28 @@
1
+ // Monotonic block cursor used by the detector's reconciliation loop.
2
+ //
3
+ // In-memory only — by design (per types.ts contract). On restart the scanner
4
+ // resets to `startBlock`. Dedup + ECDH verification prevent double-yield.
5
+
6
+ import { ScanningError } from './errors.js';
7
+
8
+ export class Cursor {
9
+ #last: bigint;
10
+
11
+ constructor(startBlock: bigint) {
12
+ if (startBlock < 0n) {
13
+ throw new ScanningError('startBlock must be non-negative');
14
+ }
15
+ this.#last = startBlock;
16
+ }
17
+
18
+ get last(): bigint {
19
+ return this.#last;
20
+ }
21
+
22
+ /** Advance to `block` if it is strictly greater than the current last. */
23
+ advanceTo(block: bigint): void {
24
+ if (block > this.#last) {
25
+ this.#last = block;
26
+ }
27
+ }
28
+ }
package/src/dedup.ts ADDED
@@ -0,0 +1,49 @@
1
+ // FIFO LRU deduper for announcement keys (`${txHash}:${logIndex}`).
2
+ //
3
+ // Privacy contract:
4
+ // - Keys passed in are public on-chain identifiers (txHash + logIndex). The
5
+ // deduper never sees private key material; nothing here is sensitive on its
6
+ // own. Still, no console.* logging by policy.
7
+ // - JS Map iteration order is insertion order, which gives us FIFO eviction
8
+ // for free without an explicit doubly-linked list.
9
+
10
+ import { ScanningError } from './errors.js';
11
+
12
+ export class LRUDeduper {
13
+ readonly #capacity: number;
14
+ readonly #map: Map<string, true>;
15
+
16
+ constructor(capacity: number) {
17
+ if (!Number.isInteger(capacity) || capacity < 1) {
18
+ throw new ScanningError('LRU capacity must be a positive integer');
19
+ }
20
+ this.#capacity = capacity;
21
+ this.#map = new Map<string, true>();
22
+ }
23
+
24
+ has(key: string): boolean {
25
+ return this.#map.has(key);
26
+ }
27
+
28
+ /** Add a key. Returns true if newly added, false if already present. */
29
+ add(key: string): boolean {
30
+ if (this.#map.has(key)) return false;
31
+ this.#map.set(key, true);
32
+ if (this.#map.size > this.#capacity) {
33
+ // FIFO eviction: drop the oldest entry (first in insertion order).
34
+ for (const oldest of this.#map.keys()) {
35
+ this.#map.delete(oldest);
36
+ break;
37
+ }
38
+ }
39
+ return true;
40
+ }
41
+
42
+ clear(): void {
43
+ this.#map.clear();
44
+ }
45
+
46
+ get size(): number {
47
+ return this.#map.size;
48
+ }
49
+ }
@@ -0,0 +1,497 @@
1
+ // Stealth-payment detector — orchestrates view-tag prefilter, ECDH recovery,
2
+ // finality gating, dedup, and reconciliation backfill.
3
+ //
4
+ // Privacy contract (binding — enforced by tests):
5
+ // - No console.* anywhere in this file.
6
+ // - No error message embeds key material, ephemeral pubkey bytes, or stealth
7
+ // private key. Short structured tags only ("verify failed", "backend
8
+ // unreachable").
9
+ // - No JSON.stringify of keys, raw announcements, or RawAnnouncement.metadata.
10
+ // - The recovered stealth private key only surfaces via the explicit return
11
+ // value DetectedPayment.stealthPrivateKey — never logged.
12
+ // - Backpressure note: the live watch() queue is unbounded by design for v1.
13
+ // A consumer that ignores yields will see memory grow; document if surfaces.
14
+
15
+ import { bytesToHex, type Hex } from 'viem';
16
+ import { privateKeyToAccount } from 'viem/accounts';
17
+ import {
18
+ checkViewTag,
19
+ computeStealthKey,
20
+ InvalidKeyError,
21
+ StealthAddressError,
22
+ } from '@shroud-fi/core';
23
+ import { ERC5564_ANNOUNCER } from '@shroud-fi/transport';
24
+ import {
25
+ DEFAULT_DEDUP_CAPACITY,
26
+ DEFAULT_FINALITY,
27
+ DEFAULT_GETLOGS_CHUNK_SIZE,
28
+ DEFAULT_RECONCILE_INTERVAL_MS,
29
+ DEFAULT_SCHEME_ID,
30
+ MIN_METADATA_LENGTH,
31
+ } from './constants.js';
32
+ import { Cursor } from './cursor.js';
33
+ import { LRUDeduper } from './dedup.js';
34
+ import {
35
+ BackendUnreachableError,
36
+ MissingPublicClientError,
37
+ ScanningError,
38
+ } from './errors.js';
39
+ import { ViemScannerBackend } from './viem-backend.js';
40
+ import type {
41
+ DetectedPayment,
42
+ RawAnnouncement,
43
+ Scanner,
44
+ ScannerBackend,
45
+ ScannerConfig,
46
+ UnsubscribeFn,
47
+ } from './types.js';
48
+
49
+ /**
50
+ * Build a Scanner instance.
51
+ *
52
+ * Construction validates config + resolves defaults. The Scanner is inert until
53
+ * the caller starts iterating watch() or scanRange(). close() is idempotent.
54
+ */
55
+ export function createScanner(config: ScannerConfig): Scanner {
56
+ if (config.transport === undefined || config.transport.publicClient === undefined) {
57
+ throw new MissingPublicClientError();
58
+ }
59
+ if (config.startBlock < 0n) {
60
+ throw new ScanningError('startBlock must be non-negative');
61
+ }
62
+
63
+ const schemeId = config.schemeId ?? DEFAULT_SCHEME_ID;
64
+ const finality = config.finality ?? DEFAULT_FINALITY;
65
+ const reconcileIntervalMs =
66
+ config.reconcileIntervalMs ?? DEFAULT_RECONCILE_INTERVAL_MS;
67
+ const dedupCapacity = config.dedupCapacity ?? DEFAULT_DEDUP_CAPACITY;
68
+ const getLogsChunkSize =
69
+ config.getLogsChunkSize ?? DEFAULT_GETLOGS_CHUNK_SIZE;
70
+ const contractAddress = config.contractAddress ?? ERC5564_ANNOUNCER;
71
+
72
+ const backend: ScannerBackend =
73
+ config.backend ??
74
+ new ViemScannerBackend(config.transport.publicClient, {
75
+ getLogsChunkSize,
76
+ });
77
+
78
+ const dedup = new LRUDeduper(dedupCapacity);
79
+ const cursor = new Cursor(config.startBlock);
80
+ const pendingFinality = new Map<string, RawAnnouncement>();
81
+
82
+ let reconcileTimer: ReturnType<typeof setInterval> | undefined;
83
+ let unsubscribe: UnsubscribeFn | undefined;
84
+ let closed = false;
85
+ // Reject concurrent watch() calls. The closure singletons above can hold at
86
+ // most one live subscription + timer; a second watch() would silently leak
87
+ // the first iterator's resources. Caller must close() before re-watching.
88
+ let watchActive = false;
89
+ // Cap pending events so a stuck backend can't grow memory unboundedly.
90
+ const pendingCap = Math.max(dedupCapacity, 100);
91
+
92
+ // ---- Verification pipeline ----------------------------------------------
93
+
94
+ /**
95
+ * Run view-tag prefilter + ECDH derivation + address-match check against
96
+ * a raw announcement. Returns recovered stealth private key bytes on match,
97
+ * or null when the announcement is not for this recipient (or is malformed).
98
+ * Never throws — returning null is the privacy-preserving signal.
99
+ */
100
+ function verifyAnnouncement(raw: RawAnnouncement): Uint8Array | null {
101
+ // Defence: metadata too short to even contain a view tag.
102
+ if (raw.metadata.length < MIN_METADATA_LENGTH) return null;
103
+
104
+ const viewTag = raw.metadata[0]!;
105
+
106
+ // Fast-path filter — drops ~99.6% of non-matching announcements.
107
+ let viewTagMatch: boolean;
108
+ try {
109
+ viewTagMatch = checkViewTag(
110
+ config.scanningKey,
111
+ raw.ephemeralPubKey,
112
+ viewTag,
113
+ );
114
+ } catch {
115
+ // Garbage ephemeral pubkey — drop silently.
116
+ return null;
117
+ }
118
+ if (!viewTagMatch) return null;
119
+
120
+ // ECDH derivation of stealth private key.
121
+ let stealthPriv: Uint8Array;
122
+ try {
123
+ stealthPriv = computeStealthKey(
124
+ config.scanningKey,
125
+ config.spendingKey,
126
+ raw.ephemeralPubKey,
127
+ );
128
+ } catch (err) {
129
+ if (err instanceof InvalidKeyError || err instanceof StealthAddressError) {
130
+ return null;
131
+ }
132
+ // Re-wrap unknown failure with a privacy-safe message.
133
+ throw new ScanningError('verify failed');
134
+ }
135
+
136
+ // Address-match check — guards against view-tag false positives and any
137
+ // log unrelated to this recipient.
138
+ let derivedAddress: string;
139
+ try {
140
+ const stealthPrivHex = bytesToHex(stealthPriv) as Hex;
141
+ derivedAddress = privateKeyToAccount(stealthPrivHex).address;
142
+ } catch {
143
+ return null;
144
+ }
145
+
146
+ if (derivedAddress.toLowerCase() !== raw.stealthAddress.toLowerCase()) {
147
+ return null;
148
+ }
149
+
150
+ return stealthPriv;
151
+ }
152
+
153
+ function dedupKey(raw: RawAnnouncement): string {
154
+ return `${raw.txHash}:${raw.logIndex}`;
155
+ }
156
+
157
+ function toDetected(
158
+ raw: RawAnnouncement,
159
+ stealthPriv: Uint8Array,
160
+ payloadFinality: 'safe' | 'finalized',
161
+ ): DetectedPayment {
162
+ return {
163
+ stealthAddress: raw.stealthAddress,
164
+ ephemeralPubKey: bytesToHex(raw.ephemeralPubKey),
165
+ stealthPrivateKey: bytesToHex(stealthPriv),
166
+ blockNumber: raw.blockNumber,
167
+ blockHash: raw.blockHash,
168
+ txHash: raw.txHash,
169
+ logIndex: raw.logIndex,
170
+ finality: payloadFinality,
171
+ };
172
+ }
173
+
174
+ // The configured `finality` is 'unsafe' | 'safe' | 'finalized', but the
175
+ // emitted DetectedPayment.finality is only 'safe' | 'finalized'. Treat
176
+ // 'unsafe' as 'safe' on the emit path (caller asked for no gating; we still
177
+ // tag what we yield as the safer label).
178
+ const emitFinality: 'safe' | 'finalized' =
179
+ finality === 'finalized' ? 'finalized' : 'safe';
180
+
181
+ // ---- watch() ------------------------------------------------------------
182
+
183
+ function watch(signal?: AbortSignal): AsyncIterable<DetectedPayment> {
184
+ return {
185
+ [Symbol.asyncIterator]: () => {
186
+ if (watchActive) {
187
+ // Surface as a rejecting first next() so callers learn via the
188
+ // async-iterator protocol instead of a sync throw from `for await`.
189
+ return {
190
+ next: () =>
191
+ Promise.reject(
192
+ new ScanningError('watch already active — close() first'),
193
+ ),
194
+ return: () =>
195
+ Promise.resolve({ value: undefined, done: true } as IteratorResult<
196
+ DetectedPayment
197
+ >),
198
+ };
199
+ }
200
+ watchActive = true;
201
+ const queue: DetectedPayment[] = [];
202
+ let waiter:
203
+ | { resolve: (v: IteratorResult<DetectedPayment>) => void }
204
+ | undefined;
205
+ let finished = false;
206
+ let abortListener: (() => void) | undefined;
207
+
208
+ function finish(): void {
209
+ if (finished) return;
210
+ finished = true;
211
+ if (waiter !== undefined) {
212
+ const w = waiter;
213
+ waiter = undefined;
214
+ w.resolve({ value: undefined, done: true });
215
+ }
216
+ }
217
+
218
+ function emit(payment: DetectedPayment): void {
219
+ if (finished) return;
220
+ if (waiter !== undefined) {
221
+ const w = waiter;
222
+ waiter = undefined;
223
+ w.resolve({ value: payment, done: false });
224
+ return;
225
+ }
226
+ queue.push(payment);
227
+ }
228
+
229
+ async function tryEmitOrDefer(raw: RawAnnouncement): Promise<void> {
230
+ const key = dedupKey(raw);
231
+ // Two-tier dedup: emitted (LRU) + in-flight pending. Skip duplicates
232
+ // arriving while the first delivery is still mid-pipeline.
233
+ if (dedup.has(key) || pendingFinality.has(key)) return;
234
+
235
+ const stealthPriv = verifyAnnouncement(raw);
236
+ if (stealthPriv === null) return;
237
+
238
+ let tip: bigint;
239
+ try {
240
+ tip = await backend.getLatestBlock(finality);
241
+ } catch {
242
+ // Backend transient failure — keep the candidate around for the
243
+ // next reconciliation tick. Do not throw into the consumer stream.
244
+ pendingFinality.set(key, raw);
245
+ return;
246
+ }
247
+
248
+ if (raw.blockNumber > tip) {
249
+ // Cap pending map to prevent unbounded growth on stuck finality.
250
+ if (pendingFinality.size >= pendingCap) {
251
+ const oldest = pendingFinality.keys().next().value;
252
+ if (oldest !== undefined) pendingFinality.delete(oldest);
253
+ }
254
+ pendingFinality.set(key, raw);
255
+ return;
256
+ }
257
+
258
+ // Race guard: if a concurrent path already added this key (e.g. the
259
+ // same announcement was delivered twice and both passed the initial
260
+ // has() check before either reached this point), drop the dup.
261
+ if (!dedup.add(key)) return;
262
+ pendingFinality.delete(key);
263
+ emit(toDetected(raw, stealthPriv, emitFinality));
264
+ }
265
+
266
+ // Already-aborted: bail before touching the backend.
267
+ if (signal?.aborted === true) {
268
+ finished = true;
269
+ return {
270
+ next: () =>
271
+ Promise.resolve({ value: undefined, done: true } as IteratorResult<
272
+ DetectedPayment
273
+ >),
274
+ return: () =>
275
+ Promise.resolve({ value: undefined, done: true } as IteratorResult<
276
+ DetectedPayment
277
+ >),
278
+ };
279
+ }
280
+
281
+ // Subscribe to live announcements.
282
+ try {
283
+ unsubscribe = backend.watchAnnouncements(
284
+ (raw) => {
285
+ if (finished || closed) return;
286
+ // Fire-and-forget per backend contract.
287
+ void tryEmitOrDefer(raw).catch(() => {
288
+ // Swallow — never let pipeline errors crash the watcher.
289
+ });
290
+ },
291
+ { contractAddress, schemeId },
292
+ );
293
+ } catch {
294
+ // If subscription fails outright, surface a privacy-safe error on
295
+ // the first next() and shut down.
296
+ finished = true;
297
+ return {
298
+ next: () =>
299
+ Promise.reject(
300
+ new BackendUnreachableError('watch subscribe failed'),
301
+ ),
302
+ return: () =>
303
+ Promise.resolve({ value: undefined, done: true } as IteratorResult<
304
+ DetectedPayment
305
+ >),
306
+ };
307
+ }
308
+
309
+ // Reconciliation tick — backfill missed blocks + re-check pending.
310
+ const timer = setInterval(() => {
311
+ void reconcileOnce().catch(() => {
312
+ // Never throw out of the interval.
313
+ });
314
+ }, reconcileIntervalMs);
315
+ // Don't keep the Node event loop alive solely on this timer.
316
+ if (typeof (timer as { unref?: () => void }).unref === 'function') {
317
+ (timer as { unref: () => void }).unref();
318
+ }
319
+ reconcileTimer = timer;
320
+
321
+ async function reconcileOnce(): Promise<void> {
322
+ if (finished || closed) return;
323
+
324
+ // Phase 1: backfill any blocks between cursor and current tip.
325
+ let tip: bigint;
326
+ try {
327
+ tip = await backend.getLatestBlock(finality);
328
+ } catch {
329
+ return; // Skip this tick — try again later.
330
+ }
331
+
332
+ const from = cursor.last + 1n;
333
+ if (tip >= from) {
334
+ let logs: readonly RawAnnouncement[] = [];
335
+ try {
336
+ logs = await backend.getAnnouncementsInRange(from, tip, {
337
+ contractAddress,
338
+ schemeId,
339
+ });
340
+ } catch {
341
+ // Leave cursor alone — try again next tick.
342
+ return;
343
+ }
344
+
345
+ for (const raw of logs) {
346
+ if (finished || closed) return;
347
+ const key = dedupKey(raw);
348
+ if (dedup.has(key)) continue;
349
+ const stealthPriv = verifyAnnouncement(raw);
350
+ if (stealthPriv === null) continue;
351
+ if (!dedup.add(key)) continue;
352
+ pendingFinality.delete(key);
353
+ emit(toDetected(raw, stealthPriv, emitFinality));
354
+ }
355
+ // Re-check after the loop: close() may have run mid-iteration.
356
+ if (finished || closed) return;
357
+ cursor.advanceTo(tip);
358
+ }
359
+
360
+ // Phase 2: re-check pending announcements that had not finalized.
361
+ if (pendingFinality.size > 0) {
362
+ for (const [key, raw] of Array.from(pendingFinality.entries())) {
363
+ if (finished || closed) return;
364
+ if (raw.blockNumber <= tip) {
365
+ pendingFinality.delete(key);
366
+ if (dedup.has(key)) continue;
367
+ const stealthPriv = verifyAnnouncement(raw);
368
+ if (stealthPriv === null) continue;
369
+ if (!dedup.add(key)) continue;
370
+ emit(toDetected(raw, stealthPriv, emitFinality));
371
+ }
372
+ }
373
+ }
374
+ }
375
+
376
+ // Wire abort. Listener handle retained so we can remove it on return().
377
+ if (signal !== undefined) {
378
+ abortListener = () => {
379
+ finish();
380
+ close();
381
+ };
382
+ signal.addEventListener('abort', abortListener, { once: true });
383
+ }
384
+
385
+ function teardown(): void {
386
+ if (signal !== undefined && abortListener !== undefined) {
387
+ try {
388
+ signal.removeEventListener('abort', abortListener);
389
+ } catch {
390
+ // Some environments swallow this; safe to ignore.
391
+ }
392
+ abortListener = undefined;
393
+ }
394
+ watchActive = false;
395
+ }
396
+
397
+ return {
398
+ next(): Promise<IteratorResult<DetectedPayment>> {
399
+ // queue.length and waiter assignment are both synchronous within
400
+ // this tick — no interleaving window exists between the empty
401
+ // check and the executor body. emit() will see `waiter` set by
402
+ // the time it can fire (next microtask).
403
+ if (queue.length > 0) {
404
+ const value = queue.shift()!;
405
+ return Promise.resolve({ value, done: false });
406
+ }
407
+ if (finished) {
408
+ return Promise.resolve({ value: undefined, done: true });
409
+ }
410
+ return new Promise<IteratorResult<DetectedPayment>>((resolve) => {
411
+ waiter = { resolve };
412
+ });
413
+ },
414
+ return(): Promise<IteratorResult<DetectedPayment>> {
415
+ finish();
416
+ teardown();
417
+ close();
418
+ return Promise.resolve({ value: undefined, done: true });
419
+ },
420
+ throw(): Promise<IteratorResult<DetectedPayment>> {
421
+ finish();
422
+ teardown();
423
+ close();
424
+ return Promise.resolve({ value: undefined, done: true });
425
+ },
426
+ };
427
+ },
428
+ };
429
+ }
430
+
431
+ // ---- scanRange() --------------------------------------------------------
432
+
433
+ function scanRange(
434
+ fromBlock: bigint,
435
+ toBlock: bigint,
436
+ signal?: AbortSignal,
437
+ ): AsyncIterable<DetectedPayment> {
438
+ return {
439
+ [Symbol.asyncIterator]: async function* () {
440
+ if (signal !== undefined && signal.aborted) return;
441
+ if (toBlock < fromBlock) return;
442
+
443
+ let logs: readonly RawAnnouncement[];
444
+ try {
445
+ logs = await backend.getAnnouncementsInRange(fromBlock, toBlock, {
446
+ contractAddress,
447
+ schemeId,
448
+ });
449
+ } catch {
450
+ throw new BackendUnreachableError('getLogs failed');
451
+ }
452
+
453
+ // Sort deterministically by (blockNumber, logIndex).
454
+ const sorted = [...logs].sort((a, b) => {
455
+ if (a.blockNumber === b.blockNumber) {
456
+ return a.logIndex - b.logIndex;
457
+ }
458
+ return a.blockNumber < b.blockNumber ? -1 : 1;
459
+ });
460
+
461
+ for (const raw of sorted) {
462
+ if (signal !== undefined && signal.aborted) return;
463
+ const key = dedupKey(raw);
464
+ if (dedup.has(key)) continue;
465
+ const stealthPriv = verifyAnnouncement(raw);
466
+ if (stealthPriv === null) continue;
467
+ if (!dedup.add(key)) continue;
468
+ // Caller invoked a historical range — finality is achieved.
469
+ yield toDetected(raw, stealthPriv, 'safe');
470
+ }
471
+ },
472
+ };
473
+ }
474
+
475
+ // ---- close() ------------------------------------------------------------
476
+
477
+ function close(): void {
478
+ if (closed) return;
479
+ closed = true;
480
+ if (unsubscribe !== undefined) {
481
+ try {
482
+ unsubscribe();
483
+ } catch {
484
+ // Swallow — close must not throw.
485
+ }
486
+ unsubscribe = undefined;
487
+ }
488
+ if (reconcileTimer !== undefined) {
489
+ clearInterval(reconcileTimer);
490
+ reconcileTimer = undefined;
491
+ }
492
+ // Reset re-entry guard so close() + new watch() works.
493
+ watchActive = false;
494
+ }
495
+
496
+ return { watch, scanRange, close };
497
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,45 @@
1
+ // Privacy-safe error classes for @shroud-fi/scanning.
2
+ //
3
+ // Invariants enforced by tests in test/privacy-invariants.test.ts:
4
+ // - Error messages MUST NOT embed any 32-byte hex (key material, txHash is
5
+ // allowed but should not be the whole error payload).
6
+ // - Error messages MUST NOT embed ephemeral pubkey bytes.
7
+ // - Error messages MUST NOT embed transfer amounts.
8
+ // - Constructors take only short, structured tags (chainId, block, logIndex,
9
+ // short reason). The thrown error gets its full stack from V8; we don't
10
+ // attach extra fields.
11
+
12
+ export class ScanningError extends Error {
13
+ override readonly name: string = 'ScanningError';
14
+ constructor(message: string) {
15
+ super(message);
16
+ }
17
+ }
18
+
19
+ export class BackendUnreachableError extends ScanningError {
20
+ override readonly name: string = 'BackendUnreachableError';
21
+ constructor(reason: string) {
22
+ super(`Scanner backend unreachable: ${reason}`);
23
+ }
24
+ }
25
+
26
+ export class MalformedAnnouncementError extends ScanningError {
27
+ override readonly name: string = 'MalformedAnnouncementError';
28
+ constructor(reason: string) {
29
+ super(`Malformed announcement skipped: ${reason}`);
30
+ }
31
+ }
32
+
33
+ export class MissingPublicClientError extends ScanningError {
34
+ override readonly name: string = 'MissingPublicClientError';
35
+ constructor() {
36
+ super('Scanner transport has no publicClient');
37
+ }
38
+ }
39
+
40
+ export class InvalidFinalityLevelError extends ScanningError {
41
+ override readonly name: string = 'InvalidFinalityLevelError';
42
+ constructor(level: string) {
43
+ super(`Invalid finality level: ${level}`);
44
+ }
45
+ }
package/src/index.ts ADDED
@@ -0,0 +1,48 @@
1
+ // Public surface — @shroud-fi/scanning
2
+ // Phase 3: detection-only stealth payment scanner. Caller drives sweep.
3
+ // Sweep queue / log-normal delay / destination rotation are deferred phases.
4
+
5
+ // Factory
6
+ export { createScanner } from './detector.js';
7
+
8
+ // Backend interface + viem implementation
9
+ export { ViemScannerBackend } from './viem-backend.js';
10
+ export type {
11
+ ScannerBackend,
12
+ RawAnnouncement,
13
+ AnnouncementHandler,
14
+ UnsubscribeFn,
15
+ BackendWatchOptions,
16
+ FinalityLevel,
17
+ } from './backend.js';
18
+
19
+ // Public types
20
+ export type {
21
+ Scanner,
22
+ ScannerConfig,
23
+ ScannerTransportLike,
24
+ DetectedPayment,
25
+ } from './types.js';
26
+
27
+ // Errors
28
+ export {
29
+ ScanningError,
30
+ BackendUnreachableError,
31
+ MalformedAnnouncementError,
32
+ MissingPublicClientError,
33
+ InvalidFinalityLevelError,
34
+ } from './errors.js';
35
+
36
+ // Utilities (advanced consumers)
37
+ export { LRUDeduper } from './dedup.js';
38
+ export { Cursor } from './cursor.js';
39
+
40
+ // Defaults
41
+ export {
42
+ DEFAULT_SCHEME_ID,
43
+ DEFAULT_RECONCILE_INTERVAL_MS,
44
+ DEFAULT_DEDUP_CAPACITY,
45
+ DEFAULT_GETLOGS_CHUNK_SIZE,
46
+ DEFAULT_FINALITY,
47
+ MIN_METADATA_LENGTH,
48
+ } from './constants.js';
package/src/types.ts ADDED
@@ -0,0 +1,127 @@
1
+ // Public surface types for @shroud-fi/scanning (Phase 3 — detection only).
2
+ //
3
+ // Privacy contract:
4
+ // - ScannerConfig accepts branded ScanningKey + SpendingKey directly. These
5
+ // remain in-process; the scanner never logs them, serializes them, or
6
+ // surfaces them in errors. Spending key is required because ECDH derivation
7
+ // of the per-payment stealth private key needs it; see compute-key.ts.
8
+ // - DetectedPayment.stealthPrivateKey is the recovered private key for the
9
+ // one-time stealth address. It NEVER touches a relayer or logger. Caller
10
+ // drives the sweep (via @shroud-fi/payments sweepETH/sweepERC20) and is
11
+ // responsible for not leaking it.
12
+ // - Detection is idempotent. Cursor is in-memory only and resets to
13
+ // `startBlock` on restart. The dedup LRU prevents double-yield within a
14
+ // single Scanner lifetime.
15
+
16
+ import type { Address, Hex, PublicClient, Chain, Transport } from 'viem';
17
+ import type { ScanningKey, SpendingKey } from '@shroud-fi/core';
18
+
19
+ /** Finality threshold an event must satisfy before it is yielded. */
20
+ export type FinalityLevel = 'unsafe' | 'safe' | 'finalized';
21
+
22
+ /** Optional handle for the caller-supplied viem PublicClient. */
23
+ export interface ScannerTransportLike {
24
+ readonly publicClient: PublicClient<Transport, Chain>;
25
+ }
26
+
27
+ /** Stealth address detection configuration. */
28
+ export interface ScannerConfig {
29
+ /** A ShroudFi transport (or any object exposing a viem PublicClient). */
30
+ readonly transport: ScannerTransportLike;
31
+ /** Recipient's scanning private key (branded). */
32
+ readonly scanningKey: ScanningKey;
33
+ /** Recipient's spending private key (branded). Required for ECDH derivation. */
34
+ readonly spendingKey: SpendingKey;
35
+ /** Scheme ID filter. Defaults to 1 (secp256k1 + view tag). */
36
+ readonly schemeId?: bigint;
37
+ /**
38
+ * ERC-5564 Announcer contract to watch. Defaults to the canonical address
39
+ * exported from @shroud-fi/transport (`ERC5564_ANNOUNCER`).
40
+ */
41
+ readonly contractAddress?: Address;
42
+ /** First block scanned. Detection state resets to this on restart. */
43
+ readonly startBlock: bigint;
44
+ /** Finality threshold for yielding. Defaults to `'safe'`. */
45
+ readonly finality?: FinalityLevel;
46
+ /** Reconciliation tick interval in ms. Defaults to 300_000 (5 minutes). */
47
+ readonly reconcileIntervalMs?: number;
48
+ /** LRU dedup capacity. Defaults to 10_000. */
49
+ readonly dedupCapacity?: number;
50
+ /** Max blocks per getLogs call (auto-chunking). Defaults to 10_000n. */
51
+ readonly getLogsChunkSize?: bigint;
52
+ /** Optional override of the backend implementation (for tests / Envio). */
53
+ readonly backend?: ScannerBackend;
54
+ }
55
+
56
+ /** Raw on-chain announcement before view-tag filtering or ECDH verification. */
57
+ export interface RawAnnouncement {
58
+ readonly schemeId: bigint;
59
+ readonly stealthAddress: Address;
60
+ readonly caller: Address;
61
+ readonly ephemeralPubKey: Uint8Array;
62
+ readonly metadata: Uint8Array;
63
+ readonly blockNumber: bigint;
64
+ readonly blockHash: Hex;
65
+ readonly txHash: Hex;
66
+ readonly logIndex: number;
67
+ }
68
+
69
+ /** Successfully detected stealth payment destined for this recipient. */
70
+ export interface DetectedPayment {
71
+ readonly stealthAddress: Address;
72
+ readonly ephemeralPubKey: Hex;
73
+ readonly stealthPrivateKey: Hex;
74
+ readonly blockNumber: bigint;
75
+ readonly blockHash: Hex;
76
+ readonly txHash: Hex;
77
+ readonly logIndex: number;
78
+ readonly finality: 'safe' | 'finalized';
79
+ }
80
+
81
+ /** Callback signature passed to backend.watchAnnouncements. */
82
+ export type AnnouncementHandler = (
83
+ announcement: RawAnnouncement,
84
+ ) => void | Promise<void>;
85
+
86
+ /** Unsubscribe handle returned by backend.watchAnnouncements. */
87
+ export type UnsubscribeFn = () => void;
88
+
89
+ /** Backend options shared between watch + getAnnouncementsInRange. */
90
+ export interface BackendWatchOptions {
91
+ readonly contractAddress: Address;
92
+ readonly schemeId?: bigint;
93
+ }
94
+
95
+ /**
96
+ * Indexer-agnostic announcement source. ViemScannerBackend is the only impl
97
+ * shipping in P3. A future EnvioScannerBackend lands behind this interface.
98
+ */
99
+ export interface ScannerBackend {
100
+ /** Subscribe to live announcements. Returns unsubscribe handle. */
101
+ watchAnnouncements(
102
+ handler: AnnouncementHandler,
103
+ options: BackendWatchOptions,
104
+ ): UnsubscribeFn;
105
+ /** Historical range query (auto-chunked). */
106
+ getAnnouncementsInRange(
107
+ fromBlock: bigint,
108
+ toBlock: bigint,
109
+ options: BackendWatchOptions,
110
+ ): Promise<readonly RawAnnouncement[]>;
111
+ /** Latest block number for a finality level. */
112
+ getLatestBlock(level: FinalityLevel): Promise<bigint>;
113
+ }
114
+
115
+ /** Scanner runtime returned by createScanner. */
116
+ export interface Scanner {
117
+ /** Live async iterator over detected payments. Cancellable via AbortSignal. */
118
+ watch(signal?: AbortSignal): AsyncIterable<DetectedPayment>;
119
+ /** Backfill a historical block range, yielding any detected payments. */
120
+ scanRange(
121
+ fromBlock: bigint,
122
+ toBlock: bigint,
123
+ signal?: AbortSignal,
124
+ ): AsyncIterable<DetectedPayment>;
125
+ /** Stop watching, clear timers, release the backend subscription. */
126
+ close(): void;
127
+ }
@@ -0,0 +1,216 @@
1
+ // viem-backed ScannerBackend. The only backend that ships in Phase 3.
2
+ //
3
+ // Privacy contract (binding):
4
+ // - No console.* anywhere in this file.
5
+ // - Error messages NEVER embed hex strings, key material, amounts, or raw RPC
6
+ // payloads. Short reason tags only ("getLogs failed", "watch failed",
7
+ // "getBlockNumber failed").
8
+ // - No JSON.stringify of args or logs.
9
+ // - Pending / malformed logs (missing blockNumber, blockHash, transactionHash,
10
+ // or logIndex) are silently skipped — they aren't actionable for detection
11
+ // and we don't want to leak their existence via thrown errors.
12
+
13
+ import { getAbiItem, hexToBytes, type Hex, type PublicClient } from 'viem';
14
+ import { ERC5564AnnouncerAbi } from '@shroud-fi/transport';
15
+ import { DEFAULT_GETLOGS_CHUNK_SIZE } from './constants.js';
16
+ import {
17
+ BackendUnreachableError,
18
+ InvalidFinalityLevelError,
19
+ } from './errors.js';
20
+ import type {
21
+ AnnouncementHandler,
22
+ BackendWatchOptions,
23
+ FinalityLevel,
24
+ RawAnnouncement,
25
+ ScannerBackend,
26
+ UnsubscribeFn,
27
+ } from './types.js';
28
+
29
+ const ANNOUNCEMENT_EVENT = getAbiItem({
30
+ abi: ERC5564AnnouncerAbi,
31
+ name: 'Announcement',
32
+ });
33
+
34
+ interface ViemBackendOptions {
35
+ readonly getLogsChunkSize?: bigint;
36
+ }
37
+
38
+ interface AnnouncementLogArgs {
39
+ readonly schemeId?: bigint;
40
+ readonly stealthAddress?: `0x${string}`;
41
+ readonly caller?: `0x${string}`;
42
+ readonly ephemeralPubKey?: Hex;
43
+ readonly metadata?: Hex;
44
+ }
45
+
46
+ interface AnnouncementLog {
47
+ readonly args?: AnnouncementLogArgs;
48
+ readonly blockNumber?: bigint | null;
49
+ readonly blockHash?: Hex | null;
50
+ readonly transactionHash?: Hex | null;
51
+ readonly logIndex?: number | null;
52
+ }
53
+
54
+ export class ViemScannerBackend implements ScannerBackend {
55
+ readonly #publicClient: PublicClient;
56
+ readonly #chunkSize: bigint;
57
+
58
+ constructor(publicClient: PublicClient, options?: ViemBackendOptions) {
59
+ this.#publicClient = publicClient;
60
+ this.#chunkSize = options?.getLogsChunkSize ?? DEFAULT_GETLOGS_CHUNK_SIZE;
61
+ }
62
+
63
+ watchAnnouncements(
64
+ handler: AnnouncementHandler,
65
+ options: BackendWatchOptions,
66
+ ): UnsubscribeFn {
67
+ const watchArgs = {
68
+ address: options.contractAddress,
69
+ abi: ERC5564AnnouncerAbi,
70
+ eventName: 'Announcement' as const,
71
+ onLogs: (logs: readonly unknown[]) => {
72
+ for (const log of logs) {
73
+ const decoded = decodeAnnouncementLog(log as AnnouncementLog);
74
+ if (decoded === null) continue;
75
+ // Fire-and-forget. Handler may return a promise; we intentionally do
76
+ // not await — backend semantics are "deliver, don't gate".
77
+ void handler(decoded);
78
+ }
79
+ },
80
+ onError: (_err: unknown) => {
81
+ // Privacy: do not surface the underlying RPC error payload. Tag only.
82
+ throw new BackendUnreachableError('watch failed');
83
+ },
84
+ };
85
+
86
+ const unsubscribe =
87
+ options.schemeId !== undefined
88
+ ? this.#publicClient.watchContractEvent({
89
+ ...watchArgs,
90
+ args: { schemeId: options.schemeId },
91
+ })
92
+ : this.#publicClient.watchContractEvent(watchArgs);
93
+
94
+ return unsubscribe;
95
+ }
96
+
97
+ async getAnnouncementsInRange(
98
+ fromBlock: bigint,
99
+ toBlock: bigint,
100
+ options: BackendWatchOptions,
101
+ ): Promise<readonly RawAnnouncement[]> {
102
+ if (toBlock < fromBlock) return [];
103
+
104
+ const collected: RawAnnouncement[] = [];
105
+ let cursor = fromBlock;
106
+ while (cursor <= toBlock) {
107
+ const chunkEnd =
108
+ cursor + this.#chunkSize - 1n > toBlock
109
+ ? toBlock
110
+ : cursor + this.#chunkSize - 1n;
111
+
112
+ let logs: readonly unknown[];
113
+ try {
114
+ const getLogsArgs =
115
+ options.schemeId !== undefined
116
+ ? {
117
+ address: options.contractAddress,
118
+ event: ANNOUNCEMENT_EVENT,
119
+ args: { schemeId: options.schemeId },
120
+ fromBlock: cursor,
121
+ toBlock: chunkEnd,
122
+ }
123
+ : {
124
+ address: options.contractAddress,
125
+ event: ANNOUNCEMENT_EVENT,
126
+ fromBlock: cursor,
127
+ toBlock: chunkEnd,
128
+ };
129
+ logs = await this.#publicClient.getLogs(getLogsArgs as never);
130
+ } catch {
131
+ // Privacy: short tag only. No original error payload.
132
+ throw new BackendUnreachableError('getLogs failed');
133
+ }
134
+
135
+ for (const log of logs) {
136
+ const decoded = decodeAnnouncementLog(log as AnnouncementLog);
137
+ if (decoded === null) continue;
138
+ collected.push(decoded);
139
+ }
140
+
141
+ cursor = chunkEnd + 1n;
142
+ }
143
+
144
+ return collected;
145
+ }
146
+
147
+ async getLatestBlock(level: FinalityLevel): Promise<bigint> {
148
+ // viem.getBlockNumber() always returns the *latest* head; for 'safe' and
149
+ // 'finalized' we must go through getBlock({ blockTag }) and extract .number.
150
+ if (level === 'unsafe') {
151
+ try {
152
+ return await this.#publicClient.getBlockNumber();
153
+ } catch {
154
+ throw new BackendUnreachableError('getBlockNumber failed');
155
+ }
156
+ }
157
+
158
+ if (level !== 'safe' && level !== 'finalized') {
159
+ throw new InvalidFinalityLevelError(String(level));
160
+ }
161
+
162
+ try {
163
+ const block = await this.#publicClient.getBlock({ blockTag: level });
164
+ const num = block.number;
165
+ if (num === null) {
166
+ throw new BackendUnreachableError('getBlockNumber failed');
167
+ }
168
+ return num;
169
+ } catch (err) {
170
+ if (err instanceof BackendUnreachableError) throw err;
171
+ throw new BackendUnreachableError('getBlockNumber failed');
172
+ }
173
+ }
174
+ }
175
+
176
+ /**
177
+ * Convert a viem decoded log into a RawAnnouncement. Returns null for any
178
+ * pending / malformed log (missing block fields, missing args). Never throws.
179
+ */
180
+ function decodeAnnouncementLog(log: AnnouncementLog): RawAnnouncement | null {
181
+ const args = log.args;
182
+ if (args === undefined) return null;
183
+ if (
184
+ args.schemeId === undefined ||
185
+ args.stealthAddress === undefined ||
186
+ args.caller === undefined ||
187
+ args.ephemeralPubKey === undefined ||
188
+ args.metadata === undefined
189
+ ) {
190
+ return null;
191
+ }
192
+ if (
193
+ log.blockNumber === undefined ||
194
+ log.blockNumber === null ||
195
+ log.blockHash === undefined ||
196
+ log.blockHash === null ||
197
+ log.transactionHash === undefined ||
198
+ log.transactionHash === null ||
199
+ log.logIndex === undefined ||
200
+ log.logIndex === null
201
+ ) {
202
+ return null;
203
+ }
204
+
205
+ return {
206
+ schemeId: args.schemeId,
207
+ stealthAddress: args.stealthAddress,
208
+ caller: args.caller,
209
+ ephemeralPubKey: hexToBytes(args.ephemeralPubKey),
210
+ metadata: hexToBytes(args.metadata),
211
+ blockNumber: log.blockNumber,
212
+ blockHash: log.blockHash,
213
+ txHash: log.transactionHash,
214
+ logIndex: log.logIndex,
215
+ };
216
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,9 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "outDir": "./dist/esm",
5
+ "rootDir": "./src"
6
+ },
7
+ "include": ["src/**/*.ts"],
8
+ "exclude": ["dist", "test", "node_modules"]
9
+ }