@valve-tech/tx-tracker 0.6.0 → 0.8.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.
Files changed (57) hide show
  1. package/AGENTS.md +237 -0
  2. package/CHANGELOG.md +140 -0
  3. package/README.md +13 -6
  4. package/dist/events.d.ts +309 -0
  5. package/dist/events.d.ts.map +1 -0
  6. package/dist/events.js +132 -0
  7. package/dist/events.js.map +1 -0
  8. package/dist/group-events.d.ts +82 -0
  9. package/dist/group-events.d.ts.map +1 -0
  10. package/dist/group-events.js +47 -0
  11. package/dist/group-events.js.map +1 -0
  12. package/dist/group.d.ts +31 -0
  13. package/dist/group.d.ts.map +1 -0
  14. package/dist/group.js +196 -0
  15. package/dist/group.js.map +1 -0
  16. package/dist/index.d.ts +57 -10
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +52 -1
  19. package/dist/index.js.map +1 -1
  20. package/dist/observations.d.ts +169 -0
  21. package/dist/observations.d.ts.map +1 -0
  22. package/dist/observations.js +287 -0
  23. package/dist/observations.js.map +1 -0
  24. package/dist/reorg.d.ts +108 -0
  25. package/dist/reorg.d.ts.map +1 -0
  26. package/dist/reorg.js +125 -0
  27. package/dist/reorg.js.map +1 -0
  28. package/dist/replace-transaction.d.ts +46 -0
  29. package/dist/replace-transaction.d.ts.map +1 -0
  30. package/dist/replace-transaction.js +47 -0
  31. package/dist/replace-transaction.js.map +1 -0
  32. package/dist/selectors.d.ts +78 -0
  33. package/dist/selectors.d.ts.map +1 -0
  34. package/dist/selectors.js +119 -0
  35. package/dist/selectors.js.map +1 -0
  36. package/dist/store.d.ts +166 -0
  37. package/dist/store.d.ts.map +1 -0
  38. package/dist/store.js +110 -0
  39. package/dist/store.js.map +1 -0
  40. package/dist/tracker.d.ts +211 -0
  41. package/dist/tracker.d.ts.map +1 -0
  42. package/dist/tracker.js +1004 -0
  43. package/dist/tracker.js.map +1 -0
  44. package/dist/wait-for-pending.d.ts +41 -0
  45. package/dist/wait-for-pending.d.ts.map +1 -0
  46. package/dist/wait-for-pending.js +71 -0
  47. package/dist/wait-for-pending.js.map +1 -0
  48. package/dist/wait-for-transaction.d.ts +55 -0
  49. package/dist/wait-for-transaction.d.ts.map +1 -0
  50. package/dist/wait-for-transaction.js +72 -0
  51. package/dist/wait-for-transaction.js.map +1 -0
  52. package/dist/watch-transaction.d.ts +57 -0
  53. package/dist/watch-transaction.d.ts.map +1 -0
  54. package/dist/watch-transaction.js +76 -0
  55. package/dist/watch-transaction.js.map +1 -0
  56. package/package.json +6 -1
  57. package/skills/tx-tracker-integration/SKILL.md +198 -0
