Skip to content

fix(api): serve tx receipt as soon as its block is queryable#4913

Draft
raullenchai wants to merge 1 commit into
masterfrom
fix/receipt-availability-ordering
Draft

fix(api): serve tx receipt as soon as its block is queryable#4913
raullenchai wants to merge 1 commit into
masterfrom
fix/receipt-availability-ordering

Conversation

@raullenchai

Copy link
Copy Markdown
Member

Problem

Closes #4677.

A transaction's receipt was returned as nil by eth_getTransactionReceipt for a brief window right after its block became the latest block (typically during syncing), then became available a few seconds later. The block was queryable via eth_getBlockByNumber("latest") / eth_getBlockByHash before its receipt/action index was ready.

Root cause

Inside blockdao.PutBlock, the block is written to the block store first — which advances the blockchain tip and makes the block queryable — and only afterwards are its actions/receipts written to the action indexer. The block store must be written before the indexers because crash recovery (BlockIndexerChecker) requires the indexers to never be ahead of the block store.

By default EnableAsyncIndexWrite is true, so the action indexer is driven by a block subscriber (IndexBuilder) on a separate goroutine. This widens the gap: the block is already latest while its actionHash → blockHeight index (used by both ActionByActionHash and ReceiptByActionHash) has not been committed yet, so the receipt cannot be resolved by hash.

The receipt data itself is in the block store as soon as the block is queryable; the only missing piece during the window is the action index.

Fix

Cap the API-visible tip (coreService.TipHeight()) at the action-indexer height:

tip := bc.TipHeight()
if idxHeight < tip { return idxHeight }
return tip

A block therefore becomes latest only once its actions/receipts are indexed and can be served, so the block and its receipts appear atomically to API clients.

Why it's safe

  • Non-consensus, non-hardfork. This only changes the API's view of the tip. Consensus reads blockchain.TipHeight() directly and is untouched, as are hardfork/chain-id gating checks (core.bc.TipHeight()).
  • Correct readiness boundary. The indexer's Height() shares the same lock as its PutBlock, so once it reports height N the action index for block N is committed and queryable.
  • Cheap. Height() is an in-memory RLock read of a counting-index size.
  • Monotonic. Both chain tip and indexer height are append-only, so min(...) is non-decreasing. If Height() errors, it falls back to the chain tip (no worse than before).

Tests

Added TestTipHeight covering: no indexer, indexer behind, indexer caught up, and indexer error fallback. Codex review: CONVERGED, no blocking issues.

🤖 Generated with Claude Code

A block is written to the block store (advancing the blockchain tip) before
its actions and receipts are written to the action indexer, and by default
that indexing runs asynchronously (EnableAsyncIndexWrite). This opened a brief
window where a block was already the "latest" block returned by
eth_getBlockByNumber("latest") but its receipts could not yet be resolved by
hash, so eth_getTransactionReceipt returned nil until the indexer caught up.

Cap the API-visible tip (coreService.TipHeight) at the action-indexer height so
a block becomes "latest" only once its actions/receipts are indexed and can be
served. The block and its receipts then appear atomically to API clients. The
indexer's Height() is an in-memory read guarded by the same lock as its writes,
so once it reports height N the action index for block N is committed. This
changes only the API's view of the tip; consensus reads blockchain.TipHeight()
directly and is unaffected.

Closes #4677

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@raullenchai raullenchai requested a review from a team as a code owner July 10, 2026 05:14
@sonarqubecloud

Copy link
Copy Markdown

@raullenchai raullenchai marked this pull request as draft July 10, 2026 05:48
@raullenchai

Copy link
Copy Markdown
Member Author

Converting to draft — an independent adversarial codex review found the global TipHeight() cap has broader side effects than the fix intends. Four issues:

  1. newHeads subscriptions still publish the real chain tip — the subscription path doesn't go through the capped TipHeight(), so a client that subscribes and then calls eth_getTransactionReceipt on the new head still hits the nil-receipt window. The fix doesn't cover the very path this bug is most visible on.
  2. latest-state paths disagree with eth_blockNumber — several handlers resolve a sentinel height 0 via bc.TipHeight() (real tip) for latest state (eth_call, balance/nonce reads), while eth_blockNumber is now capped to the indexer height. latest state can correspond to a height greater than eth_blockNumber, which is worse for reconciliation than the original lag.
  3. hot-path lock contentionindexer.Height() shares the lock with the indexer's PutBlock/WriteBatch, so capping TipHeight() (called by many handlers per request) can block behind index writes.
  4. non-monotonic fallback — when Height() errors the code falls back to the uncapped bc.TipHeight(), so the API tip can jump forward then regress.

Direction for a v2: don't cap the global tip. Instead fix it locally where the bug actually is — have the receipt/action-by-hash lookups fall back to reading from the block's own stored receipts (which are present as soon as the block is queryable) when the action index lags, leaving TipHeight() and all latest-state/subscription semantics untouched. Keeping this open as draft.

@envestcc envestcc marked this pull request as ready for review July 13, 2026 09:11
@envestcc envestcc marked this pull request as draft July 13, 2026 09:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[api] Delayed transaction receipt availability

2 participants