Skip to content

Commit 64f7af7

Browse files
committed
Wait for soroban ledger ingestion at chain head instead of failing fatally on -32600
1 parent 5d41a0f commit 64f7af7

5 files changed

Lines changed: 311 additions & 22 deletions

File tree

packages/node/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
55
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
66

77
## [Unreleased]
8+
### Fixed
9+
- Crash at the chain head when the soroban endpoint has not yet ingested the target ledger: JSON-RPC -32600 `startLedger must be within the ledger range` is now treated as a transient condition and the fetch waits for ingestion (bounded by the new `sorobanIngestWaitSeconds` endpoint config, default 600s) instead of failing fatally after retries
810

911
## [6.2.0] - 2026-01-21
1012
### Changed

packages/node/src/stellar/api.stellar.spec.ts

Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
11
// Copyright 2020-2025 SubQuery Pte Ltd authors & contributors
22
// SPDX-License-Identifier: GPL-3.0
33

4+
import { delay } from '@subql/node-core';
45
import { StellarApi } from './api.stellar';
56
import { SorobanServer } from './soroban.server';
67

8+
jest.mock('@subql/node-core', () => ({
9+
...jest.requireActual('@subql/node-core'),
10+
delay: jest.fn(() => Promise.resolve()),
11+
}));
12+
713
const HTTP_ENDPOINT = 'https://horizon-futurenet.stellar.org';
814
const SOROBAN_ENDPOINT = 'https://rpc-futurenet.stellar.org';
915

