/** * Per-record decision functions — the **pure** logic that turns one * upstream observation (a block or a mempool snapshot) plus one * tracked record's current state into the events that should be * emitted and the state patch the orchestrator should apply. * * These functions are extracted from `tracker.ts`'s `onBlock` / * `onMempool` so each branch of the per-record state machine is * testable with literal fixture inputs — no stub source, no async * orchestration, no shared mutable closure. This is the same * primitive-vs-orchestrator split that `oracle.ts` (`reducePollInputs` * pure / poll loop stateful) and `chain-source` (math pure / source * stateful) already follow. * * Inputs are immutable; outputs are immutable. The caller in * `tracker.ts` applies the returned `statusPatch` and `identityPatch` * to its mutable `TrackedRecord`, then emits the returned `events` * via its event-bus + store-audit-log machinery. */ import type { EventSource, RawTx, TransactionReceipt } from '@valve-tech/chain-source'; import { type At, type Hash, type TxEvent, type TxStatus } from './events.js'; /** * Read-only projection of `TrackedRecord` that the decision functions * consume. The orchestrator passes its mutable record through this * shape so the pure layer cannot accidentally mutate state. */ export interface ReadonlyTrackedRecord { hash: Hash; status: TxStatus; identity: { from: string; nonce: string; } | null; inLastMempoolSnapshot: boolean; unseenThresholdBlocks: number; } /** * Cached `(from, nonce)` identity of the tracked tx. Used for * replacement detection. */ export interface IdentityPatch { from: string; nonce: string; } /** * Result of a decision function: events to emit and patches to apply * to the tracked record. The patch shapes are deliberately narrow — * only the fields the function decided to change. The orchestrator * merges them into its mutable record. */ export interface ObservationResult { events: TxEvent[]; statusPatch: Partial; identityPatch: IdentityPatch | null; /** Set when the per-mempool-tick "in-snapshot?" flag should change. */ inMempoolPatch: boolean | null; } /** * Inputs to `decideBlockObservation`. The orchestrator builds a * `txHashSet` once per block and passes it for O(1) "did this hash * appear?" lookups across every tracked record. */ export interface BlockObservationInput { record: ReadonlyTrackedRecord; blockHash: Hash; blockNumber: bigint; txHashSet: ReadonlySet; txs: ReadonlyArray; chainId: number; eventSource: EventSource; envelope: At; /** * The previous canonical tip's block number, or `null` if this is * the first block the tracker has seen. Used to gate the * "confirmation bump" path — we don't bump confirmations on the * very first block we observe. */ previousTipNumber: bigint | null; /** * Pre-fetched receipts for hashes whose `withReceipts` flag is set. * When a hash appears in this map, the receipt is attached to the * `seen-in-block` event. Omitted or empty means no enrichment. This * is the sole path for F2 eager receipt attachment (spec §18.2) — * the orchestrator fetches and populates this map before calling * `decideBlockObservation`, so the first emitted event carries the * receipt without a follow-up re-emit. */ prefetchedReceipts?: ReadonlyMap; /** * Threshold for the mined-and-confirmed terminal transition. When * non-null and the record's confirmations reach this value, the * record is marked terminal (anchored on the current block, same * pattern as the other terminal arms) and `confirmed-terminal` * fires exactly once. `null` preserves the v0.14 behavior of never * setting terminal via the mined path. */ confirmationsForTerminal: number | null; } /** * Per-record decision for one new canonical block. Returns the events * to emit and the state patch to apply. Mutually-exclusive paths, * evaluated in order: * * 1. Hash is in this block → fresh inclusion (emit `seen-in-block` * with `confirmations: 1`) OR same-block re-observation (no * emit; `lastSeenInBlock` is already current). * 2. Hash NOT in this block but was previously included → bump * `confirmations` on the cached observation, emit a fresh * `seen-in-block` carrying the new count. * 3. Hash NOT in this block, no prior inclusion, but identity is * cached AND a different hash with the same `(from, nonce)` is * in this block → emit `replaced-by` with the replacement's * block number. * 4. Truly unseen → bump the unseen-block streak; emit * `unseen-for-N-blocks` when the streak crosses the * subscription's threshold. Does NOT emit on the first block * after subscription (no `firstObservedAtBlock` yet). */ export declare const decideBlockObservation: (input: BlockObservationInput) => ObservationResult; /** * Inputs to `decideMempoolObservation`. The orchestrator builds the * hash-keyed snapshot index once per mempool tick. */ export interface MempoolObservationInput { record: ReadonlyTrackedRecord; presence: { bucket: 'pending' | 'queued'; tx: RawTx; } | null; /** * The replacement candidate found in the snapshot for this record's * `(from, nonce)` identity, or `null` if none. Computed by the * orchestrator once per record so this function stays pure on * inputs (no closure over the snapshot). */ replacementInMempool: RawTx | null; chainId: number; eventSource: EventSource; envelope: At; /** * The current canonical-tip block number the orchestrator is using * for `firstObservedAtBlock` / `lastObservedAtBlock` book-keeping. * Falls back to `0n` when no tip has been observed yet. */ tipBlockNumber: bigint; } /** * Per-record decision for one mempool snapshot. Three independent * outputs that may all fire on the same call: * * - **Presence transition** — emit `seen-in-mempool` on first * observation or bucket change; emit `left-mempool` when a * previously-seen hash is absent from this snapshot. * - **Replacement** — emit `replaced-by` (with `null` block) when * the orchestrator's pre-computed `replacementInMempool` is set * AND the record hasn't already recorded a replacement. */ export declare const decideMempoolObservation: (input: MempoolObservationInput) => ObservationResult; /** * Find a tx in `txs` whose `(from, nonce)` matches `identity` but * whose hash differs from `originalHash` — the replacement candidate * for the original tracked tx. Compares senders case-insensitively * since upstreams disagree on checksum form. */ export declare const findReplacementInBlock: (identity: { from: string; nonce: string; }, originalHash: Hash, txs: ReadonlyArray) => RawTx | null; /** * Cache the tx's `(from, nonce)` as the record's identity if it's * not already cached AND the tx carries both fields. Returns the * patch to apply (or `null` when no change). */ export declare const cacheIdentity: (current: { from: string; nonce: string; } | null, tx: RawTx) => IdentityPatch | null; //# sourceMappingURL=observations.d.ts.map