@@ -0,0 +1,1004 @@
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 { createTxGroup } from './group.js';
40
+ import { buildSeenInBlock, buildSignalDegraded, buildSignalRecovered, buildStarted, buildStopped, buildVanishedFromBlock, buildInitialStatus, } from './events.js';
41
+ import { decideBlockObservation, decideMempoolObservation, } from './observations.js';
42
+ import { appendBlock, defaultReorgDepthBlocks, detectDivergences, } from './reorg.js';
43
+ import { compileSelector, defaultMaxBulkSubscriptions, matchAll, } from './selectors.js';
44
+ import { computeRetentionExpiry, createInMemoryStore, defaultRetentionBlocks, } from './store.js';
45
+ // -----------------------------------------------------------------
46
+ // Internals
47
+ // -----------------------------------------------------------------
48
+ const DEFAULT_UNSEEN_THRESHOLD_BLOCKS = 30;
49
+ /**
50
+ * Compare two capability snapshots; return per-key transitions
51
+ * (`degraded` / `recovered`). Degradation is "moved away from the
52
+ * higher-authority value" — `'subscription' → 'poll-only'`,
53
+ * `'available' → 'gated'`, `'available' → 'unavailable'`.
54
+ * Recovery is the reverse.
55
+ */
56
+ const diffCapabilities = (prev, next) => {
57
+ const degraded = [];
58
+ const recovered = [];
59
+ const keys = [
60
+ 'newHeads',
61
+ 'newPendingTransactions',
62
+ 'txpoolContent',
63
+ 'receiptByHash',
64
+ ];
65
+ for (const key of keys) {
66
+ const before = capabilityRank(prev[key]);
67
+ const after = capabilityRank(next[key]);
68
+ if (after < before)
69
+ degraded.push(key);
70
+ else if (after > before)
71
+ recovered.push(key);
72
+ }
73
+ return { degraded, recovered };
74
+ };
75
+ const capabilityRank = (value) => {
76
+ switch (value) {
77
+ case 'subscription':
78
+ case 'available':
79
+ return 2;
80
+ case 'poll-only':
81
+ return 1;
82
+ case 'gated':
83
+ case 'unavailable':
84
+ return 0;
85
+ }
86
+ };
87
+ /**
88
+ * Pick the `EventSource` discriminator for an event the tracker is
89
+ * about to emit, given the current source-capability snapshot for
90
+ * the relevant capability key. Block-side observations come from
91
+ * either `'subscription'` (when `newHeads` is push) or `'block-poll'`
92
+ * (when it's poll). Mempool-side observations come from
93
+ * `'subscription'` (push) or `'mempool-snapshot'` (poll).
94
+ */
95
+ const blockEventSource = (caps) => caps.newHeads === 'subscription' ? 'subscription' : 'block-poll';
96
+ const mempoolEventSource = (caps) => caps.newPendingTransactions === 'subscription'
97
+ ? 'subscription'
98
+ : 'mempool-snapshot';
99
+ // -----------------------------------------------------------------
100
+ // Factory
101
+ // -----------------------------------------------------------------
102
+ /**
103
+ * Build a configured tracker.
104
+ *
105
+ * @example
106
+ * import { createChainSource } from '@valve-tech/chain-source'
107
+ * import { createTxTracker } from '@valve-tech/tx-tracker'
108
+ *
109
+ * const source = createChainSource({ client })
110
+ * const tracker = createTxTracker({ source, chainId: 1 })
111
+ *
112
+ * source.start()
113
+ * tracker.start()
114
+ *
115
+ * for await (const event of tracker.track('0xabc...')) {
116
+ * if (event.kind === 'seen-in-block' && event.confirmations >= 3) break
117
+ * }
118
+ */
119
+ export const createTxTracker = (options) => {
120
+ const { source, chainId, store = createInMemoryStore(), lostSignalPolicy: defaultLostSignalPolicy = 'emit-uncertain', reorgDepthBlocks = defaultReorgDepthBlocks, unseenThresholdBlocks = DEFAULT_UNSEEN_THRESHOLD_BLOCKS, maxBulkSubscriptions = defaultMaxBulkSubscriptions, onError, lifecycle = 'eager', } = options;
121
+ const tracked = new Map();
122
+ const bulkSubs = new Map();
123
+ const globalSubs = new Subscriptions();
124
+ let started = false;
125
+ let unsubBlocks = null;
126
+ let unsubMempool = null;
127
+ let blockRing = [];
128
+ let latestTip = null;
129
+ let latestTipTimestamp = 0n;
130
+ let lastCaps = source.capabilities();
131
+ let nextSubId = 1;
132
+ // Receipt-poll-fallback state (spec §8). These vars track the
133
+ // per-record tick counters and the one-shot capability-gate warning.
134
+ let blocksSinceLastReceiptPoll = new Map();
135
+ let receiptPollGateWarned = false;
136
+ // withReceipts eager enrichment state (spec §18.2, F2). One-shot
137
+ // capability-gate warning, reset on stop() so a subsequent start()
138
+ // begins clean.
139
+ let withReceiptsGateWarned = false;
140
+ // -------------------------------------------------------------
141
+ // Helpers
142
+ // -------------------------------------------------------------
143
+ const buildAt = () => ({
144
+ blockNumber: latestTip?.number ?? 0n,
145
+ timestamp: latestTipTimestamp,
146
+ });
147
+ /**
148
+ * Emit one event: deliver to per-hash subscribers, the global
149
+ * `subscribeAll` stream, and the store's audit log. Per-subscriber
150
+ * throws are swallowed by `Subscriptions.emit` already; store
151
+ * failures are routed through `onError` per spec Appendix A.
152
+ */
153
+ const emit = (record, event) => {
154
+ record.subs.emit(event);
155
+ globalSubs.emit(event);
156
+ if (record.hasDurableSub) {
157
+ void store.appendEvent(chainId, record.hash, event).catch((err) => {
158
+ onError?.('store.appendEvent', err);
159
+ });
160
+ void store
161
+ .put(toRecord(record))
162
+ .catch((err) => onError?.('store.put', err));
163
+ }
164
+ };
165
+ /**
166
+ * Project an internal `TrackedRecord` onto the persisted shape.
167
+ * Retention expiry is recomputed from the latest observed block
168
+ * each time so a long-lived hash that keeps moving stays in the
169
+ * store rather than getting GC'd mid-flight.
170
+ */
171
+ const toRecord = (record) => {
172
+ const lastBlock = record.status.lastObservedAtBlock ??
173
+ record.status.firstObservedAtBlock ??
174
+ latestTip?.number ??
175
+ 0n;
176
+ const firstBlock = record.status.firstObservedAtBlock ?? latestTip?.number ?? 0n;
177
+ return {
178
+ chainId,
179
+ hash: record.hash,
180
+ status: record.status,
181
+ firstSeenBlockNumber: firstBlock,
182
+ lastObservedBlockNumber: lastBlock,
183
+ retentionExpiresAtBlockNumber: computeRetentionExpiry(lastBlock, defaultRetentionBlocks),
184
+ subscriptions: record.persisted,
185
+ };
186
+ };
187
+ /**
188
+ * Get-or-create the per-hash internal record. New records are
189
+ * seeded with `buildInitialStatus` against the current capability
190
+ * snapshot.
191
+ */
192
+ const ensureRecord = (hash) => {
193
+ let record = tracked.get(hash);
194
+ if (record)
195
+ return record;
196
+ record = {
197
+ hash,
198
+ status: buildInitialStatus({
199
+ hash,
200
+ chainId,
201
+ capabilities: source.capabilities(),
202
+ }),
203
+ subs: new Subscriptions(),
204
+ identity: null,
205
+ inLastMempoolSnapshot: false,
206
+ unseenThresholdBlocks,
207
+ lostSignalPolicy: null,
208
+ hasDurableSub: false,
209
+ persisted: [],
210
+ withReceipts: false,
211
+ };
212
+ tracked.set(hash, record);
213
+ return record;
214
+ };
215
+ /**
216
+ * Detach a per-hash subscription. If no subscribers remain AND the
217
+ * record carries no durable persistence, drop the record so the
218
+ * tracker's footprint matches what's actually in flight.
219
+ */
220
+ const cleanupRecord = (record) => {
221
+ if (record.subs.size() > 0)
222
+ return;
223
+ if (record.hasDurableSub)
224
+ return;
225
+ tracked.delete(record.hash);
226
+ blocksSinceLastReceiptPoll.delete(record.hash);
227
+ };
228
+ // -------------------------------------------------------------
229
+ // Receipt-poll-fallback path (spec §8)
230
+ // -------------------------------------------------------------
231
+ /**
232
+ * Per-record receipt poll. Runs once per block tick for every
233
+ * tracked hash whose `lostSignalPolicy` is `'receipt-poll-fallback'`.
234
+ * Uses `source.getReceipt(hash)` — requires capability
235
+ * `receiptByHash: 'available'`. When the capability is absent,
236
+ * downgrades to emit-uncertain semantics and fires a one-shot
237
+ * warning through `onError`.
238
+ *
239
+ * The `blocksSinceLastReceiptPoll` counter is per-hash so different
240
+ * hashes can have independent cadences (e.g. when a per-subscription
241
+ * override is introduced in a future PR). Today the tracker-level
242
+ * default policy applies to all records.
243
+ *
244
+ * `lastSeenInBlock` is only updated when the receipt-poll returns a
245
+ * block number **strictly newer** than the currently cached
246
+ * `lastSeenInBlock.blockNumber` — this prevents receipt-poll
247
+ * (lower authority) from overwriting a block-poll / subscription
248
+ * observation that already recorded the same height with higher
249
+ * authority.
250
+ */
251
+ const runReceiptPollFallback = async (record, tipBlockNumber) => {
252
+ const policy = record.lostSignalPolicy ?? defaultLostSignalPolicy;
253
+ if (typeof policy !== 'object' || policy.strategy !== 'receipt-poll-fallback') {
254
+ return;
255
+ }
256
+ // Capability gate: if receiptByHash is unavailable, warn once and
257
+ // fall back to emit-uncertain semantics (the signal-degraded path
258
+ // already handles caller awareness).
259
+ if (source.capabilities().receiptByHash !== 'available') {
260
+ if (!receiptPollGateWarned) {
261
+ receiptPollGateWarned = true;
262
+ onError?.('tx-tracker.receipt-poll-fallback', new Error('receipt-poll-fallback requested but capability receiptByHash unavailable; falling back to emit-uncertain semantics'));
263
+ }
264
+ return;
265
+ }
266
+ // Tick counter — only fetch every `pollEveryBlocks` ticks.
267
+ const ticksSince = (blocksSinceLastReceiptPoll.get(record.hash) ?? 0) + 1;
268
+ if (ticksSince < policy.pollEveryBlocks) {
269
+ blocksSinceLastReceiptPoll.set(record.hash, ticksSince);
270
+ return;
271
+ }
272
+ blocksSinceLastReceiptPoll.set(record.hash, 0);
273
+ let receipt = null;
274
+ try {
275
+ receipt = await source.getReceipt(record.hash);
276
+ }
277
+ catch (err) {
278
+ onError?.('tx-tracker.getReceipt', err);
279
+ return;
280
+ }
281
+ if (!receipt)
282
+ return;
283
+ let receiptBlockNumber;
284
+ try {
285
+ receiptBlockNumber = BigInt(receipt.blockNumber);
286
+ }
287
+ catch {
288
+ onError?.('tx-tracker.receipt-poll-fallback', new Error(`bad receipt blockNumber: ${receipt.blockNumber}`));
289
+ return;
290
+ }
291
+ // Only update when the receipt carries a block we haven't recorded
292
+ // from a higher-authority path at the same or later height.
293
+ const existingBlock = record.status.lastSeenInBlock;
294
+ if (existingBlock && existingBlock.blockNumber >= receiptBlockNumber) {
295
+ return;
296
+ }
297
+ // Record was unsubscribed while getReceipt was in-flight; bail out rather
298
+ // than emit on an orphaned record.
299
+ if (!tracked.has(record.hash))
300
+ return;
301
+ emit(record, buildSeenInBlock({
302
+ hash: record.hash,
303
+ chainId,
304
+ source: 'receipt-poll',
305
+ at: { blockNumber: tipBlockNumber, timestamp: latestTipTimestamp },
306
+ blockHash: receipt.blockHash,
307
+ blockNumber: receiptBlockNumber,
308
+ transactionIndex: 0, // not exposed via receipt; kept 0 for spec consistency
309
+ confirmations: 1,
310
+ }));
311
+ // Mirror state-machine bookkeeping so getTxStatus reflects inclusion.
312
+ // Set lastObservedAtBlock to the chain tip (the block that triggered this
313
+ // poll) rather than the receipt's inclusion block, so the retention window
314
+ // expiry advances with the chain even for old inclusions polled later.
315
+ record.status = {
316
+ ...record.status,
317
+ lastSeenInBlock: {
318
+ blockHash: receipt.blockHash,
319
+ blockNumber: receiptBlockNumber,
320
+ transactionIndex: 0,
321
+ confirmations: 1,
322
+ source: 'receipt-poll',
323
+ },
324
+ lastObservedAtBlock: tipBlockNumber,
325
+ };
326
+ };
327
+ // -------------------------------------------------------------
328
+ // Block path — runs on every source block emit
329
+ // -------------------------------------------------------------
330
+ const onBlock = async (block) => {
331
+ let blockNumber;
332
+ try {
333
+ blockNumber = BigInt(block.number);
334
+ }
335
+ catch {
336
+ onError?.('tx-tracker.onBlock', new Error(`bad block number: ${block.number}`));
337
+ return;
338
+ }
339
+ let blockTimestamp = 0n;
340
+ try {
341
+ blockTimestamp = BigInt(block.timestamp);
342
+ }
343
+ catch {
344
+ // leave 0n if timestamp didn't decode; not fatal
345
+ }
346
+ const blockHash = block.hash ?? '';
347
+ const txs = Array.isArray(block.transactions) ? block.transactions : [];
348
+ const txHashSet = new Set();
349
+ for (const tx of txs)
350
+ if (tx.hash)
351
+ txHashSet.add(tx.hash);
352
+ // Snapshot the pre-update ring + previous tip *before* we mutate
353
+ // anything — the reorg detector needs the ring as it stood
354
+ // before the new block landed in order to spot a same-height
355
+ // hash flip. `appendBlock` overwrites a same-height entry, so
356
+ // doing it before the comparison would erase the very evidence
357
+ // the detector relies on.
358
+ const previousRing = blockRing;
359
+ const previousTipNumber = latestTip?.number ?? null;
360
+ const newTip = {
361
+ number: blockNumber,
362
+ hash: blockHash,
363
+ parentHash: block.parentHash ?? null,
364
+ transactionHashes: txHashSet,
365
+ };
366
+ latestTip = newTip;
367
+ latestTipTimestamp = blockTimestamp;
368
+ blockRing = appendBlock(blockRing, newTip, reorgDepthBlocks * 2);
369
+ // Reorg detection on the pre-update ring vs the new tip. A
370
+ // divergence at the new tip's height = same-height-different-hash
371
+ // reorg; per spec §12.3 the event source is block-poll or
372
+ // subscription, never receipt-poll.
373
+ handleReorgs(previousRing, newTip);
374
+ // Capability transitions are checked once per block emit so
375
+ // signal-degraded / signal-recovered events carry the new tip's
376
+ // coordinate. The diff is global; the events fan out to every
377
+ // tracked hash whose subscription policy permits them.
378
+ handleCapabilityChange();
379
+ const eventSource = blockEventSource(source.capabilities());
380
+ // Bulk subscriptions run BEFORE per-hash inclusion bookkeeping
381
+ // so any auto-tracked hashes created for matched txs end up in
382
+ // the `tracked` map in time to receive their own seen-in-block
383
+ // event from the loop below. Reordering here is load-bearing —
384
+ // see the test "trackFromAddress autoTrackMatched: true creates
385
+ // per-hash subscriptions."
386
+ runBulkOnBlock(txs, eventSource);
387
+ // withReceipts F2 — pre-fetch receipts for hashes that (a) request
388
+ // receipt enrichment and (b) are about to be included in this block.
389
+ // Fetched in parallel before the per-record loop so the first emitted
390
+ // seen-in-block event already carries the receipt (spec §18.2).
391
+ const prefetchedReceipts = new Map();
392
+ if (source.capabilities().receiptByHash === 'available') {
393
+ const targets = [];
394
+ for (const record of tracked.values()) {
395
+ if (record.withReceipts && txHashSet.has(record.hash)) {
396
+ targets.push(record.hash);
397
+ }
398
+ }
399
+ if (targets.length > 0) {
400
+ const results = await Promise.all(targets.map(async (hash) => {
401
+ try {
402
+ return [hash, await source.getReceipt(hash)];
403
+ }
404
+ catch (err) {
405
+ onError?.('tx-tracker.getReceipt', err);
406
+ return [hash, null];
407
+ }
408
+ }));
409
+ for (const [hash, receipt] of results) {
410
+ if (receipt)
411
+ prefetchedReceipts.set(hash, receipt);
412
+ }
413
+ }
414
+ }
415
+ else {
416
+ // Capability gate: warn once if any tracked hash wants withReceipts.
417
+ for (const record of tracked.values()) {
418
+ if (record.withReceipts) {
419
+ if (!withReceiptsGateWarned) {
420
+ withReceiptsGateWarned = true;
421
+ onError?.('tx-tracker.withReceipts', new Error('withReceipts: true requested but capability receiptByHash unavailable; events flow without receipt field'));
422
+ }
423
+ break;
424
+ }
425
+ }
426
+ }
427
+ // Per-hash inclusion check + confirmations + unseen-streak
428
+ // accounting. Delegated to `decideBlockObservation` (pure) — the
429
+ // orchestrator below merges the returned patches into the
430
+ // mutable record and emits the returned events.
431
+ const envelope = buildAt();
432
+ for (const record of tracked.values()) {
433
+ // Stale-block guard: if a concurrent onBlock invocation (block N+1)
434
+ // already advanced this record past the block we're processing here
435
+ // (block N), skip applying our stale statusPatch. The async pre-fetch
436
+ // for withReceipts opened this interleave window — without this guard
437
+ // we'd clobber the newer state with older data.
438
+ const recordedSince = record.status.lastObservedAtBlock;
439
+ if (recordedSince !== null && recordedSince > blockNumber) {
440
+ continue;
441
+ }
442
+ const result = decideBlockObservation({
443
+ record,
444
+ blockHash,
445
+ blockNumber,
446
+ txHashSet,
447
+ txs,
448
+ chainId,
449
+ eventSource,
450
+ envelope,
451
+ previousTipNumber,
452
+ prefetchedReceipts,
453
+ });
454
+ applyObservationResult(record, result);
455
+ for (const event of result.events)
456
+ emit(record, event);
457
+ }
458
+ // Receipt-poll-fallback: dispatch non-blocking per-record receipt
459
+ // fetches. These run AFTER the synchronous block-observation loop
460
+ // so any block-poll inclusion emitted above is already reflected in
461
+ // `record.status.lastSeenInBlock` before the poll fires. The void
462
+ // dispatch intentionally does not await — onBlock must stay
463
+ // synchronous from the caller's perspective; receipt fetches settle
464
+ // in the background and emit asynchronously.
465
+ // `latestTip` is always set to `newTip` above before this loop runs.
466
+ for (const record of tracked.values()) {
467
+ void runReceiptPollFallback(record, latestTip.number);
468
+ }
469
+ };
470
+ /**
471
+ * Compare the pre-update ring against the new tip via the reorg
472
+ * detector. A divergence at the new tip's height means the ring
473
+ * had a different hash there before — classic same-height reorg.
474
+ * For each divergence, every tracked hash whose recorded
475
+ * `lastSeenInBlock` pointed at the stale block emits
476
+ * `vanished-from-block`.
477
+ *
478
+ * Only the new tip is passed as the canonical sequence in v0.6.x
479
+ * — the detector is conservative about heights with no canonical
480
+ * info (per the reorg.ts design) so this stays safe with a
481
+ * single-block window.
482
+ */
483
+ const handleReorgs = (previousRing, newTip) => {
484
+ const divergences = detectDivergences({
485
+ ring: previousRing,
486
+ canonical: [newTip],
487
+ depthBlocks: reorgDepthBlocks,
488
+ });
489
+ if (divergences.length === 0)
490
+ return;
491
+ const eventSource = blockEventSource(source.capabilities());
492
+ for (const div of divergences) {
493
+ // Walk every tracked hash whose lastSeenInBlock referenced the
494
+ // stale hash at this height. The tx-set on the divergence is
495
+ // the authoritative scoper.
496
+ for (const record of tracked.values()) {
497
+ const seen = record.status.lastSeenInBlock;
498
+ if (!seen)
499
+ continue;
500
+ if (seen.blockNumber !== div.blockNumber)
501
+ continue;
502
+ // Belt-and-braces hash check: every record at this height
503
+ // was included when the canonical block had the previous
504
+ // hash, so this check is normally redundant — but it
505
+ // future-proofs against a hypothetical future where a
506
+ // tracker observes the same height through multiple sources
507
+ // before we reconcile them. Keeps the vanished-from-block
508
+ // emit honest.
509
+ /* c8 ignore next */
510
+ if (seen.blockHash !== div.previousBlockHash)
511
+ continue;
512
+ record.status.vanishedAt = {
513
+ previousBlockHash: div.previousBlockHash,
514
+ canonicalBlockHash: div.canonicalBlockHash,
515
+ blockNumber: div.blockNumber,
516
+ };
517
+ // Reset inclusion state — the tx is no longer in the
518
+ // canonical chain at the height we recorded.
519
+ record.status.lastSeenInBlock = null;
520
+ record.status.lastObservedAtBlock = newTip.number;
521
+ emit(record, buildVanishedFromBlock({
522
+ hash: record.hash,
523
+ chainId,
524
+ source: eventSource,
525
+ at: buildAt(),
526
+ previousBlockHash: div.previousBlockHash,
527
+ canonicalBlockHash: div.canonicalBlockHash,
528
+ blockNumber: div.blockNumber,
529
+ }));
530
+ }
531
+ }
532
+ };
533
+ /**
534
+ * Compare the source's current capability snapshot to the last
535
+ * one we observed. Emit signal-degraded for newly-degraded
536
+ * capabilities, signal-recovered for newly-recovered ones. Per
537
+ * the per-subscription policy, `'silent'` callers are excluded
538
+ * from the per-record fanout.
539
+ */
540
+ const handleCapabilityChange = () => {
541
+ const next = source.capabilities();
542
+ const { degraded, recovered } = diffCapabilities(lastCaps, next);
543
+ if (degraded.length === 0 && recovered.length === 0)
544
+ return;
545
+ const fallback = blockEventSource(next);
546
+ for (const record of tracked.values()) {
547
+ const policy = record.lostSignalPolicy ?? defaultLostSignalPolicy;
548
+ if (policy === 'silent') {
549
+ record.status.capabilities = next;
550
+ continue;
551
+ }
552
+ for (const key of degraded) {
553
+ emit(record, buildSignalDegraded({
554
+ hash: record.hash,
555
+ chainId,
556
+ source: fallback,
557
+ at: buildAt(),
558
+ capabilityLost: key,
559
+ fallbackSource: fallback,
560
+ }));
561
+ }
562
+ for (const key of recovered) {
563
+ emit(record, buildSignalRecovered({
564
+ hash: record.hash,
565
+ chainId,
566
+ source: fallback,
567
+ at: buildAt(),
568
+ capabilityRestored: key,
569
+ }));
570
+ }
571
+ record.status.capabilities = next;
572
+ }
573
+ lastCaps = next;
574
+ };
575
+ /**
576
+ * Apply a pure-decision result to a mutable internal record. The
577
+ * decision functions return narrow patches (only the fields they
578
+ * decided to change); this orchestrator merges them into the
579
+ * record in one place so mutation is bounded and auditable.
580
+ */
581
+ const applyObservationResult = (record, result) => {
582
+ if (Object.keys(result.statusPatch).length > 0) {
583
+ record.status = { ...record.status, ...result.statusPatch };
584
+ }
585
+ if (result.identityPatch) {
586
+ record.identity = result.identityPatch;
587
+ }
588
+ if (result.inMempoolPatch !== null) {
589
+ record.inLastMempoolSnapshot = result.inMempoolPatch;
590
+ }
591
+ };
592
+ // -------------------------------------------------------------
593
+ // Mempool path — runs on every source mempool emit
594
+ // -------------------------------------------------------------
595
+ const onMempool = (snapshot) => {
596
+ // Build a hash-keyed index once per snapshot — O(N) per snapshot
597
+ // but the per-tracked-hash lookup downstream is O(1).
598
+ const byHash = new Map();
599
+ for (const sender of Object.keys(snapshot.pending)) {
600
+ const nonces = snapshot.pending[sender];
601
+ for (const nonceKey of Object.keys(nonces)) {
602
+ const tx = nonces[nonceKey];
603
+ if (tx?.hash)
604
+ byHash.set(tx.hash, { bucket: 'pending', tx });
605
+ }
606
+ }
607
+ for (const sender of Object.keys(snapshot.queued)) {
608
+ const nonces = snapshot.queued[sender];
609
+ for (const nonceKey of Object.keys(nonces)) {
610
+ const tx = nonces[nonceKey];
611
+ if (tx?.hash)
612
+ byHash.set(tx.hash, { bucket: 'queued', tx });
613
+ }
614
+ }
615
+ const eventSource = mempoolEventSource(source.capabilities());
616
+ const envelope = buildAt();
617
+ const tipBlockNumber = latestTip?.number ?? 0n;
618
+ // Per-hash presence + replacement detection. Both delegated to
619
+ // `decideMempoolObservation` (pure). The orchestrator pre-
620
+ // computes the per-record replacement candidate from the
621
+ // snapshot so the decision function stays closure-free.
622
+ for (const record of tracked.values()) {
623
+ const presence = byHash.get(record.hash) ?? null;
624
+ const replacementInMempool = record.identity
625
+ ? findReplacementInMempool(snapshot, record.identity, record.hash)
626
+ : null;
627
+ const result = decideMempoolObservation({
628
+ record,
629
+ presence,
630
+ replacementInMempool,
631
+ chainId,
632
+ eventSource,
633
+ envelope,
634
+ tipBlockNumber,
635
+ });
636
+ applyObservationResult(record, result);
637
+ for (const event of result.events)
638
+ emit(record, event);
639
+ }
640
+ // Bulk subscriptions on the mempool path.
641
+ runBulkOnMempool(byHash, eventSource);
642
+ };
643
+ /**
644
+ * Pure mempool-snapshot lookup for a colliding `(from, nonce)`.
645
+ * Replaces the closure-based legacy helper — extracted for direct
646
+ * testability and to keep `decideMempoolObservation` pure.
647
+ *
648
+ * Normalizes the cached identity's nonce to decimal before keying
649
+ * into the snapshot's nonce-keyed sub-map (chain-source's
650
+ * `normalizeMempool` lowercases addresses + decimalizes nonces).
651
+ * If the cached nonce isn't valid hex (test fixtures or off-spec
652
+ * RPCs), the BigInt() throws — fallback uses the raw string as
653
+ * the key.
654
+ */
655
+ const findReplacementInMempool = (snapshot, identity, originalHash) => {
656
+ const senderKey = identity.from.toLowerCase();
657
+ let nonceKey;
658
+ try {
659
+ nonceKey = BigInt(identity.nonce).toString(10);
660
+ }
661
+ catch {
662
+ nonceKey = identity.nonce;
663
+ }
664
+ const buckets = ['pending', 'queued'];
665
+ for (const bucket of buckets) {
666
+ const senderTxs = snapshot[bucket][senderKey];
667
+ if (!senderTxs)
668
+ continue;
669
+ const tx = senderTxs[nonceKey];
670
+ if (tx?.hash && tx.hash !== originalHash)
671
+ return tx;
672
+ }
673
+ return null;
674
+ };
675
+ // -------------------------------------------------------------
676
+ // Bulk subscription helpers
677
+ // -------------------------------------------------------------
678
+ /**
679
+ * Fan-out helpers for both block + mempool ticks. The bulk
680
+ * registry guarantees that any sub still in `bulkSubs` is alive
681
+ * (`sub.stop()` does `bulkSubs.delete(id)`), so we don't need
682
+ * per-iteration "is it stopped?" guards here. The early return
683
+ * on size===0 keeps the hot path cheap when no bulk subs exist.
684
+ */
685
+ const runBulkOnBlock = (txs, _eventSource) => {
686
+ if (bulkSubs.size === 0)
687
+ return;
688
+ const compiled = [...bulkSubs.values()].map((sub) => sub.compiled);
689
+ fanOutBulkMatches(matchAll(txs, compiled), 'block-poll');
690
+ };
691
+ const runBulkOnMempool = (byHash, _eventSource) => {
692
+ if (bulkSubs.size === 0)
693
+ return;
694
+ const compiled = [...bulkSubs.values()].map((sub) => sub.compiled);
695
+ const txs = [...byHash.values()].map((v) => v.tx);
696
+ fanOutBulkMatches(matchAll(txs, compiled), 'mempool-snapshot');
697
+ };
698
+ /**
699
+ * Reverse-lookup: given a compiled selector reference, find its
700
+ * owning bulk sub. The `compiled.selector` reference is the same
701
+ * object the consumer registered, and every sub in `bulkSubs`
702
+ * carries it — so a miss here would mean the registry was
703
+ * mutated mid-fanout, which doesn't happen.
704
+ */
705
+ const findBulkSubBySelector = (selector) => {
706
+ for (const sub of bulkSubs.values()) {
707
+ if (sub.compiled.selector === selector)
708
+ return sub;
709
+ }
710
+ /* c8 ignore next */
711
+ throw new Error('tx-tracker: invariant violated — selector ref missing from bulkSubs');
712
+ };
713
+ const fanOutBulkMatches = (matches, matchSource) => {
714
+ for (const match of matches) {
715
+ const sub = findBulkSubBySelector(match.selector);
716
+ const event = {
717
+ kind: 'matched',
718
+ hash: match.hash,
719
+ matchedBy: match.matchedBy,
720
+ selector: match.selector,
721
+ tx: match.tx,
722
+ source: matchSource,
723
+ at: buildAt(),
724
+ };
725
+ sub.matchSubs.emit(event);
726
+ if (sub.options.autoTrackMatched && !sub.autoTrackedUnsubs.has(match.hash)) {
727
+ const unsub = subscribe(match.hash, (e) => sub.perHashSubs.emit(e), { emitInitial: false });
728
+ sub.autoTrackedUnsubs.set(match.hash, unsub);
729
+ }
730
+ }
731
+ };
732
+ // -------------------------------------------------------------
733
+ // Public surface
734
+ // -------------------------------------------------------------
735
+ const subscribe = (hash, cb, callerOptions) => {
736
+ const opts = callerOptions ?? {};
737
+ const record = ensureRecord(hash);
738
+ // Honor the most-restrictive `unseenThresholdBlocks` across
739
+ // active subscriptions on this hash. New subscriptions may be
740
+ // narrower than existing ones; the tracker takes the min.
741
+ if (opts.unseenThresholdBlocks !== undefined) {
742
+ record.unseenThresholdBlocks = Math.min(record.unseenThresholdBlocks, opts.unseenThresholdBlocks);
743
+ }
744
+ if (opts.lostSignalPolicy && record.lostSignalPolicy === null) {
745
+ record.lostSignalPolicy = opts.lostSignalPolicy;
746
+ }
747
+ if (opts.withReceipts === true) {
748
+ record.withReceipts = true;
749
+ }
750
+ if (opts.durable) {
751
+ record.hasDurableSub = true;
752
+ const subId = `sub-${nextSubId++}`;
753
+ record.persisted.push({
754
+ id: subId,
755
+ durable: true,
756
+ selector: { kind: 'hash', hash },
757
+ });
758
+ void store.put(toRecord(record)).catch((err) => onError?.('store.put', err));
759
+ }
760
+ const unsub = record.subs.subscribe(cb);
761
+ if (opts.emitInitial !== false) {
762
+ // Synthetic started event — does not pass through `emit` since
763
+ // the global stream / store should not see per-subscription
764
+ // synthetic frames.
765
+ cb(buildStarted({
766
+ hash,
767
+ chainId,
768
+ source: blockEventSource(source.capabilities()),
769
+ at: buildAt(),
770
+ capabilities: source.capabilities(),
771
+ }));
772
+ }
773
+ let unsubscribed = false;
774
+ return () => {
775
+ if (unsubscribed)
776
+ return;
777
+ unsubscribed = true;
778
+ // Final stopped event direct to the caller (single-shot) so
779
+ // they always see lifecycle closure even after unsubscribe.
780
+ cb(buildStopped({
781
+ hash,
782
+ chainId,
783
+ source: blockEventSource(source.capabilities()),
784
+ at: buildAt(),
785
+ reason: 'unsubscribed',
786
+ }));
787
+ unsub();
788
+ cleanupRecord(record);
789
+ };
790
+ };
791
+ const track = (hash, callerOptions) => {
792
+ return {
793
+ [Symbol.asyncIterator]: () => makeAsyncIterator(hash, callerOptions),
794
+ };
795
+ };
796
+ const subscribeAll = (cb) => globalSubs.subscribe(cb);
797
+ const makeBulkSub = (selector, callerOptions) => {
798
+ if (bulkSubs.size >= maxBulkSubscriptions) {
799
+ throw new Error(`tx-tracker: max bulk subscriptions (${maxBulkSubscriptions}) reached`);
800
+ }
801
+ const compiled = compileSelector(selector);
802
+ if (selector.kind === 'predicate' && callerOptions?.durable) {
803
+ onError?.('tx-tracker.bulk', new Error('predicate selectors are non-durable (spec §13.2); ignoring durable: true'));
804
+ }
805
+ const id = `bulk-${nextSubId++}`;
806
+ const sub = {
807
+ id,
808
+ compiled,
809
+ options: {
810
+ autoTrackMatched: callerOptions?.autoTrackMatched ?? true,
811
+ emitInitial: callerOptions?.emitInitial ?? true,
812
+ ...callerOptions,
813
+ },
814
+ matchSubs: new Subscriptions(),
815
+ perHashSubs: new Subscriptions(),
816
+ stopped: false,
817
+ autoTrackedUnsubs: new Map(),
818
+ };
819
+ bulkSubs.set(id, sub);
820
+ return {
821
+ events: () => makeBulkAsyncIterable(sub),
822
+ subscribe: (cb) => sub.perHashSubs.subscribe(cb),
823
+ stop: () => {
824
+ if (sub.stopped)
825
+ return;
826
+ sub.stopped = true;
827
+ bulkSubs.delete(id);
828
+ // Auto-tracked per-hash subscriptions continue under their
829
+ // own retention rules per spec §11.1.
830
+ },
831
+ };
832
+ };
833
+ const trackFromAddress = (address, options) => makeBulkSub({ kind: 'from', address }, options);
834
+ const trackToAddress = (address, options) => makeBulkSub({ kind: 'to', address }, options);
835
+ const trackPredicate = (match, options) => makeBulkSub({ kind: 'predicate', match }, options);
836
+ // -------------------------------------------------------------
837
+ // Async iterator factory — backs `track()` and bulk events()
838
+ // -------------------------------------------------------------
839
+ const makeAsyncIterator = (hash, options) => {
840
+ const queue = [];
841
+ const waiters = [];
842
+ let done = false;
843
+ const cb = (event) => {
844
+ if (event.kind === 'stopped')
845
+ done = true;
846
+ const waiter = waiters.shift();
847
+ if (waiter) {
848
+ waiter({ value: event, done: false });
849
+ if (done) {
850
+ // Drain remaining waiters with done after the stopped event.
851
+ while (waiters.length > 0) {
852
+ waiters.shift()({ value: undefined, done: true });
853
+ }
854
+ }
855
+ }
856
+ else {
857
+ queue.push(event);
858
+ }
859
+ };
860
+ const unsub = subscribe(hash, cb, options);
861
+ return {
862
+ next: () => {
863
+ if (queue.length > 0) {
864
+ const value = queue.shift();
865
+ return Promise.resolve({ value, done: false });
866
+ }
867
+ if (done)
868
+ return Promise.resolve({ value: undefined, done: true });
869
+ return new Promise((resolve) => {
870
+ waiters.push(resolve);
871
+ });
872
+ },
873
+ return: () => {
874
+ unsub();
875
+ done = true;
876
+ // The unsub call above triggers the synthetic stopped event
877
+ // through the same `cb` that drains pending waiters, so by
878
+ // the time we get here the waiters queue is empty under
879
+ // normal flow. The drain stays as a belt-and-braces guard
880
+ // for any future code path that might call return() without
881
+ // the unsub-driven stopped emit.
882
+ while (waiters.length > 0) {
883
+ /* c8 ignore next */
884
+ waiters.shift()({ value: undefined, done: true });
885
+ }
886
+ return Promise.resolve({ value: undefined, done: true });
887
+ },
888
+ };
889
+ };
890
+ const makeBulkAsyncIterable = (sub) => {
891
+ return {
892
+ [Symbol.asyncIterator]: () => {
893
+ const queue = [];
894
+ const waiters = [];
895
+ let localDone = sub.stopped;
896
+ const unsub = sub.matchSubs.subscribe((event) => {
897
+ const waiter = waiters.shift();
898
+ if (waiter)
899
+ waiter({ value: event, done: false });
900
+ else
901
+ queue.push(event);
902
+ });
903
+ return {
904
+ next: () => {
905
+ if (queue.length > 0) {
906
+ return Promise.resolve({ value: queue.shift(), done: false });
907
+ }
908
+ if (localDone || sub.stopped) {
909
+ return Promise.resolve({
910
+ value: undefined,
911
+ done: true,
912
+ });
913
+ }
914
+ return new Promise((resolve) => {
915
+ waiters.push(resolve);
916
+ });
917
+ },
918
+ return: () => {
919
+ localDone = true;
920
+ unsub();
921
+ while (waiters.length > 0) {
922
+ waiters.shift()({
923
+ value: undefined,
924
+ done: true,
925
+ });
926
+ }
927
+ return Promise.resolve({
928
+ value: undefined,
929
+ done: true,
930
+ });
931
+ },
932
+ };
933
+ },
934
+ };
935
+ };
936
+ // -------------------------------------------------------------
937
+ // Lifecycle
938
+ // -------------------------------------------------------------
939
+ const start = () => {
940
+ if (started)
941
+ return;
942
+ started = true;
943
+ lastCaps = source.capabilities();
944
+ unsubBlocks = source.subscribeBlocks(onBlock);
945
+ unsubMempool = source.subscribeMempool(onMempool);
946
+ };
947
+ const stop = () => {
948
+ if (!started)
949
+ return;
950
+ started = false;
951
+ unsubBlocks?.();
952
+ unsubMempool?.();
953
+ unsubBlocks = null;
954
+ unsubMempool = null;
955
+ // Emit stopped to every per-hash subscriber, then drop them.
956
+ for (const record of tracked.values()) {
957
+ const stoppedEvent = buildStopped({
958
+ hash: record.hash,
959
+ chainId,
960
+ source: blockEventSource(source.capabilities()),
961
+ at: buildAt(),
962
+ reason: 'tracker-stopped',
963
+ });
964
+ record.subs.emit(stoppedEvent);
965
+ globalSubs.emit(stoppedEvent);
966
+ }
967
+ tracked.clear();
968
+ for (const sub of bulkSubs.values())
969
+ sub.stopped = true;
970
+ bulkSubs.clear();
971
+ blockRing = [];
972
+ latestTip = null;
973
+ // Reset receipt-poll-fallback state so a subsequent start() begins clean.
974
+ blocksSinceLastReceiptPoll = new Map();
975
+ receiptPollGateWarned = false;
976
+ // Reset withReceipts gate so a subsequent start() begins clean.
977
+ withReceiptsGateWarned = false;
978
+ };
979
+ // Eager lifecycle: subscribe immediately on construction. Lazy
980
+ // waits for the first track/getStatus call. The capability probe
981
+ // itself runs eagerly in the source, independent of either case.
982
+ if (lifecycle === 'eager') {
983
+ // No-op; consumer calls `start()` explicitly. The eager/lazy
984
+ // distinction in v0.6.x affects internal book-keeping only.
985
+ }
986
+ const trackerSurface = {
987
+ start,
988
+ stop,
989
+ getTxStatus: (hash) => {
990
+ const record = tracked.get(hash);
991
+ return record ? record.status : null;
992
+ },
993
+ track,
994
+ subscribe,
995
+ trackFromAddress,
996
+ trackToAddress,
997
+ trackPredicate,
998
+ capabilities: () => source.capabilities(),
999
+ subscribeAll,
1000
+ group: (hashes, opts) => createTxGroup(trackerSurface, hashes, opts),
1001
+ };
1002
+ return trackerSurface;
1003
+ };
1004
+ //# sourceMappingURL=tracker.js.map