@valve-tech/tx-tracker 0.6.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.
- package/AGENTS.md +237 -0
- package/CHANGELOG.md +125 -0
- package/README.md +13 -6
- package/dist/events.d.ts +299 -0
- package/dist/events.d.ts.map +1 -0
- package/dist/events.js +131 -0
- package/dist/events.js.map +1 -0
- package/dist/index.d.ts +46 -10
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +46 -1
- package/dist/index.js.map +1 -1
- package/dist/observations.d.ts +159 -0
- package/dist/observations.d.ts.map +1 -0
- package/dist/observations.js +283 -0
- package/dist/observations.js.map +1 -0
- package/dist/reorg.d.ts +108 -0
- package/dist/reorg.d.ts.map +1 -0
- package/dist/reorg.js +125 -0
- package/dist/reorg.js.map +1 -0
- package/dist/selectors.d.ts +78 -0
- package/dist/selectors.d.ts.map +1 -0
- package/dist/selectors.js +119 -0
- package/dist/selectors.js.map +1 -0
- package/dist/store.d.ts +166 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/store.js +110 -0
- package/dist/store.js.map +1 -0
- package/dist/tracker.d.ts +165 -0
- package/dist/tracker.d.ts.map +1 -0
- package/dist/tracker.js +823 -0
- package/dist/tracker.js.map +1 -0
- package/package.json +6 -1
- package/skills/tx-tracker-integration/SKILL.md +168 -0
package/dist/tracker.js
ADDED
|
@@ -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
|