@solard/cli 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.
@@ -1,301 +1,301 @@
1
- import { configure, createMeasure } from "measure-fn";
2
- import slrd, {
3
- type LaunchEvent,
4
- type TradeEvent,
5
- type TradeListener,
6
- } from "@solard/sdk";
7
-
8
- type Flags = Map<string, string>;
9
- type Emit = (value: string) => void;
10
- type VenueId = "pump" | "raydium-launchlab";
11
- type WatchState = {
12
- mint: string;
13
- venue: VenueId;
14
- createdAtMs: number;
15
- lastSeenAtMs: number;
16
- supplyUi: number;
17
- qualified: boolean;
18
- athMarketCapUsd: number | null;
19
- lastNotifiedAthUsd: number | null;
20
- };
21
-
22
- function flag(flags: Flags, key: string): string | undefined {
23
- const value = flags.get(key);
24
- return value && value !== "true" ? value : undefined;
25
- }
26
-
27
- function numberFlag(flags: Flags, key: string, fallback: number): number {
28
- const value = flag(flags, key);
29
- if (value == null) return fallback;
30
- const parsed = Number(value);
31
- if (!Number.isFinite(parsed)) throw new Error(`Invalid --${key}: ${value}`);
32
- return parsed;
33
- }
34
-
35
- function duration(value: string | undefined, fallbackMs: number): number {
36
- if (!value) return fallbackMs;
37
- const match = /^(\d+(?:\.\d+)?)(ms|s|m|h)?$/i.exec(value.trim());
38
- if (!match) throw new Error(`Invalid duration: ${value}`);
39
- const scale = ({ ms: 1, s: 1_000, m: 60_000, h: 3_600_000 } as const)[
40
- (match[2]?.toLowerCase() ?? "ms") as "ms" | "s" | "m" | "h"
41
- ];
42
- return Math.max(100, Math.floor(Number(match[1]) * scale));
43
- }
44
-
45
- function venueList(flags: Flags): VenueId[] {
46
- const raw = flag(flags, "venue") ?? "pump,raydium-launchlab";
47
- const values = raw
48
- .split(",")
49
- .map((value) => value.trim().toLowerCase())
50
- .filter(Boolean)
51
- .map((value) =>
52
- value === "launchlab" || value === "raydium" || value === "stonkfun"
53
- ? "raydium-launchlab"
54
- : value,
55
- );
56
- const out = [...new Set(values)] as string[];
57
- for (const value of out)
58
- if (value !== "pump" && value !== "raydium-launchlab")
59
- throw new Error(`Unsupported --venue value: ${value}`);
60
- return out as VenueId[];
61
- }
62
-
63
- function requireSingleEndpoint(flags: Flags): void {
64
- if (flags.has("rpc") || flags.has("ws"))
65
- throw new Error(
66
- "Set only RPC_ENDPOINT in the environment. --rpc and --ws are intentionally unsupported.",
67
- );
68
- if (!process.env.RPC_ENDPOINT?.trim())
69
- throw new Error(
70
- "Missing RPC_ENDPOINT. Set one Solana RPC URL in RPC_ENDPOINT; WebSocket access is derived from the same endpoint.",
71
- );
72
- }
73
-
74
- export async function runLaunchWatchCommand(args: {
75
- flags: Flags;
76
- emit: Emit;
77
- forcedVenues?: VenueId[];
78
- }): Promise<void> {
79
- configure({
80
- silent: false,
81
- logger(_event: unknown, next?: () => void) {
82
- next?.();
83
- },
84
- });
85
- const json = args.flags.has("json");
86
- const m = createMeasure("slrd:launch-watch", { maxResultLength: 1600 });
87
- const report = <T extends Record<string, unknown>>(
88
- label: string,
89
- value: T,
90
- ): T => {
91
- if (json) {
92
- args.emit(`${JSON.stringify({ type: label, ...value })}\n`);
93
- return value;
94
- }
95
- return m.sync(
96
- { start: () => label, end: (result: T) => result },
97
- () => value,
98
- );
99
- };
100
- const venues = args.forcedVenues ?? venueList(args.flags);
101
- const minMarketCapUsd = numberFlag(args.flags, "min-mcap", 5_000);
102
- const athStepPct = numberFlag(args.flags, "ath-step-pct", 20);
103
- if (!(minMarketCapUsd > 0)) throw new Error("--min-mcap must be > 0");
104
- if (!(athStepPct > 0)) throw new Error("--ath-step-pct must be > 0");
105
- const trackTtlMs = duration(flag(args.flags, "track-ttl"), 30 * 60_000);
106
- const showNew = args.flags.has("show-new");
107
- const showPrices = args.flags.has("trades") || args.flags.has("prices");
108
- requireSingleEndpoint(args.flags);
109
- const states = new Map<string, WatchState>();
110
- let launches = 0;
111
- let prices = 0;
112
- let qualified = 0;
113
- let thresholdNotifications = 0;
114
- let athNotifications = 0;
115
- let ignoredMayhem = 0;
116
- let stopped = false;
117
- let tradeSubscription: TradeListener | null = null;
118
-
119
- const onLaunch = (event: LaunchEvent) => {
120
- if (!venues.includes(event.venue)) return;
121
- if (event.isMayhemMode === true) {
122
- ignoredMayhem += 1;
123
- return;
124
- }
125
- states.set(event.mint, {
126
- mint: event.mint,
127
- venue: event.venue,
128
- createdAtMs: event.atMs,
129
- lastSeenAtMs: event.atMs,
130
- supplyUi: event.supplyUi,
131
- qualified: false,
132
- athMarketCapUsd: null,
133
- lastNotifiedAthUsd: null,
134
- });
135
- void tradeSubscription?.add(event.mint);
136
- launches += 1;
137
- if (showNew)
138
- report("new launch", {
139
- at: new Date(event.atMs).toISOString(),
140
- venue: event.venue,
141
- mint: event.mint,
142
- symbol: event.symbol,
143
- name: event.name,
144
- supplyUi: event.supplyUi,
145
- });
146
- };
147
-
148
- const onTrade = (event: TradeEvent) => {
149
- const state = states.get(event.mint);
150
- if (!state) return;
151
- state.lastSeenAtMs = event.atMs;
152
- const priceSol = event.market.priceSol;
153
- const priceUsd = event.market.priceUsd;
154
- const marketCapUsd = event.market.marketCapUsd;
155
- prices += 1;
156
- if (showPrices)
157
- report("price", {
158
- at: new Date(event.atMs).toISOString(),
159
- venue: event.venue,
160
- mint: event.mint,
161
- marketCapUsd,
162
- priceUsd,
163
- priceSol,
164
- source: `${event.venue}-trade`,
165
- });
166
- if (!(
167
- marketCapUsd != null &&
168
- Number.isFinite(marketCapUsd) &&
169
- marketCapUsd > 0
170
- ))
171
- return;
172
- state.athMarketCapUsd = Math.max(state.athMarketCapUsd ?? 0, marketCapUsd);
173
- if (!state.qualified) {
174
- if (marketCapUsd < minMarketCapUsd) return;
175
- state.qualified = true;
176
- state.lastNotifiedAthUsd = marketCapUsd;
177
- qualified += 1;
178
- thresholdNotifications += 1;
179
- const nextAthNotifyUsd = marketCapUsd * (1 + athStepPct / 100);
180
- if (!args.flags.has("no-bell") && !json) process.stderr.write("\x07");
181
- report("market cap threshold", {
182
- at: new Date(event.atMs).toISOString(),
183
- venue: state.venue,
184
- mint: event.mint,
185
- marketCapUsd,
186
- thresholdMarketCapUsd: minMarketCapUsd,
187
- nextAthNotifyUsd,
188
- athStepPct,
189
- priceUsd,
190
- priceSol,
191
- signature: event.signature,
192
- });
193
- return;
194
- }
195
- const previous = state.lastNotifiedAthUsd;
196
- if (!(previous != null && previous > 0)) {
197
- state.lastNotifiedAthUsd = marketCapUsd;
198
- return;
199
- }
200
- const target = previous * (1 + athStepPct / 100);
201
- if (marketCapUsd + 1e-12 < target) return;
202
- state.lastNotifiedAthUsd = marketCapUsd;
203
- athNotifications += 1;
204
- const increasePct = (marketCapUsd / previous - 1) * 100;
205
- const nextAthNotifyUsd = marketCapUsd * (1 + athStepPct / 100);
206
- if (!args.flags.has("no-bell") && !json) process.stderr.write("\x07");
207
- report("new ath", {
208
- at: new Date(event.atMs).toISOString(),
209
- venue: state.venue,
210
- mint: event.mint,
211
- marketCapUsd,
212
- previousNotifiedAthUsd: previous,
213
- nextAthNotifyUsd,
214
- athStepPct,
215
- increasePct,
216
- priceUsd,
217
- priceSol,
218
- signature: event.signature,
219
- });
220
- };
221
-
222
- const controller = new AbortController();
223
- tradeSubscription = await slrd.listenTrades({
224
- tokens: [],
225
- venues: [
226
- ...(venues.includes("pump") ? (["pump", "pumpswap"] as const) : []),
227
- ...(venues.includes("raydium-launchlab")
228
- ? (["raydium-launchlab"] as const)
229
- : []),
230
- ],
231
- signal: controller.signal,
232
- onStatus(event, data) {
233
- report(`trades ${event}`, data ?? {});
234
- },
235
- });
236
- tradeSubscription.onTrade(onTrade);
237
- const launchSubscription = await slrd.subscribeLaunches({
238
- venues,
239
- signal: controller.signal,
240
- onLaunch,
241
- onStatus(event, data) {
242
- report(`launches ${event}`, data ?? {});
243
- },
244
- });
245
-
246
- const stop = () => {
247
- stopped = true;
248
- controller.abort();
249
- };
250
- process.once("SIGINT", stop);
251
- process.once("SIGTERM", stop);
252
- report("ready", {
253
- rpc: "RPC_ENDPOINT",
254
- websocket: "derived-from-RPC_ENDPOINT",
255
- venues,
256
- minMarketCapUsd,
257
- athStepPct,
258
- excludeMayhem: true,
259
- trackTtlMs,
260
- upstreamSubscriptions: venues.length,
261
- });
262
- const heartbeat = setInterval(
263
- () => {
264
- const now = Date.now();
265
- for (const [mint, state] of states) {
266
- if (!state.qualified && now - state.lastSeenAtMs > trackTtlMs) {
267
- states.delete(mint);
268
- void tradeSubscription?.remove(mint);
269
- }
270
- }
271
- report("heartbeat", {
272
- launches,
273
- ignoredMayhem,
274
- tracked: states.size,
275
- qualified,
276
- prices,
277
- thresholdNotifications,
278
- athNotifications,
279
- watchedTokens: tradeSubscription?.list().length ?? 0,
280
- });
281
- },
282
- Math.max(1_000, Math.trunc(numberFlag(args.flags, "heartbeat-ms", 15_000))),
283
- );
284
-
285
- try {
286
- while (!stopped) await new Promise((resolve) => setTimeout(resolve, 250));
287
- } finally {
288
- clearInterval(heartbeat);
289
- controller.abort();
290
- await launchSubscription.close();
291
- await tradeSubscription.close();
292
- process.removeListener("SIGINT", stop);
293
- process.removeListener("SIGTERM", stop);
294
- report("stopped", {
295
- launches,
296
- prices,
297
- thresholdNotifications,
298
- athNotifications,
299
- });
300
- }
301
- }
1
+ import { configure, createMeasure } from "measure-fn";
2
+ import slrd, {
3
+ type LaunchEvent,
4
+ type TradeEvent,
5
+ type TradeListener,
6
+ } from "@solard/sdk";
7
+
8
+ type Flags = Map<string, string>;
9
+ type Emit = (value: string) => void;
10
+ type VenueId = "pump" | "raydium-launchlab";
11
+ type WatchState = {
12
+ mint: string;
13
+ venue: VenueId;
14
+ createdAtMs: number;
15
+ lastSeenAtMs: number;
16
+ supplyUi: number;
17
+ qualified: boolean;
18
+ athMarketCapUsd: number | null;
19
+ lastNotifiedAthUsd: number | null;
20
+ };
21
+
22
+ function flag(flags: Flags, key: string): string | undefined {
23
+ const value = flags.get(key);
24
+ return value && value !== "true" ? value : undefined;
25
+ }
26
+
27
+ function numberFlag(flags: Flags, key: string, fallback: number): number {
28
+ const value = flag(flags, key);
29
+ if (value == null) return fallback;
30
+ const parsed = Number(value);
31
+ if (!Number.isFinite(parsed)) throw new Error(`Invalid --${key}: ${value}`);
32
+ return parsed;
33
+ }
34
+
35
+ function duration(value: string | undefined, fallbackMs: number): number {
36
+ if (!value) return fallbackMs;
37
+ const match = /^(\d+(?:\.\d+)?)(ms|s|m|h)?$/i.exec(value.trim());
38
+ if (!match) throw new Error(`Invalid duration: ${value}`);
39
+ const scale = ({ ms: 1, s: 1_000, m: 60_000, h: 3_600_000 } as const)[
40
+ (match[2]?.toLowerCase() ?? "ms") as "ms" | "s" | "m" | "h"
41
+ ];
42
+ return Math.max(100, Math.floor(Number(match[1]) * scale));
43
+ }
44
+
45
+ function venueList(flags: Flags): VenueId[] {
46
+ const raw = flag(flags, "venue") ?? "pump,raydium-launchlab";
47
+ const values = raw
48
+ .split(",")
49
+ .map((value) => value.trim().toLowerCase())
50
+ .filter(Boolean)
51
+ .map((value) =>
52
+ value === "launchlab" || value === "raydium" || value === "stonkfun"
53
+ ? "raydium-launchlab"
54
+ : value,
55
+ );
56
+ const out = [...new Set(values)] as string[];
57
+ for (const value of out)
58
+ if (value !== "pump" && value !== "raydium-launchlab")
59
+ throw new Error(`Unsupported --venue value: ${value}`);
60
+ return out as VenueId[];
61
+ }
62
+
63
+ function requireSingleEndpoint(flags: Flags): void {
64
+ if (flags.has("rpc") || flags.has("ws"))
65
+ throw new Error(
66
+ "Set only RPC_ENDPOINT in the environment. --rpc and --ws are intentionally unsupported.",
67
+ );
68
+ if (!process.env.RPC_ENDPOINT?.trim())
69
+ throw new Error(
70
+ "Missing RPC_ENDPOINT. Set one Solana RPC URL in RPC_ENDPOINT; WebSocket access is derived from the same endpoint.",
71
+ );
72
+ }
73
+
74
+ export async function runLaunchWatchCommand(args: {
75
+ flags: Flags;
76
+ emit: Emit;
77
+ forcedVenues?: VenueId[];
78
+ }): Promise<void> {
79
+ configure({
80
+ silent: false,
81
+ logger(_event: unknown, next?: () => void) {
82
+ next?.();
83
+ },
84
+ });
85
+ const json = args.flags.has("json");
86
+ const m = createMeasure("slrd:launch-watch", { maxResultLength: 1600 });
87
+ const report = <T extends Record<string, unknown>>(
88
+ label: string,
89
+ value: T,
90
+ ): T => {
91
+ if (json) {
92
+ args.emit(`${JSON.stringify({ type: label, ...value })}\n`);
93
+ return value;
94
+ }
95
+ return m.sync(
96
+ { start: () => label, end: (result: T) => result },
97
+ () => value,
98
+ );
99
+ };
100
+ const venues = args.forcedVenues ?? venueList(args.flags);
101
+ const minMarketCapUsd = numberFlag(args.flags, "min-mcap", 5_000);
102
+ const athStepPct = numberFlag(args.flags, "ath-step-pct", 20);
103
+ if (!(minMarketCapUsd > 0)) throw new Error("--min-mcap must be > 0");
104
+ if (!(athStepPct > 0)) throw new Error("--ath-step-pct must be > 0");
105
+ const trackTtlMs = duration(flag(args.flags, "track-ttl"), 30 * 60_000);
106
+ const showNew = args.flags.has("show-new");
107
+ const showPrices = args.flags.has("trades") || args.flags.has("prices");
108
+ requireSingleEndpoint(args.flags);
109
+ const states = new Map<string, WatchState>();
110
+ let launches = 0;
111
+ let prices = 0;
112
+ let qualified = 0;
113
+ let thresholdNotifications = 0;
114
+ let athNotifications = 0;
115
+ let ignoredMayhem = 0;
116
+ let stopped = false;
117
+ let tradeSubscription: TradeListener | null = null;
118
+
119
+ const onLaunch = (event: LaunchEvent) => {
120
+ if (!venues.includes(event.venue)) return;
121
+ if (event.isMayhemMode === true) {
122
+ ignoredMayhem += 1;
123
+ return;
124
+ }
125
+ states.set(event.mint, {
126
+ mint: event.mint,
127
+ venue: event.venue,
128
+ createdAtMs: event.atMs,
129
+ lastSeenAtMs: event.atMs,
130
+ supplyUi: event.supplyUi,
131
+ qualified: false,
132
+ athMarketCapUsd: null,
133
+ lastNotifiedAthUsd: null,
134
+ });
135
+ void tradeSubscription?.add(event.mint);
136
+ launches += 1;
137
+ if (showNew)
138
+ report("new launch", {
139
+ at: new Date(event.atMs).toISOString(),
140
+ venue: event.venue,
141
+ mint: event.mint,
142
+ symbol: event.symbol,
143
+ name: event.name,
144
+ supplyUi: event.supplyUi,
145
+ });
146
+ };
147
+
148
+ const onTrade = (event: TradeEvent) => {
149
+ const state = states.get(event.mint);
150
+ if (!state) return;
151
+ state.lastSeenAtMs = event.atMs;
152
+ const priceSol = event.market.priceSol;
153
+ const priceUsd = event.market.priceUsd;
154
+ const marketCapUsd = event.market.marketCapUsd;
155
+ prices += 1;
156
+ if (showPrices)
157
+ report("price", {
158
+ at: new Date(event.atMs).toISOString(),
159
+ venue: event.venue,
160
+ mint: event.mint,
161
+ marketCapUsd,
162
+ priceUsd,
163
+ priceSol,
164
+ source: `${event.venue}-trade`,
165
+ });
166
+ if (!(
167
+ marketCapUsd != null &&
168
+ Number.isFinite(marketCapUsd) &&
169
+ marketCapUsd > 0
170
+ ))
171
+ return;
172
+ state.athMarketCapUsd = Math.max(state.athMarketCapUsd ?? 0, marketCapUsd);
173
+ if (!state.qualified) {
174
+ if (marketCapUsd < minMarketCapUsd) return;
175
+ state.qualified = true;
176
+ state.lastNotifiedAthUsd = marketCapUsd;
177
+ qualified += 1;
178
+ thresholdNotifications += 1;
179
+ const nextAthNotifyUsd = marketCapUsd * (1 + athStepPct / 100);
180
+ if (!args.flags.has("no-bell") && !json) process.stderr.write("\x07");
181
+ report("market cap threshold", {
182
+ at: new Date(event.atMs).toISOString(),
183
+ venue: state.venue,
184
+ mint: event.mint,
185
+ marketCapUsd,
186
+ thresholdMarketCapUsd: minMarketCapUsd,
187
+ nextAthNotifyUsd,
188
+ athStepPct,
189
+ priceUsd,
190
+ priceSol,
191
+ signature: event.signature,
192
+ });
193
+ return;
194
+ }
195
+ const previous = state.lastNotifiedAthUsd;
196
+ if (!(previous != null && previous > 0)) {
197
+ state.lastNotifiedAthUsd = marketCapUsd;
198
+ return;
199
+ }
200
+ const target = previous * (1 + athStepPct / 100);
201
+ if (marketCapUsd + 1e-12 < target) return;
202
+ state.lastNotifiedAthUsd = marketCapUsd;
203
+ athNotifications += 1;
204
+ const increasePct = (marketCapUsd / previous - 1) * 100;
205
+ const nextAthNotifyUsd = marketCapUsd * (1 + athStepPct / 100);
206
+ if (!args.flags.has("no-bell") && !json) process.stderr.write("\x07");
207
+ report("new ath", {
208
+ at: new Date(event.atMs).toISOString(),
209
+ venue: state.venue,
210
+ mint: event.mint,
211
+ marketCapUsd,
212
+ previousNotifiedAthUsd: previous,
213
+ nextAthNotifyUsd,
214
+ athStepPct,
215
+ increasePct,
216
+ priceUsd,
217
+ priceSol,
218
+ signature: event.signature,
219
+ });
220
+ };
221
+
222
+ const controller = new AbortController();
223
+ tradeSubscription = await slrd.listenTrades({
224
+ tokens: [],
225
+ venues: [
226
+ ...(venues.includes("pump") ? (["pump", "pumpswap"] as const) : []),
227
+ ...(venues.includes("raydium-launchlab")
228
+ ? (["raydium-launchlab"] as const)
229
+ : []),
230
+ ],
231
+ signal: controller.signal,
232
+ onStatus(event, data) {
233
+ report(`trades ${event}`, data ?? {});
234
+ },
235
+ });
236
+ tradeSubscription.onTrade(onTrade);
237
+ const launchSubscription = await slrd.subscribeLaunches({
238
+ venues,
239
+ signal: controller.signal,
240
+ onLaunch,
241
+ onStatus(event, data) {
242
+ report(`launches ${event}`, data ?? {});
243
+ },
244
+ });
245
+
246
+ const stop = () => {
247
+ stopped = true;
248
+ controller.abort();
249
+ };
250
+ process.once("SIGINT", stop);
251
+ process.once("SIGTERM", stop);
252
+ report("ready", {
253
+ rpc: "RPC_ENDPOINT",
254
+ websocket: "derived-from-RPC_ENDPOINT",
255
+ venues,
256
+ minMarketCapUsd,
257
+ athStepPct,
258
+ excludeMayhem: true,
259
+ trackTtlMs,
260
+ upstreamSubscriptions: venues.length,
261
+ });
262
+ const heartbeat = setInterval(
263
+ () => {
264
+ const now = Date.now();
265
+ for (const [mint, state] of states) {
266
+ if (!state.qualified && now - state.lastSeenAtMs > trackTtlMs) {
267
+ states.delete(mint);
268
+ void tradeSubscription?.remove(mint);
269
+ }
270
+ }
271
+ report("heartbeat", {
272
+ launches,
273
+ ignoredMayhem,
274
+ tracked: states.size,
275
+ qualified,
276
+ prices,
277
+ thresholdNotifications,
278
+ athNotifications,
279
+ watchedTokens: tradeSubscription?.list().length ?? 0,
280
+ });
281
+ },
282
+ Math.max(1_000, Math.trunc(numberFlag(args.flags, "heartbeat-ms", 15_000))),
283
+ );
284
+
285
+ try {
286
+ while (!stopped) await new Promise((resolve) => setTimeout(resolve, 250));
287
+ } finally {
288
+ clearInterval(heartbeat);
289
+ controller.abort();
290
+ await launchSubscription.close();
291
+ await tradeSubscription.close();
292
+ process.removeListener("SIGINT", stop);
293
+ process.removeListener("SIGTERM", stop);
294
+ report("stopped", {
295
+ launches,
296
+ prices,
297
+ thresholdNotifications,
298
+ athNotifications,
299
+ });
300
+ }
301
+ }
@@ -0,0 +1,27 @@
1
+ import { expect, test } from "bun:test";
2
+ import { retryLiquidationRead, runLiquidationWallets } from "./liquidation-wallets.ts";
3
+
4
+ test("read quota failures back off, keep completed wallets, never replay execution", async () => {
5
+ const starts: string[] = [], executions: string[] = [], waits: number[] = [];
6
+ let reads = 0;
7
+ const failures = await runLiquidationWallets(["a", "a", "b", "c"], async wallet => {
8
+ await retryLiquidationRead(async () => {
9
+ if (wallet === "b" && reads++ < 2) throw Error("RPC_PROVIDER_UNAVAILABLE");
10
+ }, () => {}, async ms => { waits.push(ms); });
11
+ executions.push(wallet);
12
+ if (wallet === "b") throw Error("Submission outcome uncertain");
13
+ }, wallet => starts.push(wallet), () => {});
14
+ expect(starts).toEqual(["a", "b", "c"]);
15
+ expect(executions).toEqual(["a", "b", "c"]);
16
+ expect(failures).toEqual(["b"]);
17
+ expect(waits).toEqual([5000, 10000]);
18
+ });
19
+
20
+ test("invalid metadata is not retried; provider failures are bounded", async () => {
21
+ let calls = 0;
22
+ await expect(retryLiquidationRead(async () => { calls++; throw Error("Unknown wallet"); }, () => {}, async () => {})).rejects.toThrow("Unknown wallet");
23
+ expect(calls).toBe(1);
24
+ calls = 0;
25
+ await expect(retryLiquidationRead(async () => { calls++; throw Error("HTTP 429"); }, () => {}, async () => {})).rejects.toThrow("429");
26
+ expect(calls).toBe(7);
27
+ });
@@ -0,0 +1,33 @@
1
+ /** Retry reads only. An execution callback is never repeated by this runner. */
2
+ export async function retryLiquidationRead<T>(
3
+ read: () => Promise<T>,
4
+ onRetry: (attempt: number, delayMs: number) => void,
5
+ sleep: (ms: number) => Promise<unknown> = ms => Bun.sleep(ms),
6
+ ): Promise<T> {
7
+ for (let attempt = 0; ; attempt++) {
8
+ try { return await read(); }
9
+ catch (error) {
10
+ const message = error instanceof Error ? error.message : String(error);
11
+ if (attempt >= 6 || !/RPC_PROVIDER_UNAVAILABLE|RPC_QUEUE_TIMEOUT|RPC_TIMEOUT|429|rate.limit|fetch failed|ECONNRESET|timed? ?out/i.test(message)) throw error;
12
+ const delayMs = Math.min(60_000, 5_000 * 2 ** attempt);
13
+ onRetry(attempt + 1, delayMs);
14
+ await sleep(delayMs);
15
+ }
16
+ }
17
+ }
18
+
19
+ export async function runLiquidationWallets(
20
+ wallets: string[],
21
+ run: (wallet: string) => Promise<unknown>,
22
+ onStart: (wallet: string, index: number, total: number) => void,
23
+ onFailure: (wallet: string, error: unknown) => void,
24
+ ): Promise<string[]> {
25
+ const failures: string[] = [];
26
+ const unique = [...new Set(wallets)];
27
+ for (const [index, wallet] of unique.entries()) {
28
+ onStart(wallet, index + 1, unique.length);
29
+ try { await run(wallet); }
30
+ catch (error) { failures.push(wallet); onFailure(wallet, error); }
31
+ }
32
+ return failures;
33
+ }