@solard/sdk 0.2.27 → 0.2.31
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 +21 -6
- package/package.json +2 -2
- package/src/client.trade-fees.test.ts +60 -0
- package/src/client.ts +263 -280
- package/src/default.ts +51 -51
- package/src/export-surface.test.ts +204 -188
- package/src/index.ts +142 -100
- package/src/live.test.ts +108 -15
- package/src/live.ts +468 -307
- package/src/operator-api.architecture.test.ts +45 -44
package/src/live.ts
CHANGED
|
@@ -1,332 +1,493 @@
|
|
|
1
|
-
import { Connection } from "@solana/web3.js";
|
|
2
|
-
import {
|
|
3
|
-
subscribeLaunches as subscribeCoreLaunches,
|
|
4
|
-
subscribeMigrations as subscribeCoreMigrations,
|
|
5
|
-
subscribeTrades as subscribeCoreTrades,
|
|
6
|
-
type TradeEvent,
|
|
1
|
+
import { Connection } from "@solana/web3.js";
|
|
2
|
+
import {
|
|
3
|
+
subscribeLaunches as subscribeCoreLaunches,
|
|
4
|
+
subscribeMigrations as subscribeCoreMigrations,
|
|
5
|
+
subscribeTrades as subscribeCoreTrades,
|
|
6
|
+
type TradeEvent,
|
|
7
7
|
type TradeSubscription,
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
type MigrationEvent,
|
|
9
|
+
} from "@solard/core";
|
|
10
|
+
|
|
11
|
+
export type LiveEndpointOptions = {
|
|
12
|
+
/** Ordered endpoints on the same cluster; public RPC can be supplied last. */
|
|
13
|
+
rpcUrls?: readonly string[];
|
|
14
|
+
/** Fresh HTTP and WebSocket readiness probes for pooled trade streams (default 30s). */
|
|
15
|
+
healthCheckIntervalMs?: number;
|
|
16
|
+
};
|
|
10
17
|
export type SubscribeLaunchesOptions = Omit<
|
|
11
|
-
Parameters<typeof subscribeCoreLaunches>[0],
|
|
12
|
-
"connection"
|
|
13
|
-
|
|
14
|
-
export type SubscribeMigrationsOptions = Omit<
|
|
15
|
-
Parameters<typeof subscribeCoreMigrations>[0],
|
|
16
|
-
"connection"
|
|
17
|
-
|
|
18
|
-
export type SubscribeTradesOptions = Omit<
|
|
19
|
-
Parameters<typeof subscribeCoreTrades>[0],
|
|
20
|
-
"connection"
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
export type ListenTradesOptions = Omit<SubscribeTradesOptions, "onTrade">;
|
|
24
|
-
|
|
18
|
+
Parameters<typeof subscribeCoreLaunches>[0],
|
|
19
|
+
"connection"
|
|
20
|
+
> & LiveEndpointOptions;
|
|
21
|
+
export type SubscribeMigrationsOptions = Omit<
|
|
22
|
+
Parameters<typeof subscribeCoreMigrations>[0],
|
|
23
|
+
"connection"
|
|
24
|
+
> & LiveEndpointOptions;
|
|
25
|
+
export type SubscribeTradesOptions = Omit<
|
|
26
|
+
Parameters<typeof subscribeCoreTrades>[0],
|
|
27
|
+
"connection"
|
|
28
|
+
> & LiveEndpointOptions;
|
|
29
|
+
|
|
30
|
+
export type ListenTradesOptions = Omit<SubscribeTradesOptions, "onTrade">;
|
|
31
|
+
|
|
25
32
|
export type TradeListener = {
|
|
26
|
-
add(tokens: string | readonly string[]): Promise<void>;
|
|
27
|
-
remove(tokens: string | readonly string[]): Promise<void>;
|
|
28
|
-
has(token: string): boolean;
|
|
29
|
-
list(): string[];
|
|
33
|
+
add(tokens: string | readonly string[]): Promise<void>;
|
|
34
|
+
remove(tokens: string | readonly string[]): Promise<void>;
|
|
35
|
+
has(token: string): boolean;
|
|
36
|
+
list(): string[];
|
|
30
37
|
onTrade(callback: (event: TradeEvent) => void | Promise<void>): () => void;
|
|
31
|
-
|
|
32
|
-
|
|
38
|
+
onMigration(callback: (event: TradeListenerMigration) => void | Promise<void>): () => void;
|
|
39
|
+
close(): Promise<void>;
|
|
40
|
+
readonly closed: Promise<void>;
|
|
33
41
|
};
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
+
export type TradeListenerMigration = MigrationEvent & { oldPool: string | null; newPool: string };
|
|
43
|
+
|
|
44
|
+
type LiveConnection = {
|
|
45
|
+
endpoint: string;
|
|
46
|
+
websocketEndpoint: string;
|
|
47
|
+
connection: Connection;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
let shared: LiveConnection | null = null;
|
|
42
51
|
let readiness: { endpoint: string; promise: Promise<void> } | null = null;
|
|
43
52
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
);
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
throw new Error("RPC_ENDPOINT must be a valid http:// or https:// URL.");
|
|
53
|
+
/** HTTP + a fresh WebSocket handshake must both succeed before choosing an endpoint. */
|
|
54
|
+
export class LiveEndpointPool {
|
|
55
|
+
private active = 0;
|
|
56
|
+
readonly endpoints: readonly string[];
|
|
57
|
+
constructor(endpoints: readonly string[], private readonly probe: (endpoint: string) => Promise<void> = async (endpoint) => {
|
|
58
|
+
await Promise.all([preflightRpc(endpoint), preflightWebSocket(endpoint)]);
|
|
59
|
+
}) {
|
|
60
|
+
this.endpoints = [...new Set(endpoints)];
|
|
61
|
+
if (!this.endpoints.length) throw new Error("Live RPC endpoint pool cannot be empty");
|
|
62
|
+
for (const endpoint of this.endpoints) deriveWebSocketEndpoint(endpoint);
|
|
55
63
|
}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
else if (url.protocol === "http:") url.protocol = "ws:";
|
|
65
|
-
else throw new Error("RPC endpoint must use http:// or https://.");
|
|
66
|
-
return url.toString();
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
export function redactRpcEndpoint(endpoint: string): string {
|
|
70
|
-
try {
|
|
71
|
-
const url = new URL(endpoint);
|
|
72
|
-
const keys: string[] = [];
|
|
73
|
-
url.searchParams.forEach((_value, key) => keys.push(key));
|
|
74
|
-
for (const key of keys) {
|
|
75
|
-
if (/key|token|secret|auth/i.test(key))
|
|
76
|
-
url.searchParams.set(key, "<redacted>");
|
|
64
|
+
get endpoint(): string { return this.endpoints[this.active]!; }
|
|
65
|
+
async select(skipCurrent = false): Promise<string> {
|
|
66
|
+
const start = this.active;
|
|
67
|
+
let lastError: unknown;
|
|
68
|
+
for (let offset = skipCurrent ? 1 : 0; offset < this.endpoints.length + (skipCurrent ? 1 : 0); offset++) {
|
|
69
|
+
const index = (start + offset) % this.endpoints.length;
|
|
70
|
+
try { await this.probe(this.endpoints[index]!); this.active = index; return this.endpoint; }
|
|
71
|
+
catch (error) { lastError = error; }
|
|
77
72
|
}
|
|
78
|
-
|
|
79
|
-
} catch {
|
|
80
|
-
return endpoint.replace(
|
|
81
|
-
/([?&](?:api-?key|api_key|apikey|key|token|secret|auth)=)[^&]+/gi,
|
|
82
|
-
"$1<redacted>",
|
|
83
|
-
);
|
|
73
|
+
throw lastError;
|
|
84
74
|
}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
try {
|
|
89
|
-
return new URL(endpoint).hostname.endsWith("helius-rpc.com")
|
|
90
|
-
? "Helius"
|
|
91
|
-
: "Solana RPC provider";
|
|
92
|
-
} catch {
|
|
93
|
-
return "Solana RPC provider";
|
|
75
|
+
async check(): Promise<string> {
|
|
76
|
+
try { await this.probe(this.endpoint); return this.endpoint; }
|
|
77
|
+
catch { return await this.select(true); }
|
|
94
78
|
}
|
|
95
79
|
}
|
|
96
|
-
|
|
97
|
-
function
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
)
|
|
104
|
-
.slice(0, 400);
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
function rpcFailure(endpoint: string, detail: string): Error {
|
|
108
|
-
return new Error(
|
|
109
|
-
`${providerName(endpoint)} rejected RPC_ENDPOINT ${redactRpcEndpoint(endpoint)}. Check the API key, credits/quota, rate limits, and account status. ${detail}`,
|
|
110
|
-
);
|
|
80
|
+
const livePools = new Map<string, LiveEndpointPool>();
|
|
81
|
+
function endpointPool(options: LiveEndpointOptions): LiveEndpointPool | undefined {
|
|
82
|
+
if (!options.rpcUrls?.length) return undefined;
|
|
83
|
+
const key = JSON.stringify(options.rpcUrls);
|
|
84
|
+
let pool = livePools.get(key);
|
|
85
|
+
if (!pool) { pool = new LiveEndpointPool(options.rpcUrls); livePools.set(key, pool); }
|
|
86
|
+
return pool;
|
|
111
87
|
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
return new Error(
|
|
115
|
-
`${providerName(endpoint)} rejected WebSocket access derived from RPC_ENDPOINT ${redactRpcEndpoint(endpoint)}. Check the API key, credits/quota, rate limits, and WebSocket connection limits. ${detail}`,
|
|
116
|
-
);
|
|
88
|
+
function connectionFor(endpoint: string): LiveConnection {
|
|
89
|
+
return { endpoint, websocketEndpoint: deriveWebSocketEndpoint(endpoint), connection: new Connection(endpoint, "confirmed") };
|
|
117
90
|
}
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
const
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
)
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
91
|
+
|
|
92
|
+
function endpointFromEnv(): string {
|
|
93
|
+
const endpoint = process.env.RPC_ENDPOINT?.trim();
|
|
94
|
+
if (!endpoint)
|
|
95
|
+
throw new Error(
|
|
96
|
+
"Missing RPC_ENDPOINT. Set one Solana RPC URL in RPC_ENDPOINT; Solard derives WebSocket access from the same endpoint.",
|
|
97
|
+
);
|
|
98
|
+
let parsed: URL;
|
|
99
|
+
try {
|
|
100
|
+
parsed = new URL(endpoint);
|
|
101
|
+
} catch {
|
|
102
|
+
throw new Error("RPC_ENDPOINT must be a valid http:// or https:// URL.");
|
|
103
|
+
}
|
|
104
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:")
|
|
105
|
+
throw new Error("RPC_ENDPOINT must use http:// or https://.");
|
|
106
|
+
return endpoint;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function deriveWebSocketEndpoint(endpoint: string): string {
|
|
110
|
+
const url = new URL(endpoint);
|
|
111
|
+
if (url.protocol === "https:") url.protocol = "wss:";
|
|
112
|
+
else if (url.protocol === "http:") url.protocol = "ws:";
|
|
113
|
+
else throw new Error("RPC endpoint must use http:// or https://.");
|
|
114
|
+
return url.toString();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function redactRpcEndpoint(endpoint: string): string {
|
|
118
|
+
try {
|
|
119
|
+
const url = new URL(endpoint);
|
|
120
|
+
const keys: string[] = [];
|
|
121
|
+
url.searchParams.forEach((_value, key) => keys.push(key));
|
|
122
|
+
for (const key of keys) {
|
|
123
|
+
if (/key|token|secret|auth/i.test(key))
|
|
124
|
+
url.searchParams.set(key, "<redacted>");
|
|
125
|
+
}
|
|
126
|
+
return url.toString().replaceAll("%3Credacted%3E", "<redacted>");
|
|
127
|
+
} catch {
|
|
128
|
+
return endpoint.replace(
|
|
129
|
+
/([?&](?:api-?key|api_key|apikey|key|token|secret|auth)=)[^&]+/gi,
|
|
130
|
+
"$1<redacted>",
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function providerName(endpoint: string): string {
|
|
136
|
+
try {
|
|
137
|
+
return new URL(endpoint).hostname.endsWith("helius-rpc.com")
|
|
138
|
+
? "Helius"
|
|
139
|
+
: "Solana RPC provider";
|
|
140
|
+
} catch {
|
|
141
|
+
return "Solana RPC provider";
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function safeMessage(error: unknown): string {
|
|
146
|
+
const value = error instanceof Error ? error.message : String(error);
|
|
147
|
+
return value
|
|
148
|
+
.replace(
|
|
149
|
+
/([?&](?:api-?key|api_key|apikey|key|token|secret|auth)=)[^&\s]+/gi,
|
|
150
|
+
"$1<redacted>",
|
|
151
|
+
)
|
|
152
|
+
.slice(0, 400);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function rpcFailure(endpoint: string, detail: string): Error {
|
|
156
|
+
return new Error(
|
|
157
|
+
`${providerName(endpoint)} rejected RPC_ENDPOINT ${redactRpcEndpoint(endpoint)}. Check the API key, credits/quota, rate limits, and account status. ${detail}`,
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function websocketFailure(endpoint: string, detail: string): Error {
|
|
162
|
+
return new Error(
|
|
163
|
+
`${providerName(endpoint)} rejected WebSocket access derived from RPC_ENDPOINT ${redactRpcEndpoint(endpoint)}. Check the API key, credits/quota, rate limits, and WebSocket connection limits. ${detail}`,
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function preflightRpc(endpoint: string): Promise<void> {
|
|
168
|
+
const controller = new AbortController();
|
|
169
|
+
const timer = setTimeout(() => controller.abort(), 6_000);
|
|
170
|
+
try {
|
|
171
|
+
const response = await fetch(endpoint, {
|
|
172
|
+
method: "POST",
|
|
173
|
+
headers: { "content-type": "application/json" },
|
|
174
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "getHealth" }),
|
|
175
|
+
signal: controller.signal,
|
|
176
|
+
});
|
|
177
|
+
if (!response.ok) {
|
|
178
|
+
const label =
|
|
179
|
+
response.status === 401 || response.status === 403
|
|
180
|
+
? "Authentication failed."
|
|
181
|
+
: response.status === 402
|
|
182
|
+
? "Billing or credits are unavailable."
|
|
183
|
+
: response.status === 429
|
|
184
|
+
? "Rate limit or quota was exceeded."
|
|
185
|
+
: `HTTP ${response.status}.`;
|
|
186
|
+
throw rpcFailure(endpoint, label);
|
|
187
|
+
}
|
|
188
|
+
const body = (await response.json()) as {
|
|
189
|
+
result?: unknown;
|
|
190
|
+
error?: { code?: unknown; message?: unknown };
|
|
191
|
+
};
|
|
192
|
+
if (body.error) {
|
|
193
|
+
const code =
|
|
194
|
+
body.error.code == null ? "" : `code ${String(body.error.code)}: `;
|
|
195
|
+
throw rpcFailure(
|
|
196
|
+
endpoint,
|
|
197
|
+
`${code}${String(body.error.message ?? "RPC error")}`,
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
} catch (error) {
|
|
201
|
+
if (
|
|
202
|
+
error instanceof Error &&
|
|
203
|
+
error.message.includes("rejected RPC_ENDPOINT")
|
|
204
|
+
)
|
|
205
|
+
throw error;
|
|
206
|
+
if (controller.signal.aborted)
|
|
207
|
+
throw rpcFailure(endpoint, "Connection timed out.");
|
|
208
|
+
throw rpcFailure(endpoint, safeMessage(error));
|
|
209
|
+
} finally {
|
|
210
|
+
clearTimeout(timer);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async function preflightWebSocket(endpoint: string): Promise<void> {
|
|
215
|
+
const websocketEndpoint = deriveWebSocketEndpoint(endpoint);
|
|
216
|
+
await new Promise<void>((resolve, reject) => {
|
|
217
|
+
let settled = false;
|
|
218
|
+
let socket: WebSocket | null = null;
|
|
219
|
+
const finish = (error?: Error) => {
|
|
220
|
+
if (settled) return;
|
|
221
|
+
settled = true;
|
|
222
|
+
clearTimeout(timer);
|
|
223
|
+
if (error && socket) {
|
|
224
|
+
try {
|
|
225
|
+
socket.close();
|
|
226
|
+
} catch {}
|
|
227
|
+
}
|
|
228
|
+
if (error) reject(error);
|
|
229
|
+
else resolve();
|
|
230
|
+
};
|
|
231
|
+
const timer = setTimeout(
|
|
232
|
+
() =>
|
|
233
|
+
finish(websocketFailure(endpoint, "WebSocket handshake timed out.")),
|
|
234
|
+
6_000,
|
|
235
|
+
);
|
|
236
|
+
try {
|
|
237
|
+
socket = new WebSocket(websocketEndpoint);
|
|
238
|
+
} catch (error) {
|
|
239
|
+
finish(websocketFailure(endpoint, safeMessage(error)));
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
socket.addEventListener(
|
|
243
|
+
"open",
|
|
244
|
+
() => {
|
|
245
|
+
finish();
|
|
246
|
+
try {
|
|
247
|
+
socket?.close(1000);
|
|
248
|
+
} catch {}
|
|
249
|
+
},
|
|
250
|
+
{ once: true },
|
|
251
|
+
);
|
|
252
|
+
socket.addEventListener(
|
|
253
|
+
"error",
|
|
254
|
+
(event) => {
|
|
255
|
+
const detail = safeMessage(
|
|
256
|
+
(event as { error?: unknown }).error ??
|
|
257
|
+
"WebSocket handshake was rejected.",
|
|
258
|
+
);
|
|
259
|
+
finish(websocketFailure(endpoint, detail));
|
|
260
|
+
},
|
|
261
|
+
{ once: true },
|
|
262
|
+
);
|
|
263
|
+
socket.addEventListener(
|
|
264
|
+
"close",
|
|
265
|
+
(event) => {
|
|
266
|
+
if (settled) return;
|
|
267
|
+
const detail = event.reason
|
|
268
|
+
? `WebSocket closed during handshake: ${event.reason}`
|
|
269
|
+
: `WebSocket closed during handshake with code ${event.code}.`;
|
|
270
|
+
finish(websocketFailure(endpoint, detail));
|
|
271
|
+
},
|
|
272
|
+
{ once: true },
|
|
273
|
+
);
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
async function ensureReady(endpoint: string): Promise<void> {
|
|
278
|
+
if (readiness?.endpoint === endpoint) return await readiness.promise;
|
|
279
|
+
const promise = Promise.all([
|
|
280
|
+
preflightRpc(endpoint),
|
|
281
|
+
preflightWebSocket(endpoint),
|
|
282
|
+
]).then(() => undefined);
|
|
283
|
+
readiness = { endpoint, promise };
|
|
284
|
+
try {
|
|
285
|
+
await promise;
|
|
286
|
+
} catch (error) {
|
|
287
|
+
if (readiness?.promise === promise) readiness = null;
|
|
288
|
+
throw error;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
async function liveConnection(options: LiveEndpointOptions = {}): Promise<LiveConnection> {
|
|
293
|
+
const pool = endpointPool(options);
|
|
294
|
+
if (pool) return connectionFor(await pool.select());
|
|
295
|
+
const endpoint = endpointFromEnv();
|
|
296
|
+
await ensureReady(endpoint);
|
|
297
|
+
if (shared?.endpoint === endpoint) return shared;
|
|
298
|
+
shared = {
|
|
299
|
+
endpoint,
|
|
300
|
+
websocketEndpoint: deriveWebSocketEndpoint(endpoint),
|
|
301
|
+
connection: new Connection(endpoint, "confirmed"),
|
|
302
|
+
};
|
|
303
|
+
return shared;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function notifyReady(
|
|
307
|
+
options: {
|
|
308
|
+
onStatus?: (event: string, data?: Record<string, unknown>) => void;
|
|
309
|
+
},
|
|
310
|
+
live: LiveConnection,
|
|
311
|
+
): void {
|
|
312
|
+
try {
|
|
313
|
+
options.onStatus?.("rpc-ready", {
|
|
314
|
+
rpc: redactRpcEndpoint(live.endpoint),
|
|
315
|
+
websocket: redactRpcEndpoint(live.websocketEndpoint),
|
|
316
|
+
});
|
|
317
|
+
} catch {}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
export async function subscribeTrades(options: SubscribeTradesOptions) {
|
|
321
|
+
let live = await liveConnection(options);
|
|
322
|
+
notifyReady(options, live);
|
|
323
|
+
const pool = endpointPool(options);
|
|
324
|
+
if (!pool) return await subscribeCoreTrades({ ...options, connection: live.connection });
|
|
325
|
+
// Keep duplicate suppression across connection changes. Provider probes detect
|
|
326
|
+
// dead credentials/quota; they cannot diagnose a silent old socket when a new
|
|
327
|
+
// handshake to that provider remains healthy.
|
|
328
|
+
const seen = new Set<string>();
|
|
329
|
+
const onTrade = async (event: TradeEvent) => {
|
|
330
|
+
const key = [event.signature, event.venue, event.mint, event.pool, event.side, String(event.baseRaw), String(event.quoteRaw)].join(":");
|
|
331
|
+
if (seen.has(key)) return;
|
|
332
|
+
seen.add(key);
|
|
333
|
+
if (seen.size > 4096) seen.delete(seen.values().next().value!);
|
|
334
|
+
await options.onTrade(event);
|
|
335
|
+
};
|
|
336
|
+
let subscription = await subscribeCoreTrades({ ...options, signal: undefined, onTrade, connection: live.connection });
|
|
337
|
+
let stopped = false;
|
|
338
|
+
let tail: Promise<unknown> = Promise.resolve();
|
|
339
|
+
const serial = <T>(work: () => Promise<T>): Promise<T> => {
|
|
340
|
+
const pending = tail.then(work, work); tail = pending.catch(() => undefined); return pending;
|
|
341
|
+
};
|
|
342
|
+
let settle!: () => void;
|
|
343
|
+
const closed = new Promise<void>((resolve) => { settle = resolve; });
|
|
344
|
+
const requestedInterval = options.healthCheckIntervalMs ?? 30_000;
|
|
345
|
+
if (!Number.isFinite(requestedInterval) || requestedInterval < 1000) {
|
|
346
|
+
await subscription.close();
|
|
347
|
+
throw new Error("healthCheckIntervalMs must be at least 1000");
|
|
163
348
|
}
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
349
|
+
let checking = false;
|
|
350
|
+
const timer = setInterval(() => {
|
|
351
|
+
if (stopped || checking) return;
|
|
352
|
+
checking = true;
|
|
353
|
+
void serial(async () => {
|
|
354
|
+
if (stopped) return;
|
|
355
|
+
try {
|
|
356
|
+
const endpoint = await pool.check();
|
|
357
|
+
if (stopped || endpoint === live.endpoint) return;
|
|
358
|
+
const tokens = subscription.listTokens();
|
|
359
|
+
const nextLive = connectionFor(endpoint);
|
|
360
|
+
const next = await subscribeCoreTrades({ ...options, signal: undefined, tokens, onTrade, connection: nextLive.connection });
|
|
361
|
+
await subscription.close();
|
|
362
|
+
subscription = next;
|
|
363
|
+
live = nextLive;
|
|
364
|
+
notifyReady(options, live);
|
|
365
|
+
} catch (error) {
|
|
366
|
+
try { options.onStatus?.("rpc-unavailable", { error: safeMessage(error) }); } catch {}
|
|
179
367
|
}
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
);
|
|
188
|
-
|
|
189
|
-
socket = new WebSocket(websocketEndpoint);
|
|
190
|
-
} catch (error) {
|
|
191
|
-
finish(websocketFailure(endpoint, safeMessage(error)));
|
|
192
|
-
return;
|
|
193
|
-
}
|
|
194
|
-
socket.addEventListener(
|
|
195
|
-
"open",
|
|
196
|
-
() => {
|
|
197
|
-
try {
|
|
198
|
-
socket?.close(1000);
|
|
199
|
-
} catch {}
|
|
200
|
-
finish();
|
|
201
|
-
},
|
|
202
|
-
{ once: true },
|
|
203
|
-
);
|
|
204
|
-
socket.addEventListener(
|
|
205
|
-
"error",
|
|
206
|
-
(event) => {
|
|
207
|
-
const detail = safeMessage(
|
|
208
|
-
(event as { error?: unknown }).error ??
|
|
209
|
-
"WebSocket handshake was rejected.",
|
|
210
|
-
);
|
|
211
|
-
finish(websocketFailure(endpoint, detail));
|
|
212
|
-
},
|
|
213
|
-
{ once: true },
|
|
214
|
-
);
|
|
215
|
-
socket.addEventListener(
|
|
216
|
-
"close",
|
|
217
|
-
(event) => {
|
|
218
|
-
if (settled) return;
|
|
219
|
-
const detail = event.reason
|
|
220
|
-
? `WebSocket closed during handshake: ${event.reason}`
|
|
221
|
-
: `WebSocket closed during handshake with code ${event.code}.`;
|
|
222
|
-
finish(websocketFailure(endpoint, detail));
|
|
223
|
-
},
|
|
224
|
-
{ once: true },
|
|
225
|
-
);
|
|
226
|
-
});
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
async function ensureReady(endpoint: string): Promise<void> {
|
|
230
|
-
if (readiness?.endpoint === endpoint) return await readiness.promise;
|
|
231
|
-
const promise = Promise.all([
|
|
232
|
-
preflightRpc(endpoint),
|
|
233
|
-
preflightWebSocket(endpoint),
|
|
234
|
-
]).then(() => undefined);
|
|
235
|
-
readiness = { endpoint, promise };
|
|
236
|
-
try {
|
|
237
|
-
await promise;
|
|
238
|
-
} catch (error) {
|
|
239
|
-
if (readiness?.promise === promise) readiness = null;
|
|
240
|
-
throw error;
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
async function liveConnection(): Promise<LiveConnection> {
|
|
245
|
-
const endpoint = endpointFromEnv();
|
|
246
|
-
await ensureReady(endpoint);
|
|
247
|
-
if (shared?.endpoint === endpoint) return shared;
|
|
248
|
-
shared = {
|
|
249
|
-
endpoint,
|
|
250
|
-
websocketEndpoint: deriveWebSocketEndpoint(endpoint),
|
|
251
|
-
connection: new Connection(endpoint, "confirmed"),
|
|
368
|
+
}).finally(() => { checking = false; });
|
|
369
|
+
}, Math.trunc(requestedInterval));
|
|
370
|
+
timer.unref?.();
|
|
371
|
+
const close = async () => {
|
|
372
|
+
if (stopped) return await closed;
|
|
373
|
+
stopped = true;
|
|
374
|
+
clearInterval(timer);
|
|
375
|
+
options.signal?.removeEventListener("abort", abort);
|
|
376
|
+
await serial(async () => { await subscription.close(); settle(); });
|
|
252
377
|
};
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
options.onStatus?.("rpc-ready", {
|
|
264
|
-
rpc: redactRpcEndpoint(live.endpoint),
|
|
265
|
-
websocket: redactRpcEndpoint(live.websocketEndpoint),
|
|
266
|
-
});
|
|
267
|
-
} catch {}
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
export async function subscribeTrades(options: SubscribeTradesOptions) {
|
|
271
|
-
const live = await liveConnection();
|
|
272
|
-
notifyReady(options, live);
|
|
273
|
-
return await subscribeCoreTrades({ ...options, connection: live.connection });
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
export async function subscribeLaunches(options: SubscribeLaunchesOptions) {
|
|
277
|
-
const live = await liveConnection();
|
|
278
|
-
notifyReady(options, live);
|
|
279
|
-
return await subscribeCoreLaunches({
|
|
280
|
-
...options,
|
|
281
|
-
connection: live.connection,
|
|
282
|
-
});
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
export async function subscribeMigrations(options: SubscribeMigrationsOptions) {
|
|
286
|
-
const live = await liveConnection();
|
|
287
|
-
notifyReady(options, live);
|
|
288
|
-
return await subscribeCoreMigrations({
|
|
289
|
-
...options,
|
|
290
|
-
connection: live.connection,
|
|
291
|
-
});
|
|
378
|
+
const abort = () => { void close(); };
|
|
379
|
+
if (options.signal?.aborted) await close();
|
|
380
|
+
else options.signal?.addEventListener("abort", abort, { once: true });
|
|
381
|
+
return {
|
|
382
|
+
addTokens: (tokens: string | readonly string[]) => serial(async () => { if (stopped) throw new Error("Trade subscription is closed"); await subscription.addTokens(tokens); }),
|
|
383
|
+
removeTokens: (tokens: string | readonly string[]) => serial(async () => { if (!stopped) await subscription.removeTokens(tokens); }),
|
|
384
|
+
hasToken: (token: string) => !stopped && subscription.hasToken(token),
|
|
385
|
+
listTokens: () => stopped ? [] : subscription.listTokens(),
|
|
386
|
+
close, closed,
|
|
387
|
+
} satisfies TradeSubscription;
|
|
292
388
|
}
|
|
389
|
+
|
|
390
|
+
export async function subscribeLaunches(options: SubscribeLaunchesOptions) {
|
|
391
|
+
const live = await liveConnection(options);
|
|
392
|
+
notifyReady(options, live);
|
|
393
|
+
return await subscribeCoreLaunches({
|
|
394
|
+
...options,
|
|
395
|
+
connection: live.connection,
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
export async function subscribeMigrations(options: SubscribeMigrationsOptions) {
|
|
400
|
+
const live = await liveConnection(options);
|
|
401
|
+
notifyReady(options, live);
|
|
402
|
+
return await subscribeCoreMigrations({
|
|
403
|
+
...options,
|
|
404
|
+
connection: live.connection,
|
|
405
|
+
});
|
|
406
|
+
}
|
|
293
407
|
export async function listenTrades(
|
|
294
|
-
options: ListenTradesOptions,
|
|
295
|
-
): Promise<TradeListener> {
|
|
296
|
-
const callbacks = new Set<(event: TradeEvent) => void | Promise<void>>();
|
|
408
|
+
options: ListenTradesOptions,
|
|
409
|
+
): Promise<TradeListener> {
|
|
410
|
+
const callbacks = new Set<(event: TradeEvent) => void | Promise<void>>();
|
|
297
411
|
const pending: TradeEvent[] = [];
|
|
412
|
+
const migrationCallbacks = new Set<(event: TradeListenerMigration) => void | Promise<void>>();
|
|
413
|
+
const pendingMigrations: TradeListenerMigration[] = [];
|
|
414
|
+
const watched = new Set(options.tokens);
|
|
415
|
+
const lastPools = new Map<string, string | null>();
|
|
298
416
|
let subscription: TradeSubscription;
|
|
417
|
+
let migrations: Awaited<ReturnType<typeof subscribeMigrations>> | undefined;
|
|
418
|
+
let stopped = false;
|
|
419
|
+
let tail: Promise<unknown> = Promise.resolve();
|
|
420
|
+
const serial = <T>(work: () => Promise<T>): Promise<T> => { const job = tail.then(work, work); tail = job.catch(() => undefined); return job; };
|
|
421
|
+
const onMigration = async (event: MigrationEvent) => {
|
|
422
|
+
if (stopped || !watched.has(event.mint)) return;
|
|
423
|
+
const migration = { ...event, oldPool: lastPools.get(event.mint) ?? null, newPool: event.pool };
|
|
424
|
+
lastPools.set(event.mint, event.pool);
|
|
425
|
+
await serial(async () => {
|
|
426
|
+
if (stopped || !watched.has(event.mint)) return;
|
|
427
|
+
// Mint mentions cover the new pool; rebuilding resets cached identities,
|
|
428
|
+
// decimals and supply without changing the caller's watched token list.
|
|
429
|
+
await subscription.removeTokens(event.mint);
|
|
430
|
+
await subscription.addTokens(event.mint);
|
|
431
|
+
});
|
|
432
|
+
if (!migrationCallbacks.size) { pendingMigrations.push(migration); if (pendingMigrations.length > 1000) pendingMigrations.shift(); }
|
|
433
|
+
else await Promise.all([...migrationCallbacks].map(callback => callback(migration)));
|
|
434
|
+
};
|
|
299
435
|
const dispatch = async (event: TradeEvent) => {
|
|
300
|
-
if (
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
436
|
+
if (!watched.has(event.mint) || stopped) return;
|
|
437
|
+
lastPools.set(event.mint, event.pool);
|
|
438
|
+
if (callbacks.size === 0) {
|
|
439
|
+
pending.push(event);
|
|
440
|
+
if (pending.length > 1_000) pending.shift();
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
await Promise.all(
|
|
444
|
+
[...callbacks].map(async (callback) => {
|
|
445
|
+
await callback(event);
|
|
446
|
+
}),
|
|
447
|
+
);
|
|
448
|
+
};
|
|
449
|
+
const onStatus = (event: string, data?: Record<string, unknown>) => {
|
|
450
|
+
options.onStatus?.(event, data);
|
|
451
|
+
if (event === "rpc-ready" && migrations && !stopped) void serial(async () => {
|
|
452
|
+
if (stopped) return;
|
|
453
|
+
const next = await subscribeMigrations({ rpcUrls: options.rpcUrls, tokens: [...watched], commitment: options.commitment, metadata: options.metadata, onMigration, onStatus: options.onStatus });
|
|
454
|
+
await migrations?.close(); migrations = next;
|
|
455
|
+
}).catch(error => options.onStatus?.("migration-resubscribe-error", { error: safeMessage(error) }));
|
|
456
|
+
};
|
|
457
|
+
subscription = await subscribeTrades({ ...options, signal: undefined, onStatus, onTrade: dispatch });
|
|
458
|
+
try { migrations = await subscribeMigrations({ rpcUrls: options.rpcUrls, tokens: [...watched], commitment: options.commitment, metadata: options.metadata, onMigration, onStatus: options.onStatus }); }
|
|
459
|
+
catch (error) { await subscription.close(); throw error; }
|
|
460
|
+
const close = async () => {
|
|
461
|
+
if (stopped) return;
|
|
462
|
+
stopped = true; options.signal?.removeEventListener("abort", abort);
|
|
463
|
+
await serial(async () => { await Promise.all([subscription.close(), migrations?.close()]); watched.clear(); });
|
|
310
464
|
};
|
|
311
|
-
|
|
465
|
+
const abort = () => { void close(); };
|
|
466
|
+
if (options.signal?.aborted) await close();
|
|
467
|
+
else options.signal?.addEventListener("abort", abort, { once: true });
|
|
312
468
|
return {
|
|
313
|
-
add: (tokens) => subscription.addTokens(
|
|
314
|
-
remove: (tokens) => subscription.removeTokens(
|
|
315
|
-
has: (token) =>
|
|
316
|
-
list: () =>
|
|
317
|
-
onTrade(callback) {
|
|
318
|
-
callbacks.add(callback);
|
|
319
|
-
if (pending.length) {
|
|
320
|
-
const rows = pending.splice(0);
|
|
321
|
-
void Promise.resolve().then(async () => {
|
|
322
|
-
for (const event of rows) await callback(event);
|
|
323
|
-
});
|
|
324
|
-
}
|
|
325
|
-
return () => callbacks.delete(callback);
|
|
469
|
+
add: (tokens) => serial(async () => { if (stopped) throw new Error("Trade listener is closed"); const values = typeof tokens === "string" ? [tokens] : [...tokens]; await subscription.addTokens(values); await migrations!.addTokens(values); values.forEach(token => watched.add(token)); }),
|
|
470
|
+
remove: (tokens) => serial(async () => { const values = typeof tokens === "string" ? [tokens] : [...tokens]; values.forEach(token => { watched.delete(token); lastPools.delete(token); }); await Promise.all([subscription.removeTokens(values), migrations!.removeTokens(values)]); }),
|
|
471
|
+
has: (token) => !stopped && watched.has(token),
|
|
472
|
+
list: () => stopped ? [] : [...watched],
|
|
473
|
+
onTrade(callback) {
|
|
474
|
+
callbacks.add(callback);
|
|
475
|
+
if (pending.length) {
|
|
476
|
+
const rows = pending.splice(0);
|
|
477
|
+
void Promise.resolve().then(async () => {
|
|
478
|
+
for (const event of rows) await callback(event);
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
return () => callbacks.delete(callback);
|
|
482
|
+
},
|
|
483
|
+
onMigration(callback) {
|
|
484
|
+
migrationCallbacks.add(callback);
|
|
485
|
+
if (pendingMigrations.length) { const rows = pendingMigrations.splice(0); void Promise.resolve().then(async () => { for (const event of rows) await callback(event); }); }
|
|
486
|
+
return () => migrationCallbacks.delete(callback);
|
|
326
487
|
},
|
|
327
|
-
close
|
|
328
|
-
get closed() {
|
|
329
|
-
return subscription.closed;
|
|
330
|
-
},
|
|
331
|
-
};
|
|
332
|
-
}
|
|
488
|
+
close,
|
|
489
|
+
get closed() {
|
|
490
|
+
return subscription.closed;
|
|
491
|
+
},
|
|
492
|
+
};
|
|
493
|
+
}
|