@@ -129,3 +135,217 @@ describe('StellarApi', () => {
129135
expect(tx?.operations[3].events.length).toEqual(0);
130136
});
131137
});
138+
139+
describe('StellarApi soroban ingestion lag', () => {
140+
const mockedDelay = delay as unknown as jest.Mock;
141+
142+
const rangeError = (lo: number, hi: number) => ({
143+
code: -32600,
144+
message: `startLedger must be within the ledger range: ${lo} - ${hi}`,
145+
});
146+
147+
const makeApi = (soroban: any, waitSeconds?: number) =>
148+
new StellarApi(HTTP_ENDPOINT, soroban as SorobanServer, {
149+
sorobanIngestWaitSeconds: waitSeconds,
150+
});
151+
152+
beforeEach(() => {
153+
mockedDelay.mockClear();
154+
});
155+
156+
it('waits for the soroban endpoint to ingest the ledger then recovers', async () => {
157+
let calls = 0;
158+
const soroban = {
159+
getEvents: jest.fn(({ startLedger }: { startLedger: number }) => {
160+
calls++;
161+
if (calls <= 2) return Promise.reject(rangeError(100, startLedger - 1));
162+
return Promise.resolve({
163+
events: [
164+
{ ledger: startLedger, id: 'e1', operationIndex: 0, txHash: 't1' },
165+
],
166+
latestLedger: startLedger,
167+
});
168+
}),
169+
};
170+
const api = makeApi(soroban);
171+
172+
const events = await (api as any).getEventsWhenIngested(200);
173+
174+
expect(soroban.getEvents).toHaveBeenCalledTimes(3);
175+
expect(mockedDelay).toHaveBeenCalledTimes(2);
176+
expect(events).toHaveLength(1);
177+
expect(events[0].id).toEqual('e1');
178+
});
179+
180+
it('rethrows immediately when the ledger is below the retention window', async () => {
181+
const soroban = {
182+
getEvents: jest.fn(() => Promise.reject(rangeError(1000, 2000))),
183+
getLatestLedger: jest.fn(() => Promise.resolve({ sequence: 2000 })),
184+
};
185+
const api = makeApi(soroban);
186+
187+
await expect((api as any).getEventsWhenIngested(500)).rejects.toMatchObject(
188+
{
189+
code: -32600,
190+
},
191+
);
192+
expect(soroban.getEvents).toHaveBeenCalledTimes(1);
193+
expect(mockedDelay).not.toHaveBeenCalled();
194+
});
195+
196+
it('keeps the explanatory error for the legacy oldest-ledger message', async () => {
197+
const soroban = {
198+
getEvents: jest.fn(() =>
199+
Promise.reject(new Error('start is before oldest ledger')),
200+
),
201+
};
202+
const api = makeApi(soroban);
203+
204+
await expect((api as any).getEventsWhenIngested(500)).rejects.toThrow(
205+
'older than the oldest ledger',
206+
);
207+
expect(mockedDelay).not.toHaveBeenCalled();
208+
});
209+
210+
it('treats the legacy after-newest-ledger message as transient', async () => {
211+
let calls = 0;
212+
const soroban = {
213+
getEvents: jest.fn(({ startLedger }: { startLedger: number }) => {
214+
calls++;
215+
if (calls === 1) {
216+
return Promise.reject(new Error('start is after newest ledger'));
217+
}
218+
return Promise.resolve({ events: [], latestLedger: startLedger });
219+
}),
220+
};
221+
const api = makeApi(soroban);
222+
223+
const events = await (api as any).getEventsWhenIngested(200);
224+
225+
expect(events).toEqual([]);
226+
expect(soroban.getEvents).toHaveBeenCalledTimes(2);
227+
});
228+
229+
it('falls back to getLatestLedger for a -32600 with unknown wording', async () => {
230+
let calls = 0;
231+
const soroban = {
232+
getEvents: jest.fn(({ startLedger }: { startLedger: number }) => {
233+
calls++;
234+
if (calls === 1) {
235+
return Promise.reject({
236+
code: -32600,
237+
message: `startLedger ${startLedger} exceeds latest ledger`,
238+
});
239+
}
240+
return Promise.resolve({ events: [], latestLedger: startLedger });
241+
}),
242+
getLatestLedger: jest.fn(() => Promise.resolve({ sequence: 199 })),
243+
};
244+
const api = makeApi(soroban);
245+
246+
const events = await (api as any).getEventsWhenIngested(200);
247+
248+
expect(events).toEqual([]);
249+
expect(soroban.getEvents).toHaveBeenCalledTimes(2);
250+
expect(soroban.getLatestLedger).toHaveBeenCalled();
251+
});
252+
253+
it('rethrows a genuine -32600 when the soroban head is ahead of the sequence', async () => {
254+
const soroban = {
255+
getEvents: jest.fn(() =>
256+
Promise.reject({ code: -32600, message: 'some other invalid request' }),
257+
),
258+
getLatestLedger: jest.fn(() => Promise.resolve({ sequence: 500 })),
259+
};
260+
const api = makeApi(soroban);
261+
262+
await expect((api as any).getEventsWhenIngested(200)).rejects.toMatchObject(
263+
{
264+
message: 'some other invalid request',
265+
},
266+
);
267+
expect(soroban.getEvents).toHaveBeenCalledTimes(1);
268+
expect(mockedDelay).not.toHaveBeenCalled();
269+
});
270+
271+
it('rethrows once the wait deadline is exhausted', async () => {
272+
const soroban = {
273+
getEvents: jest.fn(({ startLedger }: { startLedger: number }) =>
274+
Promise.reject(rangeError(100, startLedger - 1)),
275+
),
276+
};
277+
const api = makeApi(soroban, 0);
278+
279+
await expect((api as any).getEventsWhenIngested(200)).rejects.toMatchObject(
280+
{
281+
code: -32600,
282+
},
283+
);
284+
expect(soroban.getEvents).toHaveBeenCalledTimes(1);
285+
expect(mockedDelay).not.toHaveBeenCalled();
286+
});
287+
288+
it('wires the wait loop into fetchAndWrapLedger', async () => {
289+
let calls = 0;
290+
const soroban = {
291+
getEvents: jest.fn(({ startLedger }: { startLedger: number }) => {
292+
calls++;
293+
if (calls === 1) {
294+
return Promise.reject(rangeError(100, startLedger - 1));
295+
}
296+
return Promise.resolve({
297+
events: [
298+
{ ledger: startLedger, id: 'e9', operationIndex: 0, txHash: 't1' },
299+
],
300+
latestLedger: startLedger,
301+
});
302+
}),
303+
};
304+
const api = makeApi(soroban);
305+
306+
const emptyPage: any = {
307+
records: [],
308+
next: () => Promise.resolve(emptyPage),
309+
};
310+
(api as any).stellarClient = {
311+
ledgers: () => ({
312+
ledger: () => ({
313+
call: () => Promise.resolve({ sequence: 300, hash: 'abc' }),
314+
}),
315+
}),
316+
transactions: () => ({
317+
forLedger: () => ({
318+
limit: () => ({ call: () => Promise.resolve(emptyPage) }),
319+
}),
320+
}),
321+
operations: () => ({
322+
forLedger: () => ({
323+
limit: () => ({
324+
call: () =>
325+
Promise.resolve({
326+
records: [
327+
{
328+
type: 'invoke_host_function',
329+
id: '1',
330+
transaction_hash: 't1',
331+
},
332+
],
333+
next: () => Promise.resolve(emptyPage),
334+
}),
335+
}),
336+
}),
337+
}),
338+
effects: () => ({
339+
forLedger: () => ({
340+
limit: () => ({ call: () => Promise.resolve(emptyPage) }),
341+
}),
342+
}),
343+
};
344+
345+
const block = await (api as any).fetchAndWrapLedger(300);
346+
347+
expect(soroban.getEvents).toHaveBeenCalledTimes(2);
348+
expect(block.block.events).toHaveLength(1);
349+
expect(block.block.events[0].id).toEqual('e9');
350+
});
351+
});

