@solard/sdk 0.2.33 → 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/README.md +34 -34
- 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.exact-sell.test.ts +95 -45
- package/src/client.listen-trades.test.ts +64 -34
- package/src/client.trade-fees.test.ts +37 -15
- package/src/client.ts +135 -48
- package/src/default.ts +2 -5
- package/src/export-surface.test.ts +46 -17
- package/src/index.ts +11 -4
- package/src/live-recovery.test.ts +122 -0
- package/src/live.test.ts +236 -60
- package/src/live.ts +427 -133
- package/src/operator-api.architecture.test.ts +3 -1
package/src/live.ts
CHANGED
|
@@ -5,35 +5,53 @@ import {
|
|
|
5
5
|
subscribeTrades as subscribeCoreTrades,
|
|
6
6
|
type TradeEvent,
|
|
7
7
|
type TradeSubscription,
|
|
8
|
-
type MigrationEvent,
|
|
9
|
-
createLiveTradeHistoryRecorder,
|
|
10
|
-
type LiveTradeHistoryOptions,
|
|
8
|
+
type MigrationEvent,
|
|
9
|
+
createLiveTradeHistoryRecorder,
|
|
10
|
+
type LiveTradeHistoryOptions,
|
|
11
11
|
} from "@solard/core";
|
|
12
12
|
|
|
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
|
};
|
|
19
20
|
export type SubscribeLaunchesOptions = Omit<
|
|
20
21
|
Parameters<typeof subscribeCoreLaunches>[0],
|
|
21
22
|
"connection"
|
|
22
|
-
> &
|
|
23
|
+
> &
|
|
24
|
+
LiveEndpointOptions;
|
|
23
25
|
export type SubscribeMigrationsOptions = Omit<
|
|
24
26
|
Parameters<typeof subscribeCoreMigrations>[0],
|
|
25
27
|
"connection"
|
|
26
|
-
> &
|
|
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
|
+
};
|
|
27
39
|
export type SubscribeTradesOptions = Omit<
|
|
28
40
|
Parameters<typeof subscribeCoreTrades>[0],
|
|
29
41
|
"connection"
|
|
30
|
-
> &
|
|
42
|
+
> &
|
|
43
|
+
LiveEndpointOptions;
|
|
31
44
|
|
|
32
|
-
export type ListenTradesOptions = Omit<SubscribeTradesOptions, "onTrade"> & {
|
|
33
|
-
dbPath?: string;
|
|
34
|
-
/** Enabled by default; collected live history is partial and retains commitment. */
|
|
35
|
-
history?:
|
|
36
|
-
|
|
45
|
+
export type ListenTradesOptions = Omit<SubscribeTradesOptions, "onTrade"> & {
|
|
46
|
+
dbPath?: string;
|
|
47
|
+
/** Enabled by default; collected live history is partial and retains commitment. */
|
|
48
|
+
history?:
|
|
49
|
+
| false
|
|
50
|
+
| Pick<
|
|
51
|
+
LiveTradeHistoryOptions,
|
|
52
|
+
"flushIntervalMs" | "batchSize" | "maxPending"
|
|
53
|
+
>;
|
|
54
|
+
};
|
|
37
55
|
|
|
38
56
|
export type TradeListener = {
|
|
39
57
|
add(tokens: string | readonly string[]): Promise<void>;
|
|
@@ -41,11 +59,18 @@ export type TradeListener = {
|
|
|
41
59
|
has(token: string): boolean;
|
|
42
60
|
list(): string[];
|
|
43
61
|
onTrade(callback: (event: TradeEvent) => void | Promise<void>): () => void;
|
|
44
|
-
onMigration(
|
|
62
|
+
onMigration(
|
|
63
|
+
callback: (event: TradeListenerMigration) => void | Promise<void>,
|
|
64
|
+
): () => void;
|
|
45
65
|
close(): Promise<void>;
|
|
46
66
|
readonly closed: Promise<void>;
|
|
67
|
+
readonly feedVerified: boolean;
|
|
68
|
+
verifyAndResume(verify: FeedRecoveryVerification, timeoutMs?: number): Promise<boolean>;
|
|
69
|
+
};
|
|
70
|
+
export type TradeListenerMigration = MigrationEvent & {
|
|
71
|
+
oldPool: string | null;
|
|
72
|
+
newPool: string;
|
|
47
73
|
};
|
|
48
|
-
export type TradeListenerMigration = MigrationEvent & { oldPool: string | null; newPool: string };
|
|
49
74
|
|
|
50
75
|
type LiveConnection = {
|
|
51
76
|
endpoint: string;
|
|
@@ -60,39 +85,77 @@ let readiness: { endpoint: string; promise: Promise<void> } | null = null;
|
|
|
60
85
|
export class LiveEndpointPool {
|
|
61
86
|
private active = 0;
|
|
62
87
|
readonly endpoints: readonly string[];
|
|
63
|
-
constructor(
|
|
64
|
-
|
|
65
|
-
|
|
88
|
+
constructor(
|
|
89
|
+
endpoints: readonly string[],
|
|
90
|
+
private readonly probe: (endpoint: string) => Promise<void> = async (
|
|
91
|
+
endpoint,
|
|
92
|
+
) => {
|
|
93
|
+
await Promise.all([preflightRpc(endpoint), preflightWebSocket(endpoint)]);
|
|
94
|
+
},
|
|
95
|
+
) {
|
|
66
96
|
this.endpoints = [...new Set(endpoints)];
|
|
67
|
-
if (!this.endpoints.length)
|
|
97
|
+
if (!this.endpoints.length)
|
|
98
|
+
throw new Error("Live RPC endpoint pool cannot be empty");
|
|
68
99
|
for (const endpoint of this.endpoints) deriveWebSocketEndpoint(endpoint);
|
|
69
100
|
}
|
|
70
|
-
get endpoint(): string {
|
|
101
|
+
get endpoint(): string {
|
|
102
|
+
return this.endpoints[this.active]!;
|
|
103
|
+
}
|
|
71
104
|
async select(skipCurrent = false): Promise<string> {
|
|
72
105
|
const start = this.active;
|
|
73
106
|
let lastError: unknown;
|
|
74
|
-
for (
|
|
107
|
+
for (
|
|
108
|
+
let offset = skipCurrent ? 1 : 0;
|
|
109
|
+
offset < this.endpoints.length + (skipCurrent ? 1 : 0);
|
|
110
|
+
offset++
|
|
111
|
+
) {
|
|
75
112
|
const index = (start + offset) % this.endpoints.length;
|
|
76
|
-
try {
|
|
77
|
-
|
|
113
|
+
try {
|
|
114
|
+
await this.probe(this.endpoints[index]!);
|
|
115
|
+
this.active = index;
|
|
116
|
+
return this.endpoint;
|
|
117
|
+
} catch (error) {
|
|
118
|
+
lastError = error;
|
|
119
|
+
}
|
|
78
120
|
}
|
|
79
121
|
throw lastError;
|
|
80
122
|
}
|
|
81
123
|
async check(): Promise<string> {
|
|
82
|
-
try {
|
|
83
|
-
|
|
124
|
+
try {
|
|
125
|
+
await this.probe(this.endpoint);
|
|
126
|
+
return this.endpoint;
|
|
127
|
+
} catch {
|
|
128
|
+
return await this.select(true);
|
|
129
|
+
}
|
|
84
130
|
}
|
|
85
131
|
}
|
|
86
132
|
const livePools = new Map<string, LiveEndpointPool>();
|
|
87
|
-
function endpointPool(
|
|
88
|
-
|
|
89
|
-
|
|
133
|
+
function endpointPool(
|
|
134
|
+
options: LiveEndpointOptions,
|
|
135
|
+
): LiveEndpointPool | undefined {
|
|
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]);
|
|
90
141
|
let pool = livePools.get(key);
|
|
91
|
-
if (!pool) {
|
|
142
|
+
if (!pool) {
|
|
143
|
+
pool = new LiveEndpointPool(urls, wsCandidates(options).length ? preflightRpc : undefined);
|
|
144
|
+
livePools.set(key, pool);
|
|
145
|
+
}
|
|
92
146
|
return pool;
|
|
93
147
|
}
|
|
94
|
-
function
|
|
95
|
-
|
|
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 }) };
|
|
96
159
|
}
|
|
97
160
|
|
|
98
161
|
function endpointFromEnv(): string {
|
|
@@ -121,21 +184,8 @@ export function deriveWebSocketEndpoint(endpoint: string): string {
|
|
|
121
184
|
}
|
|
122
185
|
|
|
123
186
|
export function redactRpcEndpoint(endpoint: string): string {
|
|
124
|
-
try {
|
|
125
|
-
|
|
126
|
-
const keys: string[] = [];
|
|
127
|
-
url.searchParams.forEach((_value, key) => keys.push(key));
|
|
128
|
-
for (const key of keys) {
|
|
129
|
-
if (/key|token|secret|auth/i.test(key))
|
|
130
|
-
url.searchParams.set(key, "<redacted>");
|
|
131
|
-
}
|
|
132
|
-
return url.toString().replaceAll("%3Credacted%3E", "<redacted>");
|
|
133
|
-
} catch {
|
|
134
|
-
return endpoint.replace(
|
|
135
|
-
/([?&](?:api-?key|api_key|apikey|key|token|secret|auth)=)[^&]+/gi,
|
|
136
|
-
"$1<redacted>",
|
|
137
|
-
);
|
|
138
|
-
}
|
|
187
|
+
try { const url = new URL(endpoint); return `${url.protocol}//${url.host}/***`; }
|
|
188
|
+
catch { return "<invalid RPC endpoint>"; }
|
|
139
189
|
}
|
|
140
190
|
|
|
141
191
|
function providerName(endpoint: string): string {
|
|
@@ -150,12 +200,7 @@ function providerName(endpoint: string): string {
|
|
|
150
200
|
|
|
151
201
|
function safeMessage(error: unknown): string {
|
|
152
202
|
const value = error instanceof Error ? error.message : String(error);
|
|
153
|
-
return value
|
|
154
|
-
.replace(
|
|
155
|
-
/([?&](?:api-?key|api_key|apikey|key|token|secret|auth)=)[^&\s]+/gi,
|
|
156
|
-
"$1<redacted>",
|
|
157
|
-
)
|
|
158
|
-
.slice(0, 400);
|
|
203
|
+
return value.replace(/(?:https?|wss?):\/\/[^\s]+/gi, redactRpcEndpoint).slice(0,400);
|
|
159
204
|
}
|
|
160
205
|
|
|
161
206
|
function rpcFailure(endpoint: string, detail: string): Error {
|
|
@@ -177,7 +222,7 @@ async function preflightRpc(endpoint: string): Promise<void> {
|
|
|
177
222
|
const response = await fetch(endpoint, {
|
|
178
223
|
method: "POST",
|
|
179
224
|
headers: { "content-type": "application/json" },
|
|
180
|
-
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "
|
|
225
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "getGenesisHash" }),
|
|
181
226
|
signal: controller.signal,
|
|
182
227
|
});
|
|
183
228
|
if (!response.ok) {
|
|
@@ -203,6 +248,16 @@ async function preflightRpc(endpoint: string): Promise<void> {
|
|
|
203
248
|
`${code}${String(body.error.message ?? "RPC error")}`,
|
|
204
249
|
);
|
|
205
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");
|
|
206
261
|
} catch (error) {
|
|
207
262
|
if (
|
|
208
263
|
error instanceof Error &&
|
|
@@ -218,7 +273,7 @@ async function preflightRpc(endpoint: string): Promise<void> {
|
|
|
218
273
|
}
|
|
219
274
|
|
|
220
275
|
async function preflightWebSocket(endpoint: string): Promise<void> {
|
|
221
|
-
const websocketEndpoint = deriveWebSocketEndpoint(endpoint);
|
|
276
|
+
const websocketEndpoint = /^wss?:\/\//.test(endpoint) ? endpoint : deriveWebSocketEndpoint(endpoint);
|
|
222
277
|
await new Promise<void>((resolve, reject) => {
|
|
223
278
|
let settled = false;
|
|
224
279
|
let socket: WebSocket | null = null;
|
|
@@ -245,16 +300,21 @@ async function preflightWebSocket(endpoint: string): Promise<void> {
|
|
|
245
300
|
finish(websocketFailure(endpoint, safeMessage(error)));
|
|
246
301
|
return;
|
|
247
302
|
}
|
|
248
|
-
socket.addEventListener(
|
|
249
|
-
"
|
|
250
|
-
()
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
+
});
|
|
258
318
|
socket.addEventListener(
|
|
259
319
|
"error",
|
|
260
320
|
(event) => {
|
|
@@ -295,17 +355,25 @@ async function ensureReady(endpoint: string): Promise<void> {
|
|
|
295
355
|
}
|
|
296
356
|
}
|
|
297
357
|
|
|
298
|
-
async function liveConnection(
|
|
358
|
+
async function liveConnection(
|
|
359
|
+
options: LiveEndpointOptions = {},
|
|
360
|
+
): Promise<LiveConnection> {
|
|
299
361
|
const pool = endpointPool(options);
|
|
300
|
-
|
|
301
|
-
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);
|
|
302
374
|
await ensureReady(endpoint);
|
|
303
375
|
if (shared?.endpoint === endpoint) return shared;
|
|
304
|
-
shared =
|
|
305
|
-
endpoint,
|
|
306
|
-
websocketEndpoint: deriveWebSocketEndpoint(endpoint),
|
|
307
|
-
connection: new Connection(endpoint, "confirmed"),
|
|
308
|
-
};
|
|
376
|
+
shared = connectionFor(endpoint);
|
|
309
377
|
return shared;
|
|
310
378
|
}
|
|
311
379
|
|
|
@@ -323,30 +391,56 @@ function notifyReady(
|
|
|
323
391
|
} catch {}
|
|
324
392
|
}
|
|
325
393
|
|
|
326
|
-
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");
|
|
327
396
|
let live = await liveConnection(options);
|
|
328
397
|
notifyReady(options, live);
|
|
329
|
-
const pool = endpointPool(options);
|
|
330
|
-
if (!pool) return await subscribeCoreTrades({ ...options, connection: live.connection });
|
|
398
|
+
const pool = endpointPool(options) ?? new LiveEndpointPool([live.endpoint]);
|
|
331
399
|
// Keep duplicate suppression across connection changes. Provider probes detect
|
|
332
400
|
// dead credentials/quota; they cannot diagnose a silent old socket when a new
|
|
333
401
|
// handshake to that provider remains healthy.
|
|
334
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++; };
|
|
335
408
|
const onTrade = async (event: TradeEvent) => {
|
|
336
|
-
|
|
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
|
+
}
|
|
414
|
+
const key = [
|
|
415
|
+
event.signature,
|
|
416
|
+
event.venue,
|
|
417
|
+
event.mint,
|
|
418
|
+
event.pool,
|
|
419
|
+
event.eventIndex ?? `${event.side}:${event.baseRaw}:${event.quoteRaw}`,
|
|
420
|
+
].join(":");
|
|
337
421
|
if (seen.has(key)) return;
|
|
338
422
|
seen.add(key);
|
|
339
423
|
if (seen.size > 4096) seen.delete(seen.values().next().value!);
|
|
424
|
+
lastAcceptedAt = Date.now();
|
|
340
425
|
await options.onTrade(event);
|
|
341
426
|
};
|
|
342
|
-
let subscription = await subscribeCoreTrades({
|
|
427
|
+
let subscription = await subscribeCoreTrades({
|
|
428
|
+
...options,
|
|
429
|
+
signal: undefined,
|
|
430
|
+
onTrade,
|
|
431
|
+
connection: live.connection,
|
|
432
|
+
});
|
|
343
433
|
let stopped = false;
|
|
344
434
|
let tail: Promise<unknown> = Promise.resolve();
|
|
345
435
|
const serial = <T>(work: () => Promise<T>): Promise<T> => {
|
|
346
|
-
const pending = tail.then(work, work);
|
|
436
|
+
const pending = tail.then(work, work);
|
|
437
|
+
tail = pending.catch(() => undefined);
|
|
438
|
+
return pending;
|
|
347
439
|
};
|
|
348
440
|
let settle!: () => void;
|
|
349
|
-
const closed = new Promise<void>((resolve) => {
|
|
441
|
+
const closed = new Promise<void>((resolve) => {
|
|
442
|
+
settle = resolve;
|
|
443
|
+
});
|
|
350
444
|
const requestedInterval = options.healthCheckIntervalMs ?? 30_000;
|
|
351
445
|
if (!Number.isFinite(requestedInterval) || requestedInterval < 1000) {
|
|
352
446
|
await subscription.close();
|
|
@@ -358,39 +452,136 @@ export async function subscribeTrades(options: SubscribeTradesOptions) {
|
|
|
358
452
|
checking = true;
|
|
359
453
|
void serial(async () => {
|
|
360
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
|
+
}
|
|
361
461
|
try {
|
|
362
462
|
const endpoint = await pool.check();
|
|
363
|
-
|
|
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 {}
|
|
364
481
|
const tokens = subscription.listTokens();
|
|
365
|
-
const nextLive = connectionFor(endpoint);
|
|
366
|
-
const next = await subscribeCoreTrades({
|
|
482
|
+
const nextLive = connectionFor(endpoint, ws);
|
|
483
|
+
const next = await subscribeCoreTrades({
|
|
484
|
+
...options,
|
|
485
|
+
signal: undefined,
|
|
486
|
+
tokens,
|
|
487
|
+
onTrade,
|
|
488
|
+
connection: nextLive.connection,
|
|
489
|
+
});
|
|
367
490
|
await subscription.close();
|
|
368
491
|
subscription = next;
|
|
369
492
|
live = nextLive;
|
|
370
493
|
notifyReady(options, live);
|
|
371
|
-
} catch (error) {
|
|
372
|
-
|
|
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) });
|
|
499
|
+
} catch {}
|
|
373
500
|
}
|
|
374
|
-
}).finally(() => {
|
|
501
|
+
}).finally(() => {
|
|
502
|
+
checking = false;
|
|
503
|
+
});
|
|
375
504
|
}, Math.trunc(requestedInterval));
|
|
376
505
|
timer.unref?.();
|
|
377
506
|
const close = async () => {
|
|
378
507
|
if (stopped) return await closed;
|
|
379
508
|
stopped = true;
|
|
509
|
+
invalidate();
|
|
510
|
+
for (const controller of recoveries) controller.abort();
|
|
380
511
|
clearInterval(timer);
|
|
381
512
|
options.signal?.removeEventListener("abort", abort);
|
|
382
|
-
await serial(async () => {
|
|
513
|
+
await serial(async () => {
|
|
514
|
+
await subscription.close();
|
|
515
|
+
settle();
|
|
516
|
+
});
|
|
517
|
+
};
|
|
518
|
+
const abort = () => {
|
|
519
|
+
void close();
|
|
383
520
|
};
|
|
384
|
-
const abort = () => { void close(); };
|
|
385
521
|
if (options.signal?.aborted) await close();
|
|
386
522
|
else options.signal?.addEventListener("abort", abort, { once: true });
|
|
387
523
|
return {
|
|
388
|
-
addTokens: (tokens: string | readonly string[]) =>
|
|
389
|
-
|
|
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 () => {
|
|
528
|
+
if (stopped) throw new Error("Trade subscription is closed");
|
|
529
|
+
await subscription.addTokens(tokens);
|
|
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 () => {
|
|
536
|
+
if (!stopped) await subscription.removeTokens(tokens);
|
|
537
|
+
});
|
|
538
|
+
},
|
|
390
539
|
hasToken: (token: string) => !stopped && subscription.hasToken(token),
|
|
391
|
-
listTokens: () => stopped ? [] : subscription.listTokens(),
|
|
392
|
-
|
|
393
|
-
|
|
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
|
+
},
|
|
582
|
+
close,
|
|
583
|
+
closed,
|
|
584
|
+
} satisfies RecoverableTradeSubscription;
|
|
394
585
|
}
|
|
395
586
|
|
|
396
587
|
export async function subscribeLaunches(options: SubscribeLaunchesOptions) {
|
|
@@ -413,25 +604,46 @@ export async function subscribeMigrations(options: SubscribeMigrationsOptions) {
|
|
|
413
604
|
export async function listenTrades(
|
|
414
605
|
options: ListenTradesOptions,
|
|
415
606
|
): Promise<TradeListener> {
|
|
416
|
-
const callbacks = new Set<(event: TradeEvent) => void | Promise<void>>();
|
|
417
|
-
const history =
|
|
607
|
+
const callbacks = new Set<(event: TradeEvent) => void | Promise<void>>();
|
|
608
|
+
const history =
|
|
609
|
+
options.history === false
|
|
610
|
+
? undefined
|
|
611
|
+
: createLiveTradeHistoryRecorder({
|
|
612
|
+
...options.history,
|
|
613
|
+
dbPath: options.dbPath,
|
|
614
|
+
commitment: options.commitment,
|
|
615
|
+
onStatus: options.onStatus,
|
|
616
|
+
});
|
|
418
617
|
const pending: TradeEvent[] = [];
|
|
419
|
-
const migrationCallbacks = new Set<
|
|
618
|
+
const migrationCallbacks = new Set<
|
|
619
|
+
(event: TradeListenerMigration) => void | Promise<void>
|
|
620
|
+
>();
|
|
420
621
|
const pendingMigrations: TradeListenerMigration[] = [];
|
|
421
622
|
const watched = new Set(options.tokens);
|
|
422
623
|
const lastPools = new Map<string, string | null>();
|
|
423
|
-
let subscription:
|
|
424
|
-
let migrations: Awaited<ReturnType<typeof subscribeMigrations>> | undefined;
|
|
425
|
-
let stopped = false;
|
|
426
|
-
let resolveClosed!: () => void;
|
|
427
|
-
let rejectClosed!: (error: unknown) => void;
|
|
428
|
-
const closed = new Promise<void>((resolve, reject) => {
|
|
429
|
-
|
|
624
|
+
let subscription: RecoverableTradeSubscription;
|
|
625
|
+
let migrations: Awaited<ReturnType<typeof subscribeMigrations>> | undefined;
|
|
626
|
+
let stopped = false;
|
|
627
|
+
let resolveClosed!: () => void;
|
|
628
|
+
let rejectClosed!: (error: unknown) => void;
|
|
629
|
+
const closed = new Promise<void>((resolve, reject) => {
|
|
630
|
+
resolveClosed = resolve;
|
|
631
|
+
rejectClosed = reject;
|
|
632
|
+
});
|
|
633
|
+
void closed.catch(() => undefined);
|
|
430
634
|
let tail: Promise<unknown> = Promise.resolve();
|
|
431
|
-
const serial = <T>(work: () => Promise<T>): Promise<T> => {
|
|
635
|
+
const serial = <T>(work: () => Promise<T>): Promise<T> => {
|
|
636
|
+
const job = tail.then(work, work);
|
|
637
|
+
tail = job.catch(() => undefined);
|
|
638
|
+
return job;
|
|
639
|
+
};
|
|
432
640
|
const onMigration = async (event: MigrationEvent) => {
|
|
433
641
|
if (stopped || !watched.has(event.mint)) return;
|
|
434
|
-
const migration = {
|
|
642
|
+
const migration = {
|
|
643
|
+
...event,
|
|
644
|
+
oldPool: lastPools.get(event.mint) ?? null,
|
|
645
|
+
newPool: event.pool,
|
|
646
|
+
};
|
|
435
647
|
lastPools.set(event.mint, event.pool);
|
|
436
648
|
await serial(async () => {
|
|
437
649
|
if (stopped || !watched.has(event.mint)) return;
|
|
@@ -440,14 +652,24 @@ export async function listenTrades(
|
|
|
440
652
|
await subscription.removeTokens(event.mint);
|
|
441
653
|
await subscription.addTokens(event.mint);
|
|
442
654
|
});
|
|
443
|
-
if (!migrationCallbacks.size) {
|
|
444
|
-
|
|
655
|
+
if (!migrationCallbacks.size) {
|
|
656
|
+
pendingMigrations.push(migration);
|
|
657
|
+
if (pendingMigrations.length > 1000) pendingMigrations.shift();
|
|
658
|
+
} else
|
|
659
|
+
await Promise.all(
|
|
660
|
+
[...migrationCallbacks].map((callback) => callback(migration)),
|
|
661
|
+
);
|
|
445
662
|
};
|
|
446
|
-
const dispatch = async (event: TradeEvent) => {
|
|
447
|
-
if (!watched.has(event.mint) || stopped) return;
|
|
448
|
-
lastPools.set(event.mint, event.pool);
|
|
449
|
-
try {
|
|
450
|
-
|
|
663
|
+
const dispatch = async (event: TradeEvent) => {
|
|
664
|
+
if (!watched.has(event.mint) || stopped) return;
|
|
665
|
+
lastPools.set(event.mint, event.pool);
|
|
666
|
+
try {
|
|
667
|
+
await history?.record(event);
|
|
668
|
+
} catch (error) {
|
|
669
|
+
options.onStatus?.("history-persistence-error", {
|
|
670
|
+
error: safeMessage(error),
|
|
671
|
+
});
|
|
672
|
+
}
|
|
451
673
|
if (callbacks.size === 0) {
|
|
452
674
|
pending.push(event);
|
|
453
675
|
if (pending.length > 1_000) pending.shift();
|
|
@@ -461,32 +683,99 @@ export async function listenTrades(
|
|
|
461
683
|
};
|
|
462
684
|
const onStatus = (event: string, data?: Record<string, unknown>) => {
|
|
463
685
|
options.onStatus?.(event, data);
|
|
464
|
-
if (event === "
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
686
|
+
if (event === "feed-gap-unverified" || event === "feed-stale") pending.length = 0;
|
|
687
|
+
if (event === "rpc-ready" && migrations && !stopped)
|
|
688
|
+
void serial(async () => {
|
|
689
|
+
if (stopped) return;
|
|
690
|
+
const next = await subscribeMigrations({
|
|
691
|
+
rpcUrls: options.rpcUrls,
|
|
692
|
+
websocketUrls: options.websocketUrls,
|
|
693
|
+
tokens: [...watched],
|
|
694
|
+
commitment: options.commitment,
|
|
695
|
+
metadata: options.metadata,
|
|
696
|
+
onMigration,
|
|
697
|
+
onStatus: options.onStatus,
|
|
698
|
+
});
|
|
699
|
+
await migrations?.close();
|
|
700
|
+
migrations = next;
|
|
701
|
+
}).catch((error) =>
|
|
702
|
+
options.onStatus?.("migration-resubscribe-error", {
|
|
703
|
+
error: safeMessage(error),
|
|
704
|
+
}),
|
|
705
|
+
);
|
|
706
|
+
};
|
|
707
|
+
try {
|
|
708
|
+
subscription = await subscribeTrades({
|
|
709
|
+
...options,
|
|
710
|
+
signal: undefined,
|
|
711
|
+
onStatus,
|
|
712
|
+
onTrade: dispatch,
|
|
713
|
+
});
|
|
714
|
+
} catch (error) {
|
|
715
|
+
await history?.close();
|
|
716
|
+
throw error;
|
|
717
|
+
}
|
|
718
|
+
try {
|
|
719
|
+
migrations = await subscribeMigrations({
|
|
720
|
+
rpcUrls: options.rpcUrls,
|
|
721
|
+
websocketUrls: options.websocketUrls,
|
|
722
|
+
tokens: [...watched],
|
|
723
|
+
commitment: options.commitment,
|
|
724
|
+
metadata: options.metadata,
|
|
725
|
+
onMigration,
|
|
726
|
+
onStatus: options.onStatus,
|
|
727
|
+
});
|
|
728
|
+
} catch (error) {
|
|
729
|
+
await subscription.close();
|
|
730
|
+
await history?.close();
|
|
731
|
+
throw error;
|
|
732
|
+
}
|
|
733
|
+
const close = async () => {
|
|
734
|
+
if (stopped) return await closed;
|
|
735
|
+
stopped = true;
|
|
736
|
+
options.signal?.removeEventListener("abort", abort);
|
|
737
|
+
try {
|
|
738
|
+
await serial(async () => {
|
|
739
|
+
await Promise.all([subscription.close(), migrations?.close()]);
|
|
740
|
+
watched.clear();
|
|
741
|
+
await history?.close();
|
|
742
|
+
});
|
|
743
|
+
resolveClosed();
|
|
744
|
+
} catch (error) {
|
|
745
|
+
rejectClosed(error);
|
|
746
|
+
throw error;
|
|
747
|
+
}
|
|
469
748
|
};
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
try { migrations = await subscribeMigrations({ rpcUrls: options.rpcUrls, tokens: [...watched], commitment: options.commitment, metadata: options.metadata, onMigration, onStatus: options.onStatus }); }
|
|
473
|
-
catch (error) { await subscription.close(); await history?.close(); throw error; }
|
|
474
|
-
const close = async () => {
|
|
475
|
-
if (stopped) return await closed;
|
|
476
|
-
stopped = true; options.signal?.removeEventListener("abort", abort);
|
|
477
|
-
try {
|
|
478
|
-
await serial(async () => { await Promise.all([subscription.close(), migrations?.close()]); watched.clear(); await history?.close(); });
|
|
479
|
-
resolveClosed();
|
|
480
|
-
} catch (error) { rejectClosed(error); throw error; }
|
|
749
|
+
const abort = () => {
|
|
750
|
+
void close();
|
|
481
751
|
};
|
|
482
|
-
const abort = () => { void close(); };
|
|
483
752
|
if (options.signal?.aborted) await close();
|
|
484
753
|
else options.signal?.addEventListener("abort", abort, { once: true });
|
|
485
754
|
return {
|
|
486
|
-
add: (tokens) =>
|
|
487
|
-
|
|
755
|
+
add: (tokens) =>
|
|
756
|
+
serial(async () => {
|
|
757
|
+
if (stopped) throw new Error("Trade listener is closed");
|
|
758
|
+
const values = typeof tokens === "string" ? [tokens] : [...tokens];
|
|
759
|
+
await subscription.addTokens(values);
|
|
760
|
+
await migrations!.addTokens(values);
|
|
761
|
+
values.forEach((token) => watched.add(token));
|
|
762
|
+
}),
|
|
763
|
+
remove: (tokens) =>
|
|
764
|
+
serial(async () => {
|
|
765
|
+
const values = typeof tokens === "string" ? [tokens] : [...tokens];
|
|
766
|
+
values.forEach((token) => {
|
|
767
|
+
watched.delete(token);
|
|
768
|
+
lastPools.delete(token);
|
|
769
|
+
});
|
|
770
|
+
await Promise.all([
|
|
771
|
+
subscription.removeTokens(values),
|
|
772
|
+
migrations!.removeTokens(values),
|
|
773
|
+
]);
|
|
774
|
+
}),
|
|
775
|
+
get feedVerified() { return !stopped && subscription.feedVerified === true; },
|
|
776
|
+
verifyAndResume: (verify, timeoutMs) => subscription.verifyAndResume(verify, timeoutMs),
|
|
488
777
|
has: (token) => !stopped && watched.has(token),
|
|
489
|
-
list: () => stopped ? [] : [...watched],
|
|
778
|
+
list: () => (stopped ? [] : [...watched]),
|
|
490
779
|
onTrade(callback) {
|
|
491
780
|
callbacks.add(callback);
|
|
492
781
|
if (pending.length) {
|
|
@@ -499,12 +788,17 @@ export async function listenTrades(
|
|
|
499
788
|
},
|
|
500
789
|
onMigration(callback) {
|
|
501
790
|
migrationCallbacks.add(callback);
|
|
502
|
-
if (pendingMigrations.length) {
|
|
791
|
+
if (pendingMigrations.length) {
|
|
792
|
+
const rows = pendingMigrations.splice(0);
|
|
793
|
+
void Promise.resolve().then(async () => {
|
|
794
|
+
for (const event of rows) await callback(event);
|
|
795
|
+
});
|
|
796
|
+
}
|
|
503
797
|
return () => migrationCallbacks.delete(callback);
|
|
504
798
|
},
|
|
505
|
-
close,
|
|
506
|
-
get closed() {
|
|
507
|
-
return closed;
|
|
799
|
+
close,
|
|
800
|
+
get closed() {
|
|
801
|
+
return closed;
|
|
508
802
|
},
|
|
509
803
|
};
|
|
510
804
|
}
|