hood-alerts 0.1.0

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.
Files changed (107) hide show
  1. package/LICENSE +20 -0
  2. package/README.md +369 -0
  3. package/dist/bot/index.cjs +1008 -0
  4. package/dist/bot/index.cjs.map +1 -0
  5. package/dist/bot/index.d.cts +241 -0
  6. package/dist/bot/index.d.ts +241 -0
  7. package/dist/bot/index.js +30 -0
  8. package/dist/bot/index.js.map +1 -0
  9. package/dist/chunk-32GS6XVE.js +732 -0
  10. package/dist/chunk-32GS6XVE.js.map +1 -0
  11. package/dist/chunk-3LHCQA4Z.js +76 -0
  12. package/dist/chunk-3LHCQA4Z.js.map +1 -0
  13. package/dist/chunk-3NHLSKZE.js +1 -0
  14. package/dist/chunk-3NHLSKZE.js.map +1 -0
  15. package/dist/chunk-6JQK5G3Z.js +72 -0
  16. package/dist/chunk-6JQK5G3Z.js.map +1 -0
  17. package/dist/chunk-CBNZ6AZT.js +598 -0
  18. package/dist/chunk-CBNZ6AZT.js.map +1 -0
  19. package/dist/chunk-CIURPPOW.js +1 -0
  20. package/dist/chunk-CIURPPOW.js.map +1 -0
  21. package/dist/chunk-IUOEGBF6.js +49 -0
  22. package/dist/chunk-IUOEGBF6.js.map +1 -0
  23. package/dist/chunk-NBMINJ5E.js +230 -0
  24. package/dist/chunk-NBMINJ5E.js.map +1 -0
  25. package/dist/chunk-O2RY2SWN.js +1 -0
  26. package/dist/chunk-O2RY2SWN.js.map +1 -0
  27. package/dist/chunk-PLMVKTQM.js +1 -0
  28. package/dist/chunk-PLMVKTQM.js.map +1 -0
  29. package/dist/chunk-RSTLW7XH.js +130 -0
  30. package/dist/chunk-RSTLW7XH.js.map +1 -0
  31. package/dist/chunk-SOBZ2FPP.js +355 -0
  32. package/dist/chunk-SOBZ2FPP.js.map +1 -0
  33. package/dist/chunk-VC5MKEY2.js +170 -0
  34. package/dist/chunk-VC5MKEY2.js.map +1 -0
  35. package/dist/chunk-W2GUAWUR.js +147 -0
  36. package/dist/chunk-W2GUAWUR.js.map +1 -0
  37. package/dist/chunk-Y2M3USWZ.js +355 -0
  38. package/dist/chunk-Y2M3USWZ.js.map +1 -0
  39. package/dist/chunk-Z4DJPESQ.js +1216 -0
  40. package/dist/chunk-Z4DJPESQ.js.map +1 -0
  41. package/dist/chunk-ZDM5VNWB.js +1 -0
  42. package/dist/chunk-ZDM5VNWB.js.map +1 -0
  43. package/dist/events/index.cjs +820 -0
  44. package/dist/events/index.cjs.map +1 -0
  45. package/dist/events/index.d.cts +58 -0
  46. package/dist/events/index.d.ts +58 -0
  47. package/dist/events/index.js +69 -0
  48. package/dist/events/index.js.map +1 -0
  49. package/dist/format-6RG4OcyE.d.ts +57 -0
  50. package/dist/format-B7d40f65.d.cts +57 -0
  51. package/dist/index.cjs +4122 -0
  52. package/dist/index.cjs.map +1 -0
  53. package/dist/index.d.cts +21 -0
  54. package/dist/index.d.ts +21 -0
  55. package/dist/index.js +274 -0
  56. package/dist/index.js.map +1 -0
  57. package/dist/logger-3phvV_fl.d.cts +185 -0
  58. package/dist/logger-CYza_0eU.d.ts +185 -0
  59. package/dist/logs-D7sYBvSy.d.cts +66 -0
  60. package/dist/logs-D7sYBvSy.d.ts +66 -0
  61. package/dist/notifiers/index.cjs +620 -0
  62. package/dist/notifiers/index.cjs.map +1 -0
  63. package/dist/notifiers/index.d.cts +186 -0
  64. package/dist/notifiers/index.d.ts +186 -0
  65. package/dist/notifiers/index.js +70 -0
  66. package/dist/notifiers/index.js.map +1 -0
  67. package/dist/pricing-BumgLpvB.d.cts +79 -0
  68. package/dist/pricing-BumgLpvB.d.ts +79 -0
  69. package/dist/ratelimit-BKVQG2oY.d.cts +42 -0
  70. package/dist/ratelimit-CGLlXNIS.d.ts +42 -0
  71. package/dist/registry-D3We8Lg6.d.ts +50 -0
  72. package/dist/registry-DePA3IyS.d.cts +50 -0
  73. package/dist/rules/index.cjs +614 -0
  74. package/dist/rules/index.cjs.map +1 -0
  75. package/dist/rules/index.d.cts +166 -0
  76. package/dist/rules/index.d.ts +166 -0
  77. package/dist/rules/index.js +60 -0
  78. package/dist/rules/index.js.map +1 -0
  79. package/dist/schema-DuDMPqpq.d.ts +183 -0
  80. package/dist/schema-NnIdKFl2.d.cts +183 -0
  81. package/dist/service/index.cjs +3774 -0
  82. package/dist/service/index.cjs.map +1 -0
  83. package/dist/service/index.d.cts +262 -0
  84. package/dist/service/index.d.ts +262 -0
  85. package/dist/service/index.js +45 -0
  86. package/dist/service/index.js.map +1 -0
  87. package/dist/service/main.cjs +3759 -0
  88. package/dist/service/main.cjs.map +1 -0
  89. package/dist/service/main.d.cts +1 -0
  90. package/dist/service/main.d.ts +1 -0
  91. package/dist/service/main.js +60 -0
  92. package/dist/service/main.js.map +1 -0
  93. package/dist/sources-CAaQR6-N.d.ts +159 -0
  94. package/dist/sources-CpDOUlzx.d.cts +159 -0
  95. package/dist/telegram-CpodYJrP.d.cts +64 -0
  96. package/dist/telegram-pvphmBwn.d.ts +64 -0
  97. package/dist/tiers/index.cjs +471 -0
  98. package/dist/tiers/index.cjs.map +1 -0
  99. package/dist/tiers/index.d.cts +231 -0
  100. package/dist/tiers/index.d.ts +231 -0
  101. package/dist/tiers/index.js +34 -0
  102. package/dist/tiers/index.js.map +1 -0
  103. package/dist/types-C0ETog04.d.ts +109 -0
  104. package/dist/types-CcoKpStA.d.cts +109 -0
  105. package/dist/types-DFPpTMWo.d.cts +186 -0
  106. package/dist/types-DFPpTMWo.d.ts +186 -0
  107. package/package.json +114 -0
