Context
hydra-chain-observer got its Blockfrost mode in October 2024 (#1631), months before hydra-node's Blockfrost chain backend existed (April 2025). When the node backend was added, the reuse direction was inverted: the existing code lived in the downstream package, so it was copied into hydra-node instead of extracted, and the two copies have drifted ever since, accumulating the same classes of bugs independently.
#2753 made the cost concrete: the node's copy was overhauled (batched block fetching via getNextBlocks', tx_count-based skip of empty-block fetches, sleep only at tip, structured 429 handling with capped backoff, immediate submission error reporting), while the observer still runs the original design: chasing _blockNextBlock one getBlock at a time, with NotEnoughBlockConfirmations/MissingNextBlockHash exception control flow that the node has since deleted. Only the rate-limit pieces are shared today (the observer imports rateLimitBackoff/maxRateLimitRetries from Hydra.Chain.Blockfrost.Client); its runBlockfrostM, error type, isRetryable, toChainPoint, and follower remain private near-twins in hydra-chain-observer/src/Hydra/Blockfrost/ChainObserver.hs.
What is duplicated
┌─────────────────────────────────────────────────────────────────────┬────────────────────────────────────────────────────────────────────────┐
│ hydra-node │ hydra-chain-observer │
├─────────────────────────────────────────────────────────────────────┼────────────────────────────────────────────────────────────────────────┤
│ Hydra.Chain.Blockfrost.Client.APIBlockfrostError + isRetryable │ private APIBlockfrostError + isRetryable (still carries the │
│ │ constructors the node removed) │
├─────────────────────────────────────────────────────────────────────┼────────────────────────────────────────────────────────────────────────┤
│ runBlockfrostM with 429 backoff │ near-identical private copy │
├─────────────────────────────────────────────────────────────────────┼────────────────────────────────────────────────────────────────────────┤
│ batched follower (pollForNewBlocks + processBlock in │ old walk-the-next-pointer rollForward loop │
│ Hydra.Chain.Blockfrost) │ │
├─────────────────────────────────────────────────────────────────────┼────────────────────────────────────────────────────────────────────────┤
│ toChainPoint, CBOR-to-Tx decoding │ private copies │
└─────────────────────────────────────────────────────────────────────┴────────────────────────────────────────────────────────────────────────┘
Proposal
- Extract a shared follower from Hydra.Chain.Blockfrost (the pollForNewBlocks/processBlock pair), parameterized by a per-block callback with a state parameter: the node's callback is stateless (wallet update + ChainSyncHandler), but the observer threads state through the loop (its UTxO set and known script versions), so the callback shape is roughly s -> BlockHeader -> [Tx] -> m s.
- Port hydra-chain-observer onto it, deleting its private runBlockfrostM, error type, isRetryable, toChainPoint, and rollForward. The observer inherits bounded catch-up, the empty-block skip, and steady ~1-block-from-tip behavior.
- Unify on hydra-node's APIBlockfrostError; the dependency direction (observer already depends on hydra-node) makes this the legal sharing direction.
- Add at least one Blockfrost-mode test for the observer while touching it; today its only e2e runs against a direct devnet, which is how the drift went unnoticed.
Non-goals
- Rollback handling: still unhandled on the Blockfrost path in both consumers (the follower wedges retrying an orphaned hash); tracked separately.
- Removing the observer's dependency on hydra-node (e.g. a standalone hydra-blockfrost package); worth considering only if that dependency is being cut anyway.
Follow-up to #2753.
Context
hydra-chain-observer got its Blockfrost mode in October 2024 (#1631), months before hydra-node's Blockfrost chain backend existed (April 2025). When the node backend was added, the reuse direction was inverted: the existing code lived in the downstream package, so it was copied into hydra-node instead of extracted, and the two copies have drifted ever since, accumulating the same classes of bugs independently.
#2753 made the cost concrete: the node's copy was overhauled (batched block fetching via getNextBlocks', tx_count-based skip of empty-block fetches, sleep only at tip, structured 429 handling with capped backoff, immediate submission error reporting), while the observer still runs the original design: chasing _blockNextBlock one getBlock at a time, with NotEnoughBlockConfirmations/MissingNextBlockHash exception control flow that the node has since deleted. Only the rate-limit pieces are shared today (the observer imports rateLimitBackoff/maxRateLimitRetries from Hydra.Chain.Blockfrost.Client); its runBlockfrostM, error type, isRetryable, toChainPoint, and follower remain private near-twins in hydra-chain-observer/src/Hydra/Blockfrost/ChainObserver.hs.
What is duplicated
┌─────────────────────────────────────────────────────────────────────┬────────────────────────────────────────────────────────────────────────┐
│ hydra-node │ hydra-chain-observer │
├─────────────────────────────────────────────────────────────────────┼────────────────────────────────────────────────────────────────────────┤
│ Hydra.Chain.Blockfrost.Client.APIBlockfrostError + isRetryable │ private APIBlockfrostError + isRetryable (still carries the │
│ │ constructors the node removed) │
├─────────────────────────────────────────────────────────────────────┼────────────────────────────────────────────────────────────────────────┤
│ runBlockfrostM with 429 backoff │ near-identical private copy │
├─────────────────────────────────────────────────────────────────────┼────────────────────────────────────────────────────────────────────────┤
│ batched follower (pollForNewBlocks + processBlock in │ old walk-the-next-pointer rollForward loop │
│ Hydra.Chain.Blockfrost) │ │
├─────────────────────────────────────────────────────────────────────┼────────────────────────────────────────────────────────────────────────┤
│ toChainPoint, CBOR-to-Tx decoding │ private copies │
└─────────────────────────────────────────────────────────────────────┴────────────────────────────────────────────────────────────────────────┘
Proposal
Non-goals
Follow-up to #2753.