@shroud-fi/scanning 0.1.1 → 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 +7 -4
- package/src/backend.ts +12 -0
- package/src/constants.ts +21 -0
- package/src/cursor.ts +28 -0
- package/src/dedup.ts +49 -0
- package/src/detector.ts +497 -0
- package/src/errors.ts +45 -0
- package/src/index.ts +48 -0
- package/src/types.ts +127 -0
- package/src/viem-backend.ts +216 -0
- package/tsconfig.json +9 -0
- package/dist/cjs/backend.d.ts.map +0 -1
- package/dist/cjs/backend.js.map +0 -1
- package/dist/cjs/constants.d.ts.map +0 -1
- package/dist/cjs/constants.js.map +0 -1
- package/dist/cjs/cursor.d.ts.map +0 -1
- package/dist/cjs/cursor.js.map +0 -1
- package/dist/cjs/dedup.d.ts.map +0 -1
- package/dist/cjs/dedup.js.map +0 -1
- package/dist/cjs/detector.d.ts.map +0 -1
- package/dist/cjs/detector.js.map +0 -1
- package/dist/cjs/errors.d.ts.map +0 -1
- package/dist/cjs/errors.js.map +0 -1
- package/dist/cjs/index.d.ts.map +0 -1
- package/dist/cjs/index.js.map +0 -1
- package/dist/cjs/types.d.ts.map +0 -1
- package/dist/cjs/types.js.map +0 -1
- package/dist/cjs/viem-backend.d.ts.map +0 -1
- package/dist/cjs/viem-backend.js.map +0 -1
- package/dist/esm/backend.d.ts.map +0 -1
- package/dist/esm/backend.js.map +0 -1
- package/dist/esm/constants.d.ts.map +0 -1
- package/dist/esm/constants.js.map +0 -1
- package/dist/esm/cursor.d.ts.map +0 -1
- package/dist/esm/cursor.js.map +0 -1
- package/dist/esm/dedup.d.ts.map +0 -1
- package/dist/esm/dedup.js.map +0 -1
- package/dist/esm/detector.d.ts.map +0 -1
- package/dist/esm/detector.js.map +0 -1
- package/dist/esm/errors.d.ts.map +0 -1
- package/dist/esm/errors.js.map +0 -1
- package/dist/esm/index.d.ts.map +0 -1
- package/dist/esm/index.js.map +0 -1
- package/dist/esm/types.d.ts.map +0 -1
- package/dist/esm/types.js.map +0 -1
- package/dist/esm/viem-backend.d.ts.map +0 -1
- package/dist/esm/viem-backend.js.map +0 -1
- package/dist/tsconfig.cjs.tsbuildinfo +0 -1
- package/dist/tsconfig.esm.tsbuildinfo +0 -1
- package/dist/tsconfig.tsbuildinfo +0 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shroud-fi/scanning",
|
|
3
|
-
"version": "0.1.
|
|
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.
|
|
33
|
-
"@shroud-fi/transport": "0.1.
|
|
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';
|
package/src/constants.ts
ADDED
|
@@ -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
|
+
}
|
package/src/detector.ts
ADDED
|
@@ -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
|
+
}
|