/** * `createTxTracker` — the per-tx state machine that turns a * `ChainSource`'s block + mempool stream into a stream of neutral * observations per tracked hash. * * Per `docs/tx-tracker-spec.md` §5.2 + §6 + §11 + §12. This file is * the load-bearing piece of `@valve-tech/tx-tracker`; everything * else is supporting infrastructure (events, store, reorg detector, * selectors). * * Design rules carried in from the spec and the contributing skill: * * - **Three consumption shapes, one underlying stream** (§5.3). * `getTxStatus(hash)` reads the cached snapshot; `subscribe(hash, cb)` * attaches a callback; `track(hash)` returns an async iterator. * All three see consistent state because they read from one * internal `Subscriptions` per hash. * * - **Neutral observations only** (§2.1). The tracker emits * `seen-in-mempool` / `seen-in-block` / `vanished-from-block` / * `replaced-by` / `unseen-for-N-blocks` and lets the consumer * write the policy that says "confirmed" or "stuck" in their * UX voice. * * - **No silent downgrade** (§2.2). Every emitted event carries a * `source` discriminator. When the source's `capabilities()` * change between ticks, the tracker emits `signal-degraded` / * `signal-recovered` per affected capability. * * - **No own poll cycle** (§3.1, contributing-skill rule 3). The * tracker hangs off `source.subscribeBlocks` and * `source.subscribeMempool`; every per-tick computation runs * inside those callbacks. * * - **Browser/mobile safe** (§2.4). No Node-only deps; the * pub/sub primitive is `chain-source`'s `Subscriptions`. */ import type { ChainSource, Capabilities, Logger, RawTx } from '@valve-tech/chain-source'; import type { TxGroupEvent } from './group-events.js'; import { type Address, type At, type Hash, type TxEvent, type TxStatus } from './events.js'; import { type BulkSelector, type TxTrackerStore } from './store.js'; /** * Lost-signal policy (spec §8). `'emit-uncertain'` is the default * — every transition to a degraded source emits `signal-degraded`. * `'silent'` keeps the events to itself; `'receipt-poll-fallback'` * fetches `eth_getTransactionReceipt` every `pollEveryBlocks` block * ticks and emits `seen-in-block` with `source: 'receipt-poll'` on a * hit. Requires `capabilities().receiptByHash === 'available'`; * when unavailable, downgrades to emit-uncertain semantics with a * one-shot warning via `onError`. */ export type LostSignalPolicy = 'emit-uncertain' | 'silent' | { strategy: 'receipt-poll-fallback'; pollEveryBlocks: number; }; /** * Probe return shape. The tracker emits `seen-in-block` with * `transactionIndex: 0` and `confirmations: 1`; the consumer's authoritative * tip is the tracker's, so confirmations are derived, not supplied. */ export type ProbeMinedResult = { blockHash: string; blockNumber: bigint; }; /** * Consumer-supplied fallback for per-hash status (`eth_getTransactionByHash`). * Attached per-subscription via {@link TrackOptions.probeTransaction}. The * tracker calls `source.getTransaction(hash)` first on each status-poll tick; * if that returns null AND this probe is attached, the tracker calls the probe * as a fallback. Use to consult a different RPC, multi-RPC fan-out, an indexer * with mempool support, or a commercial mempool service. * * Return value semantics mirror `eth_getTransactionByHash`: * * - `null` → tx unknown to your fallback source. * - `{ blockHash: null/undefined, from, nonce, ... }` → pending. Tracker * caches identity (for replacement detection) and emits `seen-in-mempool` * with `source: 'receipt-poll'` on the first pending-state observation * it makes via this path. * - `{ blockHash: '0x…', ... }` → mined. Tracker emits `seen-in-block` * with `source: 'receipt-poll'`, subject to the height-ordering rule. * * The probe is NOT permitted to drive reorg / vanished-from-block events * (spec §12.3) — divergence detection stays anchored on the source's block * stream where parent-hash chains are authoritative. * * First-set wins across multiple subscribes on the same hash (mirrors * `lostSignalPolicy` / `probeMined`). */ export type ProbeTransaction = (hash: Hash) => Promise; /** * Consumer-supplied mined-detection probe. Attached per-subscription via * {@link TrackOptions.probeMined}. The tracker dispatches it for the tracked * hash on every block tick, in addition to its own block-poll inclusion check. * * - Return `null` when the probe can't confirm inclusion. Probe throws are * routed through `onError` and treated as null. * - Whichever path — block-poll or probe — reports a strictly newer * inclusion wins; the existing height-ordering rule prevents the * lower-authority arrival from clobbering the higher. * * The probe is NOT permitted to drive reorg / vanished-from-block events * (spec §12.3) — divergence detection stays anchored on the source's block * stream where the parent-hash chain is authoritative. * * Probe-derived observations emit `seen-in-block` with * `source: 'receipt-poll'` (widened to mean "any per-hash mined check that * isn't the source's own block-poll"; see chain-source `EventSource` docs). */ export type ProbeMined = (hash: Hash) => Promise; /** * Per-subscription overrides on top of the tracker defaults. See * spec §5.4. */ export interface TrackOptions { /** * Emit a synthetic `started` event on subscribe even if no real * observation has fired yet. Default true. Wallets use this to * render an "awaiting first observation" state without polling. */ emitInitial?: boolean; /** * Persist this subscription via the store. Default false — the * subscription survives only the current process. Indexer / relay * consumers set this true. */ durable?: boolean; /** Per-subscription override of the tracker's `lostSignalPolicy`. */ lostSignalPolicy?: LostSignalPolicy; /** * How many consecutive blocks the hash must be unseen (not in * mempool, not in the canonical block) before * `unseen-for-N-blocks` fires. Default 30 (spec §6.1). */ unseenThresholdBlocks?: number; /** * Eager receipt enrichment. When true, fetch the transaction * receipt at seen-in-block time and attach it to the event via * the `receipt` field. Adds one RPC per inclusion. Default false. * Capability gate: requires source.capabilities().receiptByHash === * 'available'; when unavailable, events still flow but `receipt` * is absent and a one-shot warning surfaces via onError. */ withReceipts?: boolean; /** * Consumer-supplied mined-detection probe. See {@link ProbeMined}. * First-set wins across subscriptions on the same hash — once attached, * subsequent subscribes on the same hash with a different probe are * ignored (mirrors the {@link TrackOptions.lostSignalPolicy} contract). */ probeMined?: ProbeMined; /** * Consumer-supplied fallback for `eth_getTransactionByHash`-style status * checks. See {@link ProbeTransaction}. Called only when the source's * default `getTransaction(hash)` returns null — for cases where the * consumer has broader visibility (different RPC, multi-RPC fan-out, * indexer with mempool support, commercial mempool service). First-set * wins across subscriptions on the same hash. */ probeTransaction?: ProbeTransaction; /** * Caller-provided stable identifier for the persisted subscription * (only meaningful when `durable: true`). When set, repeated calls * with the same `subscriptionId` are idempotent — the persisted * subscription is recorded exactly once even across many subscribes * (e.g. React component remounts, hot-reloads, page reloads with a * cross-process store). * * Without an explicit id, the tracker auto-dedups by `(durable, * selector)`: a second `subscribe` on the same hash with `durable: true` * reuses the prior persisted entry rather than appending a duplicate. * Use the explicit id when you need a stable handle to a specific * persisted entry — e.g., a long-lived component lifecycle that wants * to guarantee its persisted entry never multiplies. * * No-op when `durable` is falsy. */ subscriptionId?: string; } /** Bulk subscription options — extends per-hash `TrackOptions`. */ export interface BulkTrackOptions extends TrackOptions { /** * Auto-track every tx the selector matches by starting an * implicit per-hash subscription for it. Default true — an * indexer wiring `trackFromAddress(treasury)` typically wants * the per-hash event stream too. Set false to receive only the * raw `matched` stream without per-hash detail. */ autoTrackMatched?: boolean; } /** One emit from a bulk subscription — see spec §11.1. */ export interface TxMatchEvent { kind: 'matched'; hash: Hash; matchedBy: 'from' | 'to' | 'predicate'; selector: BulkSelector; tx: RawTx; source: 'mempool-snapshot' | 'block-poll'; at: At; } /** Handle returned by every bulk-track method. */ export interface TxSubscription { /** * Async iterator over the raw `matched` stream. Iteration ends * when `stop()` is called or the tracker stops. */ events(): AsyncIterable; /** * Imperative subscription to per-hash events on every matched tx. * Returns an unsubscribe handle. */ subscribe(cb: (event: TxEvent) => void): () => void; /** * Stop the bulk subscription. Per-hash subscriptions auto-tracked * via this bulk subscription continue under their own retention * rules (spec §11.1). */ stop(): void; } /** Factory options. */ export interface CreateTxTrackerOptions { source: ChainSource; chainId: number; store?: TxTrackerStore; lostSignalPolicy?: LostSignalPolicy; reorgDepthBlocks?: number; /** Default `unseenThresholdBlocks` for new subscriptions. */ unseenThresholdBlocks?: number; /** Cap on simultaneous bulk subscriptions (spec §11.3). */ maxBulkSubscriptions?: number; /** * How many blocks past a terminal-and-finalized state (`replaced-by` * or `unseen-for-N-blocks` emitted) before the tracker drops a * record and emits `Stopped({ reason: 'retention-expired' })`. * Default `64` (spec §10). Pass the same value to your store * implementation so persisted retention matches in-memory. * Records still in flight (no terminal observation) are not subject * to retention; they live until their last subscriber leaves AND * they have no durable subscription (cleanupRecord path). */ retentionBlocks?: number; onError?: (method: string, err: unknown) => void; /** * Optional logger callback. Same shape as * `@valve-tech/chain-source` — `(level, message, meta?) => void`. * The tracker calls it at decision points the consumer might want * to surface: rehydration counts, dedup-migration writes, terminal * transitions, retention-expiry firings, capability-degradation / * recovery transitions. Errors continue to flow through `onError`; * the logger covers the "what did the tracker decide" question. */ logger?: Logger; lifecycle?: 'eager' | 'lazy'; /** * Mined-and-confirmed terminal threshold. When non-null and a tracked * record's `lastSeenInBlock.confirmations` reaches this value, the * record transitions to terminal (`terminalAtBlockNumber` anchored on * the current tip — same pattern as the existing replacement / * unseen-for-N terminal arms) and `confirmed-terminal` fires once. * The retention countdown then begins from that block. * * Pre-v0.15, normally-mined transactions never reached terminal — * retention enforcement only fired on replacement or * unseen-for-N-blocks paths, so successful txs accumulated in * long-lived stores forever. `null` (default) preserves that * behavior; opt in with a value `>= reorgDepthBlocks` to safely * retire mined records past the reorg window. * * Validation: must be `null`, `undefined`, or a positive integer. * Recommended: `≥ reorgDepthBlocks` (default reorg depth = 12) to * avoid premature terminal during a same-height reorg unmining * the tx. */ confirmationsForTerminal?: number | null; /** * Cadence for per-hash status polling via * `source.getTransaction(hash)` (`eth_getTransactionByHash`). Runs once * per block tick for every tracked record by default — `1` polls every * block; `2` every other block; `0` disables the path entirely. The * status-poll path is the consumer-friendly default for chains where * `txpool_content` is gated or where mempool gossip is unreliable * (PulseChain et al): `eth_getTransactionByHash` is universally exposed * and queries the node's indexed-tx store, so it sees txs that aren't * currently in the local pool but the node has seen referenced. * * Cost: one `eth_getTransactionByHash` RPC per tracked record per * matching tick. Override per-subscription via * {@link TrackOptions.probeTransaction} when the consumer has a * better-visibility per-hash check than the default RPC method. * * Default `1`. Set `0` to disable (existing behavior pre-v0.14). */ statusPollEveryBlocks?: number; } /** * Options for a group subscription. All fields are optional — the group * works with defaults. See `createTxGroup` in `group.ts`. */ export interface GroupOptions { /** Optional human-readable group ID echoed in events. Default: random. */ groupId?: string; /** Per-member TrackOptions applied to each hash. */ memberOptions?: TrackOptions; } /** * Handle returned by `tracker.group(hashes, options?)`. Exposes three * consumption shapes (async iterator, callback, snapshot) over the same * group-event stream, plus a `stop()` to tear down all member * subscriptions. */ export interface TxGroupSubscription { /** Async-iterable surface over the group event stream. */ events(): AsyncIterable; /** * Imperative callback subscription. Returns an unsubscribe handle. */ subscribe(cb: (event: TxGroupEvent) => void): () => void; /** Snapshot of each member's current `TxStatus` (null if not yet observed). */ snapshot(): Record; /** Tear down all member subscriptions and emit `group-stopped`. */ stop(): void; } /** Public surface returned by `createTxTracker`. */ export interface TxTracker { start(): void; stop(): void; /** * Promise that resolves when durable-subscription rehydration * triggered by the most recent `start()` has completed. For * in-memory stores this typically resolves on the next microtask; * for cross-process restart with Redis / SQLite / etc, this is the * gate indexer / relay consumers should `await` before assuming * the tracked-set is fully restored: * * tracker.start() * await tracker.ready() * // safe to begin processing — durable records from previous run * // are now registered against the source. * * Returns an already-resolved promise when `start()` has not been * called or the previous rehydration already finished. Resolves to * `void` — errors during rehydration are routed through `onError` * and don't reject this promise (one bad store call shouldn't * crash consumer flow that's waiting for ready). */ ready(): Promise; getTxStatus(hash: Hash): TxStatus | null; track(hash: Hash, options?: TrackOptions): AsyncIterable; subscribe(hash: Hash, cb: (event: TxEvent) => void, options?: TrackOptions): () => void; trackFromAddress(address: Address, options?: BulkTrackOptions): TxSubscription; trackToAddress(address: Address, options?: BulkTrackOptions): TxSubscription; trackPredicate(match: (tx: RawTx) => boolean, options?: BulkTrackOptions): TxSubscription; capabilities(): Capabilities; subscribeAll(cb: (event: TxEvent) => void): () => void; /** * Cross-tx correlation — track a logical group of related hashes * (e.g., a wallet's "claim + swap" pair). Emits group-level * synthesis events derived from the per-member event streams. * See spec §18.1, v0.8.0 design F3. */ group(hashes: Hash[], options?: GroupOptions): TxGroupSubscription; } /** * Build a configured tracker. * * @example * import { createChainSource } from '@valve-tech/chain-source' * import { createTxTracker } from '@valve-tech/tx-tracker' * * const source = createChainSource({ client }) * const tracker = createTxTracker({ source, chainId: 1 }) * * source.start() * tracker.start() * * for await (const event of tracker.track('0xabc...')) { * if (event.kind === 'seen-in-block' && event.confirmations >= 3) break * } */ export declare const createTxTracker: (options: CreateTxTrackerOptions) => TxTracker; //# sourceMappingURL=tracker.d.ts.map