@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.
- package/AGENTS.md +237 -0
- package/CHANGELOG.md +140 -0
- package/README.md +13 -6
- package/dist/events.d.ts +309 -0
- package/dist/events.d.ts.map +1 -0
- package/dist/events.js +132 -0
- package/dist/events.js.map +1 -0
- package/dist/group-events.d.ts +82 -0
- package/dist/group-events.d.ts.map +1 -0
- package/dist/group-events.js +47 -0
- package/dist/group-events.js.map +1 -0
- package/dist/group.d.ts +31 -0
- package/dist/group.d.ts.map +1 -0
- package/dist/group.js +196 -0
- package/dist/group.js.map +1 -0
- package/dist/index.d.ts +57 -10
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +52 -1
- package/dist/index.js.map +1 -1
- package/dist/observations.d.ts +169 -0
- package/dist/observations.d.ts.map +1 -0
- package/dist/observations.js +287 -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/replace-transaction.d.ts +46 -0
- package/dist/replace-transaction.d.ts.map +1 -0
- package/dist/replace-transaction.js +47 -0
- package/dist/replace-transaction.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 +211 -0
- package/dist/tracker.d.ts.map +1 -0
- package/dist/tracker.js +1004 -0
- package/dist/tracker.js.map +1 -0
- package/dist/wait-for-pending.d.ts +41 -0
- package/dist/wait-for-pending.d.ts.map +1 -0
- package/dist/wait-for-pending.js +71 -0
- package/dist/wait-for-pending.js.map +1 -0
- package/dist/wait-for-transaction.d.ts +55 -0
- package/dist/wait-for-transaction.d.ts.map +1 -0
- package/dist/wait-for-transaction.js +72 -0
- package/dist/wait-for-transaction.js.map +1 -0
- package/dist/watch-transaction.d.ts +57 -0
- package/dist/watch-transaction.d.ts.map +1 -0
- package/dist/watch-transaction.js +76 -0
- package/dist/watch-transaction.js.map +1 -0
- package/package.json +6 -1
- package/skills/tx-tracker-integration/SKILL.md +198 -0
package/dist/tracker.js
ADDED
|
@@ -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
|