From 97dc34ca7178aa26c919ca0a928e5bb03d3e2c63 Mon Sep 17 00:00:00 2001 From: Okeke Christian Chimezie Date: Fri, 3 Jul 2026 14:33:24 +0100 Subject: [PATCH] fix(node): handle skipped Solana slots as unavailable blocks --- packages/node/src/solana/api.solana.spec.ts | 12 +++++- packages/node/src/solana/api.solana.ts | 43 ++++++++++++------- .../node/src/solana/api.solana.unit.spec.ts | 17 ++++++++ packages/node/src/solana/block.solana.spec.ts | 27 ++++++++++++ packages/node/src/solana/block.solana.ts | 24 +++++++++-- 5 files changed, 102 insertions(+), 21 deletions(-) create mode 100644 packages/node/src/solana/api.solana.unit.spec.ts create mode 100644 packages/node/src/solana/block.solana.spec.ts diff --git a/packages/node/src/solana/api.solana.spec.ts b/packages/node/src/solana/api.solana.spec.ts index 379166ccb..4307594a5 100644 --- a/packages/node/src/solana/api.solana.spec.ts +++ b/packages/node/src/solana/api.solana.spec.ts @@ -4,7 +4,7 @@ import { IdlV01 } from '@codama/nodes-from-anchor'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { TransactionFilter } from '@subql/common-solana'; -import { IBlock } from '@subql/node-core'; +import { IBlock, BlockUnavailableError } from '@subql/node-core'; import { SolanaBlock, SolanaBlockFilter, @@ -286,6 +286,16 @@ describe('Api.solana', () => { ), ).toBeDefined(); }); + + it('treats a real skipped mainnet slot as BlockUnavailableError', async () => { + await expect(solanaApi.fetchBlock(394_555_688)).rejects.toBeInstanceOf( + BlockUnavailableError, + ); + + await expect( + solanaApi.getHeaderByHeight(394_555_688), + ).rejects.toBeInstanceOf(BlockUnavailableError); + }, 30_000); }); // Tests that data types can be stringified, this is important to test because of circular references diff --git a/packages/node/src/solana/api.solana.ts b/packages/node/src/solana/api.solana.ts index bf3e8858f..c48d3e615 100644 --- a/packages/node/src/solana/api.solana.ts +++ b/packages/node/src/solana/api.solana.ts @@ -8,7 +8,6 @@ import { SolanaRpcApi } from '@solana/rpc-api'; import { backoffRetry, BlockUnavailableError, - delay, getLogger, Header, IBlock, @@ -29,6 +28,7 @@ const logger = getLogger('api.ethereum'); export type SolanaSafeApi = undefined; const REQUEST_TIMEOUT = 30_000; +const SKIPPED_SLOT_ERROR = 'was skipped'; export class SolanaApi { #client: Rpc; @@ -153,22 +153,26 @@ export class SolanaApi { } async getHeaderByHeight(height: number | bigint): Promise
{ - const slot = typeof height === 'number' ? BigInt(height) : height; - const block = await this.#client - .getBlock(slot, { - maxSupportedTransactionVersion: 0, - transactionDetails: 'none', - rewards: false, - commitment: 'confirmed', // This is used by unfinalized blocks - }) - .send({ abortSignal: AbortSignal.timeout(this.#requestTimeout) }); + try { + const slot = typeof height === 'number' ? BigInt(height) : height; + const block = await this.#client + .getBlock(slot, { + maxSupportedTransactionVersion: 0, + transactionDetails: 'none', + rewards: false, + commitment: 'confirmed', // This is used by unfinalized blocks + }) + .send({ abortSignal: AbortSignal.timeout(this.#requestTimeout) }); - if (!block) { - // No block for that slot - throw new BlockUnavailableError(); - } + if (!block) { + // No block for that slot + throw new BlockUnavailableError(); + } - return solanaBlockToHeader(block); + return solanaBlockToHeader(block, slot); + } catch (e: any) { + throw this.handleError(e); + } } async fetchBlock(blockNumber: number): Promise> { @@ -187,7 +191,10 @@ export class SolanaApi { } this.eventEmitter.emit('fetchBlock'); - return formatBlockUtil(transformBlock(rawBlock, this.decoder)); + return formatBlockUtil( + transformBlock(rawBlock, this.decoder, blockNumber), + blockNumber, + ); } catch (e: any) { console.log('Failed to fetch block', blockNumber, e.message); throw this.handleError(e); @@ -250,6 +257,10 @@ export class SolanaApi { return new Error(`Rate Limited at endpoint: ${hostname}`); } + if (e.message?.includes(SKIPPED_SLOT_ERROR)) { + return new BlockUnavailableError(); + } + return e; } } diff --git a/packages/node/src/solana/api.solana.unit.spec.ts b/packages/node/src/solana/api.solana.unit.spec.ts new file mode 100644 index 000000000..b5f2eaf03 --- /dev/null +++ b/packages/node/src/solana/api.solana.unit.spec.ts @@ -0,0 +1,17 @@ +// Copyright 2020-2025 SubQuery Pte Ltd authors & contributors +// SPDX-License-Identifier: GPL-3.0 + +import { BlockUnavailableError } from '@subql/node-core'; +import { SolanaApi } from './api.solana'; + +describe('SolanaApi errors', () => { + it('normalises skipped slots to BlockUnavailableError', () => { + const error = new Error( + 'Slot 394555688 was skipped, or missing due to ledger jump to recent snapshot', + ); + + expect(SolanaApi.prototype.handleError.call({}, error)).toBeInstanceOf( + BlockUnavailableError, + ); + }); +}); diff --git a/packages/node/src/solana/block.solana.spec.ts b/packages/node/src/solana/block.solana.spec.ts new file mode 100644 index 000000000..441eb9286 --- /dev/null +++ b/packages/node/src/solana/block.solana.spec.ts @@ -0,0 +1,27 @@ +// Copyright 2020-2025 SubQuery Pte Ltd authors & contributors +// SPDX-License-Identifier: GPL-3.0 + +import { solanaBlockToHeader, transformBlock } from './block.solana'; +import { SolanaDecoder } from './decoder'; + +const mockBlock = { + blockHeight: 10n, + blockTime: 1_744_000_000n, + blockhash: 'block-hash', + parentSlot: 12n, + previousBlockhash: 'parent-hash', + transactions: [], +} as any; + +describe('Solana block utils', () => { + it('uses the fetched slot as the header height', () => { + expect(solanaBlockToHeader(mockBlock, 15).blockHeight).toBe(15); + }); + + it('preserves the fetched slot through transformed blocks', () => { + const block = transformBlock(mockBlock, new SolanaDecoder(), 15); + + expect(solanaBlockToHeader(block).blockHeight).toBe(15); + expect(Object.getOwnPropertyNames(block)).not.toContain('slot'); + }); +}); diff --git a/packages/node/src/solana/block.solana.ts b/packages/node/src/solana/block.solana.ts index bcf63b290..2fa4c317b 100644 --- a/packages/node/src/solana/block.solana.ts +++ b/packages/node/src/solana/block.solana.ts @@ -18,6 +18,7 @@ import type { import { SolanaDecoder } from './decoder'; const logger = getLogger('SolanaBlock'); +const SLOT = Symbol('slot'); type RawSolanaBlock = Readonly<{ /** The number of blocks beneath this block */ @@ -209,9 +210,10 @@ function wrapDictionaryLogs( export function transformBlock( block: RawSolanaBlock, decoder: SolanaDecoder, + slot?: number | bigint, ): SolanaBlock { const { transactions, ...baseBlock } = block; - return { + const transformedBlock = { ...baseBlock, transactions: transactions.map((tx) => { try { @@ -261,20 +263,34 @@ export function transformBlock( } }), }; + + if (slot !== undefined) { + Object.defineProperty(transformedBlock, SLOT, { + value: Number(slot), + }); + } + + return transformedBlock; } export function formatBlockUtil( block: B, + slot?: number | bigint, ): IBlock { return { block, - getHeader: () => solanaBlockToHeader(block), + getHeader: () => solanaBlockToHeader(block, slot), }; } -export function solanaBlockToHeader(block: BaseSolanaBlock): Header { +export function solanaBlockToHeader( + block: BaseSolanaBlock, + slot?: number | bigint, +): Header { return { - blockHeight: Number(block.parentSlot) + 1, // The blocks don't include the slot because they assume you know that when making the request + blockHeight: Number( + slot ?? (block as any)[SLOT] ?? Number(block.parentSlot) + 1, + ), blockHash: block.blockhash, parentHash: block.previousBlockhash, timestamp: new Date(Number(block.blockTime) * 1000),