@solard/sdk 0.2.37 → 0.2.40
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/types/browser/client.d.ts +129 -0
- package/dist/types/browser/index.d.ts +19 -0
- package/dist/types/browser/pump.d.ts +20 -0
- package/dist/types/browser/storage.d.ts +34 -0
- package/dist/types/browser/types.d.ts +90 -0
- package/dist/types/browser/wallet.d.ts +37 -0
- package/dist/types/client.d.ts +117 -0
- package/dist/types/default.d.ts +10 -0
- package/dist/types/index.d.ts +16 -0
- package/dist/types/live.d.ts +60 -0
- package/dist/types/protocol.d.ts +2 -0
- package/package.json +14 -8
- package/src/client.ts +33 -4
- package/src/export-surface.test.ts +12 -2
- package/src/index.ts +7 -1
- package/src/live-recovery.test.ts +122 -0
- package/src/live.test.ts +53 -10
- package/src/live.ts +178 -69
package/src/live.ts
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
export type LiveEndpointOptions = {
|
|
14
14
|
/** Ordered endpoints on the same cluster; public RPC can be supplied last. */
|
|
15
15
|
rpcUrls?: readonly string[];
|
|
16
|
+
websocketUrls?: readonly string[];
|
|
16
17
|
/** Fresh HTTP and WebSocket readiness probes for pooled trade streams (default 30s). */
|
|
17
18
|
healthCheckIntervalMs?: number;
|
|
18
19
|
};
|
|
@@ -26,6 +27,15 @@ export type SubscribeMigrationsOptions = Omit<
|
|
|
26
27
|
"connection"
|
|
27
28
|
> &
|
|
28
29
|
LiveEndpointOptions;
|
|
30
|
+
/** Applications must explicitly revalidate fresh on-chain pool/mint state.
|
|
31
|
+
* Returning true asserts current state only, not historical coverage. */
|
|
32
|
+
export type FeedRecoveryVerification = (input: {
|
|
33
|
+
connection: Connection; tokens: readonly string[]; signal: AbortSignal;
|
|
34
|
+
}) => Promise<boolean>;
|
|
35
|
+
export type RecoverableTradeSubscription = TradeSubscription & {
|
|
36
|
+
readonly feedVerified: boolean;
|
|
37
|
+
verifyAndResume(verify: FeedRecoveryVerification, timeoutMs?: number): Promise<boolean>;
|
|
38
|
+
};
|
|
29
39
|
export type SubscribeTradesOptions = Omit<
|
|
30
40
|
Parameters<typeof subscribeCoreTrades>[0],
|
|
31
41
|
"connection"
|
|
@@ -54,6 +64,8 @@ export type TradeListener = {
|
|
|
54
64
|
): () => void;
|
|
55
65
|
close(): Promise<void>;
|
|
56
66
|
readonly closed: Promise<void>;
|
|
67
|
+
readonly feedVerified: boolean;
|
|
68
|
+
verifyAndResume(verify: FeedRecoveryVerification, timeoutMs?: number): Promise<boolean>;
|
|
57
69
|
};
|
|
58
70
|
export type TradeListenerMigration = MigrationEvent & {
|
|
59
71
|
oldPool: string | null;
|
|
@@ -121,21 +133,29 @@ const livePools = new Map<string, LiveEndpointPool>();
|
|
|
121
133
|
function endpointPool(
|
|
122
134
|
options: LiveEndpointOptions,
|
|
123
135
|
): LiveEndpointPool | undefined {
|
|
124
|
-
|
|
125
|
-
const
|
|
136
|
+
const env = process.env.SLRD_RPC_URLS;
|
|
137
|
+
const urls = options.rpcUrls ?? (env ? JSON.parse(env) as string[] :
|
|
138
|
+
wsCandidates(options).length ? [endpointFromEnv()] : []);
|
|
139
|
+
if (!urls.length) return undefined;
|
|
140
|
+
const key = JSON.stringify([urls, options.websocketUrls ?? process.env.SLRD_WS_URLS]);
|
|
126
141
|
let pool = livePools.get(key);
|
|
127
142
|
if (!pool) {
|
|
128
|
-
pool = new LiveEndpointPool(options.
|
|
143
|
+
pool = new LiveEndpointPool(urls, wsCandidates(options).length ? preflightRpc : undefined);
|
|
129
144
|
livePools.set(key, pool);
|
|
130
145
|
}
|
|
131
146
|
return pool;
|
|
132
147
|
}
|
|
133
|
-
function
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
148
|
+
function wsCandidates(options: LiveEndpointOptions): string[] {
|
|
149
|
+
const fallback = process.env.SLRD_WS_URLS ? JSON.parse(process.env.SLRD_WS_URLS) as unknown : [];
|
|
150
|
+
const urls = options.websocketUrls ?? fallback;
|
|
151
|
+
if (!Array.isArray(urls) || !urls.every(value => typeof value === "string" && /^wss?:\/\//.test(value)))
|
|
152
|
+
throw new Error("Invalid WebSocket endpoint list");
|
|
153
|
+
return [...new Set(urls)] as string[];
|
|
154
|
+
}
|
|
155
|
+
function connectionFor(endpoint: string, ws = deriveWebSocketEndpoint(endpoint)): LiveConnection {
|
|
156
|
+
return { endpoint, websocketEndpoint: ws,
|
|
157
|
+
connection: new Connection(endpoint, { commitment: "confirmed", wsEndpoint: ws,
|
|
158
|
+
disableRetryOnRateLimit: true }) };
|
|
139
159
|
}
|
|
140
160
|
|
|
141
161
|
function endpointFromEnv(): string {
|
|
@@ -164,21 +184,8 @@ export function deriveWebSocketEndpoint(endpoint: string): string {
|
|
|
164
184
|
}
|
|
165
185
|
|
|
166
186
|
export function redactRpcEndpoint(endpoint: string): string {
|
|
167
|
-
try {
|
|
168
|
-
|
|
169
|
-
const keys: string[] = [];
|
|
170
|
-
url.searchParams.forEach((_value, key) => keys.push(key));
|
|
171
|
-
for (const key of keys) {
|
|
172
|
-
if (/key|token|secret|auth/i.test(key))
|
|
173
|
-
url.searchParams.set(key, "<redacted>");
|
|
174
|
-
}
|
|
175
|
-
return url.toString().replaceAll("%3Credacted%3E", "<redacted>");
|
|
176
|
-
} catch {
|
|
177
|
-
return endpoint.replace(
|
|
178
|
-
/([?&](?:api-?key|api_key|apikey|key|token|secret|auth)=)[^&]+/gi,
|
|
179
|
-
"$1<redacted>",
|
|
180
|
-
);
|
|
181
|
-
}
|
|
187
|
+
try { const url = new URL(endpoint); return `${url.protocol}//${url.host}/***`; }
|
|
188
|
+
catch { return "<invalid RPC endpoint>"; }
|
|
182
189
|
}
|
|
183
190
|
|
|
184
191
|
function providerName(endpoint: string): string {
|
|
@@ -193,12 +200,7 @@ function providerName(endpoint: string): string {
|
|
|
193
200
|
|
|
194
201
|
function safeMessage(error: unknown): string {
|
|
195
202
|
const value = error instanceof Error ? error.message : String(error);
|
|
196
|
-
return value
|
|
197
|
-
.replace(
|
|
198
|
-
/([?&](?:api-?key|api_key|apikey|key|token|secret|auth)=)[^&\s]+/gi,
|
|
199
|
-
"$1<redacted>",
|
|
200
|
-
)
|
|
201
|
-
.slice(0, 400);
|
|
203
|
+
return value.replace(/(?:https?|wss?):\/\/[^\s]+/gi, redactRpcEndpoint).slice(0,400);
|
|
202
204
|
}
|
|
203
205
|
|
|
204
206
|
function rpcFailure(endpoint: string, detail: string): Error {
|
|
@@ -220,7 +222,7 @@ async function preflightRpc(endpoint: string): Promise<void> {
|
|
|
220
222
|
const response = await fetch(endpoint, {
|
|
221
223
|
method: "POST",
|
|
222
224
|
headers: { "content-type": "application/json" },
|
|
223
|
-
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "
|
|
225
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "getGenesisHash" }),
|
|
224
226
|
signal: controller.signal,
|
|
225
227
|
});
|
|
226
228
|
if (!response.ok) {
|
|
@@ -246,6 +248,16 @@ async function preflightRpc(endpoint: string): Promise<void> {
|
|
|
246
248
|
`${code}${String(body.error.message ?? "RPC error")}`,
|
|
247
249
|
);
|
|
248
250
|
}
|
|
251
|
+
if (body.result !== "5eykt4UsFv8P8NJdTREpY1vzqKqZKvdpKuc147dw2N9d")
|
|
252
|
+
throw rpcFailure(endpoint, "Non-mainnet genesis hash");
|
|
253
|
+
const account = await fetch(endpoint, { method: "POST", signal: controller.signal,
|
|
254
|
+
headers: { "content-type": "application/json" },
|
|
255
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: 2, method: "getBalance",
|
|
256
|
+
params: ["11111111111111111111111111111111"] }) });
|
|
257
|
+
if (!account.ok) throw rpcFailure(endpoint, `Balance HTTP ${account.status}`);
|
|
258
|
+
const balance = await account.json() as { error?: unknown; result?: { context?: {slot?:number}; value?:number } };
|
|
259
|
+
if (balance.error || !Number.isFinite(balance.result?.context?.slot) || !Number.isFinite(balance.result?.value))
|
|
260
|
+
throw rpcFailure(endpoint, "Balance reads unavailable");
|
|
249
261
|
} catch (error) {
|
|
250
262
|
if (
|
|
251
263
|
error instanceof Error &&
|
|
@@ -261,7 +273,7 @@ async function preflightRpc(endpoint: string): Promise<void> {
|
|
|
261
273
|
}
|
|
262
274
|
|
|
263
275
|
async function preflightWebSocket(endpoint: string): Promise<void> {
|
|
264
|
-
const websocketEndpoint = deriveWebSocketEndpoint(endpoint);
|
|
276
|
+
const websocketEndpoint = /^wss?:\/\//.test(endpoint) ? endpoint : deriveWebSocketEndpoint(endpoint);
|
|
265
277
|
await new Promise<void>((resolve, reject) => {
|
|
266
278
|
let settled = false;
|
|
267
279
|
let socket: WebSocket | null = null;
|
|
@@ -288,16 +300,21 @@ async function preflightWebSocket(endpoint: string): Promise<void> {
|
|
|
288
300
|
finish(websocketFailure(endpoint, safeMessage(error)));
|
|
289
301
|
return;
|
|
290
302
|
}
|
|
291
|
-
socket.addEventListener(
|
|
292
|
-
"
|
|
293
|
-
()
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
303
|
+
socket.addEventListener("open", () => {
|
|
304
|
+
try { socket?.send(JSON.stringify({ jsonrpc: "2.0", id: 71, method: "slotSubscribe" })); }
|
|
305
|
+
catch { finish(websocketFailure(endpoint, "Subscription send failed")); }
|
|
306
|
+
}, { once: true });
|
|
307
|
+
socket.addEventListener("message", event => {
|
|
308
|
+
try { const packet = JSON.parse(String(event.data)) as { id?: number; result?: number; error?: unknown };
|
|
309
|
+
if (packet.id !== 71) return;
|
|
310
|
+
if (packet.error || !Number.isSafeInteger(packet.result)) {
|
|
311
|
+
finish(websocketFailure(endpoint, "Subscription was rejected")); return;
|
|
312
|
+
}
|
|
313
|
+
try { socket?.send(JSON.stringify({ jsonrpc: "2.0", id: 72,
|
|
314
|
+
method: "slotUnsubscribe", params: [packet.result] })); } catch {}
|
|
315
|
+
finish(); try { socket?.close(1000); } catch {}
|
|
316
|
+
} catch { finish(websocketFailure(endpoint, "Malformed subscription reply")); }
|
|
317
|
+
});
|
|
301
318
|
socket.addEventListener(
|
|
302
319
|
"error",
|
|
303
320
|
(event) => {
|
|
@@ -342,15 +359,21 @@ async function liveConnection(
|
|
|
342
359
|
options: LiveEndpointOptions = {},
|
|
343
360
|
): Promise<LiveConnection> {
|
|
344
361
|
const pool = endpointPool(options);
|
|
345
|
-
|
|
346
|
-
const
|
|
362
|
+
const endpoint = pool ? await pool.select() : endpointFromEnv();
|
|
363
|
+
const independent = wsCandidates(options);
|
|
364
|
+
if (independent.length) {
|
|
365
|
+
await preflightRpc(endpoint);
|
|
366
|
+
let last: unknown;
|
|
367
|
+
for (const ws of independent) {
|
|
368
|
+
try { await preflightWebSocket(ws); return connectionFor(endpoint, ws); }
|
|
369
|
+
catch (error) { last = error; }
|
|
370
|
+
}
|
|
371
|
+
throw last ?? new Error("No usable WSS endpoint");
|
|
372
|
+
}
|
|
373
|
+
if (pool) return connectionFor(endpoint);
|
|
347
374
|
await ensureReady(endpoint);
|
|
348
375
|
if (shared?.endpoint === endpoint) return shared;
|
|
349
|
-
shared =
|
|
350
|
-
endpoint,
|
|
351
|
-
websocketEndpoint: deriveWebSocketEndpoint(endpoint),
|
|
352
|
-
connection: new Connection(endpoint, "confirmed"),
|
|
353
|
-
};
|
|
376
|
+
shared = connectionFor(endpoint);
|
|
354
377
|
return shared;
|
|
355
378
|
}
|
|
356
379
|
|
|
@@ -368,20 +391,26 @@ function notifyReady(
|
|
|
368
391
|
} catch {}
|
|
369
392
|
}
|
|
370
393
|
|
|
371
|
-
export async function subscribeTrades(options: SubscribeTradesOptions) {
|
|
394
|
+
export async function subscribeTrades(options: SubscribeTradesOptions): Promise<RecoverableTradeSubscription> {
|
|
395
|
+
if (options.signal?.aborted) throw new DOMException("Trade subscription cancelled", "AbortError");
|
|
372
396
|
let live = await liveConnection(options);
|
|
373
397
|
notifyReady(options, live);
|
|
374
|
-
const pool = endpointPool(options);
|
|
375
|
-
if (!pool)
|
|
376
|
-
return await subscribeCoreTrades({
|
|
377
|
-
...options,
|
|
378
|
-
connection: live.connection,
|
|
379
|
-
});
|
|
398
|
+
const pool = endpointPool(options) ?? new LiveEndpointPool([live.endpoint]);
|
|
380
399
|
// Keep duplicate suppression across connection changes. Provider probes detect
|
|
381
400
|
// dead credentials/quota; they cannot diagnose a silent old socket when a new
|
|
382
401
|
// handshake to that provider remains healthy.
|
|
383
402
|
const seen = new Set<string>();
|
|
403
|
+
let gapUnverified = false;
|
|
404
|
+
let lastAcceptedAt = Date.now();
|
|
405
|
+
let generation = 0;
|
|
406
|
+
const recoveries = new Set<AbortController>();
|
|
407
|
+
const invalidate = () => { gapUnverified = true; generation++; };
|
|
384
408
|
const onTrade = async (event: TradeEvent) => {
|
|
409
|
+
if (!gapUnverified && Date.now() - lastAcceptedAt >= 60_000) invalidate();
|
|
410
|
+
if (gapUnverified || !Number.isFinite(event.atMs) || Date.now() - event.atMs > 30_000 || event.atMs > Date.now() + 10_000) {
|
|
411
|
+
try { options.onStatus?.("feed-stale", { reason: gapUnverified ? "gap-unverified" : "event-outside-live-window" }); } catch {}
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
385
414
|
const key = [
|
|
386
415
|
event.signature,
|
|
387
416
|
event.venue,
|
|
@@ -392,6 +421,7 @@ export async function subscribeTrades(options: SubscribeTradesOptions) {
|
|
|
392
421
|
if (seen.has(key)) return;
|
|
393
422
|
seen.add(key);
|
|
394
423
|
if (seen.size > 4096) seen.delete(seen.values().next().value!);
|
|
424
|
+
lastAcceptedAt = Date.now();
|
|
395
425
|
await options.onTrade(event);
|
|
396
426
|
};
|
|
397
427
|
let subscription = await subscribeCoreTrades({
|
|
@@ -422,11 +452,34 @@ export async function subscribeTrades(options: SubscribeTradesOptions) {
|
|
|
422
452
|
checking = true;
|
|
423
453
|
void serial(async () => {
|
|
424
454
|
if (stopped) return;
|
|
455
|
+
if (!gapUnverified && Date.now() - lastAcceptedAt >= 60_000) {
|
|
456
|
+
invalidate();
|
|
457
|
+
try { options.onStatus?.("feed-gap-unverified", {
|
|
458
|
+
reason: "no-recent-accepted-trades", historyComplete: false,
|
|
459
|
+
}); } catch {}
|
|
460
|
+
}
|
|
425
461
|
try {
|
|
426
462
|
const endpoint = await pool.check();
|
|
427
|
-
|
|
463
|
+
const independent = wsCandidates(options);
|
|
464
|
+
let ws = independent.length ? live.websocketEndpoint : deriveWebSocketEndpoint(endpoint);
|
|
465
|
+
if (independent.length) {
|
|
466
|
+
try { await preflightWebSocket(ws); }
|
|
467
|
+
catch {
|
|
468
|
+
const candidates = independent.filter(candidate => candidate !== ws);
|
|
469
|
+
let found = false;
|
|
470
|
+
for (const candidate of candidates) {
|
|
471
|
+
try { await preflightWebSocket(candidate); ws = candidate; found = true; break; }
|
|
472
|
+
catch {}
|
|
473
|
+
}
|
|
474
|
+
if (!found) throw new Error("All independent WebSocket endpoints are unavailable");
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
if (stopped || (endpoint === live.endpoint && ws === live.websocketEndpoint)) return;
|
|
478
|
+
invalidate();
|
|
479
|
+
try { options.onStatus?.("feed-gap-unverified", { endpoint: redactRpcEndpoint(endpoint),
|
|
480
|
+
websocket: redactRpcEndpoint(ws) }); } catch {}
|
|
428
481
|
const tokens = subscription.listTokens();
|
|
429
|
-
const nextLive = connectionFor(endpoint);
|
|
482
|
+
const nextLive = connectionFor(endpoint, ws);
|
|
430
483
|
const next = await subscribeCoreTrades({
|
|
431
484
|
...options,
|
|
432
485
|
signal: undefined,
|
|
@@ -438,9 +491,11 @@ export async function subscribeTrades(options: SubscribeTradesOptions) {
|
|
|
438
491
|
subscription = next;
|
|
439
492
|
live = nextLive;
|
|
440
493
|
notifyReady(options, live);
|
|
441
|
-
} catch (error) {
|
|
442
|
-
|
|
443
|
-
|
|
494
|
+
} catch (error) {
|
|
495
|
+
invalidate();
|
|
496
|
+
try {
|
|
497
|
+
options.onStatus?.("feed-gap-unverified", { reason: "provider-readiness-failed", historyComplete: false });
|
|
498
|
+
options.onStatus?.("rpc-unavailable", { error: safeMessage(error) });
|
|
444
499
|
} catch {}
|
|
445
500
|
}
|
|
446
501
|
}).finally(() => {
|
|
@@ -451,6 +506,8 @@ export async function subscribeTrades(options: SubscribeTradesOptions) {
|
|
|
451
506
|
const close = async () => {
|
|
452
507
|
if (stopped) return await closed;
|
|
453
508
|
stopped = true;
|
|
509
|
+
invalidate();
|
|
510
|
+
for (const controller of recoveries) controller.abort();
|
|
454
511
|
clearInterval(timer);
|
|
455
512
|
options.signal?.removeEventListener("abort", abort);
|
|
456
513
|
await serial(async () => {
|
|
@@ -464,20 +521,67 @@ export async function subscribeTrades(options: SubscribeTradesOptions) {
|
|
|
464
521
|
if (options.signal?.aborted) await close();
|
|
465
522
|
else options.signal?.addEventListener("abort", abort, { once: true });
|
|
466
523
|
return {
|
|
467
|
-
addTokens: (tokens: string | readonly string[]) =>
|
|
468
|
-
|
|
524
|
+
addTokens: (tokens: string | readonly string[]) => {
|
|
525
|
+
const values = typeof tokens === "string" ? [tokens] : tokens;
|
|
526
|
+
if (values.some(token => !subscription.hasToken(token))) invalidate();
|
|
527
|
+
return serial(async () => {
|
|
469
528
|
if (stopped) throw new Error("Trade subscription is closed");
|
|
470
529
|
await subscription.addTokens(tokens);
|
|
471
|
-
})
|
|
472
|
-
|
|
473
|
-
|
|
530
|
+
});
|
|
531
|
+
},
|
|
532
|
+
removeTokens: (tokens: string | readonly string[]) => {
|
|
533
|
+
const values = typeof tokens === "string" ? [tokens] : tokens;
|
|
534
|
+
if (values.some(token => subscription.hasToken(token))) invalidate();
|
|
535
|
+
return serial(async () => {
|
|
474
536
|
if (!stopped) await subscription.removeTokens(tokens);
|
|
475
|
-
})
|
|
537
|
+
});
|
|
538
|
+
},
|
|
476
539
|
hasToken: (token: string) => !stopped && subscription.hasToken(token),
|
|
477
540
|
listTokens: () => (stopped ? [] : subscription.listTokens()),
|
|
541
|
+
get feedVerified() { return !stopped && !gapUnverified &&
|
|
542
|
+
Date.now() - lastAcceptedAt < 60_000; },
|
|
543
|
+
verifyAndResume: (verify: FeedRecoveryVerification, timeoutMs = 5000) => {
|
|
544
|
+
if (stopped) return Promise.reject(new Error("Trade subscription is closed"));
|
|
545
|
+
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 30_000)
|
|
546
|
+
return Promise.reject(new Error("Recovery timeout must be 1..30000ms"));
|
|
547
|
+
// The deadline includes time queued behind health checks or token edits.
|
|
548
|
+
invalidate();
|
|
549
|
+
const expected = generation;
|
|
550
|
+
const controller = new AbortController();
|
|
551
|
+
recoveries.add(controller);
|
|
552
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
553
|
+
const onAbort = () => controller.abort();
|
|
554
|
+
options.signal?.addEventListener("abort", onAbort, { once: true });
|
|
555
|
+
if (options.signal?.aborted) controller.abort();
|
|
556
|
+
let rejectAbort!: () => void;
|
|
557
|
+
const cancelled = new Promise<never>((_, reject) => {
|
|
558
|
+
rejectAbort = () => reject(new Error("Feed verification cancelled or timed out"));
|
|
559
|
+
controller.signal.addEventListener("abort", rejectAbort, { once: true });
|
|
560
|
+
if (controller.signal.aborted) rejectAbort();
|
|
561
|
+
});
|
|
562
|
+
const pending = serial(async () => {
|
|
563
|
+
if (controller.signal.aborted) throw new Error("Feed verification cancelled or timed out");
|
|
564
|
+
if (stopped) throw new Error("Trade subscription is closed");
|
|
565
|
+
if (generation !== expected) return false;
|
|
566
|
+
const verified = await Promise.race([
|
|
567
|
+
Promise.resolve().then(() => verify({ connection: live.connection,
|
|
568
|
+
tokens: subscription.listTokens(), signal: controller.signal })), cancelled,
|
|
569
|
+
]);
|
|
570
|
+
if (verified !== true || stopped || generation !== expected || controller.signal.aborted) return false;
|
|
571
|
+
gapUnverified = false;
|
|
572
|
+
lastAcceptedAt = Date.now();
|
|
573
|
+
try { options.onStatus?.("feed-fresh-state-verified", { historyComplete: false }); } catch {}
|
|
574
|
+
return true;
|
|
575
|
+
});
|
|
576
|
+
return Promise.race([pending, cancelled]).finally(() => {
|
|
577
|
+
clearTimeout(timeout); recoveries.delete(controller);
|
|
578
|
+
controller.signal.removeEventListener("abort", rejectAbort);
|
|
579
|
+
options.signal?.removeEventListener("abort", onAbort);
|
|
580
|
+
});
|
|
581
|
+
},
|
|
478
582
|
close,
|
|
479
583
|
closed,
|
|
480
|
-
} satisfies
|
|
584
|
+
} satisfies RecoverableTradeSubscription;
|
|
481
585
|
}
|
|
482
586
|
|
|
483
587
|
export async function subscribeLaunches(options: SubscribeLaunchesOptions) {
|
|
@@ -517,7 +621,7 @@ export async function listenTrades(
|
|
|
517
621
|
const pendingMigrations: TradeListenerMigration[] = [];
|
|
518
622
|
const watched = new Set(options.tokens);
|
|
519
623
|
const lastPools = new Map<string, string | null>();
|
|
520
|
-
let subscription:
|
|
624
|
+
let subscription: RecoverableTradeSubscription;
|
|
521
625
|
let migrations: Awaited<ReturnType<typeof subscribeMigrations>> | undefined;
|
|
522
626
|
let stopped = false;
|
|
523
627
|
let resolveClosed!: () => void;
|
|
@@ -579,11 +683,13 @@ export async function listenTrades(
|
|
|
579
683
|
};
|
|
580
684
|
const onStatus = (event: string, data?: Record<string, unknown>) => {
|
|
581
685
|
options.onStatus?.(event, data);
|
|
686
|
+
if (event === "feed-gap-unverified" || event === "feed-stale") pending.length = 0;
|
|
582
687
|
if (event === "rpc-ready" && migrations && !stopped)
|
|
583
688
|
void serial(async () => {
|
|
584
689
|
if (stopped) return;
|
|
585
690
|
const next = await subscribeMigrations({
|
|
586
691
|
rpcUrls: options.rpcUrls,
|
|
692
|
+
websocketUrls: options.websocketUrls,
|
|
587
693
|
tokens: [...watched],
|
|
588
694
|
commitment: options.commitment,
|
|
589
695
|
metadata: options.metadata,
|
|
@@ -612,6 +718,7 @@ export async function listenTrades(
|
|
|
612
718
|
try {
|
|
613
719
|
migrations = await subscribeMigrations({
|
|
614
720
|
rpcUrls: options.rpcUrls,
|
|
721
|
+
websocketUrls: options.websocketUrls,
|
|
615
722
|
tokens: [...watched],
|
|
616
723
|
commitment: options.commitment,
|
|
617
724
|
metadata: options.metadata,
|
|
@@ -665,6 +772,8 @@ export async function listenTrades(
|
|
|
665
772
|
migrations!.removeTokens(values),
|
|
666
773
|
]);
|
|
667
774
|
}),
|
|
775
|
+
get feedVerified() { return !stopped && subscription.feedVerified === true; },
|
|
776
|
+
verifyAndResume: (verify, timeoutMs) => subscription.verifyAndResume(verify, timeoutMs),
|
|
668
777
|
has: (token) => !stopped && watched.has(token),
|
|
669
778
|
list: () => (stopped ? [] : [...watched]),
|
|
670
779
|
onTrade(callback) {
|