@solard/cli 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/package.json +3 -3
- package/src/commands/buy.ts +216 -207
- package/src/commands/sell.ts +248 -239
- package/src/commands/trade-fees.test.ts +15 -0
- package/src/commands/trade-fees.ts +24 -0
- package/src/commands/transfer.test.ts +33 -0
- package/src/commands/transfer.ts +205 -200
- package/src/index.ts +3330 -3329
- package/src/launch-watch-command.ts +301 -301
- package/src/price-feed-client.ts +174 -174
- package/src/price-feed-protocol.ts +45 -45
- package/src/price-feed-server.architecture.test.ts +20 -20
- package/src/price-feed-server.ts +315 -315
- package/src/refs.ts +59 -56
- package/src/trade-strategy-command.ts +1219 -1219
package/src/price-feed-server.ts
CHANGED
|
@@ -1,315 +1,315 @@
|
|
|
1
|
-
import { configure, createMeasure } from "measure-fn";
|
|
2
|
-
import slrd, { type TradeEvent, type TradeListener } from "@solard/sdk";
|
|
3
|
-
import type {
|
|
4
|
-
PriceFeedCommand,
|
|
5
|
-
PriceFeedMessage,
|
|
6
|
-
PriceFeedPrice,
|
|
7
|
-
} from "./price-feed-protocol.ts";
|
|
8
|
-
|
|
9
|
-
type Flags = Map<string, string>;
|
|
10
|
-
type Emit = (value: string) => void;
|
|
11
|
-
type Client = { mints: Set<string> };
|
|
12
|
-
|
|
13
|
-
function flag(flags: Flags, key: string): string | undefined {
|
|
14
|
-
const value = flags.get(key);
|
|
15
|
-
return value && value !== "true" ? value : undefined;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
function numberFlag(flags: Flags, key: string, fallback: number): number {
|
|
19
|
-
const raw = flag(flags, key);
|
|
20
|
-
if (raw == null) return fallback;
|
|
21
|
-
const value = Number(raw);
|
|
22
|
-
if (!Number.isFinite(value)) throw new Error(`Invalid --${key}: ${raw}`);
|
|
23
|
-
return value;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
function feedAddress(flags: Flags): { host: string; port: number } {
|
|
27
|
-
if (flags.has("rpc") || flags.has("ws")) {
|
|
28
|
-
throw new Error(
|
|
29
|
-
"Set only RPC_ENDPOINT in the environment. --rpc and --ws are intentionally unsupported.",
|
|
30
|
-
);
|
|
31
|
-
}
|
|
32
|
-
const host = flag(flags, "host") ?? "127.0.0.1";
|
|
33
|
-
if (host !== "127.0.0.1" && host !== "localhost" && host !== "::1") {
|
|
34
|
-
throw new Error("The trader price feed is loopback-only");
|
|
35
|
-
}
|
|
36
|
-
const port = Math.trunc(numberFlag(flags, "port", 8788));
|
|
37
|
-
if (!(port > 0 && port <= 65_535)) throw new Error("--port must be 1..65535");
|
|
38
|
-
return { host, port };
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
function errorText(error: unknown): string {
|
|
42
|
-
return error instanceof Error ? error.message : String(error);
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
function uniqueMints(value: unknown): string[] {
|
|
46
|
-
if (!Array.isArray(value)) throw new Error("mints must be an array");
|
|
47
|
-
return [...new Set(value.map((mint) => String(mint).trim()).filter(Boolean))];
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
function priceMessage(event: TradeEvent): PriceFeedPrice {
|
|
51
|
-
return {
|
|
52
|
-
type: "price",
|
|
53
|
-
atMs: event.atMs,
|
|
54
|
-
signature: event.signature,
|
|
55
|
-
slot: event.slot,
|
|
56
|
-
mint: event.mint,
|
|
57
|
-
pool: event.pool,
|
|
58
|
-
venue: event.venue,
|
|
59
|
-
side: event.side,
|
|
60
|
-
market: {
|
|
61
|
-
quoteMint: event.market.quoteMint,
|
|
62
|
-
baseDecimals: event.market.baseDecimals,
|
|
63
|
-
quoteDecimals: event.market.quoteDecimals,
|
|
64
|
-
supply: event.market.supply,
|
|
65
|
-
baseReserve: event.market.baseReserve,
|
|
66
|
-
quoteReserve: event.market.quoteReserve,
|
|
67
|
-
priceQuotePerToken: event.market.priceQuotePerToken,
|
|
68
|
-
marketCapQuote: event.market.marketCapQuote,
|
|
69
|
-
priceSol: event.market.priceSol,
|
|
70
|
-
marketCapSol: event.market.marketCapSol,
|
|
71
|
-
solUsd: event.market.solUsd,
|
|
72
|
-
solUsdSource: event.market.solUsdSource,
|
|
73
|
-
solUsdAtMs: event.market.solUsdAtMs,
|
|
74
|
-
priceUsd: event.market.priceUsd,
|
|
75
|
-
marketCapUsd: event.market.marketCapUsd,
|
|
76
|
-
},
|
|
77
|
-
};
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
export async function runPriceFeedServerCommand(args: {
|
|
81
|
-
flags: Flags;
|
|
82
|
-
emit: Emit;
|
|
83
|
-
}): Promise<void> {
|
|
84
|
-
configure({
|
|
85
|
-
silent: false,
|
|
86
|
-
logger(_event: unknown, next?: () => void) {
|
|
87
|
-
next?.();
|
|
88
|
-
},
|
|
89
|
-
});
|
|
90
|
-
const m = createMeasure("slrd:feed", { maxResultLength: 1600 });
|
|
91
|
-
const report = <T extends Record<string, unknown>>(
|
|
92
|
-
label: string,
|
|
93
|
-
value: T,
|
|
94
|
-
): T =>
|
|
95
|
-
m.sync({ start: () => label, end: (result: T) => result }, () => value);
|
|
96
|
-
const { host, port } = feedAddress(args.flags);
|
|
97
|
-
const controller = new AbortController();
|
|
98
|
-
const clients = new Map<unknown, Client>();
|
|
99
|
-
const refs = new Map<string, number>();
|
|
100
|
-
const latest = new Map<string, PriceFeedPrice>();
|
|
101
|
-
let tradeSubscription: TradeListener | null = null;
|
|
102
|
-
let tradeCount = 0;
|
|
103
|
-
let stopped = false;
|
|
104
|
-
let commandQueue = Promise.resolve();
|
|
105
|
-
|
|
106
|
-
const send = (
|
|
107
|
-
socket: { send(value: string): unknown },
|
|
108
|
-
value: PriceFeedMessage,
|
|
109
|
-
): void => {
|
|
110
|
-
try {
|
|
111
|
-
socket.send(JSON.stringify(value));
|
|
112
|
-
} catch {}
|
|
113
|
-
};
|
|
114
|
-
|
|
115
|
-
const status = (
|
|
116
|
-
socket: { send(value: string): unknown },
|
|
117
|
-
event: string,
|
|
118
|
-
data?: Record<string, unknown>,
|
|
119
|
-
): void => send(socket, { type: "status", atMs: Date.now(), event, data });
|
|
120
|
-
|
|
121
|
-
const onTrade = (event: TradeEvent): void => {
|
|
122
|
-
tradeCount += 1;
|
|
123
|
-
const value = priceMessage(event);
|
|
124
|
-
latest.set(event.mint, value);
|
|
125
|
-
for (const [socket, client] of clients) {
|
|
126
|
-
if (!client.mints.has(event.mint)) continue;
|
|
127
|
-
send(socket as { send(value: string): unknown }, value);
|
|
128
|
-
}
|
|
129
|
-
};
|
|
130
|
-
|
|
131
|
-
const addRef = async (mint: string): Promise<void> => {
|
|
132
|
-
const current = refs.get(mint) ?? 0;
|
|
133
|
-
if (current === 0) {
|
|
134
|
-
if (!tradeSubscription) throw new Error("Price feed is not ready");
|
|
135
|
-
await tradeSubscription.add(mint);
|
|
136
|
-
}
|
|
137
|
-
refs.set(mint, current + 1);
|
|
138
|
-
};
|
|
139
|
-
|
|
140
|
-
const removeRef = async (mint: string): Promise<void> => {
|
|
141
|
-
const current = refs.get(mint) ?? 0;
|
|
142
|
-
if (current <= 0) return;
|
|
143
|
-
if (current === 1) {
|
|
144
|
-
refs.delete(mint);
|
|
145
|
-
await tradeSubscription?.remove(mint);
|
|
146
|
-
return;
|
|
147
|
-
}
|
|
148
|
-
refs.set(mint, current - 1);
|
|
149
|
-
};
|
|
150
|
-
|
|
151
|
-
const applyCommand = async (
|
|
152
|
-
socket: unknown,
|
|
153
|
-
command: PriceFeedCommand,
|
|
154
|
-
): Promise<void> => {
|
|
155
|
-
const client = clients.get(socket);
|
|
156
|
-
if (!client) return;
|
|
157
|
-
const target = socket as { send(value: string): unknown };
|
|
158
|
-
if (command.op === "ping") {
|
|
159
|
-
status(target, "pong", {
|
|
160
|
-
watchedMints: client.mints.size,
|
|
161
|
-
upstreamMints: refs.size,
|
|
162
|
-
});
|
|
163
|
-
return;
|
|
164
|
-
}
|
|
165
|
-
const mints = uniqueMints(command.mints);
|
|
166
|
-
if (command.op === "subscribe") {
|
|
167
|
-
for (const mint of mints) {
|
|
168
|
-
if (!client.mints.has(mint)) {
|
|
169
|
-
await addRef(mint);
|
|
170
|
-
client.mints.add(mint);
|
|
171
|
-
}
|
|
172
|
-
const cached = latest.get(mint);
|
|
173
|
-
if (cached) send(target, cached);
|
|
174
|
-
}
|
|
175
|
-
status(target, "subscribed", {
|
|
176
|
-
mints,
|
|
177
|
-
watchedMints: client.mints.size,
|
|
178
|
-
upstreamMints: refs.size,
|
|
179
|
-
});
|
|
180
|
-
return;
|
|
181
|
-
}
|
|
182
|
-
for (const mint of mints) {
|
|
183
|
-
if (!client.mints.delete(mint)) continue;
|
|
184
|
-
await removeRef(mint);
|
|
185
|
-
}
|
|
186
|
-
status(target, "unsubscribed", {
|
|
187
|
-
mints,
|
|
188
|
-
watchedMints: client.mints.size,
|
|
189
|
-
upstreamMints: refs.size,
|
|
190
|
-
});
|
|
191
|
-
};
|
|
192
|
-
|
|
193
|
-
const releaseClient = async (socket: unknown): Promise<void> => {
|
|
194
|
-
const client = clients.get(socket);
|
|
195
|
-
clients.delete(socket);
|
|
196
|
-
if (!client) return;
|
|
197
|
-
for (const mint of client.mints) await removeRef(mint);
|
|
198
|
-
};
|
|
199
|
-
|
|
200
|
-
tradeSubscription = await slrd.listenTrades({
|
|
201
|
-
tokens: [],
|
|
202
|
-
commitment: "confirmed",
|
|
203
|
-
signal: controller.signal,
|
|
204
|
-
onStatus(event, data) {
|
|
205
|
-
report(`trades ${event}`, data ?? {});
|
|
206
|
-
},
|
|
207
|
-
});
|
|
208
|
-
tradeSubscription.onTrade(onTrade);
|
|
209
|
-
|
|
210
|
-
const server = Bun.serve({
|
|
211
|
-
hostname: host,
|
|
212
|
-
port,
|
|
213
|
-
fetch(request, server) {
|
|
214
|
-
const url = new URL(request.url);
|
|
215
|
-
if (url.pathname === "/ws") {
|
|
216
|
-
const upgraded = server.upgrade(request);
|
|
217
|
-
return upgraded
|
|
218
|
-
? undefined
|
|
219
|
-
: new Response("upgrade failed", { status: 400 });
|
|
220
|
-
}
|
|
221
|
-
if (url.pathname === "/health") {
|
|
222
|
-
return Response.json({
|
|
223
|
-
ok: true,
|
|
224
|
-
service: "solard-price-feed",
|
|
225
|
-
clients: clients.size,
|
|
226
|
-
upstreamMints: refs.size,
|
|
227
|
-
watchedTokens: tradeSubscription?.list().length ?? 0,
|
|
228
|
-
cachedPrices: latest.size,
|
|
229
|
-
tradeCount,
|
|
230
|
-
subscriptions: Object.fromEntries(refs),
|
|
231
|
-
});
|
|
232
|
-
}
|
|
233
|
-
return new Response("Solard trader price feed\n");
|
|
234
|
-
},
|
|
235
|
-
websocket: {
|
|
236
|
-
open(socket) {
|
|
237
|
-
clients.set(socket, { mints: new Set() });
|
|
238
|
-
status(socket, "ready", {
|
|
239
|
-
upstreamMints: refs.size,
|
|
240
|
-
});
|
|
241
|
-
},
|
|
242
|
-
message(socket, message) {
|
|
243
|
-
let command: PriceFeedCommand;
|
|
244
|
-
try {
|
|
245
|
-
command = JSON.parse(String(message)) as PriceFeedCommand;
|
|
246
|
-
if (
|
|
247
|
-
!command ||
|
|
248
|
-
(command.op !== "subscribe" &&
|
|
249
|
-
command.op !== "unsubscribe" &&
|
|
250
|
-
command.op !== "ping")
|
|
251
|
-
) {
|
|
252
|
-
throw new Error("op must be subscribe, unsubscribe, or ping");
|
|
253
|
-
}
|
|
254
|
-
} catch (error) {
|
|
255
|
-
status(socket, "client-error", { error: errorText(error) });
|
|
256
|
-
return;
|
|
257
|
-
}
|
|
258
|
-
commandQueue = commandQueue
|
|
259
|
-
.then(() => applyCommand(socket, command))
|
|
260
|
-
.catch((error) => {
|
|
261
|
-
status(socket, "client-error", { error: errorText(error) });
|
|
262
|
-
});
|
|
263
|
-
},
|
|
264
|
-
close(socket) {
|
|
265
|
-
commandQueue = commandQueue
|
|
266
|
-
.then(() => releaseClient(socket))
|
|
267
|
-
.catch((error) => {
|
|
268
|
-
report("release-client-error", { error: errorText(error) });
|
|
269
|
-
});
|
|
270
|
-
},
|
|
271
|
-
},
|
|
272
|
-
});
|
|
273
|
-
|
|
274
|
-
const heartbeat = setInterval(
|
|
275
|
-
() =>
|
|
276
|
-
report("heartbeat", {
|
|
277
|
-
clients: clients.size,
|
|
278
|
-
upstreamMints: refs.size,
|
|
279
|
-
watchedTokens: tradeSubscription?.list().length ?? 0,
|
|
280
|
-
cachedPrices: latest.size,
|
|
281
|
-
tradeCount,
|
|
282
|
-
subscriptions: Object.fromEntries(refs),
|
|
283
|
-
}),
|
|
284
|
-
Math.max(1_000, Math.trunc(numberFlag(args.flags, "heartbeat-ms", 15_000))),
|
|
285
|
-
);
|
|
286
|
-
|
|
287
|
-
report("ready", {
|
|
288
|
-
url: `ws://${host}:${server.port}/ws`,
|
|
289
|
-
health: `http://${host}:${server.port}/health`,
|
|
290
|
-
});
|
|
291
|
-
|
|
292
|
-
const stop = (): void => {
|
|
293
|
-
if (stopped) return;
|
|
294
|
-
stopped = true;
|
|
295
|
-
controller.abort();
|
|
296
|
-
};
|
|
297
|
-
process.once("SIGINT", stop);
|
|
298
|
-
process.once("SIGTERM", stop);
|
|
299
|
-
|
|
300
|
-
try {
|
|
301
|
-
await tradeSubscription.closed;
|
|
302
|
-
} finally {
|
|
303
|
-
clearInterval(heartbeat);
|
|
304
|
-
process.removeListener("SIGINT", stop);
|
|
305
|
-
process.removeListener("SIGTERM", stop);
|
|
306
|
-
await tradeSubscription.close();
|
|
307
|
-
await commandQueue.catch(() => undefined);
|
|
308
|
-
server.stop(true);
|
|
309
|
-
report("stopped", {
|
|
310
|
-
clients: clients.size,
|
|
311
|
-
upstreamMints: refs.size,
|
|
312
|
-
tradeCount,
|
|
313
|
-
});
|
|
314
|
-
}
|
|
315
|
-
}
|
|
1
|
+
import { configure, createMeasure } from "measure-fn";
|
|
2
|
+
import slrd, { type TradeEvent, type TradeListener } from "@solard/sdk";
|
|
3
|
+
import type {
|
|
4
|
+
PriceFeedCommand,
|
|
5
|
+
PriceFeedMessage,
|
|
6
|
+
PriceFeedPrice,
|
|
7
|
+
} from "./price-feed-protocol.ts";
|
|
8
|
+
|
|
9
|
+
type Flags = Map<string, string>;
|
|
10
|
+
type Emit = (value: string) => void;
|
|
11
|
+
type Client = { mints: Set<string> };
|
|
12
|
+
|
|
13
|
+
function flag(flags: Flags, key: string): string | undefined {
|
|
14
|
+
const value = flags.get(key);
|
|
15
|
+
return value && value !== "true" ? value : undefined;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function numberFlag(flags: Flags, key: string, fallback: number): number {
|
|
19
|
+
const raw = flag(flags, key);
|
|
20
|
+
if (raw == null) return fallback;
|
|
21
|
+
const value = Number(raw);
|
|
22
|
+
if (!Number.isFinite(value)) throw new Error(`Invalid --${key}: ${raw}`);
|
|
23
|
+
return value;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function feedAddress(flags: Flags): { host: string; port: number } {
|
|
27
|
+
if (flags.has("rpc") || flags.has("ws")) {
|
|
28
|
+
throw new Error(
|
|
29
|
+
"Set only RPC_ENDPOINT in the environment. --rpc and --ws are intentionally unsupported.",
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
const host = flag(flags, "host") ?? "127.0.0.1";
|
|
33
|
+
if (host !== "127.0.0.1" && host !== "localhost" && host !== "::1") {
|
|
34
|
+
throw new Error("The trader price feed is loopback-only");
|
|
35
|
+
}
|
|
36
|
+
const port = Math.trunc(numberFlag(flags, "port", 8788));
|
|
37
|
+
if (!(port > 0 && port <= 65_535)) throw new Error("--port must be 1..65535");
|
|
38
|
+
return { host, port };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function errorText(error: unknown): string {
|
|
42
|
+
return error instanceof Error ? error.message : String(error);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function uniqueMints(value: unknown): string[] {
|
|
46
|
+
if (!Array.isArray(value)) throw new Error("mints must be an array");
|
|
47
|
+
return [...new Set(value.map((mint) => String(mint).trim()).filter(Boolean))];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function priceMessage(event: TradeEvent): PriceFeedPrice {
|
|
51
|
+
return {
|
|
52
|
+
type: "price",
|
|
53
|
+
atMs: event.atMs,
|
|
54
|
+
signature: event.signature,
|
|
55
|
+
slot: event.slot,
|
|
56
|
+
mint: event.mint,
|
|
57
|
+
pool: event.pool,
|
|
58
|
+
venue: event.venue,
|
|
59
|
+
side: event.side,
|
|
60
|
+
market: {
|
|
61
|
+
quoteMint: event.market.quoteMint,
|
|
62
|
+
baseDecimals: event.market.baseDecimals,
|
|
63
|
+
quoteDecimals: event.market.quoteDecimals,
|
|
64
|
+
supply: event.market.supply,
|
|
65
|
+
baseReserve: event.market.baseReserve,
|
|
66
|
+
quoteReserve: event.market.quoteReserve,
|
|
67
|
+
priceQuotePerToken: event.market.priceQuotePerToken,
|
|
68
|
+
marketCapQuote: event.market.marketCapQuote,
|
|
69
|
+
priceSol: event.market.priceSol,
|
|
70
|
+
marketCapSol: event.market.marketCapSol,
|
|
71
|
+
solUsd: event.market.solUsd,
|
|
72
|
+
solUsdSource: event.market.solUsdSource,
|
|
73
|
+
solUsdAtMs: event.market.solUsdAtMs,
|
|
74
|
+
priceUsd: event.market.priceUsd,
|
|
75
|
+
marketCapUsd: event.market.marketCapUsd,
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function runPriceFeedServerCommand(args: {
|
|
81
|
+
flags: Flags;
|
|
82
|
+
emit: Emit;
|
|
83
|
+
}): Promise<void> {
|
|
84
|
+
configure({
|
|
85
|
+
silent: false,
|
|
86
|
+
logger(_event: unknown, next?: () => void) {
|
|
87
|
+
next?.();
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
const m = createMeasure("slrd:feed", { maxResultLength: 1600 });
|
|
91
|
+
const report = <T extends Record<string, unknown>>(
|
|
92
|
+
label: string,
|
|
93
|
+
value: T,
|
|
94
|
+
): T =>
|
|
95
|
+
m.sync({ start: () => label, end: (result: T) => result }, () => value);
|
|
96
|
+
const { host, port } = feedAddress(args.flags);
|
|
97
|
+
const controller = new AbortController();
|
|
98
|
+
const clients = new Map<unknown, Client>();
|
|
99
|
+
const refs = new Map<string, number>();
|
|
100
|
+
const latest = new Map<string, PriceFeedPrice>();
|
|
101
|
+
let tradeSubscription: TradeListener | null = null;
|
|
102
|
+
let tradeCount = 0;
|
|
103
|
+
let stopped = false;
|
|
104
|
+
let commandQueue = Promise.resolve();
|
|
105
|
+
|
|
106
|
+
const send = (
|
|
107
|
+
socket: { send(value: string): unknown },
|
|
108
|
+
value: PriceFeedMessage,
|
|
109
|
+
): void => {
|
|
110
|
+
try {
|
|
111
|
+
socket.send(JSON.stringify(value));
|
|
112
|
+
} catch {}
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const status = (
|
|
116
|
+
socket: { send(value: string): unknown },
|
|
117
|
+
event: string,
|
|
118
|
+
data?: Record<string, unknown>,
|
|
119
|
+
): void => send(socket, { type: "status", atMs: Date.now(), event, data });
|
|
120
|
+
|
|
121
|
+
const onTrade = (event: TradeEvent): void => {
|
|
122
|
+
tradeCount += 1;
|
|
123
|
+
const value = priceMessage(event);
|
|
124
|
+
latest.set(event.mint, value);
|
|
125
|
+
for (const [socket, client] of clients) {
|
|
126
|
+
if (!client.mints.has(event.mint)) continue;
|
|
127
|
+
send(socket as { send(value: string): unknown }, value);
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const addRef = async (mint: string): Promise<void> => {
|
|
132
|
+
const current = refs.get(mint) ?? 0;
|
|
133
|
+
if (current === 0) {
|
|
134
|
+
if (!tradeSubscription) throw new Error("Price feed is not ready");
|
|
135
|
+
await tradeSubscription.add(mint);
|
|
136
|
+
}
|
|
137
|
+
refs.set(mint, current + 1);
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const removeRef = async (mint: string): Promise<void> => {
|
|
141
|
+
const current = refs.get(mint) ?? 0;
|
|
142
|
+
if (current <= 0) return;
|
|
143
|
+
if (current === 1) {
|
|
144
|
+
refs.delete(mint);
|
|
145
|
+
await tradeSubscription?.remove(mint);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
refs.set(mint, current - 1);
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
const applyCommand = async (
|
|
152
|
+
socket: unknown,
|
|
153
|
+
command: PriceFeedCommand,
|
|
154
|
+
): Promise<void> => {
|
|
155
|
+
const client = clients.get(socket);
|
|
156
|
+
if (!client) return;
|
|
157
|
+
const target = socket as { send(value: string): unknown };
|
|
158
|
+
if (command.op === "ping") {
|
|
159
|
+
status(target, "pong", {
|
|
160
|
+
watchedMints: client.mints.size,
|
|
161
|
+
upstreamMints: refs.size,
|
|
162
|
+
});
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
const mints = uniqueMints(command.mints);
|
|
166
|
+
if (command.op === "subscribe") {
|
|
167
|
+
for (const mint of mints) {
|
|
168
|
+
if (!client.mints.has(mint)) {
|
|
169
|
+
await addRef(mint);
|
|
170
|
+
client.mints.add(mint);
|
|
171
|
+
}
|
|
172
|
+
const cached = latest.get(mint);
|
|
173
|
+
if (cached) send(target, cached);
|
|
174
|
+
}
|
|
175
|
+
status(target, "subscribed", {
|
|
176
|
+
mints,
|
|
177
|
+
watchedMints: client.mints.size,
|
|
178
|
+
upstreamMints: refs.size,
|
|
179
|
+
});
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
for (const mint of mints) {
|
|
183
|
+
if (!client.mints.delete(mint)) continue;
|
|
184
|
+
await removeRef(mint);
|
|
185
|
+
}
|
|
186
|
+
status(target, "unsubscribed", {
|
|
187
|
+
mints,
|
|
188
|
+
watchedMints: client.mints.size,
|
|
189
|
+
upstreamMints: refs.size,
|
|
190
|
+
});
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
const releaseClient = async (socket: unknown): Promise<void> => {
|
|
194
|
+
const client = clients.get(socket);
|
|
195
|
+
clients.delete(socket);
|
|
196
|
+
if (!client) return;
|
|
197
|
+
for (const mint of client.mints) await removeRef(mint);
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
tradeSubscription = await slrd.listenTrades({
|
|
201
|
+
tokens: [],
|
|
202
|
+
commitment: "confirmed",
|
|
203
|
+
signal: controller.signal,
|
|
204
|
+
onStatus(event, data) {
|
|
205
|
+
report(`trades ${event}`, data ?? {});
|
|
206
|
+
},
|
|
207
|
+
});
|
|
208
|
+
tradeSubscription.onTrade(onTrade);
|
|
209
|
+
|
|
210
|
+
const server = Bun.serve({
|
|
211
|
+
hostname: host,
|
|
212
|
+
port,
|
|
213
|
+
fetch(request, server) {
|
|
214
|
+
const url = new URL(request.url);
|
|
215
|
+
if (url.pathname === "/ws") {
|
|
216
|
+
const upgraded = server.upgrade(request);
|
|
217
|
+
return upgraded
|
|
218
|
+
? undefined
|
|
219
|
+
: new Response("upgrade failed", { status: 400 });
|
|
220
|
+
}
|
|
221
|
+
if (url.pathname === "/health") {
|
|
222
|
+
return Response.json({
|
|
223
|
+
ok: true,
|
|
224
|
+
service: "solard-price-feed",
|
|
225
|
+
clients: clients.size,
|
|
226
|
+
upstreamMints: refs.size,
|
|
227
|
+
watchedTokens: tradeSubscription?.list().length ?? 0,
|
|
228
|
+
cachedPrices: latest.size,
|
|
229
|
+
tradeCount,
|
|
230
|
+
subscriptions: Object.fromEntries(refs),
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
return new Response("Solard trader price feed\n");
|
|
234
|
+
},
|
|
235
|
+
websocket: {
|
|
236
|
+
open(socket) {
|
|
237
|
+
clients.set(socket, { mints: new Set() });
|
|
238
|
+
status(socket, "ready", {
|
|
239
|
+
upstreamMints: refs.size,
|
|
240
|
+
});
|
|
241
|
+
},
|
|
242
|
+
message(socket, message) {
|
|
243
|
+
let command: PriceFeedCommand;
|
|
244
|
+
try {
|
|
245
|
+
command = JSON.parse(String(message)) as PriceFeedCommand;
|
|
246
|
+
if (
|
|
247
|
+
!command ||
|
|
248
|
+
(command.op !== "subscribe" &&
|
|
249
|
+
command.op !== "unsubscribe" &&
|
|
250
|
+
command.op !== "ping")
|
|
251
|
+
) {
|
|
252
|
+
throw new Error("op must be subscribe, unsubscribe, or ping");
|
|
253
|
+
}
|
|
254
|
+
} catch (error) {
|
|
255
|
+
status(socket, "client-error", { error: errorText(error) });
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
commandQueue = commandQueue
|
|
259
|
+
.then(() => applyCommand(socket, command))
|
|
260
|
+
.catch((error) => {
|
|
261
|
+
status(socket, "client-error", { error: errorText(error) });
|
|
262
|
+
});
|
|
263
|
+
},
|
|
264
|
+
close(socket) {
|
|
265
|
+
commandQueue = commandQueue
|
|
266
|
+
.then(() => releaseClient(socket))
|
|
267
|
+
.catch((error) => {
|
|
268
|
+
report("release-client-error", { error: errorText(error) });
|
|
269
|
+
});
|
|
270
|
+
},
|
|
271
|
+
},
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
const heartbeat = setInterval(
|
|
275
|
+
() =>
|
|
276
|
+
report("heartbeat", {
|
|
277
|
+
clients: clients.size,
|
|
278
|
+
upstreamMints: refs.size,
|
|
279
|
+
watchedTokens: tradeSubscription?.list().length ?? 0,
|
|
280
|
+
cachedPrices: latest.size,
|
|
281
|
+
tradeCount,
|
|
282
|
+
subscriptions: Object.fromEntries(refs),
|
|
283
|
+
}),
|
|
284
|
+
Math.max(1_000, Math.trunc(numberFlag(args.flags, "heartbeat-ms", 15_000))),
|
|
285
|
+
);
|
|
286
|
+
|
|
287
|
+
report("ready", {
|
|
288
|
+
url: `ws://${host}:${server.port}/ws`,
|
|
289
|
+
health: `http://${host}:${server.port}/health`,
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
const stop = (): void => {
|
|
293
|
+
if (stopped) return;
|
|
294
|
+
stopped = true;
|
|
295
|
+
controller.abort();
|
|
296
|
+
};
|
|
297
|
+
process.once("SIGINT", stop);
|
|
298
|
+
process.once("SIGTERM", stop);
|
|
299
|
+
|
|
300
|
+
try {
|
|
301
|
+
await tradeSubscription.closed;
|
|
302
|
+
} finally {
|
|
303
|
+
clearInterval(heartbeat);
|
|
304
|
+
process.removeListener("SIGINT", stop);
|
|
305
|
+
process.removeListener("SIGTERM", stop);
|
|
306
|
+
await tradeSubscription.close();
|
|
307
|
+
await commandQueue.catch(() => undefined);
|
|
308
|
+
server.stop(true);
|
|
309
|
+
report("stopped", {
|
|
310
|
+
clients: clients.size,
|
|
311
|
+
upstreamMints: refs.size,
|
|
312
|
+
tradeCount,
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
}
|