packages/node/src/stellar/api.stellar.ts

Lines changed: 78 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
import assert from 'assert';
55
import { Horizon, rpc } from '@stellar/stellar-sdk';
6-
import { getLogger, IBlock } from '@subql/node-core';
6+
import { delay, getLogger, IBlock } from '@subql/node-core';
77
import {
88
SorobanEvent,
99
StellarBlock,
@@ -27,6 +27,7 @@ export class StellarApi {
2727

2828
private chainId?: string;
2929
private pageLimit = DEFAULT_PAGE_SIZE;
30+
private sorobanIngestWaitSeconds: number;
3031

3132
constructor(
3233
private endpoint: string,
@@ -35,6 +36,7 @@ export class StellarApi {
3536
) {
3637
const { hostname, protocol, searchParams } = new URL(this.endpoint);
3738
this.pageLimit = config?.pageLimit || this.pageLimit;
39+
this.sorobanIngestWaitSeconds = config?.sorobanIngestWaitSeconds ?? 600;
3840

3941
const protocolStr = protocol.replace(':', '');
4042

@@ -150,6 +152,80 @@ export class StellarApi {
150152
return effects;
151153
}
152154

155+
// The target height comes from Horizon while events are fetched from a separate
156+
// soroban endpoint; with hosted load-balanced RPCs the serving backend can lag the
157+
// target by several ledgers, in which case stellar-rpc rejects getEvents with
158+
// JSON-RPC -32600 'startLedger must be within the ledger range: X - Y' (legacy
159+
// soroban-rpc: 'start is after newest ledger'). The ledger exists, it just is not
160+
// ingested yet: a transient condition, not an invalid request.
161+
private isLedgerNotYetIngestedError(e: any, sequence: number): boolean {
162+
const message = e?.message;
163+
if (typeof message !== 'string') {
164+
return false;
165+
}
166+
if (message === 'start is after newest ledger') {
167+
return true;
168+
}
169+
const range =
170+
/startLedger must be within the ledger range: (\d+) - (\d+)/.exec(
171+
message,
172+
);
173+
return range !== null && sequence > Number(range[2]);
174+
}
175+
176+
// Fallback when a -32600 carries an unrecognized wording: compare the requested
177+
// sequence against the soroban endpoint's own head instead of parsing the message.
178+
private async isSequenceAheadOfSorobanHead(
179+
e: any,
180+
sequence: number,
181+
): Promise<boolean> {
182+
if (e?.code !== -32600) {
183+
return false;
184+
}
185+
try {
186+
const latest = await this.sorobanClient.getLatestLedger();
187+
return sequence > latest.sequence;
188+
} catch {
189+
return false;
190+
}
191+
}
192+
193+
private async getEventsWhenIngested(
194+
sequence: number,
195+
): Promise<SorobanEvent[]> {
196+
const deadline = Date.now() + this.sorobanIngestWaitSeconds * 1000;
197+
for (let attempt = 1; ; attempt++) {
198+
try {
199+
return await this.getAndWrapEvents(sequence);
200+
} catch (e: any) {
201+
if (e?.message === 'start is before oldest ledger') {
202+
throw new Error(`The requested events for ledger number ${sequence} is not available on the current soroban node.
203+
This is because you're trying to access a ledger that is older than the oldest ledger stored in this node.
204+
To resolve this issue, you can either:
205+
1. Increase the start ledger to a more recent one, or
206+
2. Connect to a different node that might have a longer history of ledgers.`);
207+
}
208+
const notYetIngested =
209+
this.isLedgerNotYetIngestedError(e, sequence) ||
210+
(await this.isSequenceAheadOfSorobanHead(e, sequence));
211+
if (!notYetIngested || Date.now() >= deadline) {
212+
if (e?.code === -32600) {
213+
logger.warn(
214+
`Giving up on events for ledger ${sequence} after JSON-RPC -32600: ${e.message}`,
215+
);
216+
}
217+
throw e;
218+
}
219+
if (attempt === 1 || attempt % 10 === 0) {
220+
logger.warn(
221+
`Events for ledger ${sequence} are not yet ingested by the soroban endpoint ("${e.message}"), waiting for it to catch up (attempt ${attempt})`,
222+
);
223+
}
224+
await delay(6);
225+
}
226+
}
227+
}
228+
153229
async getAndWrapEvents(height: number): Promise<SorobanEvent[]> {
154230
const { events: events } = await this.sorobanClient.getEvents({
155231
startLedger: height,
@@ -306,27 +382,7 @@ export class StellarApi {
306382
);
307383

308384
if (this.sorobanClient && hasInvokeHostFunctionOp) {
309-
try {
310-
eventsForSequence = await this.getAndWrapEvents(sequence);
311-
} catch (e: any) {
312-
if (e.message === 'start is after newest ledger') {
313-
const latestLedger = (await this.sorobanClient.getLatestLedger())
314-
.sequence;
315-
throw new Error(`The requested events for ledger number ${sequence} is not available on the current soroban node.
316-
This is because you're trying to access a ledger that is after the latest ledger number ${latestLedger} stored in this node.
317-
To resolve this issue, please check you endpoint node start height`);
318-
}
319-
320-
if (e.message === 'start is before oldest ledger') {
321-
throw new Error(`The requested events for ledger number ${sequence} is not available on the current soroban node.
322-
This is because you're trying to access a ledger that is older than the oldest ledger stored in this node.
323-
To resolve this issue, you can either:
324-
1. Increase the start ledger to a more recent one, or
325-
2. Connect to a different node that might have a longer history of ledgers.`);
326-
}
327-
328-
throw e;
329-
}
385+
eventsForSequence = await this.getEventsWhenIngested(sequence);
330386
}
331387

332388
const wrappedLedger: StellarBlock = {

packages/types/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
55
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
66

77
## [Unreleased]
8+
### Added
9+
- `sorobanIngestWaitSeconds` option to `IStellarEndpointConfig` to bound how long the node waits for the soroban endpoint to ingest a ledger at the chain head
810

911
## [5.2.0] - 2026-01-21
1012
### Changed

0 commit comments

Comments
 (0)