The Blockfrost chain backend is extremely slow — not because of the network or the API, but
because it models freshness with fixed threadDelays and processes blocks at a pace that
cannot exceed chain speed. A full head lifecycle (publish → seed → init → close → fanout) on an
idle preview network takes well over an hour, which is why the only Blockfrost e2e test is
parked as pendingWith "… very slow" (BlockfrostChainSpec.hs#L68)
— a coverage gap that let #2751 ship unnoticed.
Measurements (preview, 2026-07-07, network idle)
| stage |
wall-clock gap |
| publish νHead → publish νCRS (2 txs) |
~10 min |
| publish done → first faucet seeding tx |
~30 min |
| tx submission → on-chain inclusion (for comparison) |
~2 s (next block) |
Network congestion is ruled out: the six most recent preview blocks at the time carried 0–2
transactions each (4 B – 3 KB used of a ~90 KB limit), and our own transactions were included
in the next block after submission.
The chain follower also falls behind and cannot recover: in one run it needed 3m47s of
wall-clock time to advance 50 seconds of chain, ending up minutes behind tip — which then made a
30s observation timeout in the e2e spec fire even though the transaction was long since on-chain.
Root causes
-
Blind 30s sleep before every address-UTxO query —
Client.hs#L460-L463:
-- NOTE: We can't know at the time of doing a query if the information on specific
-- address UTxO is _fresh_ or not so we try to wait for sufficient period of time
-- and hope for best.
liftIO $ threadDelay $ fromIntegral queryTimeout
Every wallet/seeding/publishing step pays this, serially, and it is still not safe —
a fixed sleep is usually too long and occasionally too short.
-
The follower advances at most one block per poll interval —
Blockfrost.hs#L277-L291:
pollForNewBlocks sleeps a full block time (~20s), then processes exactly one block.
Best case it moves at chain speed, so any hiccup becomes permanent lag (catch-up only
happens once, at startup).
-
Two+ HTTP round-trips per block, even for empty blocks —
Blockfrost.hs#L330-L353:
rollForward fetches the block header and then always pages through
getBlockTxsCBOR' — although the header response already contains tx_count
(currently unused) and most testnet blocks are empty.
-
Submission errors are reported after sleeping a full block time —
Blockfrost.hs#L386-L393.
-
Rate limiting is handled preemptively instead of reactively. Blockfrost allows ~10 req/s
with a burst allowance; 1s condition-polling is nowhere near it. A 429 with Retry-After
is the signal to back off — sleeping in advance buys nothing.
Design principle for the fix
Never wait a fixed time and hope; poll a verifiable condition with a short interval and a
deadline.
The API provides enough anchors for every wait we currently do blindly:
- Transaction inclusion is monotone:
/txs/{hash} 404s until indexed and then never
disappears (absent a rollback). Poll that — not an address query preceded by a sleep.
- Read-your-writes: after
submitTx we hold the full transaction, so the UTxO it creates is
known locally. Only the inclusion signal needs the API.
- Height anchoring: when a query must reflect some block
B we already observed, poll the
cheap /blocks/latest until height ≥ B, then query. Address-UTxO responses also carry their
block anchor, so staleness is detectable instead of assumed away.
- Snapshot consistency: for multi-request reads, check
/blocks/latest before and after and
re-read if the tip changed.
Proposed changes
Expected outcome
Non-goals
The Blockfrost chain backend is extremely slow — not because of the network or the API, but
because it models freshness with fixed
threadDelays and processes blocks at a pace thatcannot exceed chain speed. A full head lifecycle (publish → seed → init → close → fanout) on an
idle preview network takes well over an hour, which is why the only Blockfrost e2e test is
parked as
pendingWith "… very slow"(BlockfrostChainSpec.hs#L68)— a coverage gap that let #2751 ship unnoticed.
Measurements (preview, 2026-07-07, network idle)
Network congestion is ruled out: the six most recent preview blocks at the time carried 0–2
transactions each (4 B – 3 KB used of a ~90 KB limit), and our own transactions were included
in the next block after submission.
The chain follower also falls behind and cannot recover: in one run it needed 3m47s of
wall-clock time to advance 50 seconds of chain, ending up minutes behind tip — which then made a
30s observation timeout in the e2e spec fire even though the transaction was long since on-chain.
Root causes
Blind 30s sleep before every address-UTxO query —
Client.hs#L460-L463:Every wallet/seeding/publishing step pays this, serially, and it is still not safe —
a fixed sleep is usually too long and occasionally too short.
The follower advances at most one block per poll interval —
Blockfrost.hs#L277-L291:pollForNewBlockssleeps a full block time (~20s), then processes exactly one block.Best case it moves at chain speed, so any hiccup becomes permanent lag (catch-up only
happens once, at startup).
Two+ HTTP round-trips per block, even for empty blocks —
Blockfrost.hs#L330-L353:rollForwardfetches the block header and then always pages throughgetBlockTxsCBOR'— although the header response already containstx_count(currently unused) and most testnet blocks are empty.
Submission errors are reported after sleeping a full block time —
Blockfrost.hs#L386-L393.Rate limiting is handled preemptively instead of reactively. Blockfrost allows ~10 req/s
with a burst allowance; 1s condition-polling is nowhere near it. A 429 with
Retry-Afteris the signal to back off — sleeping in advance buys nothing.
Design principle for the fix
The API provides enough anchors for every wait we currently do blindly:
/txs/{hash}404s until indexed and then neverdisappears (absent a rollback). Poll that — not an address query preceded by a sleep.
submitTxwe hold the full transaction, so the UTxO it creates isknown locally. Only the inclusion signal needs the API.
Bwe already observed, poll thecheap
/blocks/latestuntilheight ≥ B, then query. Address-UTxO responses also carry theirblock anchor, so staleness is detectable instead of assumed away.
/blocks/latestbefore and after andre-read if the tip changed.
Proposed changes
threadDelay queryTimeoutfromqueryUTxO; replace call sitesthat used it as "wait for my tx to be visible" with
/txs/{hash}presence-polling(1s interval, deadline), and derive the resulting UTxO locally from the submitted tx.
queryTimeout/retryTimeoutas deadlines for condition-polling ratherthan sleep durations (keeps CLI options backward-compatible).
getBlockTxsCBOR'when the block header saystx_count == 0.catch-up), not one block per interval; keep the block-time sleep only for the at-tip case.
PostTxErrorimmediately; add reactive backoff on HTTP 429honoring
Retry-After.BlockfrostChainSpecas a smoke test once the lifecycle fits a sane budget,with observation timeouts sized to follower behavior rather than 30s.
Expected outcome
2 publishes + 2 seedings + init + close + contestation ≈ 10–15 min).
hid Fanout fails with H9 (NoOutputDatumError) on a closed head every partial-fanout chunk size rejected #2751.
Non-goals
block hash chaining stays as-is).