From dc9551a86ca6348141841443f92fa76b8e0eb8d4 Mon Sep 17 00:00:00 2001 From: elina-chertova Date: Tue, 30 Jun 2026 12:58:45 +0000 Subject: [PATCH] evm-rpc: retry transient 'invalid block height' on finalized-head probe getLatestBlockhash() probes the finalized head via eth_getBlockByNumber with only validateResult and no validateError, so when a load-balanced provider transiently returns -32603 'invalid block height' for the finalized tag (its backend lagging the finalized pointer), the RpcError propagates as fatal and crash-loops the dump. getBlocks() already maps this exact error to a RetryError for Hyperliquid; apply the same mapping on the finalized-head probe so it retries instead of crashing. --- ...q-hl-finalized-retry_2026-06-30-13-00.json | 10 +++ evm/evm-rpc/src/rpc.ts | 6 +- evm/evm-rpc/test/finalized-head-retry.test.ts | 62 +++++++++++++++++++ 3 files changed, 77 insertions(+), 1 deletion(-) create mode 100644 common/changes/@subsquid/evm-rpc/alert-fix-sgI5cq-hl-finalized-retry_2026-06-30-13-00.json create mode 100644 evm/evm-rpc/test/finalized-head-retry.test.ts diff --git a/common/changes/@subsquid/evm-rpc/alert-fix-sgI5cq-hl-finalized-retry_2026-06-30-13-00.json b/common/changes/@subsquid/evm-rpc/alert-fix-sgI5cq-hl-finalized-retry_2026-06-30-13-00.json new file mode 100644 index 000000000..ccec9695c --- /dev/null +++ b/common/changes/@subsquid/evm-rpc/alert-fix-sgI5cq-hl-finalized-retry_2026-06-30-13-00.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@subsquid/evm-rpc", + "comment": "Retry the transient 'invalid block height' RPC error on the finalized-head probe (getLatestBlockhash) instead of crashing the dump", + "type": "patch" + } + ], + "packageName": "@subsquid/evm-rpc" +} diff --git a/evm/evm-rpc/src/rpc.ts b/evm/evm-rpc/src/rpc.ts index 14b3d236a..a9087465f 100644 --- a/evm/evm-rpc/src/rpc.ts +++ b/evm/evm-rpc/src/rpc.ts @@ -111,7 +111,11 @@ export class Rpc { qtyOrCommitment = commitment } let block = await this.call('eth_getBlockByNumber', [qtyOrCommitment, false], { - validateResult: getResultValidator(GetBlock) + validateResult: getResultValidator(GetBlock), + validateError: info => { + if (info.message.includes('invalid block height')) throw new RetryError() // Hyperliquid + throw new RpcError(info) + } }) return { number: qty2Int(block.number), diff --git a/evm/evm-rpc/test/finalized-head-retry.test.ts b/evm/evm-rpc/test/finalized-head-retry.test.ts new file mode 100644 index 000000000..7ee866b36 --- /dev/null +++ b/evm/evm-rpc/test/finalized-head-retry.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect } from 'vitest' +import { RetryError, RpcError } from '@subsquid/rpc-client' +import { loadBlock } from './helpers/fixture-loader' +import { Rpc } from '../src/rpc' + +/** + * Minimal client mirroring RpcClient.receiveResult + retry-on-RetryError: a + * `validateError` that throws RetryError advances through the queued responses, + * exactly as the real client re-enqueues and retries the call. + */ +class RetryingMockClient { + public attempts = 0 + public url = 'mock://test' + constructor(private responses: ({ result: any } | { error: { code: number; message: string } })[]) {} + getConcurrency(): number { return 1 } + isConnectionError(): boolean { return false } + async call(method: string, params: any[] | undefined, options?: any): Promise { + for (let i = 0; i < 20; i++) { + const res = this.responses[Math.min(this.attempts, this.responses.length - 1)] + this.attempts++ + try { + if ('error' in res) { + if (options?.validateError) return options.validateError(res.error, { method, params }) + throw new RpcError(res.error as any) + } + return options?.validateResult ? options.validateResult(res.result, { method, params }) : res.result + } catch (err) { + if (err instanceof RetryError) continue + throw err + } + } + throw new Error('retry limit exceeded') + } +} + +describe('Rpc.getLatestBlockhash finalized-head probe', () => { + it('retries the transient "invalid block height" error instead of crashing the dump', async () => { + const fixtureBlock = loadBlock('ethereum', 18000000) + // uniblock returns this for the `finalized` tag when its load-balanced + // backend lags behind the finalized pointer; a later attempt hits a + // healthy backend and serves the block. + const client = new RetryingMockClient([ + { error: { code: -32603, message: 'invalid block height: 57603085' } }, + { result: fixtureBlock }, + ]) + const rpc = new Rpc({ client: client as any }) + + const head = await rpc.getLatestBlockhash('finalized') + + expect(head.hash).toEqual(fixtureBlock.hash) + expect(client.attempts).toBeGreaterThanOrEqual(2) // proves it retried rather than crashed + }) + + it('still surfaces genuine RPC errors on the finalized-head probe', async () => { + const client = new RetryingMockClient([ + { error: { code: -32000, message: 'execution reverted' } }, + ]) + const rpc = new Rpc({ client: client as any }) + + await expect(rpc.getLatestBlockhash('finalized')).rejects.toThrow() + }) +})