@valve-tech/tx-tracker 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,823 @@
1
+ /**
2
+ * `createTxTracker` — the per-tx state machine that turns a
3
+ * `ChainSource`'s block + mempool stream into a stream of neutral
4
+ * observations per tracked hash.
5
+ *
6
+ * Per `docs/tx-tracker-spec.md` §5.2 + §6 + §11 + §12. This file is
7
+ * the load-bearing piece of `@valve-tech/tx-tracker`; everything
8
+ * else is supporting infrastructure (events, store, reorg detector,
9
+ * selectors).
10
+ *
11
+ * Design rules carried in from the spec and the contributing skill:
12
+ *
13
+ * - **Three consumption shapes, one underlying stream** (§5.3).
14
+ * `getTxStatus(hash)` reads the cached snapshot; `subscribe(hash, cb)`
15
+ * attaches a callback; `track(hash)` returns an async iterator.
16
+ * All three see consistent state because they read from one
17
+ * internal `Subscriptions<TxEvent>` per hash.
18
+ *
19
+ * - **Neutral observations only** (§2.1). The tracker emits
20
+ * `seen-in-mempool` / `seen-in-block` / `vanished-from-block` /
21
+ * `replaced-by` / `unseen-for-N-blocks` and lets the consumer
22
+ * write the policy that says "confirmed" or "stuck" in their
23
+ * UX voice.
24
+ *
25
+ * - **No silent downgrade** (§2.2). Every emitted event carries a
26
+ * `source` discriminator. When the source's `capabilities()`
27
+ * change between ticks, the tracker emits `signal-degraded` /
28
+ * `signal-recovered` per affected capability.
29
+ *
30
+ * - **No own poll cycle** (§3.1, contributing-skill rule 3). The
31
+ * tracker hangs off `source.subscribeBlocks` and
32
+ * `source.subscribeMempool`; every per-tick computation runs
33
+ * inside those callbacks.
34
+ *
35
+ * - **Browser/mobile safe** (§2.4). No Node-only deps; the
36
+ * pub/sub primitive is `chain-source`'s `Subscriptions<E>`.
37
+ */
38
+ import { Subscriptions } from '@valve-tech/chain-source';
39
+ import { buildSignalDegraded, buildSignalRecovered, buildStarted, buildStopped, buildVanishedFromBlock, buildInitialStatus, } from './events.js';
40
+ import { decideBlockObservation, decideMempoolObservation, } from './observations.js';
41
+ import { appendBlock, defaultReorgDepthBlocks, detectDivergences, } from './reorg.js';
42
+ import { compileSelector, defaultMaxBulkSubscriptions, matchAll, } from './selectors.js';
43
+ import { computeRetentionExpiry, createInMemoryStore, defaultRetentionBlocks, } from './store.js';
44
+ // -----------------------------------------------------------------
45
+ // Internals
46
+ // -----------------------------------------------------------------
47
+ const DEFAULT_UNSEEN_THRESHOLD_BLOCKS = 30;
48
+ /**
49
+ * Compare two capability snapshots; return per-key transitions
50
+ * (`degraded` / `recovered`). Degradation is "moved away from the
51
+ * higher-authority value" — `'subscription' → 'poll-only'`,
52
+ * `'available' → 'gated'`, `'available' → 'unavailable'`.
53
+ * Recovery is the reverse.
54
+ */
55
+ const diffCapabilities = (prev, next) => {
56
+ const degraded = [];
57
+ const recovered = [];
58
+ const keys = [
59
+ 'newHeads',
60
+ 'newPendingTransactions',
61
+ 'txpoolContent',
62
+ 'receiptByHash',
63
+ ];
64
+ for (const key of keys) {
65
+ const before = capabilityRank(prev[key]);
66
+ const after = capabilityRank(next[key]);
67
+ if (after < before)
68
+ degraded.push(key);
69
+ else if (after > before)
70
+ recovered.push(key);
71
+ }
72
+ return { degraded, recovered };
73
+ };
74
+ const capabilityRank = (value) => {
75
+ switch (value) {
76
+ case 'subscription':
77
+ case 'available':
78
+ return 2;
79
+ case 'poll-only':
80
+ return 1;
81
+ case 'gated':
82
+ case 'unavailable':
83
+ return 0;
84
+ }
85
+ };
86
+ /**
87
+ * Pick the `EventSource` discriminator for an event the tracker is
88
+ * about to emit, given the current source-capability snapshot for
89
+ * the relevant capability key. Block-side observations come from
90
+ * either `'subscription'` (when `newHeads` is push) or `'block-poll'`
91
+ * (when it's poll). Mempool-side observations come from
92
+ * `'subscription'` (push) or `'mempool-snapshot'` (poll).
93
+ */
94
+ const blockEventSource = (caps) => caps.newHeads === 'subscription' ? 'subscription' : 'block-poll';
95
+ const mempoolEventSource = (caps) => caps.newPendingTransactions === 'subscription'
96
+ ? 'subscription'
97
+ : 'mempool-snapshot';
98
+ // -----------------------------------------------------------------
99
+ // Factory
100
+ // -----------------------------------------------------------------
101
+ /**
102
+ * Build a configured tracker.
103
+ *
104
+ * @example
105
+ * import { createChainSource } from '@valve-tech/chain-source'
106
+ * import { createTxTracker } from '@valve-tech/tx-tracker'
107
+ *
108
+ * const source = createChainSource({ client })
109
+ * const tracker = createTxTracker({ source, chainId: 1 })
110
+ *
111
+ * source.start()
112
+ * tracker.start()
113
+ *
114
+ * for await (const event of tracker.track('0xabc...')) {
115
+ * if (event.kind === 'seen-in-block' && event.confirmations >= 3) break
116
+ * }
117
+ */
118
+ export const createTxTracker = (options) => {
119
+ const { source, chainId, store = createInMemoryStore(), lostSignalPolicy: defaultLostSignalPolicy = 'emit-uncertain', reorgDepthBlocks = defaultReorgDepthBlocks, unseenThresholdBlocks = DEFAULT_UNSEEN_THRESHOLD_BLOCKS, maxBulkSubscriptions = defaultMaxBulkSubscriptions, onError, lifecycle = 'eager', } = options;
120
+ const tracked = new Map();
121
+ const bulkSubs = new Map();
122
+ const globalSubs = new Subscriptions();
123
+ let started = false;
124
+ let unsubBlocks = null;
125
+ let unsubMempool = null;
126
+ let blockRing = [];
127
+ let latestTip = null;
128
+ let latestTipTimestamp = 0n;
129
+ let lastCaps = source.capabilities();
130
+ let nextSubId = 1;
131
+ // -------------------------------------------------------------
132
+ // Helpers
133
+ // -------------------------------------------------------------
134
+ const buildAt = () => ({
135
+ blockNumber: latestTip?.number ?? 0n,
136
+ timestamp: latestTipTimestamp,
137
+ });
138
+ /**
139
+ * Emit one event: deliver to per-hash subscribers, the global
140
+ * `subscribeAll` stream, and the store's audit log. Per-subscriber
141
+ * throws are swallowed by `Subscriptions.emit` already; store
142
+ * failures are routed through `onError` per spec Appendix A.
143
+ */
144
+ const emit = (record, event) => {
145
+ record.subs.emit(event);
146
+ globalSubs.emit(event);
147
+ if (record.hasDurableSub) {
148
+ void store.appendEvent(chainId, record.hash, event).catch((err) => {
149
+ onError?.('store.appendEvent', err);
150
+ });
151
+ void store
152
+ .put(toRecord(record))
153
+ .catch((err) => onError?.('store.put', err));
154
+ }
155
+ };
156
+ /**
157
+ * Project an internal `TrackedRecord` onto the persisted shape.
158
+ * Retention expiry is recomputed from the latest observed block
159
+ * each time so a long-lived hash that keeps moving stays in the
160
+ * store rather than getting GC'd mid-flight.
161
+ */
162
+ const toRecord = (record) => {
163
+ const lastBlock = record.status.lastObservedAtBlock ??
164
+ record.status.firstObservedAtBlock ??
165
+ latestTip?.number ??
166
+ 0n;
167
+ const firstBlock = record.status.firstObservedAtBlock ?? latestTip?.number ?? 0n;
168
+ return {
169
+ chainId,
170
+ hash: record.hash,
171
+ status: record.status,
172
+ firstSeenBlockNumber: firstBlock,
173
+ lastObservedBlockNumber: lastBlock,
174
+ retentionExpiresAtBlockNumber: computeRetentionExpiry(lastBlock, defaultRetentionBlocks),
175
+ subscriptions: record.persisted,
176
+ };
177
+ };
178
+ /**
179
+ * Get-or-create the per-hash internal record. New records are
180
+ * seeded with `buildInitialStatus` against the current capability
181
+ * snapshot.
182
+ */
183
+ const ensureRecord = (hash) => {
184
+ let record = tracked.get(hash);
185
+ if (record)
186
+ return record;
187
+ record = {
188
+ hash,
189
+ status: buildInitialStatus({
190
+ hash,
191
+ chainId,
192
+ capabilities: source.capabilities(),
193
+ }),
194
+ subs: new Subscriptions(),
195
+ identity: null,
196
+ inLastMempoolSnapshot: false,
197
+ unseenThresholdBlocks,
198
+ lostSignalPolicy: null,
199
+ hasDurableSub: false,
200
+ persisted: [],
201
+ };
202
+ tracked.set(hash, record);
203
+ return record;
204
+ };
205
+ /**
206
+ * Detach a per-hash subscription. If no subscribers remain AND the
207
+ * record carries no durable persistence, drop the record so the
208
+ * tracker's footprint matches what's actually in flight.
209
+ */
210
+ const cleanupRecord = (record) => {
211
+ if (record.subs.size() > 0)
212
+ return;
213
+ if (record.hasDurableSub)
214
+ return;
215
+ tracked.delete(record.hash);
216
+ };
217
+ // -------------------------------------------------------------
218
+ // Block path — runs on every source block emit
219
+ // -------------------------------------------------------------
220
+ const onBlock = (block) => {
221
+ let blockNumber;
222
+ try {
223
+ blockNumber = BigInt(block.number);
224
+ }
225
+ catch {
226
+ onError?.('tx-tracker.onBlock', new Error(`bad block number: ${block.number}`));
227
+ return;
228
+ }
229
+ let blockTimestamp = 0n;
230
+ try {
231
+ blockTimestamp = BigInt(block.timestamp);
232
+ }
233
+ catch {
234
+ // leave 0n if timestamp didn't decode; not fatal
235
+ }
236
+ const blockHash = block.hash ?? '';
237
+ const txs = Array.isArray(block.transactions) ? block.transactions : [];
238
+ const txHashSet = new Set();
239
+ for (const tx of txs)
240
+ if (tx.hash)
241
+ txHashSet.add(tx.hash);
242
+ // Snapshot the pre-update ring + previous tip *before* we mutate
243
+ // anything — the reorg detector needs the ring as it stood
244
+ // before the new block landed in order to spot a same-height
245
+ // hash flip. `appendBlock` overwrites a same-height entry, so
246
+ // doing it before the comparison would erase the very evidence
247
+ // the detector relies on.
248
+ const previousRing = blockRing;
249
+ const previousTipNumber = latestTip?.number ?? null;
250
+ const newTip = {
251
+ number: blockNumber,
252
+ hash: blockHash,
253
+ parentHash: block.parentHash ?? null,
254
+ transactionHashes: txHashSet,
255
+ };
256
+ latestTip = newTip;
257
+ latestTipTimestamp = blockTimestamp;
258
+ blockRing = appendBlock(blockRing, newTip, reorgDepthBlocks * 2);
259
+ // Reorg detection on the pre-update ring vs the new tip. A
260
+ // divergence at the new tip's height = same-height-different-hash
261
+ // reorg; per spec §12.3 the event source is block-poll or
262
+ // subscription, never receipt-poll.
263
+ handleReorgs(previousRing, newTip);
264
+ // Capability transitions are checked once per block emit so
265
+ // signal-degraded / signal-recovered events carry the new tip's
266
+ // coordinate. The diff is global; the events fan out to every
267
+ // tracked hash whose subscription policy permits them.
268
+ handleCapabilityChange();
269
+ const eventSource = blockEventSource(source.capabilities());
270
+ // Bulk subscriptions run BEFORE per-hash inclusion bookkeeping
271
+ // so any auto-tracked hashes created for matched txs end up in
272
+ // the `tracked` map in time to receive their own seen-in-block
273
+ // event from the loop below. Reordering here is load-bearing —
274
+ // see the test "trackFromAddress autoTrackMatched: true creates
275
+ // per-hash subscriptions."
276
+ runBulkOnBlock(txs, eventSource);
277
+ // Per-hash inclusion check + confirmations + unseen-streak
278
+ // accounting. Delegated to `decideBlockObservation` (pure) — the
279
+ // orchestrator below merges the returned patches into the
280
+ // mutable record and emits the returned events.
281
+ const envelope = buildAt();
282
+ for (const record of tracked.values()) {
283
+ const result = decideBlockObservation({
284
+ record,
285
+ blockHash,
286
+ blockNumber,
287
+ txHashSet,
288
+ txs,
289
+ chainId,
290
+ eventSource,
291
+ envelope,
292
+ previousTipNumber,
293
+ });
294
+ applyObservationResult(record, result);
295
+ for (const event of result.events)
296
+ emit(record, event);
297
+ }
298
+ };
299
+ /**
300
+ * Compare the pre-update ring against the new tip via the reorg
301
+ * detector. A divergence at the new tip's height means the ring
302
+ * had a different hash there before — classic same-height reorg.
303
+ * For each divergence, every tracked hash whose recorded
304
+ * `lastSeenInBlock` pointed at the stale block emits
305
+ * `vanished-from-block`.
306
+ *
307
+ * Only the new tip is passed as the canonical sequence in v0.6.x
308
+ * — the detector is conservative about heights with no canonical
309
+ * info (per the reorg.ts design) so this stays safe with a
310
+ * single-block window.
311
+ */
312
+ const handleReorgs = (previousRing, newTip) => {
313
+ const divergences = detectDivergences({
314
+ ring: previousRing,
315
+ canonical: [newTip],
316
+ depthBlocks: reorgDepthBlocks,
317
+ });
318
+ if (divergences.length === 0)
319
+ return;
320
+ const eventSource = blockEventSource(source.capabilities());
321
+ for (const div of divergences) {
322
+ // Walk every tracked hash whose lastSeenInBlock referenced the
323
+ // stale hash at this height. The tx-set on the divergence is
324
+ // the authoritative scoper.
325
+ for (const record of tracked.values()) {
326
+ const seen = record.status.lastSeenInBlock;
327
+ if (!seen)
328
+ continue;
329
+ if (seen.blockNumber !== div.blockNumber)
330
+ continue;
331
+ // Belt-and-braces hash check: every record at this height
332
+ // was included when the canonical block had the previous
333
+ // hash, so this check is normally redundant — but it
334
+ // future-proofs against a hypothetical future where a
335
+ // tracker observes the same height through multiple sources
336
+ // before we reconcile them. Keeps the vanished-from-block
337
+ // emit honest.
338
+ /* c8 ignore next */
339
+ if (seen.blockHash !== div.previousBlockHash)
340
+ continue;
341
+ record.status.vanishedAt = {
342
+ previousBlockHash: div.previousBlockHash,
343
+ canonicalBlockHash: div.canonicalBlockHash,
344
+ blockNumber: div.blockNumber,
345
+ };
346
+ // Reset inclusion state — the tx is no longer in the
347
+ // canonical chain at the height we recorded.
348
+ record.status.lastSeenInBlock = null;
349
+ record.status.lastObservedAtBlock = newTip.number;
350
+ emit(record, buildVanishedFromBlock({
351
+ hash: record.hash,
352
+ chainId,
353
+ source: eventSource,
354
+ at: buildAt(),
355
+ previousBlockHash: div.previousBlockHash,
356
+ canonicalBlockHash: div.canonicalBlockHash,
357
+ blockNumber: div.blockNumber,
358
+ }));
359
+ }
360
+ }
361
+ };
362
+ /**
363
+ * Compare the source's current capability snapshot to the last
364
+ * one we observed. Emit signal-degraded for newly-degraded
365
+ * capabilities, signal-recovered for newly-recovered ones. Per
366
+ * the per-subscription policy, `'silent'` callers are excluded
367
+ * from the per-record fanout.
368
+ */
369
+ const handleCapabilityChange = () => {
370
+ const next = source.capabilities();
371
+ const { degraded, recovered } = diffCapabilities(lastCaps, next);
372
+ if (degraded.length === 0 && recovered.length === 0)
373
+ return;
374
+ const fallback = blockEventSource(next);
375
+ for (const record of tracked.values()) {
376
+ const policy = record.lostSignalPolicy ?? defaultLostSignalPolicy;
377
+ if (policy === 'silent') {
378
+ record.status.capabilities = next;
379
+ continue;
380
+ }
381
+ for (const key of degraded) {
382
+ emit(record, buildSignalDegraded({
383
+ hash: record.hash,
384
+ chainId,
385
+ source: fallback,
386
+ at: buildAt(),
387
+ capabilityLost: key,
388
+ fallbackSource: fallback,
389
+ }));
390
+ }
391
+ for (const key of recovered) {
392
+ emit(record, buildSignalRecovered({
393
+ hash: record.hash,
394
+ chainId,
395
+ source: fallback,
396
+ at: buildAt(),
397
+ capabilityRestored: key,
398
+ }));
399
+ }
400
+ record.status.capabilities = next;
401
+ }
402
+ lastCaps = next;
403
+ };
404
+ /**
405
+ * Apply a pure-decision result to a mutable internal record. The
406
+ * decision functions return narrow patches (only the fields they
407
+ * decided to change); this orchestrator merges them into the
408
+ * record in one place so mutation is bounded and auditable.
409
+ */
410
+ const applyObservationResult = (record, result) => {
411
+ if (Object.keys(result.statusPatch).length > 0) {
412
+ record.status = { ...record.status, ...result.statusPatch };
413
+ }
414
+ if (result.identityPatch) {
415
+ record.identity = result.identityPatch;
416
+ }
417
+ if (result.inMempoolPatch !== null) {
418
+ record.inLastMempoolSnapshot = result.inMempoolPatch;
419
+ }
420
+ };
421
+ // -------------------------------------------------------------
422
+ // Mempool path — runs on every source mempool emit
423
+ // -------------------------------------------------------------
424
+ const onMempool = (snapshot) => {
425
+ // Build a hash-keyed index once per snapshot — O(N) per snapshot
426
+ // but the per-tracked-hash lookup downstream is O(1).
427
+ const byHash = new Map();
428
+ for (const sender of Object.keys(snapshot.pending)) {
429
+ const nonces = snapshot.pending[sender];
430
+ for (const nonceKey of Object.keys(nonces)) {
431
+ const tx = nonces[nonceKey];
432
+ if (tx?.hash)
433
+ byHash.set(tx.hash, { bucket: 'pending', tx });
434
+ }
435
+ }
436
+ for (const sender of Object.keys(snapshot.queued)) {
437
+ const nonces = snapshot.queued[sender];
438
+ for (const nonceKey of Object.keys(nonces)) {
439
+ const tx = nonces[nonceKey];
440
+ if (tx?.hash)
441
+ byHash.set(tx.hash, { bucket: 'queued', tx });
442
+ }
443
+ }
444
+ const eventSource = mempoolEventSource(source.capabilities());
445
+ const envelope = buildAt();
446
+ const tipBlockNumber = latestTip?.number ?? 0n;
447
+ // Per-hash presence + replacement detection. Both delegated to
448
+ // `decideMempoolObservation` (pure). The orchestrator pre-
449
+ // computes the per-record replacement candidate from the
450
+ // snapshot so the decision function stays closure-free.
451
+ for (const record of tracked.values()) {
452
+ const presence = byHash.get(record.hash) ?? null;
453
+ const replacementInMempool = record.identity
454
+ ? findReplacementInMempool(snapshot, record.identity, record.hash)
455
+ : null;
456
+ const result = decideMempoolObservation({
457
+ record,
458
+ presence,
459
+ replacementInMempool,
460
+ chainId,
461
+ eventSource,
462
+ envelope,
463
+ tipBlockNumber,
464
+ });
465
+ applyObservationResult(record, result);
466
+ for (const event of result.events)
467
+ emit(record, event);
468
+ }
469
+ // Bulk subscriptions on the mempool path.
470
+ runBulkOnMempool(byHash, eventSource);
471
+ };
472
+ /**
473
+ * Pure mempool-snapshot lookup for a colliding `(from, nonce)`.
474
+ * Replaces the closure-based legacy helper — extracted for direct
475
+ * testability and to keep `decideMempoolObservation` pure.
476
+ *
477
+ * Normalizes the cached identity's nonce to decimal before keying
478
+ * into the snapshot's nonce-keyed sub-map (chain-source's
479
+ * `normalizeMempool` lowercases addresses + decimalizes nonces).
480
+ * If the cached nonce isn't valid hex (test fixtures or off-spec
481
+ * RPCs), the BigInt() throws — fallback uses the raw string as
482
+ * the key.
483
+ */
484
+ const findReplacementInMempool = (snapshot, identity, originalHash) => {
485
+ const senderKey = identity.from.toLowerCase();
486
+ let nonceKey;
487
+ try {
488
+ nonceKey = BigInt(identity.nonce).toString(10);
489
+ }
490
+ catch {
491
+ nonceKey = identity.nonce;
492
+ }
493
+ const buckets = ['pending', 'queued'];
494
+ for (const bucket of buckets) {
495
+ const senderTxs = snapshot[bucket][senderKey];
496
+ if (!senderTxs)
497
+ continue;
498
+ const tx = senderTxs[nonceKey];
499
+ if (tx?.hash && tx.hash !== originalHash)
500
+ return tx;
501
+ }
502
+ return null;
503
+ };
504
+ // -------------------------------------------------------------
505
+ // Bulk subscription helpers
506
+ // -------------------------------------------------------------
507
+ /**
508
+ * Fan-out helpers for both block + mempool ticks. The bulk
509
+ * registry guarantees that any sub still in `bulkSubs` is alive
510
+ * (`sub.stop()` does `bulkSubs.delete(id)`), so we don't need
511
+ * per-iteration "is it stopped?" guards here. The early return
512
+ * on size===0 keeps the hot path cheap when no bulk subs exist.
513
+ */
514
+ const runBulkOnBlock = (txs, _eventSource) => {
515
+ if (bulkSubs.size === 0)
516
+ return;
517
+ const compiled = [...bulkSubs.values()].map((sub) => sub.compiled);
518
+ fanOutBulkMatches(matchAll(txs, compiled), 'block-poll');
519
+ };
520
+ const runBulkOnMempool = (byHash, _eventSource) => {
521
+ if (bulkSubs.size === 0)
522
+ return;
523
+ const compiled = [...bulkSubs.values()].map((sub) => sub.compiled);
524
+ const txs = [...byHash.values()].map((v) => v.tx);
525
+ fanOutBulkMatches(matchAll(txs, compiled), 'mempool-snapshot');
526
+ };
527
+ /**
528
+ * Reverse-lookup: given a compiled selector reference, find its
529
+ * owning bulk sub. The `compiled.selector` reference is the same
530
+ * object the consumer registered, and every sub in `bulkSubs`
531
+ * carries it — so a miss here would mean the registry was
532
+ * mutated mid-fanout, which doesn't happen.
533
+ */
534
+ const findBulkSubBySelector = (selector) => {
535
+ for (const sub of bulkSubs.values()) {
536
+ if (sub.compiled.selector === selector)
537
+ return sub;
538
+ }
539
+ /* c8 ignore next */
540
+ throw new Error('tx-tracker: invariant violated — selector ref missing from bulkSubs');
541
+ };
542
+ const fanOutBulkMatches = (matches, matchSource) => {
543
+ for (const match of matches) {
544
+ const sub = findBulkSubBySelector(match.selector);
545
+ const event = {
546
+ kind: 'matched',
547
+ hash: match.hash,
548
+ matchedBy: match.matchedBy,
549
+ selector: match.selector,
550
+ tx: match.tx,
551
+ source: matchSource,
552
+ at: buildAt(),
553
+ };
554
+ sub.matchSubs.emit(event);
555
+ if (sub.options.autoTrackMatched && !sub.autoTrackedUnsubs.has(match.hash)) {
556
+ const unsub = subscribe(match.hash, (e) => sub.perHashSubs.emit(e), { emitInitial: false });
557
+ sub.autoTrackedUnsubs.set(match.hash, unsub);
558
+ }
559
+ }
560
+ };
561
+ // -------------------------------------------------------------
562
+ // Public surface
563
+ // -------------------------------------------------------------
564
+ const subscribe = (hash, cb, callerOptions) => {
565
+ const opts = callerOptions ?? {};
566
+ const record = ensureRecord(hash);
567
+ // Honor the most-restrictive `unseenThresholdBlocks` across
568
+ // active subscriptions on this hash. New subscriptions may be
569
+ // narrower than existing ones; the tracker takes the min.
570
+ if (opts.unseenThresholdBlocks !== undefined) {
571
+ record.unseenThresholdBlocks = Math.min(record.unseenThresholdBlocks, opts.unseenThresholdBlocks);
572
+ }
573
+ if (opts.lostSignalPolicy && record.lostSignalPolicy === null) {
574
+ record.lostSignalPolicy = opts.lostSignalPolicy;
575
+ }
576
+ if (opts.durable) {
577
+ record.hasDurableSub = true;
578
+ const subId = `sub-${nextSubId++}`;
579
+ record.persisted.push({
580
+ id: subId,
581
+ durable: true,
582
+ selector: { kind: 'hash', hash },
583
+ });
584
+ void store.put(toRecord(record)).catch((err) => onError?.('store.put', err));
585
+ }
586
+ const unsub = record.subs.subscribe(cb);
587
+ if (opts.emitInitial !== false) {
588
+ // Synthetic started event — does not pass through `emit` since
589
+ // the global stream / store should not see per-subscription
590
+ // synthetic frames.
591
+ cb(buildStarted({
592
+ hash,
593
+ chainId,
594
+ source: blockEventSource(source.capabilities()),
595
+ at: buildAt(),
596
+ capabilities: source.capabilities(),
597
+ }));
598
+ }
599
+ let unsubscribed = false;
600
+ return () => {
601
+ if (unsubscribed)
602
+ return;
603
+ unsubscribed = true;
604
+ // Final stopped event direct to the caller (single-shot) so
605
+ // they always see lifecycle closure even after unsubscribe.
606
+ cb(buildStopped({
607
+ hash,
608
+ chainId,
609
+ source: blockEventSource(source.capabilities()),
610
+ at: buildAt(),
611
+ reason: 'unsubscribed',
612
+ }));
613
+ unsub();
614
+ cleanupRecord(record);
615
+ };
616
+ };
617
+ const track = (hash, callerOptions) => {
618
+ return {
619
+ [Symbol.asyncIterator]: () => makeAsyncIterator(hash, callerOptions),
620
+ };
621
+ };
622
+ const subscribeAll = (cb) => globalSubs.subscribe(cb);
623
+ const makeBulkSub = (selector, callerOptions) => {
624
+ if (bulkSubs.size >= maxBulkSubscriptions) {
625
+ throw new Error(`tx-tracker: max bulk subscriptions (${maxBulkSubscriptions}) reached`);
626
+ }
627
+ const compiled = compileSelector(selector);
628
+ if (selector.kind === 'predicate' && callerOptions?.durable) {
629
+ onError?.('tx-tracker.bulk', new Error('predicate selectors are non-durable (spec §13.2); ignoring durable: true'));
630
+ }
631
+ const id = `bulk-${nextSubId++}`;
632
+ const sub = {
633
+ id,
634
+ compiled,
635
+ options: {
636
+ autoTrackMatched: callerOptions?.autoTrackMatched ?? true,
637
+ emitInitial: callerOptions?.emitInitial ?? true,
638
+ ...callerOptions,
639
+ },
640
+ matchSubs: new Subscriptions(),
641
+ perHashSubs: new Subscriptions(),
642
+ stopped: false,
643
+ autoTrackedUnsubs: new Map(),
644
+ };
645
+ bulkSubs.set(id, sub);
646
+ return {
647
+ events: () => makeBulkAsyncIterable(sub),
648
+ subscribe: (cb) => sub.perHashSubs.subscribe(cb),
649
+ stop: () => {
650
+ if (sub.stopped)
651
+ return;
652
+ sub.stopped = true;
653
+ bulkSubs.delete(id);
654
+ // Auto-tracked per-hash subscriptions continue under their
655
+ // own retention rules per spec §11.1.
656
+ },
657
+ };
658
+ };
659
+ const trackFromAddress = (address, options) => makeBulkSub({ kind: 'from', address }, options);
660
+ const trackToAddress = (address, options) => makeBulkSub({ kind: 'to', address }, options);
661
+ const trackPredicate = (match, options) => makeBulkSub({ kind: 'predicate', match }, options);
662
+ // -------------------------------------------------------------
663
+ // Async iterator factory — backs `track()` and bulk events()
664
+ // -------------------------------------------------------------
665
+ const makeAsyncIterator = (hash, options) => {
666
+ const queue = [];
667
+ const waiters = [];
668
+ let done = false;
669
+ const cb = (event) => {
670
+ if (event.kind === 'stopped')
671
+ done = true;
672
+ const waiter = waiters.shift();
673
+ if (waiter) {
674
+ waiter({ value: event, done: false });
675
+ if (done) {
676
+ // Drain remaining waiters with done after the stopped event.
677
+ while (waiters.length > 0) {
678
+ waiters.shift()({ value: undefined, done: true });
679
+ }
680
+ }
681
+ }
682
+ else {
683
+ queue.push(event);
684
+ }
685
+ };
686
+ const unsub = subscribe(hash, cb, options);
687
+ return {
688
+ next: () => {
689
+ if (queue.length > 0) {
690
+ const value = queue.shift();
691
+ return Promise.resolve({ value, done: false });
692
+ }
693
+ if (done)
694
+ return Promise.resolve({ value: undefined, done: true });
695
+ return new Promise((resolve) => {
696
+ waiters.push(resolve);
697
+ });
698
+ },
699
+ return: () => {
700
+ unsub();
701
+ done = true;
702
+ // The unsub call above triggers the synthetic stopped event
703
+ // through the same `cb` that drains pending waiters, so by
704
+ // the time we get here the waiters queue is empty under
705
+ // normal flow. The drain stays as a belt-and-braces guard
706
+ // for any future code path that might call return() without
707
+ // the unsub-driven stopped emit.
708
+ while (waiters.length > 0) {
709
+ /* c8 ignore next */
710
+ waiters.shift()({ value: undefined, done: true });
711
+ }
712
+ return Promise.resolve({ value: undefined, done: true });
713
+ },
714
+ };
715
+ };
716
+ const makeBulkAsyncIterable = (sub) => {
717
+ return {
718
+ [Symbol.asyncIterator]: () => {
719
+ const queue = [];
720
+ const waiters = [];
721
+ let localDone = sub.stopped;
722
+ const unsub = sub.matchSubs.subscribe((event) => {
723
+ const waiter = waiters.shift();
724
+ if (waiter)
725
+ waiter({ value: event, done: false });
726
+ else
727
+ queue.push(event);
728
+ });
729
+ return {
730
+ next: () => {
731
+ if (queue.length > 0) {
732
+ return Promise.resolve({ value: queue.shift(), done: false });
733
+ }
734
+ if (localDone || sub.stopped) {
735
+ return Promise.resolve({
736
+ value: undefined,
737
+ done: true,
738
+ });
739
+ }
740
+ return new Promise((resolve) => {
741
+ waiters.push(resolve);
742
+ });
743
+ },
744
+ return: () => {
745
+ localDone = true;
746
+ unsub();
747
+ while (waiters.length > 0) {
748
+ waiters.shift()({
749
+ value: undefined,
750
+ done: true,
751
+ });
752
+ }
753
+ return Promise.resolve({
754
+ value: undefined,
755
+ done: true,
756
+ });
757
+ },
758
+ };
759
+ },
760
+ };
761
+ };
762
+ // -------------------------------------------------------------
763
+ // Lifecycle
764
+ // -------------------------------------------------------------
765
+ const start = () => {
766
+ if (started)
767
+ return;
768
+ started = true;
769
+ lastCaps = source.capabilities();
770
+ unsubBlocks = source.subscribeBlocks(onBlock);
771
+ unsubMempool = source.subscribeMempool(onMempool);
772
+ };
773
+ const stop = () => {
774
+ if (!started)
775
+ return;
776
+ started = false;
777
+ unsubBlocks?.();
778
+ unsubMempool?.();
779
+ unsubBlocks = null;
780
+ unsubMempool = null;
781
+ // Emit stopped to every per-hash subscriber, then drop them.
782
+ for (const record of tracked.values()) {
783
+ const stoppedEvent = buildStopped({
784
+ hash: record.hash,
785
+ chainId,
786
+ source: blockEventSource(source.capabilities()),
787
+ at: buildAt(),
788
+ reason: 'tracker-stopped',
789
+ });
790
+ record.subs.emit(stoppedEvent);
791
+ globalSubs.emit(stoppedEvent);
792
+ }
793
+ tracked.clear();
794
+ for (const sub of bulkSubs.values())
795
+ sub.stopped = true;
796
+ bulkSubs.clear();
797
+ blockRing = [];
798
+ latestTip = null;
799
+ };
800
+ // Eager lifecycle: subscribe immediately on construction. Lazy
801
+ // waits for the first track/getStatus call. The capability probe
802
+ // itself runs eagerly in the source, independent of either case.
803
+ if (lifecycle === 'eager') {
804
+ // No-op; consumer calls `start()` explicitly. The eager/lazy
805
+ // distinction in v0.6.x affects internal book-keeping only.
806
+ }
807
+ return {
808
+ start,
809
+ stop,
810
+ getTxStatus: (hash) => {
811
+ const record = tracked.get(hash);
812
+ return record ? record.status : null;
813
+ },
814
+ track,
815
+ subscribe,
816
+ trackFromAddress,
817
+ trackToAddress,
818
+ trackPredicate,
819
+ capabilities: () => source.capabilities(),
820
+ subscribeAll,
821
+ };
822
+ };
823
+ //# sourceMappingURL=tracker.js.map