Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion packages/node/src/solana/api.solana.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
43 changes: 27 additions & 16 deletions packages/node/src/solana/api.solana.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import { SolanaRpcApi } from '@solana/rpc-api';
import {
backoffRetry,
BlockUnavailableError,
delay,
getLogger,
Header,
IBlock,
Expand All @@ -29,6 +28,7 @@ const logger = getLogger('api.ethereum');
export type SolanaSafeApi = undefined;

const REQUEST_TIMEOUT = 30_000;
const SKIPPED_SLOT_ERROR = 'was skipped';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== target file: packages/node/src/solana/api.solana.ts ==\n'
wc -l packages/node/src/solana/api.solana.ts
sed -n '1,340p' packages/node/src/solana/api.solana.ts

printf '\n== search for skip-slot and solana error helpers ==\n'
rg -n "was skipped|SKIPPED_SLOT_ERROR|isSolanaError|SolanaError|context\.statusCode|context|code\)" packages/node/src packages -g '!**/dist/**' -g '!**/build/**' || true

Repository: subquery/subql-solana

Length of output: 9473


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== unit spec ==\n'
wc -l packages/node/src/solana/api.solana.unit.spec.ts
sed -n '1,120p' packages/node/src/solana/api.solana.unit.spec.ts

printf '\n== dependency versions ==\n'
rg -n '"`@solana/`(kit|rpc|rpc-api|addresses)"|solana' package.json packages/*/package.json packages/*/*/package.json -g 'package.json' || true

printf '\n== lockfile/package manager hints ==\n'
rg -n '"`@solana/kit`"|`@solana/rpc`|`@solana/rpc-api`' package-lock.json pnpm-lock.yaml yarn.lock npm-shrinkwrap.json || true

Repository: subquery/subql-solana

Length of output: 6624


🌐 Web query:

@solana/kit SolanaError isSolanaError skip slot error code message stripped __DEV__

💡 Result:

In the context of the @solana/kit and @solana/errors libraries, the SolanaError class and the isSolanaError type guard are used to handle typed errors [1][2][3]. When interacting with the Solana JSON-RPC, you may encounter server errors related to skipped or missing slots, such as: SOLANA_ERRORJSON_RPCSERVER_ERROR_SLOT_SKIPPED [4] SOLANA_ERRORJSON_RPCSERVER_ERROR_LONG_TERM_STORAGE_SLOT_SKIPPED [4] These errors often map to underlying JSON-RPC responses (like codes -32007 or -32009) [5][6][7] which indicate that a block was either explicitly skipped by the network due to leader/validator lag or is unavailable due to ledger data gaps [6]. Regarding your specific query components: 1. isSolanaError: This is a type guard function used to check if a caught error is a SolanaError and, optionally, to assert its specific error code [2][3]. When used with a code, it refines the TypeScript type of the error, allowing you to safely access the error's context (e.g., e.context) [2][8]. 2. SolanaError: A class that encapsulates an error's message, stack trace, and a specific numeric Solana error code [1][9]. 3. Slot skipped errors: These indicate that the requested slot does not contain block data, either because it was skipped during consensus or the data is missing from the node's local storage [6]. 4. DEV: In development environments, the @solana/errors library may include additional error context or more descriptive messages that are sometimes stripped or minimized in production builds to reduce bundle size. To handle these errors, you should use the type guard to check for specific error codes rather than string-matching the raw JSON-RPC error message [10][11]: import { isSolanaError, SOLANA_ERRORJSON_RPCSERVER_ERROR_SLOT_SKIPPED } from '@solana/kit'; try { //... rpc call } catch (e) { if (isSolanaError(e, SOLANA_ERRORJSON_RPCSERVER_ERROR_SLOT_SKIPPED)) { // Handle skipped slot specifically using e.context.serverMessage } }

Citations:


🌐 Web query:

site:docs.solana.com @solana/kit skipped slot error code isSolanaError

💡 Result:

There is no publicly documented error code specifically named skipped slot error code or isSolanaError associated with a package named @solana/kit in the provided technical documentation. It is possible that the package name or error nomenclature is non-standard, unofficial, or refers to a internal/deprecated library. In Solana development, the term skipped slot refers to a slot in the blockchain where no block was produced, which is a normal occurrence in the cluster [1]. Developers often encounter slot-related concepts when using commitment levels (such as processed, where a block may still be skipped) or via WebSocket subscriptions like slotSubscribe or slotsUpdatesSubscribe [1][2]. If you are encountering an error with a specific library, ensure you are using the official Solana Web3.js SDK or standard RPC methods, as these are the primary interfaces for interacting with the Solana network [1][2]. If isSolanaError is part of a custom error-handling utility in your project, it is likely a helper function designed to identify error types conforming to a specific Solana error interface.

Citations:


🌐 Web query:

@solana/kit SolanaError isSolanaError skipped slot error code message stripped __DEV__

💡 Result:

In the @solana/kit ecosystem, SolanaError is a structured error class used to standardize error handling across the library [1][2]. Key components and usage: 1. Error Codes and Identification: Solana errors are identified by specific, unique error codes (e.g., SOLANA_ERRORJSON_RPCSERVER_ERROR_SLOT_SKIPPED) [3][1]. You should never rely on string matching to identify errors; instead, use the isSolanaError helper function, which acts as a TypeScript type guard [4][5]. 2. Handling Skipped Slots: A "skipped slot" occurs when a slot does not produce a block, typically because the leader was offline or the fork was abandoned [6]. When this happens, an RPC node may return a JSON-RPC error. - In @solana/kit, this is represented by the error code SOLANA_ERRORJSON_RPCSERVER_ERROR_SLOT_SKIPPED [3]. - This error code corresponds to the underlying JSON-RPC error code -32007 [7][8]. - When using isSolanaError(error, SOLANA_ERRORJSON_RPCSERVER_ERROR_SLOT_SKIPPED), the error context provides access to the serverMessage returned by the node [3]. 3. DEV and Message Stripping: In production-optimized builds, error messages are often "stripped" or replaced with generic placeholders to reduce bundle size and protect against information leakage [3][2]. The DEV environment variable is typically used by library internals to determine whether to include detailed, developer-friendly error messages or minimal, production-safe messages in the SolanaError object [2]. Example pattern for catching these errors: import { isSolanaError, SOLANA_ERRORJSON_RPCSERVER_ERROR_SLOT_SKIPPED } from '@solana/kit'; try { //... operation that might trigger a slot error } catch (e) { if (isSolanaError(e, SOLANA_ERRORJSON_RPCSERVER_ERROR_SLOT_SKIPPED)) { console.error('The requested slot was skipped:', e.context.serverMessage); } else { throw e; } }

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== yarn lock entries for `@solana/errors` and `@solana/kit` ==\n'
rg -n '"`@solana/errors`@|`@solana/errors`"| "`@solana/kit`@|`@solana/kit`"' yarn.lock || true

printf '\n== package manifests mentioning `@solana/errors` or `@solana/kit` ==\n'
rg -n '"`@solana/errors`"| "`@solana/kit`"' packages/node/package.json packages/types/package.json package.json yarn.lock || true

Repository: subquery/subql-solana

Length of output: 3158


Use Solana’s typed skipped-slot error instead of matching the message string. e.message?.includes('was skipped') is brittle; @solana/kit/@solana/errors already expose isSolanaError(...) and a dedicated skipped-slot code, so this should use the structured error path to avoid a silent regression if the text changes or is stripped.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/node/src/solana/api.solana.ts` at line 31, Replace the brittle
skipped-slot string check with Solana’s structured error handling in
api.solana.ts. Update the logic around the skipped-slot handling to use the
typed error path from `@solana/kit/`@solana/errors, using isSolanaError(...) and
the dedicated skipped-slot error code instead of checking
e.message.includes('was skipped'). Keep the SKIPPED_SLOT_ERROR symbol only if it
is still needed for the structured comparison, and make the retry/skip decision
based on the typed error fields.


export class SolanaApi {
#client: Rpc<SolanaRpcApi>;
Expand Down Expand Up @@ -153,22 +153,26 @@ export class SolanaApi {
}

async getHeaderByHeight(height: number | bigint): Promise<Header> {
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<IBlock<SolanaBlock>> {
Expand All @@ -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);
Expand Down Expand Up @@ -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;
}
}
17 changes: 17 additions & 0 deletions packages/node/src/solana/api.solana.unit.spec.ts
Original file line number Diff line number Diff line change
@@ -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,
);
});
});
27 changes: 27 additions & 0 deletions packages/node/src/solana/block.solana.spec.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
24 changes: 20 additions & 4 deletions packages/node/src/solana/block.solana.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -261,20 +263,34 @@ export function transformBlock(
}
}),
};

if (slot !== undefined) {
Object.defineProperty(transformedBlock, SLOT, {
value: Number(slot),
});
}

return transformedBlock;
}

export function formatBlockUtil<B extends SolanaBlock = SolanaBlock>(
block: B,
slot?: number | bigint,
): IBlock<B> {
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),
Expand Down