@@ -0,0 +1,355 @@
1
+ import {
2
+ delivered,
3
+ displayLabel,
4
+ escapeDiscordMarkdown,
5
+ escapeHtml,
6
+ escapeMarkdownV2,
7
+ failed,
8
+ htmlLink,
9
+ markdownV2Link,
10
+ numericHeader,
11
+ pick,
12
+ postJson,
13
+ resolveFetch,
14
+ shortAddress,
15
+ sleep,
16
+ truncate
17
+ } from "./chunk-NBMINJ5E.js";
18
+
19
+ // src/notifiers/format.ts
20
+ import { formatUnits } from "viem";
21
+ var EMBED_COLORS = {
22
+ launch: 2278750,
23
+ curve_trade: 3718648,
24
+ graduation: 11032055,
25
+ whale_trade: 16096779
26
+ };
27
+ var KIND_EMOJI = {
28
+ launch: "\u{1F680}",
29
+ curve_trade: "\u{1F4C8}",
30
+ graduation: "\u{1F393}",
31
+ whale_trade: "\u{1F40B}"
32
+ };
33
+ var LAUNCHPAD_NAMES = { noxa: "NOXA", odyssey: "The Odyssey" };
34
+ var usdFormatter = new Intl.NumberFormat("en-US", {
35
+ style: "currency",
36
+ currency: "USD",
37
+ maximumFractionDigits: 2
38
+ });
39
+ function formatUsd(value) {
40
+ if (value === null || !Number.isFinite(value)) return "unknown";
41
+ const abs = Math.abs(value);
42
+ if (abs >= 1e6) return `$${(value / 1e6).toFixed(2)}M`;
43
+ if (abs >= 1e4) return `$${(value / 1e3).toFixed(1)}K`;
44
+ return usdFormatter.format(value);
45
+ }
46
+ function formatAmount(value) {
47
+ const abs = Math.abs(value);
48
+ if (abs === 0) return "0";
49
+ if (abs >= 1e9) return `${(value / 1e9).toFixed(2)}B`;
50
+ if (abs >= 1e6) return `${(value / 1e6).toFixed(2)}M`;
51
+ if (abs >= 1e3) return `${(value / 1e3).toFixed(2)}K`;
52
+ if (abs >= 1) return value.toFixed(4).replace(/\.?0+$/, "");
53
+ return value.toPrecision(4);
54
+ }
55
+ function formatEth(wei) {
56
+ return `${formatAmount(Number(formatUnits(wei < 0n ? -wei : wei, 18)))} ETH`;
57
+ }
58
+ function headline(event) {
59
+ const label = displayLabel(event.symbol, event.token);
60
+ const pad = LAUNCHPAD_NAMES[event.launchpad];
61
+ switch (event.kind) {
62
+ case "launch":
63
+ return `${KIND_EMOJI.launch} New launch: ${label} on ${pad}`;
64
+ case "curve_trade":
65
+ return `${KIND_EMOJI.curve_trade} Curve ${event.side}: ${label} for ${formatUsd(event.usdValue)}`;
66
+ case "graduation":
67
+ return `${KIND_EMOJI.graduation} Graduated: ${label} filled its curve`;
68
+ case "whale_trade":
69
+ return `${KIND_EMOJI.whale_trade} Whale ${event.side}: ${formatUsd(event.usdValue)} of ${label}`;
70
+ }
71
+ }
72
+ function detailRows(event) {
73
+ const rows = [];
74
+ rows.push({ label: "Launchpad", value: LAUNCHPAD_NAMES[event.launchpad], inline: true });
75
+ if (event.name) rows.push({ label: "Name", value: truncate(event.name, 64), inline: true });
76
+ rows.push({ label: "Token", value: shortAddress(event.token), inline: true });
77
+ switch (event.kind) {
78
+ case "launch": {
79
+ rows.push({
80
+ label: "Listing",
81
+ value: event.instantListing ? "Instant (live Uniswap v3 pool, no curve)" : "Bonding curve (graduates when filled)",
82
+ inline: true
83
+ });
84
+ rows.push({ label: "Deployer", value: shortAddress(event.actor), inline: true });
85
+ if (event.initialBuyAmount > 0n) {
86
+ rows.push({ label: "Initial buy", value: formatUsd(event.usdValue), inline: true });
87
+ }
88
+ break;
89
+ }
90
+ case "curve_trade": {
91
+ rows.push({ label: "Side", value: event.side === "buy" ? "Buy" : "Sell", inline: true });
92
+ rows.push({ label: "Size", value: formatEth(event.quoteAmountWei), inline: true });
93
+ rows.push({ label: "Value", value: formatUsd(event.usdValue), inline: true });
94
+ rows.push({
95
+ label: "Tokens",
96
+ value: formatAmount(Number(formatUnits(event.tokenAmount, 18))),
97
+ inline: true
98
+ });
99
+ rows.push({ label: "Trader", value: shortAddress(event.actor), inline: true });
100
+ break;
101
+ }
102
+ case "graduation": {
103
+ rows.push({ label: "Pool", value: shortAddress(event.pool), inline: true });
104
+ rows.push({ label: "Seeded", value: formatUsd(event.usdValue), inline: true });
105
+ rows.push({ label: "LP position", value: `#${event.positionId}`, inline: true });
106
+ break;
107
+ }
108
+ case "whale_trade": {
109
+ rows.push({ label: "Side", value: event.side === "buy" ? "Buy" : "Sell", inline: true });
110
+ rows.push({ label: "Value", value: formatUsd(event.usdValue), inline: true });
111
+ rows.push({
112
+ label: "Size",
113
+ value: `${formatAmount(event.tokenAmount)} for ${formatAmount(event.quoteAmount)} ${event.quoteSymbol}`,
114
+ inline: true
115
+ });
116
+ rows.push({ label: "Price", value: `${formatAmount(event.price)} ${event.quoteSymbol}`, inline: true });
117
+ rows.push({ label: "Fee tier", value: `${event.feeTier / 1e4}%`, inline: true });
118
+ rows.push({ label: "Trader", value: shortAddress(event.actor), inline: true });
119
+ break;
120
+ }
121
+ }
122
+ return rows;
123
+ }
124
+ function renderAlert(event, options = {}) {
125
+ const parseMode = options.parseMode ?? "HTML";
126
+ const title = headline(event);
127
+ const rows = detailRows(event);
128
+ const label = displayLabel(event.symbol, event.token);
129
+ const linkPairs = [
130
+ { label: "Transaction", url: event.explorer.tx },
131
+ { label: "Token", url: event.explorer.token }
132
+ ];
133
+ if (event.explorer.pool) linkPairs.push({ label: "Pool", url: event.explorer.pool });
134
+ linkPairs.push({ label: "Actor", url: event.explorer.actor });
135
+ const text = parseMode === "HTML" ? [
136
+ `<b>${escapeHtml(title)}</b>`,
137
+ "",
138
+ ...rows.map((row) => `${escapeHtml(row.label)}: <b>${escapeHtml(row.value)}</b>`),
139
+ `Contract: <code>${escapeHtml(event.token)}</code>`,
140
+ "",
141
+ linkPairs.map((link) => htmlLink(link.label, link.url)).join(" \xB7 "),
142
+ ...options.footer ? ["", `<i>${escapeHtml(options.footer)}</i>`] : []
143
+ ].join("\n") : [
144
+ `*${escapeMarkdownV2(title)}*`,
145
+ "",
146
+ ...rows.map((row) => `${escapeMarkdownV2(row.label)}: *${escapeMarkdownV2(row.value)}*`),
147
+ `Contract: \`${event.token}\``,
148
+ "",
149
+ linkPairs.map((link) => markdownV2Link(link.label, link.url)).join(" \xB7 "),
150
+ ...options.footer ? ["", `_${escapeMarkdownV2(options.footer)}_`] : []
151
+ ].join("\n");
152
+ const fields = rows.map((row) => ({
153
+ name: truncate(row.label, 256),
154
+ value: truncate(escapeDiscordMarkdown(row.value), 1024),
155
+ inline: row.inline ?? false
156
+ }));
157
+ fields.push({
158
+ name: "Links",
159
+ value: linkPairs.map((link) => `[${link.label}](${link.url})`).join(" \xB7 "),
160
+ inline: false
161
+ });
162
+ const embed = {
163
+ title: truncate(escapeDiscordMarkdown(title), 256),
164
+ url: event.explorer.tx,
165
+ description: `\`${event.token}\``,
166
+ color: EMBED_COLORS[event.kind],
167
+ fields,
168
+ ...options.footer ? { footer: { text: truncate(options.footer, 2048) } } : {},
169
+ ...event.timestampMs !== null ? { timestamp: new Date(event.timestampMs).toISOString() } : {}
170
+ };
171
+ const plain = `${title} | ${label} ${event.token} | block ${event.blockNumber} | ${event.explorer.tx}`;
172
+ return { text, parseMode, embed, plain };
173
+ }
174
+
175
+ // src/notifiers/discord.ts
176
+ function createDiscordRateLimiter(now = Date.now, sleep2 = sleep) {
177
+ const buckets = /* @__PURE__ */ new Map();
178
+ let globalResetAtMs = 0;
179
+ return {
180
+ async acquire(route) {
181
+ const globalWait = globalResetAtMs - now();
182
+ if (globalWait > 0) await sleep2(globalWait);
183
+ const bucket = buckets.get(route);
184
+ if (!bucket) return;
185
+ if (bucket.remaining > 0) return;
186
+ const wait = bucket.resetAtMs - now();
187
+ if (wait > 0) await sleep2(wait);
188
+ },
189
+ update(route, headers) {
190
+ const remaining = numericHeader(headers, "x-ratelimit-remaining");
191
+ const resetAfter = numericHeader(headers, "x-ratelimit-reset-after");
192
+ if (remaining === null && resetAfter === null) return;
193
+ buckets.set(route, {
194
+ remaining: remaining ?? 1,
195
+ resetAtMs: now() + (resetAfter ?? 0) * 1e3
196
+ });
197
+ },
198
+ penalise(route, retryAfterMs, global) {
199
+ const resetAtMs = now() + retryAfterMs;
200
+ if (global) globalResetAtMs = Math.max(globalResetAtMs, resetAtMs);
201
+ buckets.set(route, { remaining: 0, resetAtMs });
202
+ }
203
+ };
204
+ }
205
+ var PERMANENT_STATUSES = /* @__PURE__ */ new Set([400, 401, 403, 404, 405]);
206
+ async function deliver(args, options, fetchImpl, limiter) {
207
+ const maxAttempts = options.maxAttempts ?? 4;
208
+ const backoffMs = options.backoffMs ?? 500;
209
+ const maxWaitMs = options.maxWaitMs ?? 6e4;
210
+ const sleep2 = options.sleep ?? sleep;
211
+ let attempt = 0;
212
+ let lastStatus = null;
213
+ let lastError = "no attempt was made";
214
+ let retryable = true;
215
+ while (attempt < maxAttempts) {
216
+ attempt += 1;
217
+ await limiter.acquire(args.route);
218
+ let response;
219
+ try {
220
+ response = await postJson(fetchImpl, args.url, args.payload, args.headers);
221
+ } catch (error) {
222
+ lastStatus = null;
223
+ lastError = error instanceof Error ? error.message : String(error);
224
+ retryable = true;
225
+ if (attempt >= maxAttempts) break;
226
+ await sleep2(backoffMs * 2 ** (attempt - 1));
227
+ continue;
228
+ }
229
+ limiter.update(args.route, response.headers);
230
+ lastStatus = response.status;
231
+ if (response.status === 429) {
232
+ const retryAfterField = pick(response.body, "retry_after");
233
+ const retryAfterSeconds = typeof retryAfterField === "number" ? retryAfterField : numericHeader(response.headers, "retry-after") ?? backoffMs / 1e3;
234
+ const isGlobal = pick(response.body, "global") === true || response.headers.get("x-ratelimit-global") === "true";
235
+ const waitMs = retryAfterSeconds * 1e3;
236
+ limiter.penalise(args.route, waitMs, isGlobal);
237
+ lastError = `rate limited, retry after ${retryAfterSeconds}s${isGlobal ? " (global)" : ""}`;
238
+ retryable = true;
239
+ if (waitMs > maxWaitMs || attempt >= maxAttempts) break;
240
+ await sleep2(waitMs);
241
+ continue;
242
+ }
243
+ if (response.ok) {
244
+ const messageId = pick(response.body, "id");
245
+ return delivered(response.status, attempt, messageId === void 0 ? null : String(messageId));
246
+ }
247
+ const message = (typeof pick(response.body, "message") === "string" ? pick(response.body, "message") : null) ?? response.raw.slice(0, 200);
248
+ lastError = message || `Discord request failed with status ${response.status}`;
249
+ if (PERMANENT_STATUSES.has(response.status)) {
250
+ retryable = false;
251
+ break;
252
+ }
253
+ retryable = true;
254
+ if (attempt >= maxAttempts) break;
255
+ await sleep2(backoffMs * 2 ** (attempt - 1));
256
+ }
257
+ return failed(lastStatus, attempt, lastError, retryable);
258
+ }
259
+ var WEBHOOK_PATTERN = /^https:\/\/(?:\w+\.)?discord(?:app)?\.com\/api(?:\/v\d+)?\/webhooks\/\d+\/[\w-]+$/;
260
+ function createDiscordWebhookNotifier(options = {}) {
261
+ const fetchImpl = resolveFetch(options.fetch);
262
+ const limiter = options.limiter ?? createDiscordRateLimiter(options.now ?? Date.now, options.sleep ?? sleep);
263
+ return {
264
+ platform: "discord",
265
+ kind: "webhook",
266
+ async send(target, alert) {
267
+ const url = target || options.webhookUrl;
268
+ if (!url || !WEBHOOK_PATTERN.test(url)) {
269
+ return failed(
270
+ null,
271
+ 0,
272
+ `invalid Discord webhook URL "${target}": expected https://discord.com/api/webhooks/<id>/<token>`,
273
+ false
274
+ );
275
+ }
276
+ return deliver(
277
+ {
278
+ url: `${url}?wait=true`,
279
+ // Discord buckets webhook routes per webhook id.
280
+ route: `webhook:${url.split("/").slice(-2, -1)[0] ?? url}`,
281
+ payload: {
282
+ embeds: [alert.embed],
283
+ ...options.username ? { username: options.username } : {}
284
+ },
285
+ headers: {}
286
+ },
287
+ options,
288
+ fetchImpl,
289
+ limiter
290
+ );
291
+ }
292
+ };
293
+ }
294
+ function createDiscordBotNotifier(options) {
295
+ if (!options.botToken.trim()) {
296
+ throw new Error("hood-alerts: DISCORD_BOT_TOKEN is required for the Discord bot notifier");
297
+ }
298
+ const fetchImpl = resolveFetch(options.fetch);
299
+ const apiBase = (options.apiBase ?? "https://discord.com/api/v10").replace(/\/+$/, "");
300
+ const limiter = options.limiter ?? createDiscordRateLimiter(options.now ?? Date.now, options.sleep ?? sleep);
301
+ return {
302
+ platform: "discord",
303
+ kind: "bot",
304
+ async send(target, alert) {
305
+ if (!/^\d{5,25}$/.test(target)) {
306
+ return failed(null, 0, `invalid Discord channel id "${target}": expected a numeric snowflake`, false);
307
+ }
308
+ return deliver(
309
+ {
310
+ url: `${apiBase}/channels/${target}/messages`,
311
+ route: `channel:${target}`,
312
+ payload: { embeds: [alert.embed] },
313
+ headers: { authorization: `Bot ${options.botToken}` }
314
+ },
315
+ options,
316
+ fetchImpl,
317
+ limiter
318
+ );
319
+ }
320
+ };
321
+ }
322
+
323
+ // src/notifiers/capture.ts
324
+ function createCaptureNotifier(platform, onSend, now = Date.now) {
325
+ const sent = [];
326
+ return {
327
+ platform,
328
+ kind: "capture",
329
+ sent,
330
+ clear() {
331
+ sent.length = 0;
332
+ },
333
+ async send(target, alert) {
334
+ const delivery = { platform, target, alert, atMs: now() };
335
+ sent.push(delivery);
336
+ onSend?.(delivery);
337
+ return delivered(200, 1, `capture-${sent.length}`);
338
+ }
339
+ };
340
+ }
341
+
342
+ export {
343
+ EMBED_COLORS,
344
+ formatUsd,
345
+ formatAmount,
346
+ formatEth,
347
+ headline,
348
+ detailRows,
349
+ renderAlert,
350
+ createDiscordRateLimiter,
351
+ createDiscordWebhookNotifier,
352
+ createDiscordBotNotifier,
353
+ createCaptureNotifier
354
+ };
355
+ //# sourceMappingURL=chunk-SOBZ2FPP.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/notifiers/format.ts","../src/notifiers/discord.ts","../src/notifiers/capture.ts"],"sourcesContent":["import { formatUnits } from 'viem'\nimport type { AlertEvent } from '../events/types.js'\nimport {\n displayLabel,\n escapeDiscordMarkdown,\n escapeHtml,\n escapeMarkdownV2,\n htmlLink,\n markdownV2Link,\n shortAddress,\n truncate,\n} from './escape.js'\nimport type { DiscordEmbed, DiscordEmbedField, RenderedAlert } from './types.js'\n\n/**\n * Message formatting, one alert at a time, for both platforms.\n *\n * Every piece of chain-derived text (token name, symbol) goes through the\n * platform's escaper. Every piece of package-authored text (labels, headings)\n * is written to be escape-safe already, so the output stays readable instead\n * of being a wall of backslashes.\n */\n\n/** Colour per event kind, as Discord's decimal RGB. */\nexport const EMBED_COLORS = {\n launch: 0x22c55e,\n curve_trade: 0x38bdf8,\n graduation: 0xa855f7,\n whale_trade: 0xf59e0b,\n} as const\n\nconst KIND_EMOJI = {\n launch: '🚀',\n curve_trade: '📈',\n graduation: '🎓',\n whale_trade: '🐋',\n} as const\n\nconst LAUNCHPAD_NAMES = { noxa: 'NOXA', odyssey: 'The Odyssey' } as const\n\nconst usdFormatter = new Intl.NumberFormat('en-US', {\n style: 'currency',\n currency: 'USD',\n maximumFractionDigits: 2,\n})\n\n/** Compact USD rendering: `$1,234.50`, `$12.4K`, `$3.1M`. */\nexport function formatUsd(value: number | null): string {\n if (value === null || !Number.isFinite(value)) return 'unknown'\n const abs = Math.abs(value)\n if (abs >= 1_000_000) return `$${(value / 1_000_000).toFixed(2)}M`\n if (abs >= 10_000) return `$${(value / 1_000).toFixed(1)}K`\n return usdFormatter.format(value)\n}\n\n/** Human amount rendering with sane precision across 12 orders of magnitude. */\nexport function formatAmount(value: number): string {\n const abs = Math.abs(value)\n if (abs === 0) return '0'\n if (abs >= 1_000_000_000) return `${(value / 1_000_000_000).toFixed(2)}B`\n if (abs >= 1_000_000) return `${(value / 1_000_000).toFixed(2)}M`\n if (abs >= 1_000) return `${(value / 1_000).toFixed(2)}K`\n if (abs >= 1) return value.toFixed(4).replace(/\\.?0+$/, '')\n return value.toPrecision(4)\n}\n\n/** ETH amount from wei, with a trailing `ETH`. */\nexport function formatEth(wei: bigint): string {\n return `${formatAmount(Number(formatUnits(wei < 0n ? -wei : wei, 18)))} ETH`\n}\n\n/** The one-line headline for an event, before escaping. */\nexport function headline(event: AlertEvent): string {\n const label = displayLabel(event.symbol, event.token)\n const pad = LAUNCHPAD_NAMES[event.launchpad]\n switch (event.kind) {\n case 'launch':\n return `${KIND_EMOJI.launch} New launch: ${label} on ${pad}`\n case 'curve_trade':\n return `${KIND_EMOJI.curve_trade} Curve ${event.side}: ${label} for ${formatUsd(event.usdValue)}`\n case 'graduation':\n return `${KIND_EMOJI.graduation} Graduated: ${label} filled its curve`\n case 'whale_trade':\n return `${KIND_EMOJI.whale_trade} Whale ${event.side}: ${formatUsd(event.usdValue)} of ${label}`\n }\n}\n\n/** Label/value pairs describing an event, before escaping. */\nexport function detailRows(event: AlertEvent): { label: string; value: string; inline?: boolean }[] {\n const rows: { label: string; value: string; inline?: boolean }[] = []\n rows.push({ label: 'Launchpad', value: LAUNCHPAD_NAMES[event.launchpad], inline: true })\n if (event.name) rows.push({ label: 'Name', value: truncate(event.name, 64), inline: true })\n rows.push({ label: 'Token', value: shortAddress(event.token), inline: true })\n\n switch (event.kind) {\n case 'launch': {\n rows.push({\n label: 'Listing',\n value: event.instantListing\n ? 'Instant (live Uniswap v3 pool, no curve)'\n : 'Bonding curve (graduates when filled)',\n inline: true,\n })\n rows.push({ label: 'Deployer', value: shortAddress(event.actor), inline: true })\n if (event.initialBuyAmount > 0n) {\n rows.push({ label: 'Initial buy', value: formatUsd(event.usdValue), inline: true })\n }\n break\n }\n case 'curve_trade': {\n rows.push({ label: 'Side', value: event.side === 'buy' ? 'Buy' : 'Sell', inline: true })\n rows.push({ label: 'Size', value: formatEth(event.quoteAmountWei), inline: true })\n rows.push({ label: 'Value', value: formatUsd(event.usdValue), inline: true })\n rows.push({\n label: 'Tokens',\n value: formatAmount(Number(formatUnits(event.tokenAmount, 18))),\n inline: true,\n })\n rows.push({ label: 'Trader', value: shortAddress(event.actor), inline: true })\n break\n }\n case 'graduation': {\n rows.push({ label: 'Pool', value: shortAddress(event.pool), inline: true })\n rows.push({ label: 'Seeded', value: formatUsd(event.usdValue), inline: true })\n rows.push({ label: 'LP position', value: `#${event.positionId}`, inline: true })\n break\n }\n case 'whale_trade': {\n rows.push({ label: 'Side', value: event.side === 'buy' ? 'Buy' : 'Sell', inline: true })\n rows.push({ label: 'Value', value: formatUsd(event.usdValue), inline: true })\n rows.push({\n label: 'Size',\n value: `${formatAmount(event.tokenAmount)} for ${formatAmount(event.quoteAmount)} ${event.quoteSymbol}`,\n inline: true,\n })\n rows.push({ label: 'Price', value: `${formatAmount(event.price)} ${event.quoteSymbol}`, inline: true })\n rows.push({ label: 'Fee tier', value: `${event.feeTier / 10_000}%`, inline: true })\n rows.push({ label: 'Trader', value: shortAddress(event.actor), inline: true })\n break\n }\n }\n return rows\n}\n\n/** Options for {@link renderAlert}. */\nexport interface RenderOptions {\n /**\n * Telegram parse mode. HTML is the default: its escape rules have three\n * special characters instead of eighteen, which makes malformed-entity 400s\n * far less likely on adversarial token names.\n * @defaultValue `'HTML'`\n */\n parseMode?: 'HTML' | 'MarkdownV2'\n /** Footer text, e.g. the rule that matched. */\n footer?: string\n}\n\n/**\n * Render an event for both platforms.\n *\n * @example\n * ```ts\n * const alert = renderAlert(event, { footer: 'rule: whales' })\n * await telegram.send('123456789', alert)\n * await discord.send('987654321', alert)\n * ```\n */\nexport function renderAlert(event: AlertEvent, options: RenderOptions = {}): RenderedAlert {\n const parseMode = options.parseMode ?? 'HTML'\n const title = headline(event)\n const rows = detailRows(event)\n const label = displayLabel(event.symbol, event.token)\n\n const linkPairs: { label: string; url: string }[] = [\n { label: 'Transaction', url: event.explorer.tx },\n { label: 'Token', url: event.explorer.token },\n ]\n if (event.explorer.pool) linkPairs.push({ label: 'Pool', url: event.explorer.pool })\n linkPairs.push({ label: 'Actor', url: event.explorer.actor })\n\n const text =\n parseMode === 'HTML'\n ? [\n `<b>${escapeHtml(title)}</b>`,\n '',\n ...rows.map((row) => `${escapeHtml(row.label)}: <b>${escapeHtml(row.value)}</b>`),\n `Contract: <code>${escapeHtml(event.token)}</code>`,\n '',\n linkPairs.map((link) => htmlLink(link.label, link.url)).join(' · '),\n ...(options.footer ? ['', `<i>${escapeHtml(options.footer)}</i>`] : []),\n ].join('\\n')\n : [\n `*${escapeMarkdownV2(title)}*`,\n '',\n ...rows.map((row) => `${escapeMarkdownV2(row.label)}: *${escapeMarkdownV2(row.value)}*`),\n `Contract: \\`${event.token}\\``,\n '',\n linkPairs.map((link) => markdownV2Link(link.label, link.url)).join(' · '),\n ...(options.footer ? ['', `_${escapeMarkdownV2(options.footer)}_`] : []),\n ].join('\\n')\n\n const fields: DiscordEmbedField[] = rows.map((row) => ({\n name: truncate(row.label, 256),\n value: truncate(escapeDiscordMarkdown(row.value), 1024),\n inline: row.inline ?? false,\n }))\n fields.push({\n name: 'Links',\n value: linkPairs.map((link) => `[${link.label}](${link.url})`).join(' · '),\n inline: false,\n })\n\n const embed: DiscordEmbed = {\n title: truncate(escapeDiscordMarkdown(title), 256),\n url: event.explorer.tx,\n description: `\\`${event.token}\\``,\n color: EMBED_COLORS[event.kind],\n fields,\n ...(options.footer ? { footer: { text: truncate(options.footer, 2048) } } : {}),\n ...(event.timestampMs !== null\n ? { timestamp: new Date(event.timestampMs).toISOString() }\n : {}),\n }\n\n const plain = `${title} | ${label} ${event.token} | block ${event.blockNumber} | ${event.explorer.tx}`\n\n return { text, parseMode, embed, plain }\n}\n","import { numericHeader, pick, postJson, resolveFetch, sleep as realSleep } from './http.js'\nimport {\n delivered,\n failed,\n type DeliveryResult,\n type FetchLike,\n type Notifier,\n type NotifierHttpOptions,\n type RenderedAlert,\n} from './types.js'\n\n/**\n * Discord adapters: webhook and bot (application) delivery, behind the same\n * {@link Notifier} interface.\n *\n * Discord's rate limiting is a per-route bucket, published on every response:\n * `X-RateLimit-Remaining` and `X-RateLimit-Reset-After` (seconds, fractional).\n * The correct behaviour is to *not send* once a bucket is exhausted rather\n * than to send and absorb the 429, so this adapter tracks the bucket per route\n * and waits before the request. A 429 that still slips through (a global limit,\n * or another process sharing the token) is honoured through the `retry_after`\n * field in the JSON body, which unlike Telegram's is in **seconds as a float**.\n * `X-RateLimit-Global` marks a token-wide limit, which is applied to every\n * route rather than only the one that hit it.\n */\n\ninterface Bucket {\n remaining: number\n resetAtMs: number\n}\n\n/** Shared rate-limit state for one Discord credential. */\nexport interface DiscordRateLimiter {\n /** Wait until `route` may be called. */\n acquire(route: string): Promise<void>\n /** Record what a response said about the bucket. */\n update(route: string, headers: { get(name: string): string | null }): void\n /** Apply a 429, globally when Discord says so. */\n penalise(route: string, retryAfterMs: number, global: boolean): void\n}\n\n/** Build the shared rate-limit tracker. */\nexport function createDiscordRateLimiter(\n now: () => number = Date.now,\n sleep: (ms: number) => Promise<void> = realSleep,\n): DiscordRateLimiter {\n const buckets = new Map<string, Bucket>()\n let globalResetAtMs = 0\n\n return {\n async acquire(route: string): Promise<void> {\n const globalWait = globalResetAtMs - now()\n if (globalWait > 0) await sleep(globalWait)\n const bucket = buckets.get(route)\n if (!bucket) return\n if (bucket.remaining > 0) return\n const wait = bucket.resetAtMs - now()\n if (wait > 0) await sleep(wait)\n },\n update(route: string, headers: { get(name: string): string | null }): void {\n const remaining = numericHeader(headers, 'x-ratelimit-remaining')\n const resetAfter = numericHeader(headers, 'x-ratelimit-reset-after')\n if (remaining === null && resetAfter === null) return\n buckets.set(route, {\n remaining: remaining ?? 1,\n resetAtMs: now() + (resetAfter ?? 0) * 1000,\n })\n },\n penalise(route: string, retryAfterMs: number, global: boolean): void {\n const resetAtMs = now() + retryAfterMs\n if (global) globalResetAtMs = Math.max(globalResetAtMs, resetAtMs)\n buckets.set(route, { remaining: 0, resetAtMs })\n },\n }\n}\n\n/** Options shared by both Discord adapters. */\nexport interface DiscordOptionsBase extends NotifierHttpOptions {\n /** Share one limiter across adapters that use the same credential. */\n limiter?: DiscordRateLimiter\n /** Clock injection point for tests. @defaultValue `Date.now` */\n now?: () => number\n /** Username override for webhook posts. */\n username?: string\n}\n\nconst PERMANENT_STATUSES = new Set([400, 401, 403, 404, 405])\n\ninterface SendArgs {\n url: string\n route: string\n payload: unknown\n headers: Record<string, string>\n}\n\nasync function deliver(\n args: SendArgs,\n options: DiscordOptionsBase,\n fetchImpl: FetchLike,\n limiter: DiscordRateLimiter,\n): Promise<DeliveryResult> {\n const maxAttempts = options.maxAttempts ?? 4\n const backoffMs = options.backoffMs ?? 500\n const maxWaitMs = options.maxWaitMs ?? 60_000\n const sleep = options.sleep ?? realSleep\n\n let attempt = 0\n let lastStatus: number | null = null\n let lastError = 'no attempt was made'\n let retryable = true\n\n while (attempt < maxAttempts) {\n attempt += 1\n await limiter.acquire(args.route)\n\n let response\n try {\n response = await postJson(fetchImpl, args.url, args.payload, args.headers)\n } catch (error) {\n lastStatus = null\n lastError = error instanceof Error ? error.message : String(error)\n retryable = true\n if (attempt >= maxAttempts) break\n await sleep(backoffMs * 2 ** (attempt - 1))\n continue\n }\n\n limiter.update(args.route, response.headers)\n lastStatus = response.status\n\n if (response.status === 429) {\n const retryAfterField = pick(response.body, 'retry_after')\n const retryAfterSeconds =\n typeof retryAfterField === 'number'\n ? retryAfterField\n : (numericHeader(response.headers, 'retry-after') ?? backoffMs / 1000)\n const isGlobal =\n pick(response.body, 'global') === true ||\n response.headers.get('x-ratelimit-global') === 'true'\n const waitMs = retryAfterSeconds * 1000\n limiter.penalise(args.route, waitMs, isGlobal)\n lastError = `rate limited, retry after ${retryAfterSeconds}s${isGlobal ? ' (global)' : ''}`\n retryable = true\n if (waitMs > maxWaitMs || attempt >= maxAttempts) break\n await sleep(waitMs)\n continue\n }\n\n if (response.ok) {\n // Webhooks answer 204 with an empty body unless `wait=true` was used.\n const messageId = pick(response.body, 'id')\n return delivered(response.status, attempt, messageId === undefined ? null : String(messageId))\n }\n\n const message =\n (typeof pick(response.body, 'message') === 'string'\n ? (pick(response.body, 'message') as string)\n : null) ?? response.raw.slice(0, 200)\n lastError = message || `Discord request failed with status ${response.status}`\n\n if (PERMANENT_STATUSES.has(response.status)) {\n retryable = false\n break\n }\n retryable = true\n if (attempt >= maxAttempts) break\n await sleep(backoffMs * 2 ** (attempt - 1))\n }\n\n return failed(lastStatus, attempt, lastError, retryable)\n}\n\n/** Options for {@link createDiscordWebhookNotifier}. */\nexport interface DiscordWebhookOptions extends DiscordOptionsBase {\n /**\n * Default webhook URL. Optional: `send()` accepts a webhook URL as its\n * target, which is how per-subscription webhooks work.\n */\n webhookUrl?: string\n}\n\nconst WEBHOOK_PATTERN = /^https:\\/\\/(?:\\w+\\.)?discord(?:app)?\\.com\\/api(?:\\/v\\d+)?\\/webhooks\\/\\d+\\/[\\w-]+$/\n\n/**\n * Webhook delivery. No bot account, no gateway, no permissions setup: the\n * subscriber pastes a channel webhook URL and alerts arrive. This is the\n * lowest-friction Discord path and the one most subscriptions use.\n *\n * @example\n * ```ts\n * const discord = createDiscordWebhookNotifier()\n * await discord.send('https://discord.com/api/webhooks/123/abc', renderAlert(event))\n * ```\n */\nexport function createDiscordWebhookNotifier(options: DiscordWebhookOptions = {}): Notifier {\n const fetchImpl = resolveFetch(options.fetch)\n const limiter =\n options.limiter ?? createDiscordRateLimiter(options.now ?? Date.now, options.sleep ?? realSleep)\n\n return {\n platform: 'discord',\n kind: 'webhook',\n async send(target: string, alert: RenderedAlert): Promise<DeliveryResult> {\n const url = target || options.webhookUrl\n if (!url || !WEBHOOK_PATTERN.test(url)) {\n return failed(\n null,\n 0,\n `invalid Discord webhook URL \"${target}\": expected https://discord.com/api/webhooks/<id>/<token>`,\n false,\n )\n }\n return deliver(\n {\n url: `${url}?wait=true`,\n // Discord buckets webhook routes per webhook id.\n route: `webhook:${url.split('/').slice(-2, -1)[0] ?? url}`,\n payload: {\n embeds: [alert.embed],\n ...(options.username ? { username: options.username } : {}),\n },\n headers: {},\n },\n options,\n fetchImpl,\n limiter,\n )\n },\n }\n}\n\n/** Options for {@link createDiscordBotNotifier}. */\nexport interface DiscordBotOptions extends DiscordOptionsBase {\n /** Bot token from the Discord developer portal (no `Bot ` prefix). */\n botToken: string\n /** API base. @defaultValue `https://discord.com/api/v10` */\n apiBase?: string\n}\n\n/**\n * Bot delivery: posts to a channel id with the application's bot token, which\n * is what slash-command subscriptions use (the bot is already in the guild, so\n * no webhook needs creating).\n *\n * @example\n * ```ts\n * const discord = createDiscordBotNotifier({ botToken: process.env.DISCORD_BOT_TOKEN! })\n * await discord.send('1234567890', renderAlert(event))\n * ```\n */\nexport function createDiscordBotNotifier(options: DiscordBotOptions): Notifier {\n if (!options.botToken.trim()) {\n throw new Error('hood-alerts: DISCORD_BOT_TOKEN is required for the Discord bot notifier')\n }\n const fetchImpl = resolveFetch(options.fetch)\n const apiBase = (options.apiBase ?? 'https://discord.com/api/v10').replace(/\\/+$/, '')\n const limiter =\n options.limiter ?? createDiscordRateLimiter(options.now ?? Date.now, options.sleep ?? realSleep)\n\n return {\n platform: 'discord',\n kind: 'bot',\n async send(target: string, alert: RenderedAlert): Promise<DeliveryResult> {\n if (!/^\\d{5,25}$/.test(target)) {\n return failed(null, 0, `invalid Discord channel id \"${target}\": expected a numeric snowflake`, false)\n }\n return deliver(\n {\n url: `${apiBase}/channels/${target}/messages`,\n route: `channel:${target}`,\n payload: { embeds: [alert.embed] },\n headers: { authorization: `Bot ${options.botToken}` },\n },\n options,\n fetchImpl,\n limiter,\n )\n },\n }\n}\n","import type { Platform } from '../rules/schema.js'\nimport { delivered, type DeliveryResult, type Notifier, type RenderedAlert } from './types.js'\n\n/** One captured delivery. */\nexport interface CapturedDelivery {\n platform: Platform\n target: string\n alert: RenderedAlert\n atMs: number\n}\n\n/** A notifier that records deliveries instead of sending them. */\nexport interface CaptureNotifier extends Notifier {\n /** Everything captured so far, in order. */\n readonly sent: readonly CapturedDelivery[]\n /** Drop the captured history. */\n clear(): void\n}\n\n/**\n * A delivery adapter that records instead of sending.\n *\n * This is a first-class operating mode, not a stub: running the service with\n * `DRY_RUN=1` swaps the real adapters for this one, so an operator can point a\n * fresh deployment at mainnet and watch exactly which alerts *would* have gone\n * out, with the real rendered text, before handing it bot tokens. It is also\n * what the notifier tests assert against.\n *\n * @example\n * ```ts\n * const capture = createCaptureNotifier('telegram')\n * await capture.send('-1001234567890', renderAlert(event))\n * console.log(capture.sent[0]?.alert.text)\n * ```\n */\nexport function createCaptureNotifier(\n platform: Platform,\n onSend?: (delivery: CapturedDelivery) => void,\n now: () => number = Date.now,\n): CaptureNotifier {\n const sent: CapturedDelivery[] = []\n return {\n platform,\n kind: 'capture',\n sent,\n clear(): void {\n sent.length = 0\n },\n async send(target: string, alert: RenderedAlert): Promise<DeliveryResult> {\n const delivery: CapturedDelivery = { platform, target, alert, atMs: now() }\n sent.push(delivery)\n onSend?.(delivery)\n return delivered(200, 1, `capture-${sent.length}`)\n },\n }\n}\n"],"mappings":"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","names":["sleep"]}
@@ -0,0 +1,170 @@
1
+ import {
2
+ LAUNCHPAD_EVENT_KINDS
3
+ } from "./chunk-3LHCQA4Z.js";
4
+
5
+ // src/rules/schema.ts
6
+ import { z } from "zod";
7
+ var addressPattern = /^0x[0-9a-fA-F]{40}$/;
8
+ var addressSchema = z.string().regex(addressPattern, "must be a 0x-prefixed 20-byte address").transform((value) => value.toLowerCase());
9
+ var eventKindSchema = z.enum(["launch", "curve_trade", "graduation", "whale_trade"]);
10
+ var launchpadSchema = z.enum(["noxa", "odyssey"]);
11
+ var sideSchema = z.enum(["buy", "sell", "any"]);
12
+ var reputationFilterSchema = z.object({
13
+ /** Require the deployer to have launched at least this many tokens before. */
14
+ minPriorLaunches: z.number().int().min(0).max(1e4).optional(),
15
+ /** Reject serial deployers above this prior-launch count. */
16
+ maxPriorLaunches: z.number().int().min(0).max(1e4).optional(),
17
+ /**
18
+ * Reject a deployer with more than this many prior launches whose pool
19
+ * liquidity has since been drained below the rug threshold.
20
+ */
21
+ maxRuggedLaunches: z.number().int().min(0).max(1e4).optional(),
22
+ /**
23
+ * Require this launch's own LP position to be held by the launchpad's
24
+ * locker contract. Only meaningful for `launch` events that carry a pool
25
+ * (NOXA); events without a pool never satisfy it.
26
+ */
27
+ requireLpLocked: z.boolean().optional()
28
+ }).strict().refine(
29
+ (value) => value.minPriorLaunches === void 0 || value.maxPriorLaunches === void 0 || value.minPriorLaunches <= value.maxPriorLaunches,
30
+ { message: "minPriorLaunches must not exceed maxPriorLaunches" }
31
+ );
32
+ var rateLimitSchema = z.object({
33
+ /** Hard cap on alerts this rule may deliver per rolling hour. */
34
+ maxPerHour: z.number().int().min(1).max(1e4).optional(),
35
+ /** Minimum gap between two alerts from this rule, in seconds. */
36
+ minIntervalSeconds: z.number().int().min(1).max(86400).optional()
37
+ }).strict();
38
+ var ruleSchema = z.object({
39
+ /** Stable id, unique within a subscription. Used in bot commands. */
40
+ id: z.string().min(1).max(32).regex(/^[a-z0-9][a-z0-9_-]*$/, "must be lowercase letters, digits, hyphen or underscore"),
41
+ /** Human label shown in `/rules`. */
42
+ name: z.string().min(1).max(64).optional(),
43
+ enabled: z.boolean().default(true),
44
+ /** Event kinds this rule reacts to. Defaults to all four. */
45
+ kinds: z.array(eventKindSchema).min(1).max(4).default(["launch", "curve_trade", "graduation", "whale_trade"]),
46
+ /** Launchpads this rule reacts to. Defaults to both. */
47
+ launchpads: z.array(launchpadSchema).min(1).max(2).default(["noxa", "odyssey"]),
48
+ /** Minimum USD value of the event. Events with an unknown value never pass. */
49
+ minUsd: z.number().min(0).max(1e12).optional(),
50
+ /** Maximum USD value of the event. */
51
+ maxUsd: z.number().min(0).max(1e12).optional(),
52
+ /** Minimum USD liquidity of the token's deepest known pool. */
53
+ minLiquidityUsd: z.number().min(0).max(1e12).optional(),
54
+ /** Maximum USD liquidity of the token's deepest known pool. */
55
+ maxLiquidityUsd: z.number().min(0).max(1e12).optional(),
56
+ /** Watchlist. When non-empty, only these tokens match. */
57
+ tokens: z.array(addressSchema).max(500).default([]),
58
+ /** Deployer watchlist. When non-empty, only these actors match. */
59
+ deployers: z.array(addressSchema).max(500).default([]),
60
+ /** Deployers to always reject, applied after every other filter. */
61
+ excludeDeployers: z.array(addressSchema).max(500).default([]),
62
+ /** Trade direction. Ignored by `launch` and `graduation` events. */
63
+ side: sideSchema.default("any"),
64
+ reputation: reputationFilterSchema.optional(),
65
+ rateLimit: rateLimitSchema.optional()
66
+ }).strict().refine((rule) => rule.minUsd === void 0 || rule.maxUsd === void 0 || rule.minUsd <= rule.maxUsd, {
67
+ message: "minUsd must not exceed maxUsd",
68
+ path: ["minUsd"]
69
+ }).refine(
70
+ (rule) => rule.minLiquidityUsd === void 0 || rule.maxLiquidityUsd === void 0 || rule.minLiquidityUsd <= rule.maxLiquidityUsd,
71
+ { message: "minLiquidityUsd must not exceed maxLiquidityUsd", path: ["minLiquidityUsd"] }
72
+ ).refine((rule) => rule.kinds.some((kind) => rule.launchpads.some((pad) => canEmit(pad, kind))), {
73
+ message: "this combination can never fire: NOXA is an instant launcher with no bonding curve, so it emits no curve_trade and no graduation",
74
+ path: ["kinds"]
75
+ });
76
+ var platformSchema = z.enum(["telegram", "discord"]);
77
+ var subscriptionSchema = z.object({
78
+ id: z.string().min(1).max(64),
79
+ /**
80
+ * The account that owns this subscription, and the unit entitlements are
81
+ * checked against. For Telegram this is the user id, for Discord the guild
82
+ * id (a server pays once for its channels) or user id for a DM.
83
+ */
84
+ subscriberId: z.string().min(1).max(128),
85
+ platform: platformSchema,
86
+ /**
87
+ * Where alerts go: a Telegram chat id, a Discord channel id, or a Discord
88
+ * webhook URL. Validated per platform by the notifier, not here, because
89
+ * the same subscription record serves all three shapes.
90
+ */
91
+ target: z.string().min(1).max(512),
92
+ enabled: z.boolean().default(true),
93
+ rules: z.array(ruleSchema).max(100).default([]),
94
+ createdAtMs: z.number().int().nonnegative()
95
+ }).strict().refine((sub) => new Set(sub.rules.map((rule) => rule.id)).size === sub.rules.length, {
96
+ message: "rule ids must be unique within a subscription",
97
+ path: ["rules"]
98
+ });
99
+ function canEmit(launchpad, kind) {
100
+ return LAUNCHPAD_EVENT_KINDS[launchpad].includes(kind);
101
+ }
102
+ function ruleComplexity(rule) {
103
+ let score = 0;
104
+ if (rule.minUsd !== void 0) score += 1;
105
+ if (rule.maxUsd !== void 0) score += 1;
106
+ if (rule.minLiquidityUsd !== void 0) score += 1;
107
+ if (rule.maxLiquidityUsd !== void 0) score += 1;
108
+ if (rule.tokens.length > 0) score += 1;
109
+ if (rule.deployers.length > 0) score += 1;
110
+ if (rule.excludeDeployers.length > 0) score += 1;
111
+ if (rule.side !== "any") score += 1;
112
+ if (rule.reputation) score += Object.values(rule.reputation).filter((v) => v !== void 0).length;
113
+ if (rule.rateLimit) score += 1;
114
+ return score;
115
+ }
116
+ function usesOnChainLookups(rule) {
117
+ return rule.minLiquidityUsd !== void 0 || rule.maxLiquidityUsd !== void 0 || rule.reputation !== void 0 && Object.values(rule.reputation).some((value) => value !== void 0);
118
+ }
119
+ function parseRule(input) {
120
+ return ruleSchema.parse(input);
121
+ }
122
+ function safeParseRule(input) {
123
+ return ruleSchema.safeParse(input);
124
+ }
125
+ function parseSubscription(input) {
126
+ return subscriptionSchema.parse(input);
127
+ }
128
+ function safeParseSubscription(input) {
129
+ return subscriptionSchema.safeParse(input);
130
+ }
131
+ function formatValidationError(error) {
132
+ return error.issues.map((issue) => {
133
+ const path = issue.path.join(".");
134
+ return path ? `${path}: ${issue.message}` : issue.message;
135
+ }).join("; ");
136
+ }
137
+ function defaultRules() {
138
+ return [
139
+ parseRule({ id: "launches", name: "New launches", kinds: ["launch"] }),
140
+ parseRule({
141
+ id: "graduations",
142
+ name: "Odyssey graduations",
143
+ kinds: ["graduation"],
144
+ launchpads: ["odyssey"]
145
+ }),
146
+ parseRule({ id: "whales", name: "Whale trades over $5k", kinds: ["whale_trade"], minUsd: 5e3 })
147
+ ];
148
+ }
149
+
150
+ export {
151
+ addressSchema,
152
+ eventKindSchema,
153
+ launchpadSchema,
154
+ sideSchema,
155
+ reputationFilterSchema,
156
+ rateLimitSchema,
157
+ ruleSchema,
158
+ platformSchema,
159
+ subscriptionSchema,
160
+ canEmit,
161
+ ruleComplexity,
162
+ usesOnChainLookups,
163
+ parseRule,
164
+ safeParseRule,
165
+ parseSubscription,
166
+ safeParseSubscription,
167
+ formatValidationError,
168
+ defaultRules
169
+ };
170
+ //# sourceMappingURL=chunk-VC5MKEY2.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/rules/schema.ts"],"sourcesContent":["import { z } from 'zod'\nimport { LAUNCHPAD_EVENT_KINDS, type AlertEventKind, type Launchpad } from '../events/types.js'\n\n/**\n * Rules are data, not code.\n *\n * Every filter a subscriber can set is expressed as a JSON document validated\n * by these schemas, which means a rule can be stored in SQLite, edited from a\n * Telegram or Discord command, diffed, exported and re-imported without any\n * of it going through `eval`, a DSL parser or a hand-rolled validator. The\n * schema is the single source of truth for what a valid rule is: the bot, the\n * service and the tier policy all validate through it.\n */\n\nconst addressPattern = /^0x[0-9a-fA-F]{40}$/\n\n/** An EVM address, stored lowercase so comparisons never depend on checksum casing. */\nexport const addressSchema = z\n .string()\n .regex(addressPattern, 'must be a 0x-prefixed 20-byte address')\n .transform((value) => value.toLowerCase())\n\n/** The four alert kinds. */\nexport const eventKindSchema = z.enum(['launch', 'curve_trade', 'graduation', 'whale_trade'])\n\n/** The two launchpads. */\nexport const launchpadSchema = z.enum(['noxa', 'odyssey'])\n\n/** Trade direction filter. `any` disables the filter. */\nexport const sideSchema = z.enum(['buy', 'sell', 'any'])\n\n/**\n * Deployer reputation filters, all derived from on-chain history (see\n * `hood-alerts/rules` `createRpcReputationProvider`). Every bound is inclusive.\n */\nexport const reputationFilterSchema = z\n .object({\n /** Require the deployer to have launched at least this many tokens before. */\n minPriorLaunches: z.number().int().min(0).max(10_000).optional(),\n /** Reject serial deployers above this prior-launch count. */\n maxPriorLaunches: z.number().int().min(0).max(10_000).optional(),\n /**\n * Reject a deployer with more than this many prior launches whose pool\n * liquidity has since been drained below the rug threshold.\n */\n maxRuggedLaunches: z.number().int().min(0).max(10_000).optional(),\n /**\n * Require this launch's own LP position to be held by the launchpad's\n * locker contract. Only meaningful for `launch` events that carry a pool\n * (NOXA); events without a pool never satisfy it.\n */\n requireLpLocked: z.boolean().optional(),\n })\n .strict()\n .refine(\n (value) =>\n value.minPriorLaunches === undefined ||\n value.maxPriorLaunches === undefined ||\n value.minPriorLaunches <= value.maxPriorLaunches,\n { message: 'minPriorLaunches must not exceed maxPriorLaunches' },\n )\n\n/** Per-rule delivery rate limits. */\nexport const rateLimitSchema = z\n .object({\n /** Hard cap on alerts this rule may deliver per rolling hour. */\n maxPerHour: z.number().int().min(1).max(10_000).optional(),\n /** Minimum gap between two alerts from this rule, in seconds. */\n minIntervalSeconds: z.number().int().min(1).max(86_400).optional(),\n })\n .strict()\n\n/** A single alert rule. */\nexport const ruleSchema = z\n .object({\n /** Stable id, unique within a subscription. Used in bot commands. */\n id: z\n .string()\n .min(1)\n .max(32)\n .regex(/^[a-z0-9][a-z0-9_-]*$/, 'must be lowercase letters, digits, hyphen or underscore'),\n /** Human label shown in `/rules`. */\n name: z.string().min(1).max(64).optional(),\n enabled: z.boolean().default(true),\n /** Event kinds this rule reacts to. Defaults to all four. */\n kinds: z.array(eventKindSchema).min(1).max(4).default(['launch', 'curve_trade', 'graduation', 'whale_trade']),\n /** Launchpads this rule reacts to. Defaults to both. */\n launchpads: z.array(launchpadSchema).min(1).max(2).default(['noxa', 'odyssey']),\n /** Minimum USD value of the event. Events with an unknown value never pass. */\n minUsd: z.number().min(0).max(1e12).optional(),\n /** Maximum USD value of the event. */\n maxUsd: z.number().min(0).max(1e12).optional(),\n /** Minimum USD liquidity of the token's deepest known pool. */\n minLiquidityUsd: z.number().min(0).max(1e12).optional(),\n /** Maximum USD liquidity of the token's deepest known pool. */\n maxLiquidityUsd: z.number().min(0).max(1e12).optional(),\n /** Watchlist. When non-empty, only these tokens match. */\n tokens: z.array(addressSchema).max(500).default([]),\n /** Deployer watchlist. When non-empty, only these actors match. */\n deployers: z.array(addressSchema).max(500).default([]),\n /** Deployers to always reject, applied after every other filter. */\n excludeDeployers: z.array(addressSchema).max(500).default([]),\n /** Trade direction. Ignored by `launch` and `graduation` events. */\n side: sideSchema.default('any'),\n reputation: reputationFilterSchema.optional(),\n rateLimit: rateLimitSchema.optional(),\n })\n .strict()\n .refine((rule) => rule.minUsd === undefined || rule.maxUsd === undefined || rule.minUsd <= rule.maxUsd, {\n message: 'minUsd must not exceed maxUsd',\n path: ['minUsd'],\n })\n .refine(\n (rule) =>\n rule.minLiquidityUsd === undefined ||\n rule.maxLiquidityUsd === undefined ||\n rule.minLiquidityUsd <= rule.maxLiquidityUsd,\n { message: 'minLiquidityUsd must not exceed maxLiquidityUsd', path: ['minLiquidityUsd'] },\n )\n .refine((rule) => rule.kinds.some((kind) => rule.launchpads.some((pad) => canEmit(pad, kind))), {\n message:\n 'this combination can never fire: NOXA is an instant launcher with no bonding curve, so it emits no curve_trade and no graduation',\n path: ['kinds'],\n })\n\n/** A validated rule. */\nexport type Rule = z.output<typeof ruleSchema>\n/** A rule as accepted on input, before defaults are applied. */\nexport type RuleInput = z.input<typeof ruleSchema>\n/** Validated reputation filter. */\nexport type ReputationFilter = z.output<typeof reputationFilterSchema>\n/** Validated rate limit block. */\nexport type RateLimitConfig = z.output<typeof rateLimitSchema>\n\n/** Delivery platforms a subscription can target. */\nexport const platformSchema = z.enum(['telegram', 'discord'])\n/** A delivery platform. */\nexport type Platform = z.output<typeof platformSchema>\n\n/** A subscription: one delivery target plus the rules that feed it. */\nexport const subscriptionSchema = z\n .object({\n id: z.string().min(1).max(64),\n /**\n * The account that owns this subscription, and the unit entitlements are\n * checked against. For Telegram this is the user id, for Discord the guild\n * id (a server pays once for its channels) or user id for a DM.\n */\n subscriberId: z.string().min(1).max(128),\n platform: platformSchema,\n /**\n * Where alerts go: a Telegram chat id, a Discord channel id, or a Discord\n * webhook URL. Validated per platform by the notifier, not here, because\n * the same subscription record serves all three shapes.\n */\n target: z.string().min(1).max(512),\n enabled: z.boolean().default(true),\n rules: z.array(ruleSchema).max(100).default([]),\n createdAtMs: z.number().int().nonnegative(),\n })\n .strict()\n .refine((sub) => new Set(sub.rules.map((rule) => rule.id)).size === sub.rules.length, {\n message: 'rule ids must be unique within a subscription',\n path: ['rules'],\n })\n\n/** A validated subscription. */\nexport type Subscription = z.output<typeof subscriptionSchema>\n/** A subscription as accepted on input. */\nexport type SubscriptionInput = z.input<typeof subscriptionSchema>\n\n/** Can a launchpad emit this event kind at all? */\nexport function canEmit(launchpad: Launchpad, kind: AlertEventKind): boolean {\n return LAUNCHPAD_EVENT_KINDS[launchpad].includes(kind)\n}\n\n/**\n * How many filters a rule actually uses. The tier policy caps this so free\n * subscribers get real filtering without turning the service into an unpaid\n * on-chain query engine.\n */\nexport function ruleComplexity(rule: Rule): number {\n let score = 0\n if (rule.minUsd !== undefined) score += 1\n if (rule.maxUsd !== undefined) score += 1\n if (rule.minLiquidityUsd !== undefined) score += 1\n if (rule.maxLiquidityUsd !== undefined) score += 1\n if (rule.tokens.length > 0) score += 1\n if (rule.deployers.length > 0) score += 1\n if (rule.excludeDeployers.length > 0) score += 1\n if (rule.side !== 'any') score += 1\n if (rule.reputation) score += Object.values(rule.reputation).filter((v) => v !== undefined).length\n if (rule.rateLimit) score += 1\n return score\n}\n\n/** Filters that cost an extra on-chain read per candidate event. */\nexport function usesOnChainLookups(rule: Rule): boolean {\n return (\n rule.minLiquidityUsd !== undefined ||\n rule.maxLiquidityUsd !== undefined ||\n (rule.reputation !== undefined &&\n Object.values(rule.reputation).some((value) => value !== undefined))\n )\n}\n\n/**\n * Parse and validate a rule, applying defaults.\n *\n * @throws {@link z.ZodError} with a field-level message the bot renders back\n * to the user verbatim.\n *\n * @example\n * ```ts\n * const rule = parseRule({ id: 'whales', kinds: ['whale_trade'], minUsd: 5000 })\n * console.log(rule.launchpads) // ['noxa', 'odyssey'] (defaulted)\n * ```\n */\nexport function parseRule(input: unknown): Rule {\n return ruleSchema.parse(input)\n}\n\n/** Non-throwing {@link parseRule}. */\nexport function safeParseRule(input: unknown): z.ZodSafeParseResult<Rule> {\n return ruleSchema.safeParse(input)\n}\n\n/** Parse and validate a subscription. */\nexport function parseSubscription(input: unknown): Subscription {\n return subscriptionSchema.parse(input)\n}\n\n/** Non-throwing {@link parseSubscription}. */\nexport function safeParseSubscription(input: unknown): z.ZodSafeParseResult<Subscription> {\n return subscriptionSchema.safeParse(input)\n}\n\n/**\n * Render a Zod error as one line of human-readable text, for bot replies.\n * A user who typed `/threshold abc` gets \"minUsd: expected number\", not a\n * stack trace and not a silent failure.\n */\nexport function formatValidationError(error: z.ZodError): string {\n return error.issues\n .map((issue) => {\n const path = issue.path.join('.')\n return path ? `${path}: ${issue.message}` : issue.message\n })\n .join('; ')\n}\n\n/** A ready-to-use starter rule set, used by `/subscribe` before any editing. */\nexport function defaultRules(): Rule[] {\n return [\n parseRule({ id: 'launches', name: 'New launches', kinds: ['launch'] }),\n parseRule({\n id: 'graduations',\n name: 'Odyssey graduations',\n kinds: ['graduation'],\n launchpads: ['odyssey'],\n }),\n parseRule({ id: 'whales', name: 'Whale trades over $5k', kinds: ['whale_trade'], minUsd: 5000 }),\n ]\n}\n"],"mappings":"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","names":[]}