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.
- package/LICENSE +20 -0
- package/README.md +369 -0
- package/dist/bot/index.cjs +1008 -0
- package/dist/bot/index.cjs.map +1 -0
- package/dist/bot/index.d.cts +241 -0
- package/dist/bot/index.d.ts +241 -0
- package/dist/bot/index.js +30 -0
- package/dist/bot/index.js.map +1 -0
- package/dist/chunk-32GS6XVE.js +732 -0
- package/dist/chunk-32GS6XVE.js.map +1 -0
- package/dist/chunk-3LHCQA4Z.js +76 -0
- package/dist/chunk-3LHCQA4Z.js.map +1 -0
- package/dist/chunk-3NHLSKZE.js +1 -0
- package/dist/chunk-3NHLSKZE.js.map +1 -0
- package/dist/chunk-6JQK5G3Z.js +72 -0
- package/dist/chunk-6JQK5G3Z.js.map +1 -0
- package/dist/chunk-CBNZ6AZT.js +598 -0
- package/dist/chunk-CBNZ6AZT.js.map +1 -0
- package/dist/chunk-CIURPPOW.js +1 -0
- package/dist/chunk-CIURPPOW.js.map +1 -0
- package/dist/chunk-IUOEGBF6.js +49 -0
- package/dist/chunk-IUOEGBF6.js.map +1 -0
- package/dist/chunk-NBMINJ5E.js +230 -0
- package/dist/chunk-NBMINJ5E.js.map +1 -0
- package/dist/chunk-O2RY2SWN.js +1 -0
- package/dist/chunk-O2RY2SWN.js.map +1 -0
- package/dist/chunk-PLMVKTQM.js +1 -0
- package/dist/chunk-PLMVKTQM.js.map +1 -0
- package/dist/chunk-RSTLW7XH.js +130 -0
- package/dist/chunk-RSTLW7XH.js.map +1 -0
- package/dist/chunk-SOBZ2FPP.js +355 -0
- package/dist/chunk-SOBZ2FPP.js.map +1 -0
- package/dist/chunk-VC5MKEY2.js +170 -0
- package/dist/chunk-VC5MKEY2.js.map +1 -0
- package/dist/chunk-W2GUAWUR.js +147 -0
- package/dist/chunk-W2GUAWUR.js.map +1 -0
- package/dist/chunk-Y2M3USWZ.js +355 -0
- package/dist/chunk-Y2M3USWZ.js.map +1 -0
- package/dist/chunk-Z4DJPESQ.js +1216 -0
- package/dist/chunk-Z4DJPESQ.js.map +1 -0
- package/dist/chunk-ZDM5VNWB.js +1 -0
- package/dist/chunk-ZDM5VNWB.js.map +1 -0
- package/dist/events/index.cjs +820 -0
- package/dist/events/index.cjs.map +1 -0
- package/dist/events/index.d.cts +58 -0
- package/dist/events/index.d.ts +58 -0
- package/dist/events/index.js +69 -0
- package/dist/events/index.js.map +1 -0
- package/dist/format-6RG4OcyE.d.ts +57 -0
- package/dist/format-B7d40f65.d.cts +57 -0
- package/dist/index.cjs +4122 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +21 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.js +274 -0
- package/dist/index.js.map +1 -0
- package/dist/logger-3phvV_fl.d.cts +185 -0
- package/dist/logger-CYza_0eU.d.ts +185 -0
- package/dist/logs-D7sYBvSy.d.cts +66 -0
- package/dist/logs-D7sYBvSy.d.ts +66 -0
- package/dist/notifiers/index.cjs +620 -0
- package/dist/notifiers/index.cjs.map +1 -0
- package/dist/notifiers/index.d.cts +186 -0
- package/dist/notifiers/index.d.ts +186 -0
- package/dist/notifiers/index.js +70 -0
- package/dist/notifiers/index.js.map +1 -0
- package/dist/pricing-BumgLpvB.d.cts +79 -0
- package/dist/pricing-BumgLpvB.d.ts +79 -0
- package/dist/ratelimit-BKVQG2oY.d.cts +42 -0
- package/dist/ratelimit-CGLlXNIS.d.ts +42 -0
- package/dist/registry-D3We8Lg6.d.ts +50 -0
- package/dist/registry-DePA3IyS.d.cts +50 -0
- package/dist/rules/index.cjs +614 -0
- package/dist/rules/index.cjs.map +1 -0
- package/dist/rules/index.d.cts +166 -0
- package/dist/rules/index.d.ts +166 -0
- package/dist/rules/index.js +60 -0
- package/dist/rules/index.js.map +1 -0
- package/dist/schema-DuDMPqpq.d.ts +183 -0
- package/dist/schema-NnIdKFl2.d.cts +183 -0
- package/dist/service/index.cjs +3774 -0
- package/dist/service/index.cjs.map +1 -0
- package/dist/service/index.d.cts +262 -0
- package/dist/service/index.d.ts +262 -0
- package/dist/service/index.js +45 -0
- package/dist/service/index.js.map +1 -0
- package/dist/service/main.cjs +3759 -0
- package/dist/service/main.cjs.map +1 -0
- package/dist/service/main.d.cts +1 -0
- package/dist/service/main.d.ts +1 -0
- package/dist/service/main.js +60 -0
- package/dist/service/main.js.map +1 -0
- package/dist/sources-CAaQR6-N.d.ts +159 -0
- package/dist/sources-CpDOUlzx.d.cts +159 -0
- package/dist/telegram-CpodYJrP.d.cts +64 -0
- package/dist/telegram-pvphmBwn.d.ts +64 -0
- package/dist/tiers/index.cjs +471 -0
- package/dist/tiers/index.cjs.map +1 -0
- package/dist/tiers/index.d.cts +231 -0
- package/dist/tiers/index.d.ts +231 -0
- package/dist/tiers/index.js +34 -0
- package/dist/tiers/index.js.map +1 -0
- package/dist/types-C0ETog04.d.ts +109 -0
- package/dist/types-CcoKpStA.d.cts +109 -0
- package/dist/types-DFPpTMWo.d.cts +186 -0
- package/dist/types-DFPpTMWo.d.ts +186 -0
- package/package.json +114 -0
|
@@ -0,0 +1,3774 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/service/index.ts
|
|
21
|
+
var service_exports = {};
|
|
22
|
+
__export(service_exports, {
|
|
23
|
+
AlertStore: () => AlertStore,
|
|
24
|
+
LEVELS: () => LEVELS,
|
|
25
|
+
VERSION: () => VERSION,
|
|
26
|
+
createDispatcher: () => createDispatcher,
|
|
27
|
+
createHttpServer: () => createHttpServer,
|
|
28
|
+
createLogger: () => createLogger,
|
|
29
|
+
createService: () => createService,
|
|
30
|
+
createSilentLogger: () => createSilentLogger,
|
|
31
|
+
decode: () => decode,
|
|
32
|
+
decodeEvent: () => decodeEvent,
|
|
33
|
+
encode: () => encode,
|
|
34
|
+
encodeEvent: () => encodeEvent,
|
|
35
|
+
loadConfig: () => loadConfig,
|
|
36
|
+
renderMetrics: () => renderMetrics
|
|
37
|
+
});
|
|
38
|
+
module.exports = __toCommonJS(service_exports);
|
|
39
|
+
|
|
40
|
+
// src/service/config.ts
|
|
41
|
+
var import_zod = require("zod");
|
|
42
|
+
var bool = (defaultValue) => import_zod.z.string().optional().transform((value) => {
|
|
43
|
+
if (value === void 0 || value.trim() === "") return defaultValue;
|
|
44
|
+
return /^(1|true|yes|on)$/i.test(value.trim());
|
|
45
|
+
});
|
|
46
|
+
var int = (defaultValue, min, max) => import_zod.z.string().optional().transform((value) => value === void 0 || value.trim() === "" ? defaultValue : Number(value)).refine((value) => Number.isFinite(value) && value >= min && value <= max, {
|
|
47
|
+
message: `must be a number between ${min} and ${max}`
|
|
48
|
+
});
|
|
49
|
+
var bigintValue = (defaultValue) => import_zod.z.string().optional().transform((value) => value === void 0 || value.trim() === "" ? defaultValue : BigInt(value.trim()));
|
|
50
|
+
var csv = import_zod.z.string().optional().transform(
|
|
51
|
+
(value) => (value ?? "").split(",").map((entry) => entry.trim()).filter(Boolean)
|
|
52
|
+
);
|
|
53
|
+
var addressValue = import_zod.z.string().regex(/^0x[0-9a-fA-F]{40}$/, "must be a 0x-prefixed address").optional();
|
|
54
|
+
var envSchema = import_zod.z.object({
|
|
55
|
+
NETWORK: import_zod.z.enum(["mainnet", "testnet"]).optional().default("mainnet"),
|
|
56
|
+
ROBINHOOD_RPC_URL: import_zod.z.string().url().optional(),
|
|
57
|
+
DB_PATH: import_zod.z.string().optional().default("./data/hood-alerts.sqlite"),
|
|
58
|
+
PORT: int(8080, 1, 65535),
|
|
59
|
+
LOG_LEVEL: import_zod.z.enum(["debug", "info", "warn", "error"]).optional().default("info"),
|
|
60
|
+
DRY_RUN: bool(false),
|
|
61
|
+
TELEGRAM_BOT_TOKEN: import_zod.z.string().optional(),
|
|
62
|
+
DISCORD_BOT_TOKEN: import_zod.z.string().optional(),
|
|
63
|
+
DISCORD_APPLICATION_ID: import_zod.z.string().optional(),
|
|
64
|
+
DISCORD_PUBLIC_KEY: import_zod.z.string().optional(),
|
|
65
|
+
DISCORD_INTERACTIONS_PATH: import_zod.z.string().optional().default("/discord/interactions"),
|
|
66
|
+
POLL_INTERVAL_MS: int(3e3, 250, 6e5),
|
|
67
|
+
FLUSH_INTERVAL_MS: int(1e3, 100, 6e5),
|
|
68
|
+
CONFIRMATIONS: bigintValue(2n),
|
|
69
|
+
CHUNK_SIZE: bigintValue(2000n),
|
|
70
|
+
INITIAL_LOOKBACK_BLOCKS: bigintValue(5000n),
|
|
71
|
+
WHALE_MIN_USD: int(1e3, 0, 1e9),
|
|
72
|
+
WHALE_POOL_LIMIT: int(400, 1, 5e3),
|
|
73
|
+
FLUSH_BATCH_SIZE: int(50, 1, 1e3),
|
|
74
|
+
MAX_DELIVERY_ATTEMPTS: int(6, 1, 50),
|
|
75
|
+
RETRY_BACKOFF_MS: int(3e4, 100, 36e5),
|
|
76
|
+
OUTBOX_RETENTION_HOURS: int(72, 1, 8760),
|
|
77
|
+
ENTITLEMENTS: import_zod.z.enum(["static", "usdg", "both"]).optional().default("static"),
|
|
78
|
+
PREMIUM_SUBSCRIBERS: csv,
|
|
79
|
+
ALL_PREMIUM: bool(false),
|
|
80
|
+
USDG_RECEIVER: addressValue,
|
|
81
|
+
PREMIUM_PRICE_USDG: int(25, 1, 1e6),
|
|
82
|
+
PREMIUM_PERIOD_DAYS: int(30, 1, 3650),
|
|
83
|
+
PAYMENTS_FROM_BLOCK: bigintValue(0n),
|
|
84
|
+
UPGRADE_INSTRUCTIONS: import_zod.z.string().optional()
|
|
85
|
+
});
|
|
86
|
+
function loadConfig(env = process.env) {
|
|
87
|
+
const parsed = envSchema.safeParse(env);
|
|
88
|
+
if (!parsed.success) {
|
|
89
|
+
const detail = parsed.error.issues.map((issue) => `${issue.path.join(".") || "env"}: ${issue.message}`).join("\n ");
|
|
90
|
+
throw new Error(`hood-alerts: invalid configuration
|
|
91
|
+
${detail}`);
|
|
92
|
+
}
|
|
93
|
+
const value = parsed.data;
|
|
94
|
+
if (value.ENTITLEMENTS !== "static" && !value.USDG_RECEIVER) {
|
|
95
|
+
throw new Error(
|
|
96
|
+
"hood-alerts: ENTITLEMENTS=usdg (or both) needs USDG_RECEIVER, the address subscribers pay."
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
if (value.DISCORD_APPLICATION_ID && !value.DISCORD_PUBLIC_KEY) {
|
|
100
|
+
throw new Error(
|
|
101
|
+
"hood-alerts: DISCORD_APPLICATION_ID is set but DISCORD_PUBLIC_KEY is not. Interactions cannot be verified without it."
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
return {
|
|
105
|
+
network: value.NETWORK,
|
|
106
|
+
rpcUrl: value.ROBINHOOD_RPC_URL,
|
|
107
|
+
dbPath: value.DB_PATH,
|
|
108
|
+
port: value.PORT,
|
|
109
|
+
logLevel: value.LOG_LEVEL,
|
|
110
|
+
dryRun: value.DRY_RUN,
|
|
111
|
+
telegramBotToken: value.TELEGRAM_BOT_TOKEN,
|
|
112
|
+
discordBotToken: value.DISCORD_BOT_TOKEN,
|
|
113
|
+
discordApplicationId: value.DISCORD_APPLICATION_ID,
|
|
114
|
+
discordPublicKey: value.DISCORD_PUBLIC_KEY,
|
|
115
|
+
discordInteractionsPath: value.DISCORD_INTERACTIONS_PATH,
|
|
116
|
+
pollIntervalMs: value.POLL_INTERVAL_MS,
|
|
117
|
+
flushIntervalMs: value.FLUSH_INTERVAL_MS,
|
|
118
|
+
confirmations: value.CONFIRMATIONS,
|
|
119
|
+
chunkSize: value.CHUNK_SIZE,
|
|
120
|
+
initialLookbackBlocks: value.INITIAL_LOOKBACK_BLOCKS,
|
|
121
|
+
whaleMinUsd: value.WHALE_MIN_USD,
|
|
122
|
+
whalePoolLimit: value.WHALE_POOL_LIMIT,
|
|
123
|
+
flushBatchSize: value.FLUSH_BATCH_SIZE,
|
|
124
|
+
maxDeliveryAttempts: value.MAX_DELIVERY_ATTEMPTS,
|
|
125
|
+
retryBackoffMs: value.RETRY_BACKOFF_MS,
|
|
126
|
+
outboxRetentionHours: value.OUTBOX_RETENTION_HOURS,
|
|
127
|
+
entitlements: value.ENTITLEMENTS,
|
|
128
|
+
premiumSubscribers: value.PREMIUM_SUBSCRIBERS,
|
|
129
|
+
allPremium: value.ALL_PREMIUM,
|
|
130
|
+
usdgReceiver: value.USDG_RECEIVER,
|
|
131
|
+
premiumPriceUsdg: value.PREMIUM_PRICE_USDG,
|
|
132
|
+
premiumPeriodDays: value.PREMIUM_PERIOD_DAYS,
|
|
133
|
+
paymentsFromBlock: value.PAYMENTS_FROM_BLOCK,
|
|
134
|
+
upgradeInstructions: value.UPGRADE_INSTRUCTIONS
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// src/service/store.ts
|
|
139
|
+
var import_node_module = require("module");
|
|
140
|
+
var import_node_fs = require("fs");
|
|
141
|
+
var import_node_path = require("path");
|
|
142
|
+
var import_viem = require("viem");
|
|
143
|
+
|
|
144
|
+
// src/rules/schema.ts
|
|
145
|
+
var import_zod2 = require("zod");
|
|
146
|
+
|
|
147
|
+
// src/events/types.ts
|
|
148
|
+
var LAUNCHPAD_EVENT_KINDS = {
|
|
149
|
+
noxa: ["launch", "whale_trade"],
|
|
150
|
+
odyssey: ["launch", "curve_trade", "graduation", "whale_trade"]
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
// src/rules/schema.ts
|
|
154
|
+
var addressPattern = /^0x[0-9a-fA-F]{40}$/;
|
|
155
|
+
var addressSchema = import_zod2.z.string().regex(addressPattern, "must be a 0x-prefixed 20-byte address").transform((value) => value.toLowerCase());
|
|
156
|
+
var eventKindSchema = import_zod2.z.enum(["launch", "curve_trade", "graduation", "whale_trade"]);
|
|
157
|
+
var launchpadSchema = import_zod2.z.enum(["noxa", "odyssey"]);
|
|
158
|
+
var sideSchema = import_zod2.z.enum(["buy", "sell", "any"]);
|
|
159
|
+
var reputationFilterSchema = import_zod2.z.object({
|
|
160
|
+
/** Require the deployer to have launched at least this many tokens before. */
|
|
161
|
+
minPriorLaunches: import_zod2.z.number().int().min(0).max(1e4).optional(),
|
|
162
|
+
/** Reject serial deployers above this prior-launch count. */
|
|
163
|
+
maxPriorLaunches: import_zod2.z.number().int().min(0).max(1e4).optional(),
|
|
164
|
+
/**
|
|
165
|
+
* Reject a deployer with more than this many prior launches whose pool
|
|
166
|
+
* liquidity has since been drained below the rug threshold.
|
|
167
|
+
*/
|
|
168
|
+
maxRuggedLaunches: import_zod2.z.number().int().min(0).max(1e4).optional(),
|
|
169
|
+
/**
|
|
170
|
+
* Require this launch's own LP position to be held by the launchpad's
|
|
171
|
+
* locker contract. Only meaningful for `launch` events that carry a pool
|
|
172
|
+
* (NOXA); events without a pool never satisfy it.
|
|
173
|
+
*/
|
|
174
|
+
requireLpLocked: import_zod2.z.boolean().optional()
|
|
175
|
+
}).strict().refine(
|
|
176
|
+
(value) => value.minPriorLaunches === void 0 || value.maxPriorLaunches === void 0 || value.minPriorLaunches <= value.maxPriorLaunches,
|
|
177
|
+
{ message: "minPriorLaunches must not exceed maxPriorLaunches" }
|
|
178
|
+
);
|
|
179
|
+
var rateLimitSchema = import_zod2.z.object({
|
|
180
|
+
/** Hard cap on alerts this rule may deliver per rolling hour. */
|
|
181
|
+
maxPerHour: import_zod2.z.number().int().min(1).max(1e4).optional(),
|
|
182
|
+
/** Minimum gap between two alerts from this rule, in seconds. */
|
|
183
|
+
minIntervalSeconds: import_zod2.z.number().int().min(1).max(86400).optional()
|
|
184
|
+
}).strict();
|
|
185
|
+
var ruleSchema = import_zod2.z.object({
|
|
186
|
+
/** Stable id, unique within a subscription. Used in bot commands. */
|
|
187
|
+
id: import_zod2.z.string().min(1).max(32).regex(/^[a-z0-9][a-z0-9_-]*$/, "must be lowercase letters, digits, hyphen or underscore"),
|
|
188
|
+
/** Human label shown in `/rules`. */
|
|
189
|
+
name: import_zod2.z.string().min(1).max(64).optional(),
|
|
190
|
+
enabled: import_zod2.z.boolean().default(true),
|
|
191
|
+
/** Event kinds this rule reacts to. Defaults to all four. */
|
|
192
|
+
kinds: import_zod2.z.array(eventKindSchema).min(1).max(4).default(["launch", "curve_trade", "graduation", "whale_trade"]),
|
|
193
|
+
/** Launchpads this rule reacts to. Defaults to both. */
|
|
194
|
+
launchpads: import_zod2.z.array(launchpadSchema).min(1).max(2).default(["noxa", "odyssey"]),
|
|
195
|
+
/** Minimum USD value of the event. Events with an unknown value never pass. */
|
|
196
|
+
minUsd: import_zod2.z.number().min(0).max(1e12).optional(),
|
|
197
|
+
/** Maximum USD value of the event. */
|
|
198
|
+
maxUsd: import_zod2.z.number().min(0).max(1e12).optional(),
|
|
199
|
+
/** Minimum USD liquidity of the token's deepest known pool. */
|
|
200
|
+
minLiquidityUsd: import_zod2.z.number().min(0).max(1e12).optional(),
|
|
201
|
+
/** Maximum USD liquidity of the token's deepest known pool. */
|
|
202
|
+
maxLiquidityUsd: import_zod2.z.number().min(0).max(1e12).optional(),
|
|
203
|
+
/** Watchlist. When non-empty, only these tokens match. */
|
|
204
|
+
tokens: import_zod2.z.array(addressSchema).max(500).default([]),
|
|
205
|
+
/** Deployer watchlist. When non-empty, only these actors match. */
|
|
206
|
+
deployers: import_zod2.z.array(addressSchema).max(500).default([]),
|
|
207
|
+
/** Deployers to always reject, applied after every other filter. */
|
|
208
|
+
excludeDeployers: import_zod2.z.array(addressSchema).max(500).default([]),
|
|
209
|
+
/** Trade direction. Ignored by `launch` and `graduation` events. */
|
|
210
|
+
side: sideSchema.default("any"),
|
|
211
|
+
reputation: reputationFilterSchema.optional(),
|
|
212
|
+
rateLimit: rateLimitSchema.optional()
|
|
213
|
+
}).strict().refine((rule) => rule.minUsd === void 0 || rule.maxUsd === void 0 || rule.minUsd <= rule.maxUsd, {
|
|
214
|
+
message: "minUsd must not exceed maxUsd",
|
|
215
|
+
path: ["minUsd"]
|
|
216
|
+
}).refine(
|
|
217
|
+
(rule) => rule.minLiquidityUsd === void 0 || rule.maxLiquidityUsd === void 0 || rule.minLiquidityUsd <= rule.maxLiquidityUsd,
|
|
218
|
+
{ message: "minLiquidityUsd must not exceed maxLiquidityUsd", path: ["minLiquidityUsd"] }
|
|
219
|
+
).refine((rule) => rule.kinds.some((kind) => rule.launchpads.some((pad) => canEmit(pad, kind))), {
|
|
220
|
+
message: "this combination can never fire: NOXA is an instant launcher with no bonding curve, so it emits no curve_trade and no graduation",
|
|
221
|
+
path: ["kinds"]
|
|
222
|
+
});
|
|
223
|
+
var platformSchema = import_zod2.z.enum(["telegram", "discord"]);
|
|
224
|
+
var subscriptionSchema = import_zod2.z.object({
|
|
225
|
+
id: import_zod2.z.string().min(1).max(64),
|
|
226
|
+
/**
|
|
227
|
+
* The account that owns this subscription, and the unit entitlements are
|
|
228
|
+
* checked against. For Telegram this is the user id, for Discord the guild
|
|
229
|
+
* id (a server pays once for its channels) or user id for a DM.
|
|
230
|
+
*/
|
|
231
|
+
subscriberId: import_zod2.z.string().min(1).max(128),
|
|
232
|
+
platform: platformSchema,
|
|
233
|
+
/**
|
|
234
|
+
* Where alerts go: a Telegram chat id, a Discord channel id, or a Discord
|
|
235
|
+
* webhook URL. Validated per platform by the notifier, not here, because
|
|
236
|
+
* the same subscription record serves all three shapes.
|
|
237
|
+
*/
|
|
238
|
+
target: import_zod2.z.string().min(1).max(512),
|
|
239
|
+
enabled: import_zod2.z.boolean().default(true),
|
|
240
|
+
rules: import_zod2.z.array(ruleSchema).max(100).default([]),
|
|
241
|
+
createdAtMs: import_zod2.z.number().int().nonnegative()
|
|
242
|
+
}).strict().refine((sub) => new Set(sub.rules.map((rule) => rule.id)).size === sub.rules.length, {
|
|
243
|
+
message: "rule ids must be unique within a subscription",
|
|
244
|
+
path: ["rules"]
|
|
245
|
+
});
|
|
246
|
+
function canEmit(launchpad, kind) {
|
|
247
|
+
return LAUNCHPAD_EVENT_KINDS[launchpad].includes(kind);
|
|
248
|
+
}
|
|
249
|
+
function ruleComplexity(rule) {
|
|
250
|
+
let score = 0;
|
|
251
|
+
if (rule.minUsd !== void 0) score += 1;
|
|
252
|
+
if (rule.maxUsd !== void 0) score += 1;
|
|
253
|
+
if (rule.minLiquidityUsd !== void 0) score += 1;
|
|
254
|
+
if (rule.maxLiquidityUsd !== void 0) score += 1;
|
|
255
|
+
if (rule.tokens.length > 0) score += 1;
|
|
256
|
+
if (rule.deployers.length > 0) score += 1;
|
|
257
|
+
if (rule.excludeDeployers.length > 0) score += 1;
|
|
258
|
+
if (rule.side !== "any") score += 1;
|
|
259
|
+
if (rule.reputation) score += Object.values(rule.reputation).filter((v) => v !== void 0).length;
|
|
260
|
+
if (rule.rateLimit) score += 1;
|
|
261
|
+
return score;
|
|
262
|
+
}
|
|
263
|
+
function usesOnChainLookups(rule) {
|
|
264
|
+
return rule.minLiquidityUsd !== void 0 || rule.maxLiquidityUsd !== void 0 || rule.reputation !== void 0 && Object.values(rule.reputation).some((value) => value !== void 0);
|
|
265
|
+
}
|
|
266
|
+
function parseRule(input) {
|
|
267
|
+
return ruleSchema.parse(input);
|
|
268
|
+
}
|
|
269
|
+
function safeParseRule(input) {
|
|
270
|
+
return ruleSchema.safeParse(input);
|
|
271
|
+
}
|
|
272
|
+
function parseSubscription(input) {
|
|
273
|
+
return subscriptionSchema.parse(input);
|
|
274
|
+
}
|
|
275
|
+
function formatValidationError(error) {
|
|
276
|
+
return error.issues.map((issue) => {
|
|
277
|
+
const path = issue.path.join(".");
|
|
278
|
+
return path ? `${path}: ${issue.message}` : issue.message;
|
|
279
|
+
}).join("; ");
|
|
280
|
+
}
|
|
281
|
+
function defaultRules() {
|
|
282
|
+
return [
|
|
283
|
+
parseRule({ id: "launches", name: "New launches", kinds: ["launch"] }),
|
|
284
|
+
parseRule({
|
|
285
|
+
id: "graduations",
|
|
286
|
+
name: "Odyssey graduations",
|
|
287
|
+
kinds: ["graduation"],
|
|
288
|
+
launchpads: ["odyssey"]
|
|
289
|
+
}),
|
|
290
|
+
parseRule({ id: "whales", name: "Whale trades over $5k", kinds: ["whale_trade"], minUsd: 5e3 })
|
|
291
|
+
];
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// src/service/codec.ts
|
|
295
|
+
var BIGINT_TAG = "$bigint";
|
|
296
|
+
function encode(value) {
|
|
297
|
+
return JSON.stringify(
|
|
298
|
+
value,
|
|
299
|
+
(_key, raw) => typeof raw === "bigint" ? { [BIGINT_TAG]: raw.toString() } : raw
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
function decode(text) {
|
|
303
|
+
return JSON.parse(text, (_key, raw) => {
|
|
304
|
+
if (typeof raw === "object" && raw !== null && Object.keys(raw).length === 1 && typeof raw[BIGINT_TAG] === "string") {
|
|
305
|
+
return BigInt(raw[BIGINT_TAG]);
|
|
306
|
+
}
|
|
307
|
+
return raw;
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
function encodeEvent(event) {
|
|
311
|
+
return encode(event);
|
|
312
|
+
}
|
|
313
|
+
function decodeEvent(text) {
|
|
314
|
+
return decode(text);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// src/service/store.ts
|
|
318
|
+
var import_meta = {};
|
|
319
|
+
var requireFromHere = (0, import_node_module.createRequire)(
|
|
320
|
+
import_meta.url ?? `${process.cwd()}/`
|
|
321
|
+
);
|
|
322
|
+
var { DatabaseSync } = requireFromHere("node:sqlite");
|
|
323
|
+
var SCHEMA = `
|
|
324
|
+
CREATE TABLE IF NOT EXISTS subscriptions (
|
|
325
|
+
id TEXT PRIMARY KEY,
|
|
326
|
+
subscriber_id TEXT NOT NULL,
|
|
327
|
+
platform TEXT NOT NULL,
|
|
328
|
+
target TEXT NOT NULL,
|
|
329
|
+
enabled INTEGER NOT NULL DEFAULT 1,
|
|
330
|
+
rules TEXT NOT NULL,
|
|
331
|
+
created_at INTEGER NOT NULL,
|
|
332
|
+
updated_at INTEGER NOT NULL
|
|
333
|
+
);
|
|
334
|
+
CREATE INDEX IF NOT EXISTS idx_subscriptions_subscriber ON subscriptions(subscriber_id);
|
|
335
|
+
|
|
336
|
+
CREATE TABLE IF NOT EXISTS cursors (
|
|
337
|
+
source_id TEXT PRIMARY KEY,
|
|
338
|
+
next_block TEXT NOT NULL,
|
|
339
|
+
updated_at INTEGER NOT NULL
|
|
340
|
+
);
|
|
341
|
+
|
|
342
|
+
CREATE TABLE IF NOT EXISTS outbox (
|
|
343
|
+
id TEXT PRIMARY KEY,
|
|
344
|
+
subscription_id TEXT NOT NULL,
|
|
345
|
+
subscriber_id TEXT NOT NULL,
|
|
346
|
+
rule_id TEXT NOT NULL,
|
|
347
|
+
event_id TEXT NOT NULL,
|
|
348
|
+
event_json TEXT NOT NULL,
|
|
349
|
+
platform TEXT NOT NULL,
|
|
350
|
+
target TEXT NOT NULL,
|
|
351
|
+
status TEXT NOT NULL,
|
|
352
|
+
not_before INTEGER NOT NULL,
|
|
353
|
+
attempts INTEGER NOT NULL DEFAULT 0,
|
|
354
|
+
last_error TEXT,
|
|
355
|
+
created_at INTEGER NOT NULL,
|
|
356
|
+
updated_at INTEGER NOT NULL
|
|
357
|
+
);
|
|
358
|
+
CREATE INDEX IF NOT EXISTS idx_outbox_due ON outbox(status, not_before);
|
|
359
|
+
CREATE INDEX IF NOT EXISTS idx_outbox_event ON outbox(event_id);
|
|
360
|
+
|
|
361
|
+
CREATE TABLE IF NOT EXISTS deliveries (
|
|
362
|
+
key TEXT NOT NULL,
|
|
363
|
+
subscriber_id TEXT NOT NULL,
|
|
364
|
+
at INTEGER NOT NULL
|
|
365
|
+
);
|
|
366
|
+
CREATE INDEX IF NOT EXISTS idx_deliveries_key ON deliveries(key, at);
|
|
367
|
+
CREATE INDEX IF NOT EXISTS idx_deliveries_subscriber ON deliveries(subscriber_id, at);
|
|
368
|
+
|
|
369
|
+
CREATE TABLE IF NOT EXISTS pools (
|
|
370
|
+
pool TEXT PRIMARY KEY,
|
|
371
|
+
token TEXT NOT NULL,
|
|
372
|
+
quote_token TEXT NOT NULL,
|
|
373
|
+
launchpad TEXT NOT NULL,
|
|
374
|
+
created_block TEXT NOT NULL
|
|
375
|
+
);
|
|
376
|
+
CREATE INDEX IF NOT EXISTS idx_pools_token ON pools(token);
|
|
377
|
+
CREATE INDEX IF NOT EXISTS idx_pools_created ON pools(created_block);
|
|
378
|
+
|
|
379
|
+
CREATE TABLE IF NOT EXISTS wallet_links (
|
|
380
|
+
subscriber_id TEXT PRIMARY KEY,
|
|
381
|
+
address TEXT NOT NULL,
|
|
382
|
+
linked_at INTEGER NOT NULL
|
|
383
|
+
);
|
|
384
|
+
|
|
385
|
+
CREATE TABLE IF NOT EXISTS link_nonces (
|
|
386
|
+
subscriber_id TEXT PRIMARY KEY,
|
|
387
|
+
nonce TEXT NOT NULL,
|
|
388
|
+
issued_at INTEGER NOT NULL
|
|
389
|
+
);
|
|
390
|
+
|
|
391
|
+
CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
|
|
392
|
+
`;
|
|
393
|
+
var AlertStore = class _AlertStore {
|
|
394
|
+
db;
|
|
395
|
+
clock;
|
|
396
|
+
constructor(path, clock = () => Date.now()) {
|
|
397
|
+
if (path !== ":memory:") (0, import_node_fs.mkdirSync)((0, import_node_path.dirname)(path), { recursive: true });
|
|
398
|
+
this.db = new DatabaseSync(path);
|
|
399
|
+
this.clock = clock;
|
|
400
|
+
this.db.exec("PRAGMA journal_mode = WAL;");
|
|
401
|
+
this.db.exec("PRAGMA foreign_keys = ON;");
|
|
402
|
+
this.db.exec(SCHEMA);
|
|
403
|
+
}
|
|
404
|
+
/** Deterministic outbox key. The whole dedupe guarantee rests on this. */
|
|
405
|
+
static deliveryKey(eventId2, subscriptionId, ruleId) {
|
|
406
|
+
return `${eventId2}|${subscriptionId}|${ruleId}`;
|
|
407
|
+
}
|
|
408
|
+
// ---- cursors -----------------------------------------------------------
|
|
409
|
+
/** The next unprocessed block for a source, or `null` when never persisted. */
|
|
410
|
+
getCursor(sourceId) {
|
|
411
|
+
const row = this.db.prepare("SELECT next_block FROM cursors WHERE source_id = ?").get(sourceId);
|
|
412
|
+
return row ? BigInt(row.next_block) : null;
|
|
413
|
+
}
|
|
414
|
+
/** Persist the next unprocessed block. Call only after the range is enqueued. */
|
|
415
|
+
setCursor(sourceId, nextBlock) {
|
|
416
|
+
this.db.prepare(
|
|
417
|
+
`INSERT INTO cursors (source_id, next_block, updated_at) VALUES (?, ?, ?)
|
|
418
|
+
ON CONFLICT(source_id) DO UPDATE SET next_block = excluded.next_block, updated_at = excluded.updated_at`
|
|
419
|
+
).run(sourceId, nextBlock.toString(), this.clock());
|
|
420
|
+
}
|
|
421
|
+
/** Every persisted cursor, for `/metrics` and for operator inspection. */
|
|
422
|
+
listCursors() {
|
|
423
|
+
const rows = this.db.prepare("SELECT source_id, next_block FROM cursors ORDER BY source_id").all();
|
|
424
|
+
return rows.map((row) => ({ sourceId: row.source_id, nextBlock: BigInt(row.next_block) }));
|
|
425
|
+
}
|
|
426
|
+
// ---- subscriptions -----------------------------------------------------
|
|
427
|
+
/** Insert or replace a subscription. */
|
|
428
|
+
saveSubscription(subscription) {
|
|
429
|
+
const now = this.clock();
|
|
430
|
+
this.db.prepare(
|
|
431
|
+
`INSERT INTO subscriptions (id, subscriber_id, platform, target, enabled, rules, created_at, updated_at)
|
|
432
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
433
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
434
|
+
subscriber_id = excluded.subscriber_id,
|
|
435
|
+
platform = excluded.platform,
|
|
436
|
+
target = excluded.target,
|
|
437
|
+
enabled = excluded.enabled,
|
|
438
|
+
rules = excluded.rules,
|
|
439
|
+
updated_at = excluded.updated_at`
|
|
440
|
+
).run(
|
|
441
|
+
subscription.id,
|
|
442
|
+
subscription.subscriberId,
|
|
443
|
+
subscription.platform,
|
|
444
|
+
subscription.target,
|
|
445
|
+
subscription.enabled ? 1 : 0,
|
|
446
|
+
encode(subscription.rules),
|
|
447
|
+
subscription.createdAtMs,
|
|
448
|
+
now
|
|
449
|
+
);
|
|
450
|
+
}
|
|
451
|
+
hydrate(row) {
|
|
452
|
+
return parseSubscription({
|
|
453
|
+
id: row.id,
|
|
454
|
+
subscriberId: row.subscriber_id,
|
|
455
|
+
platform: row.platform,
|
|
456
|
+
target: row.target,
|
|
457
|
+
enabled: row.enabled === 1,
|
|
458
|
+
rules: decode(row.rules),
|
|
459
|
+
createdAtMs: row.created_at
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
/** One subscription by id. */
|
|
463
|
+
getSubscription(id) {
|
|
464
|
+
const row = this.db.prepare("SELECT * FROM subscriptions WHERE id = ?").get(id);
|
|
465
|
+
return row ? this.hydrate(row) : null;
|
|
466
|
+
}
|
|
467
|
+
/** Every subscription owned by a subscriber. */
|
|
468
|
+
listSubscriptionsFor(subscriberId) {
|
|
469
|
+
const rows = this.db.prepare("SELECT * FROM subscriptions WHERE subscriber_id = ? ORDER BY created_at").all(subscriberId);
|
|
470
|
+
return rows.map((row) => this.hydrate(row));
|
|
471
|
+
}
|
|
472
|
+
/** Every enabled subscription, for the dispatch fan-out. */
|
|
473
|
+
listEnabledSubscriptions() {
|
|
474
|
+
const rows = this.db.prepare("SELECT * FROM subscriptions WHERE enabled = 1 ORDER BY created_at").all();
|
|
475
|
+
return rows.map((row) => this.hydrate(row));
|
|
476
|
+
}
|
|
477
|
+
/** Remove a subscription. Returns `true` when a row was deleted. */
|
|
478
|
+
deleteSubscription(id) {
|
|
479
|
+
const result = this.db.prepare("DELETE FROM subscriptions WHERE id = ?").run(id);
|
|
480
|
+
return Number(result.changes) > 0;
|
|
481
|
+
}
|
|
482
|
+
/** Every token on any enabled subscription's watchlist, deduped. */
|
|
483
|
+
watchlistTokens() {
|
|
484
|
+
const seen = /* @__PURE__ */ new Set();
|
|
485
|
+
for (const subscription of this.listEnabledSubscriptions()) {
|
|
486
|
+
for (const rule of subscription.rules) {
|
|
487
|
+
for (const token of rule.tokens) seen.add(token.toLowerCase());
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
return [...seen].map((token) => (0, import_viem.getAddress)(token));
|
|
491
|
+
}
|
|
492
|
+
// ---- outbox ------------------------------------------------------------
|
|
493
|
+
/**
|
|
494
|
+
* Queue a delivery.
|
|
495
|
+
*
|
|
496
|
+
* @returns `true` when this call created the row, `false` when it already
|
|
497
|
+
* existed (a replayed block range, a duplicate log). The boolean is the
|
|
498
|
+
* dedupe signal the dispatcher counts.
|
|
499
|
+
*/
|
|
500
|
+
enqueue(row) {
|
|
501
|
+
const now = this.clock();
|
|
502
|
+
try {
|
|
503
|
+
this.db.prepare(
|
|
504
|
+
`INSERT INTO outbox (id, subscription_id, subscriber_id, rule_id, event_id, event_json,
|
|
505
|
+
platform, target, status, not_before, attempts, last_error, created_at, updated_at)
|
|
506
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, 0, NULL, ?, ?)`
|
|
507
|
+
).run(
|
|
508
|
+
row.id,
|
|
509
|
+
row.subscriptionId,
|
|
510
|
+
row.subscriberId,
|
|
511
|
+
row.ruleId,
|
|
512
|
+
row.eventId,
|
|
513
|
+
row.eventJson,
|
|
514
|
+
row.platform,
|
|
515
|
+
row.target,
|
|
516
|
+
row.notBeforeMs,
|
|
517
|
+
now,
|
|
518
|
+
now
|
|
519
|
+
);
|
|
520
|
+
return true;
|
|
521
|
+
} catch (error) {
|
|
522
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
523
|
+
if (/UNIQUE|PRIMARY KEY/i.test(message)) return false;
|
|
524
|
+
throw error;
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
static toOutboxRow(raw) {
|
|
528
|
+
return {
|
|
529
|
+
id: raw.id,
|
|
530
|
+
subscriptionId: raw.subscription_id,
|
|
531
|
+
subscriberId: raw.subscriber_id,
|
|
532
|
+
ruleId: raw.rule_id,
|
|
533
|
+
eventId: raw.event_id,
|
|
534
|
+
eventJson: raw.event_json,
|
|
535
|
+
platform: raw.platform,
|
|
536
|
+
target: raw.target,
|
|
537
|
+
status: raw.status,
|
|
538
|
+
notBeforeMs: raw.not_before,
|
|
539
|
+
attempts: raw.attempts,
|
|
540
|
+
lastError: raw.last_error,
|
|
541
|
+
createdAtMs: raw.created_at,
|
|
542
|
+
updatedAtMs: raw.updated_at
|
|
543
|
+
};
|
|
544
|
+
}
|
|
545
|
+
/**
|
|
546
|
+
* Claim due deliveries, moving them to `sending` in one transaction so a
|
|
547
|
+
* second flush pass cannot pick up the same rows.
|
|
548
|
+
*/
|
|
549
|
+
claimDue(nowMs, limit) {
|
|
550
|
+
const rows = this.db.prepare(
|
|
551
|
+
`SELECT * FROM outbox
|
|
552
|
+
WHERE status IN ('pending', 'failed') AND not_before <= ?
|
|
553
|
+
ORDER BY not_before, created_at
|
|
554
|
+
LIMIT ?`
|
|
555
|
+
).all(nowMs, limit);
|
|
556
|
+
if (rows.length === 0) return [];
|
|
557
|
+
const update = this.db.prepare(`UPDATE outbox SET status = 'sending', updated_at = ? WHERE id = ?`);
|
|
558
|
+
this.db.exec("BEGIN");
|
|
559
|
+
try {
|
|
560
|
+
for (const row of rows) update.run(nowMs, row.id);
|
|
561
|
+
this.db.exec("COMMIT");
|
|
562
|
+
} catch (error) {
|
|
563
|
+
this.db.exec("ROLLBACK");
|
|
564
|
+
throw error;
|
|
565
|
+
}
|
|
566
|
+
return rows.map((row) => _AlertStore.toOutboxRow(row));
|
|
567
|
+
}
|
|
568
|
+
/** Mark a delivery sent. */
|
|
569
|
+
markSent(id) {
|
|
570
|
+
this.db.prepare(`UPDATE outbox SET status = 'sent', last_error = NULL, updated_at = ? WHERE id = ?`).run(this.clock(), id);
|
|
571
|
+
}
|
|
572
|
+
/** Record a retryable failure and schedule the next attempt. */
|
|
573
|
+
markFailed(id, attempts, error, nextAttemptAtMs) {
|
|
574
|
+
this.db.prepare(
|
|
575
|
+
`UPDATE outbox SET status = 'failed', attempts = ?, last_error = ?, not_before = ?, updated_at = ? WHERE id = ?`
|
|
576
|
+
).run(attempts, error, nextAttemptAtMs, this.clock(), id);
|
|
577
|
+
}
|
|
578
|
+
/** Give up on a delivery (permanent rejection, or attempts exhausted). */
|
|
579
|
+
markDead(id, attempts, error) {
|
|
580
|
+
this.db.prepare(`UPDATE outbox SET status = 'dead', attempts = ?, last_error = ?, updated_at = ? WHERE id = ?`).run(attempts, error, this.clock(), id);
|
|
581
|
+
}
|
|
582
|
+
/**
|
|
583
|
+
* Return rows abandoned mid-flight by a crash to the queue.
|
|
584
|
+
*
|
|
585
|
+
* @returns how many rows were recovered.
|
|
586
|
+
*/
|
|
587
|
+
recoverInFlight() {
|
|
588
|
+
const result = this.db.prepare(`UPDATE outbox SET status = 'pending', updated_at = ? WHERE status = 'sending'`).run(this.clock());
|
|
589
|
+
return Number(result.changes);
|
|
590
|
+
}
|
|
591
|
+
/** Delete delivered rows older than `beforeMs`, keeping the file bounded. */
|
|
592
|
+
pruneOutbox(beforeMs) {
|
|
593
|
+
const result = this.db.prepare(`DELETE FROM outbox WHERE status = 'sent' AND updated_at < ?`).run(beforeMs);
|
|
594
|
+
return Number(result.changes);
|
|
595
|
+
}
|
|
596
|
+
/** Delete delivery history older than `beforeMs`. */
|
|
597
|
+
pruneDeliveries(beforeMs) {
|
|
598
|
+
const result = this.db.prepare("DELETE FROM deliveries WHERE at < ?").run(beforeMs);
|
|
599
|
+
return Number(result.changes);
|
|
600
|
+
}
|
|
601
|
+
// ---- rate limiting and throughput --------------------------------------
|
|
602
|
+
/** A {@link RateLimitStore} backed by the `deliveries` table. */
|
|
603
|
+
rateLimitStore() {
|
|
604
|
+
return {
|
|
605
|
+
countSince: async (key, sinceMs) => {
|
|
606
|
+
const row = this.db.prepare("SELECT COUNT(*) AS n FROM deliveries WHERE key = ? AND at >= ?").get(key, sinceMs);
|
|
607
|
+
return Number(row.n);
|
|
608
|
+
},
|
|
609
|
+
lastAt: async (key) => {
|
|
610
|
+
const row = this.db.prepare("SELECT MAX(at) AS last FROM deliveries WHERE key = ?").get(key);
|
|
611
|
+
return row.last ?? null;
|
|
612
|
+
},
|
|
613
|
+
record: async (key, subscriberId, atMs) => {
|
|
614
|
+
this.recordDelivery(key, subscriberId, atMs);
|
|
615
|
+
},
|
|
616
|
+
countForSubscriberSince: async (subscriberId, sinceMs) => {
|
|
617
|
+
return this.countDeliveriesForSubscriber(subscriberId, sinceMs);
|
|
618
|
+
}
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
/** Record a delivery for both rate limiting and throughput accounting. */
|
|
622
|
+
recordDelivery(key, subscriberId, atMs) {
|
|
623
|
+
this.db.prepare("INSERT INTO deliveries (key, subscriber_id, at) VALUES (?, ?, ?)").run(key, subscriberId, atMs);
|
|
624
|
+
}
|
|
625
|
+
/** How many alerts a subscriber received since `sinceMs`. */
|
|
626
|
+
countDeliveriesForSubscriber(subscriberId, sinceMs) {
|
|
627
|
+
const row = this.db.prepare("SELECT COUNT(*) AS n FROM deliveries WHERE subscriber_id = ? AND at >= ?").get(subscriberId, sinceMs);
|
|
628
|
+
return Number(row.n);
|
|
629
|
+
}
|
|
630
|
+
// ---- pool registry -----------------------------------------------------
|
|
631
|
+
/** A {@link PoolRegistry} backed by the `pools` table. */
|
|
632
|
+
poolRegistry() {
|
|
633
|
+
return {
|
|
634
|
+
record: async (entry) => {
|
|
635
|
+
this.db.prepare(
|
|
636
|
+
`INSERT INTO pools (pool, token, quote_token, launchpad, created_block) VALUES (?, ?, ?, ?, ?)
|
|
637
|
+
ON CONFLICT(pool) DO UPDATE SET
|
|
638
|
+
token = excluded.token, quote_token = excluded.quote_token,
|
|
639
|
+
launchpad = excluded.launchpad, created_block = excluded.created_block`
|
|
640
|
+
).run(
|
|
641
|
+
(0, import_viem.getAddress)(entry.pool),
|
|
642
|
+
(0, import_viem.getAddress)(entry.token),
|
|
643
|
+
(0, import_viem.getAddress)(entry.quoteToken),
|
|
644
|
+
entry.launchpad,
|
|
645
|
+
entry.createdBlock.toString()
|
|
646
|
+
);
|
|
647
|
+
},
|
|
648
|
+
active: async (limit, pinned = []) => {
|
|
649
|
+
const picked = /* @__PURE__ */ new Map();
|
|
650
|
+
if (pinned.length > 0) {
|
|
651
|
+
const placeholders = pinned.map(() => "?").join(",");
|
|
652
|
+
const rows2 = this.db.prepare(`SELECT * FROM pools WHERE lower(token) IN (${placeholders})`).all(...pinned.map((token) => token.toLowerCase()));
|
|
653
|
+
for (const row of rows2) {
|
|
654
|
+
const entry = _AlertStore.toPoolEntry(row);
|
|
655
|
+
picked.set(entry.pool.toLowerCase(), entry);
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
const rows = this.db.prepare(
|
|
659
|
+
// created_block is stored as text to keep bigint precision, so it
|
|
660
|
+
// is ordered numerically by length first, then lexically.
|
|
661
|
+
"SELECT * FROM pools ORDER BY length(created_block) DESC, created_block DESC LIMIT ?"
|
|
662
|
+
).all(limit);
|
|
663
|
+
for (const row of rows) {
|
|
664
|
+
if (picked.size >= limit + pinned.length) break;
|
|
665
|
+
const entry = _AlertStore.toPoolEntry(row);
|
|
666
|
+
if (!picked.has(entry.pool.toLowerCase())) picked.set(entry.pool.toLowerCase(), entry);
|
|
667
|
+
}
|
|
668
|
+
return [...picked.values()];
|
|
669
|
+
},
|
|
670
|
+
originOf: async (token) => {
|
|
671
|
+
const row = this.db.prepare("SELECT launchpad FROM pools WHERE lower(token) = ? LIMIT 1").get(token.toLowerCase());
|
|
672
|
+
return row ? row.launchpad : null;
|
|
673
|
+
},
|
|
674
|
+
poolsFor: async (token) => {
|
|
675
|
+
const rows = this.db.prepare("SELECT * FROM pools WHERE lower(token) = ?").all(token.toLowerCase());
|
|
676
|
+
return rows.map((row) => _AlertStore.toPoolEntry(row));
|
|
677
|
+
},
|
|
678
|
+
size: async () => {
|
|
679
|
+
const row = this.db.prepare("SELECT COUNT(*) AS n FROM pools").get();
|
|
680
|
+
return Number(row.n);
|
|
681
|
+
}
|
|
682
|
+
};
|
|
683
|
+
}
|
|
684
|
+
static toPoolEntry(row) {
|
|
685
|
+
return {
|
|
686
|
+
pool: (0, import_viem.getAddress)(row["pool"]),
|
|
687
|
+
token: (0, import_viem.getAddress)(row["token"]),
|
|
688
|
+
quoteToken: (0, import_viem.getAddress)(row["quote_token"]),
|
|
689
|
+
launchpad: row["launchpad"],
|
|
690
|
+
createdBlock: BigInt(row["created_block"])
|
|
691
|
+
};
|
|
692
|
+
}
|
|
693
|
+
// ---- wallet links ------------------------------------------------------
|
|
694
|
+
/** Store a signature-verified wallet link. */
|
|
695
|
+
linkWallet(subscriberId, address) {
|
|
696
|
+
this.db.prepare(
|
|
697
|
+
`INSERT INTO wallet_links (subscriber_id, address, linked_at) VALUES (?, ?, ?)
|
|
698
|
+
ON CONFLICT(subscriber_id) DO UPDATE SET address = excluded.address, linked_at = excluded.linked_at`
|
|
699
|
+
).run(subscriberId, (0, import_viem.getAddress)(address), this.clock());
|
|
700
|
+
}
|
|
701
|
+
/** A {@link WalletLinkStore} view of the links table. */
|
|
702
|
+
walletLinks() {
|
|
703
|
+
return {
|
|
704
|
+
walletOf: async (subscriberId) => {
|
|
705
|
+
const row = this.db.prepare("SELECT address FROM wallet_links WHERE subscriber_id = ?").get(subscriberId);
|
|
706
|
+
return row ? (0, import_viem.getAddress)(row.address) : null;
|
|
707
|
+
}
|
|
708
|
+
};
|
|
709
|
+
}
|
|
710
|
+
/** Issue (or reissue) the nonce a subscriber must sign to link a wallet. */
|
|
711
|
+
issueLinkNonce(subscriberId, nonce) {
|
|
712
|
+
this.db.prepare(
|
|
713
|
+
`INSERT INTO link_nonces (subscriber_id, nonce, issued_at) VALUES (?, ?, ?)
|
|
714
|
+
ON CONFLICT(subscriber_id) DO UPDATE SET nonce = excluded.nonce, issued_at = excluded.issued_at`
|
|
715
|
+
).run(subscriberId, nonce, this.clock());
|
|
716
|
+
return nonce;
|
|
717
|
+
}
|
|
718
|
+
/** The outstanding link nonce for a subscriber, or `null`. */
|
|
719
|
+
getLinkNonce(subscriberId) {
|
|
720
|
+
const row = this.db.prepare("SELECT nonce FROM link_nonces WHERE subscriber_id = ?").get(subscriberId);
|
|
721
|
+
return row?.nonce ?? null;
|
|
722
|
+
}
|
|
723
|
+
/** Consume a link nonce so a signature cannot be replayed. */
|
|
724
|
+
clearLinkNonce(subscriberId) {
|
|
725
|
+
this.db.prepare("DELETE FROM link_nonces WHERE subscriber_id = ?").run(subscriberId);
|
|
726
|
+
}
|
|
727
|
+
// ---- meta and metrics --------------------------------------------------
|
|
728
|
+
/** Read a free-form service setting. */
|
|
729
|
+
getMeta(key) {
|
|
730
|
+
const row = this.db.prepare("SELECT value FROM meta WHERE key = ?").get(key);
|
|
731
|
+
return row?.value ?? null;
|
|
732
|
+
}
|
|
733
|
+
/** Write a free-form service setting. */
|
|
734
|
+
setMeta(key, value) {
|
|
735
|
+
this.db.prepare(
|
|
736
|
+
`INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value`
|
|
737
|
+
).run(key, value);
|
|
738
|
+
}
|
|
739
|
+
/** Aggregate counters for `/metrics`. */
|
|
740
|
+
metrics() {
|
|
741
|
+
const outbox = { pending: 0, sending: 0, sent: 0, failed: 0, dead: 0 };
|
|
742
|
+
const statusRows = this.db.prepare("SELECT status, COUNT(*) AS n FROM outbox GROUP BY status").all();
|
|
743
|
+
for (const row of statusRows) outbox[row.status] = Number(row.n);
|
|
744
|
+
const subs = this.db.prepare("SELECT COUNT(*) AS total, SUM(enabled) AS enabled FROM subscriptions").get();
|
|
745
|
+
const pools = this.db.prepare("SELECT COUNT(*) AS n FROM pools").get();
|
|
746
|
+
const deliveries = this.db.prepare("SELECT COUNT(*) AS n FROM deliveries WHERE at >= ?").get(this.clock() - 36e5);
|
|
747
|
+
return {
|
|
748
|
+
subscriptions: Number(subs.total),
|
|
749
|
+
enabledSubscriptions: Number(subs.enabled ?? 0),
|
|
750
|
+
outbox,
|
|
751
|
+
pools: Number(pools.n),
|
|
752
|
+
deliveriesLastHour: Number(deliveries.n),
|
|
753
|
+
cursors: this.listCursors()
|
|
754
|
+
};
|
|
755
|
+
}
|
|
756
|
+
/** Close the database. Safe to call twice. */
|
|
757
|
+
close() {
|
|
758
|
+
try {
|
|
759
|
+
this.db.close();
|
|
760
|
+
} catch {
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
};
|
|
764
|
+
|
|
765
|
+
// src/rules/reputation.ts
|
|
766
|
+
var import_viem3 = require("viem");
|
|
767
|
+
var import_hoodchain2 = require("hoodchain");
|
|
768
|
+
|
|
769
|
+
// src/events/logs.ts
|
|
770
|
+
var defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
771
|
+
function isResultTooLarge(error) {
|
|
772
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
773
|
+
return /exceeds limit|too many results|response size exceeded|query returned more than/i.test(message);
|
|
774
|
+
}
|
|
775
|
+
function isRetryableRpcError(error) {
|
|
776
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
777
|
+
return /too many requests|rate limit|timed out|timeout|429|503|502|ECONNRESET|socket hang up|fetch failed/i.test(
|
|
778
|
+
message
|
|
779
|
+
);
|
|
780
|
+
}
|
|
781
|
+
async function fetchLogRange(fetcher, from, to, options = {}) {
|
|
782
|
+
const minSpan = options.minSpan ?? 1n;
|
|
783
|
+
const retries = options.retries ?? 5;
|
|
784
|
+
const backoffMs = options.backoffMs ?? 500;
|
|
785
|
+
const sleep2 = options.sleep ?? defaultSleep;
|
|
786
|
+
const run = async (lo, hi) => {
|
|
787
|
+
let attempt = 0;
|
|
788
|
+
for (; ; ) {
|
|
789
|
+
try {
|
|
790
|
+
return [...await fetcher(lo, hi)];
|
|
791
|
+
} catch (error) {
|
|
792
|
+
if (isResultTooLarge(error)) {
|
|
793
|
+
const span = hi - lo;
|
|
794
|
+
if (span < minSpan || span === 0n) throw error;
|
|
795
|
+
const mid = lo + span / 2n;
|
|
796
|
+
const left = await run(lo, mid);
|
|
797
|
+
const right = await run(mid + 1n, hi);
|
|
798
|
+
return [...left, ...right];
|
|
799
|
+
}
|
|
800
|
+
if (isRetryableRpcError(error) && attempt < retries) {
|
|
801
|
+
await sleep2(backoffMs * 2 ** attempt);
|
|
802
|
+
attempt += 1;
|
|
803
|
+
continue;
|
|
804
|
+
}
|
|
805
|
+
throw error;
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
};
|
|
809
|
+
if (to < from) return [];
|
|
810
|
+
return run(from, to);
|
|
811
|
+
}
|
|
812
|
+
function batchAddresses(addresses, size = 200) {
|
|
813
|
+
const batches = [];
|
|
814
|
+
for (let i = 0; i < addresses.length; i += size) {
|
|
815
|
+
batches.push(addresses.slice(i, i + size));
|
|
816
|
+
}
|
|
817
|
+
return batches;
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
// src/events/pricing.ts
|
|
821
|
+
var import_viem2 = require("viem");
|
|
822
|
+
var import_hoodchain = require("hoodchain");
|
|
823
|
+
function quoteTokens(client) {
|
|
824
|
+
return client.network === "testnet" ? { weth: import_hoodchain.TESTNET_ADDRESSES.weth, usdg: import_hoodchain.TESTNET_ADDRESSES.usdg } : { weth: import_hoodchain.MAINNET_ADDRESSES.weth, usdg: import_hoodchain.MAINNET_ADDRESSES.usdg };
|
|
825
|
+
}
|
|
826
|
+
function createPriceOracle(client, options = {}) {
|
|
827
|
+
const ttlMs = options.ttlMs ?? 3e4;
|
|
828
|
+
const probeWei = options.probeWei ?? 10n ** 18n;
|
|
829
|
+
const now = options.now ?? Date.now;
|
|
830
|
+
const { weth, usdg } = quoteTokens(client);
|
|
831
|
+
let cached = null;
|
|
832
|
+
let cachedAt = 0;
|
|
833
|
+
let inflight = null;
|
|
834
|
+
async function refresh() {
|
|
835
|
+
try {
|
|
836
|
+
const quote = await (0, import_hoodchain.quoteSwap)(client, { tokenIn: weth, tokenOut: usdg, amountIn: probeWei });
|
|
837
|
+
const usdgOut = Number((0, import_viem2.formatUnits)(quote.amountOut, import_hoodchain.USDG_DECIMALS));
|
|
838
|
+
const ethIn = Number((0, import_viem2.formatUnits)(probeWei, 18));
|
|
839
|
+
const rate = ethIn > 0 ? usdgOut / ethIn : 0;
|
|
840
|
+
cached = rate > 0 ? rate : null;
|
|
841
|
+
} catch {
|
|
842
|
+
cached = null;
|
|
843
|
+
}
|
|
844
|
+
cachedAt = now();
|
|
845
|
+
return cached;
|
|
846
|
+
}
|
|
847
|
+
async function ethUsd() {
|
|
848
|
+
if (cachedAt !== 0 && now() - cachedAt < ttlMs) return cached;
|
|
849
|
+
inflight ??= refresh().finally(() => {
|
|
850
|
+
inflight = null;
|
|
851
|
+
});
|
|
852
|
+
return inflight;
|
|
853
|
+
}
|
|
854
|
+
return {
|
|
855
|
+
ethUsd,
|
|
856
|
+
async weiToUsd(wei) {
|
|
857
|
+
const rate = await ethUsd();
|
|
858
|
+
if (rate === null) return null;
|
|
859
|
+
return Number((0, import_viem2.formatUnits)(wei < 0n ? -wei : wei, 18)) * rate;
|
|
860
|
+
},
|
|
861
|
+
usdgToUsd(atomic) {
|
|
862
|
+
return Number((0, import_viem2.formatUnits)(atomic < 0n ? -atomic : atomic, import_hoodchain.USDG_DECIMALS));
|
|
863
|
+
}
|
|
864
|
+
};
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
// src/rules/reputation.ts
|
|
868
|
+
var positionManagerAbi = [
|
|
869
|
+
{
|
|
870
|
+
type: "function",
|
|
871
|
+
name: "ownerOf",
|
|
872
|
+
stateMutability: "view",
|
|
873
|
+
inputs: [{ name: "tokenId", type: "uint256" }],
|
|
874
|
+
outputs: [{ type: "address" }]
|
|
875
|
+
}
|
|
876
|
+
];
|
|
877
|
+
var ODYSSEY_FACTORIES = [
|
|
878
|
+
import_hoodchain2.ODYSSEY_ADDRESSES.bondingCurveFactory,
|
|
879
|
+
import_hoodchain2.ODYSSEY_ADDRESSES.reflectionFactory,
|
|
880
|
+
import_hoodchain2.ODYSSEY_ADDRESSES.instantFactory
|
|
881
|
+
];
|
|
882
|
+
function createRpcReputationProvider(client, oracle, options = {}) {
|
|
883
|
+
const ttlMs = options.ttlMs ?? 6e5;
|
|
884
|
+
const maxHistory = options.maxHistory ?? 200;
|
|
885
|
+
const maxInspect = options.maxInspect ?? 25;
|
|
886
|
+
const rugThresholdUsd = options.rugThresholdUsd ?? 50;
|
|
887
|
+
const now = options.now ?? Date.now;
|
|
888
|
+
const cache = /* @__PURE__ */ new Map();
|
|
889
|
+
const inflight = /* @__PURE__ */ new Map();
|
|
890
|
+
const lockCache = /* @__PURE__ */ new Map();
|
|
891
|
+
const { weth, usdg } = quoteTokens(client);
|
|
892
|
+
async function reserveUsd(pool, quoteToken) {
|
|
893
|
+
const quote = quoteToken.toLowerCase();
|
|
894
|
+
if (quote !== weth.toLowerCase() && quote !== usdg.toLowerCase()) return null;
|
|
895
|
+
const balance = await client.public.readContract({
|
|
896
|
+
address: quoteToken,
|
|
897
|
+
abi: import_hoodchain2.erc20Abi,
|
|
898
|
+
functionName: "balanceOf",
|
|
899
|
+
args: [pool]
|
|
900
|
+
});
|
|
901
|
+
if (quote === usdg.toLowerCase()) return Number((0, import_viem3.formatUnits)(balance, import_hoodchain2.USDG_DECIMALS));
|
|
902
|
+
return oracle.weiToUsd(balance);
|
|
903
|
+
}
|
|
904
|
+
async function isLpLocked(positionId) {
|
|
905
|
+
const key = positionId.toString();
|
|
906
|
+
const hit = lockCache.get(key);
|
|
907
|
+
if (hit !== void 0) return hit;
|
|
908
|
+
try {
|
|
909
|
+
const owner = await client.public.readContract({
|
|
910
|
+
address: import_hoodchain2.MAINNET_ADDRESSES.nonfungiblePositionManager,
|
|
911
|
+
abi: positionManagerAbi,
|
|
912
|
+
functionName: "ownerOf",
|
|
913
|
+
args: [positionId]
|
|
914
|
+
});
|
|
915
|
+
const locked = owner.toLowerCase() === import_hoodchain2.NOXA_ADDRESSES.locker.toLowerCase();
|
|
916
|
+
lockCache.set(key, locked);
|
|
917
|
+
return locked;
|
|
918
|
+
} catch {
|
|
919
|
+
return false;
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
async function load(deployer) {
|
|
923
|
+
const head = await client.public.getBlockNumber();
|
|
924
|
+
const [noxaLogs, odysseyLogs] = await Promise.all([
|
|
925
|
+
fetchLogRange(
|
|
926
|
+
(from, to) => client.public.getLogs({
|
|
927
|
+
address: import_hoodchain2.NOXA_ADDRESSES.launchFactory,
|
|
928
|
+
event: import_hoodchain2.noxaTokenLaunchedEvent,
|
|
929
|
+
args: { deployer },
|
|
930
|
+
fromBlock: from,
|
|
931
|
+
toBlock: to
|
|
932
|
+
}),
|
|
933
|
+
import_hoodchain2.NOXA_ADDRESSES.deployBlock,
|
|
934
|
+
head,
|
|
935
|
+
options.logOptions ?? {}
|
|
936
|
+
),
|
|
937
|
+
fetchLogRange(
|
|
938
|
+
(from, to) => client.public.getLogs({
|
|
939
|
+
address: ODYSSEY_FACTORIES,
|
|
940
|
+
event: import_hoodchain2.odysseyTokenCreatedEvent,
|
|
941
|
+
args: { creator: deployer },
|
|
942
|
+
fromBlock: from,
|
|
943
|
+
toBlock: to
|
|
944
|
+
}),
|
|
945
|
+
import_hoodchain2.NOXA_ADDRESSES.deployBlock,
|
|
946
|
+
head,
|
|
947
|
+
options.logOptions ?? {}
|
|
948
|
+
)
|
|
949
|
+
]);
|
|
950
|
+
const launches = [];
|
|
951
|
+
for (const log of noxaLogs) {
|
|
952
|
+
const token = log.args.token;
|
|
953
|
+
if (!token) continue;
|
|
954
|
+
const pool = log.args.pool ?? null;
|
|
955
|
+
const pairToken = log.args.pairToken ?? null;
|
|
956
|
+
launches.push({
|
|
957
|
+
launchpad: "noxa",
|
|
958
|
+
token: (0, import_viem3.getAddress)(token),
|
|
959
|
+
pool: pool ? (0, import_viem3.getAddress)(pool) : null,
|
|
960
|
+
quoteToken: pairToken ? (0, import_viem3.getAddress)(pairToken) : null,
|
|
961
|
+
positionId: log.args.positionId ?? null,
|
|
962
|
+
blockNumber: log.blockNumber
|
|
963
|
+
});
|
|
964
|
+
}
|
|
965
|
+
for (const log of odysseyLogs) {
|
|
966
|
+
const token = log.args.token;
|
|
967
|
+
if (!token) continue;
|
|
968
|
+
launches.push({
|
|
969
|
+
launchpad: "odyssey",
|
|
970
|
+
token: (0, import_viem3.getAddress)(token),
|
|
971
|
+
pool: null,
|
|
972
|
+
quoteToken: null,
|
|
973
|
+
positionId: null,
|
|
974
|
+
blockNumber: log.blockNumber
|
|
975
|
+
});
|
|
976
|
+
}
|
|
977
|
+
launches.sort((a, b) => a.blockNumber < b.blockNumber ? -1 : a.blockNumber > b.blockNumber ? 1 : 0);
|
|
978
|
+
const kept = launches.slice(-maxHistory);
|
|
979
|
+
const inspectable = kept.slice(-maxInspect);
|
|
980
|
+
let lockedLaunches = 0;
|
|
981
|
+
let ruggedLaunches = 0;
|
|
982
|
+
for (const launch of inspectable) {
|
|
983
|
+
if (launch.positionId !== null && await isLpLocked(launch.positionId)) lockedLaunches += 1;
|
|
984
|
+
if (launch.pool && launch.quoteToken) {
|
|
985
|
+
try {
|
|
986
|
+
const usd = await reserveUsd(launch.pool, launch.quoteToken);
|
|
987
|
+
if (usd !== null && usd < rugThresholdUsd) ruggedLaunches += 1;
|
|
988
|
+
} catch {
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
return {
|
|
993
|
+
deployer: (0, import_viem3.getAddress)(deployer),
|
|
994
|
+
launches: kept,
|
|
995
|
+
lockedLaunches,
|
|
996
|
+
ruggedLaunches,
|
|
997
|
+
inspected: inspectable.length,
|
|
998
|
+
computedAtMs: now()
|
|
999
|
+
};
|
|
1000
|
+
}
|
|
1001
|
+
return {
|
|
1002
|
+
async get(deployer) {
|
|
1003
|
+
const key = deployer.toLowerCase();
|
|
1004
|
+
const hit = cache.get(key);
|
|
1005
|
+
if (hit && now() - hit.computedAtMs < ttlMs) return hit;
|
|
1006
|
+
const pending = inflight.get(key);
|
|
1007
|
+
if (pending) return pending;
|
|
1008
|
+
const promise = load(deployer).then((record) => {
|
|
1009
|
+
cache.set(key, record);
|
|
1010
|
+
return record;
|
|
1011
|
+
}).finally(() => {
|
|
1012
|
+
inflight.delete(key);
|
|
1013
|
+
});
|
|
1014
|
+
inflight.set(key, promise);
|
|
1015
|
+
return promise;
|
|
1016
|
+
},
|
|
1017
|
+
isLpLocked
|
|
1018
|
+
};
|
|
1019
|
+
}
|
|
1020
|
+
function priorLaunchesBefore(record, blockNumber) {
|
|
1021
|
+
return record.launches.filter((launch) => launch.blockNumber < blockNumber).length;
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
// src/rules/engine.ts
|
|
1025
|
+
var matchOk = (rule) => ({ matched: true, reason: `rule ${rule.id} matched` });
|
|
1026
|
+
var miss = (reason) => ({ matched: false, reason });
|
|
1027
|
+
function eventSide(event) {
|
|
1028
|
+
if (event.kind === "curve_trade" || event.kind === "whale_trade") return event.side;
|
|
1029
|
+
return null;
|
|
1030
|
+
}
|
|
1031
|
+
function eventPositionId(event) {
|
|
1032
|
+
if (event.kind === "launch") return event.positionId;
|
|
1033
|
+
if (event.kind === "graduation") return event.positionId;
|
|
1034
|
+
return null;
|
|
1035
|
+
}
|
|
1036
|
+
async function evaluateRule(rule, event, context = {}) {
|
|
1037
|
+
if (!rule.enabled) return miss(`rule ${rule.id} is disabled`);
|
|
1038
|
+
if (!rule.kinds.includes(event.kind)) {
|
|
1039
|
+
return miss(`kind ${event.kind} not in [${rule.kinds.join(", ")}]`);
|
|
1040
|
+
}
|
|
1041
|
+
if (!rule.launchpads.includes(event.launchpad)) {
|
|
1042
|
+
return miss(`launchpad ${event.launchpad} not in [${rule.launchpads.join(", ")}]`);
|
|
1043
|
+
}
|
|
1044
|
+
const actor = event.actor.toLowerCase();
|
|
1045
|
+
if (rule.excludeDeployers.includes(actor)) {
|
|
1046
|
+
return miss(`actor ${event.actor} is on the exclude list`);
|
|
1047
|
+
}
|
|
1048
|
+
if (rule.tokens.length > 0 && !rule.tokens.includes(event.token.toLowerCase())) {
|
|
1049
|
+
return miss(`token ${event.token} is not on the watchlist`);
|
|
1050
|
+
}
|
|
1051
|
+
if (rule.deployers.length > 0 && !rule.deployers.includes(actor)) {
|
|
1052
|
+
return miss(`actor ${event.actor} is not on the deployer watchlist`);
|
|
1053
|
+
}
|
|
1054
|
+
if (rule.side !== "any") {
|
|
1055
|
+
const side = eventSide(event);
|
|
1056
|
+
if (side === null) return miss(`side filter does not apply to ${event.kind} events`);
|
|
1057
|
+
if (side !== rule.side) return miss(`side ${side} is not ${rule.side}`);
|
|
1058
|
+
}
|
|
1059
|
+
if (rule.minUsd !== void 0) {
|
|
1060
|
+
if (event.usdValue === null) return miss("event has no USD value to compare against minUsd");
|
|
1061
|
+
if (event.usdValue < rule.minUsd) {
|
|
1062
|
+
return miss(`usdValue ${event.usdValue.toFixed(2)} below minUsd ${rule.minUsd}`);
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
if (rule.maxUsd !== void 0) {
|
|
1066
|
+
if (event.usdValue === null) return miss("event has no USD value to compare against maxUsd");
|
|
1067
|
+
if (event.usdValue > rule.maxUsd) {
|
|
1068
|
+
return miss(`usdValue ${event.usdValue.toFixed(2)} above maxUsd ${rule.maxUsd}`);
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
if (rule.minLiquidityUsd !== void 0 || rule.maxLiquidityUsd !== void 0) {
|
|
1072
|
+
if (!context.liquidityUsd) {
|
|
1073
|
+
return miss("rule filters on liquidity but no liquidity reader was provided");
|
|
1074
|
+
}
|
|
1075
|
+
const liquidity = await context.liquidityUsd(event);
|
|
1076
|
+
if (liquidity === null) return miss("token liquidity is unknown");
|
|
1077
|
+
if (rule.minLiquidityUsd !== void 0 && liquidity < rule.minLiquidityUsd) {
|
|
1078
|
+
return miss(`liquidity ${liquidity.toFixed(2)} below minLiquidityUsd ${rule.minLiquidityUsd}`);
|
|
1079
|
+
}
|
|
1080
|
+
if (rule.maxLiquidityUsd !== void 0 && liquidity > rule.maxLiquidityUsd) {
|
|
1081
|
+
return miss(`liquidity ${liquidity.toFixed(2)} above maxLiquidityUsd ${rule.maxLiquidityUsd}`);
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
const reputationFilter = rule.reputation;
|
|
1085
|
+
if (reputationFilter) {
|
|
1086
|
+
const needsRecord = reputationFilter.minPriorLaunches !== void 0 || reputationFilter.maxPriorLaunches !== void 0 || reputationFilter.maxRuggedLaunches !== void 0;
|
|
1087
|
+
if (needsRecord) {
|
|
1088
|
+
if (!context.reputation) {
|
|
1089
|
+
return miss("rule filters on reputation but no reputation provider was configured");
|
|
1090
|
+
}
|
|
1091
|
+
const record = await context.reputation(event.actor);
|
|
1092
|
+
const prior = priorLaunchesBefore(record, event.blockNumber);
|
|
1093
|
+
if (reputationFilter.minPriorLaunches !== void 0 && prior < reputationFilter.minPriorLaunches) {
|
|
1094
|
+
return miss(`deployer had ${prior} prior launches, needs ${reputationFilter.minPriorLaunches}`);
|
|
1095
|
+
}
|
|
1096
|
+
if (reputationFilter.maxPriorLaunches !== void 0 && prior > reputationFilter.maxPriorLaunches) {
|
|
1097
|
+
return miss(`deployer had ${prior} prior launches, max is ${reputationFilter.maxPriorLaunches}`);
|
|
1098
|
+
}
|
|
1099
|
+
if (reputationFilter.maxRuggedLaunches !== void 0 && record.ruggedLaunches > reputationFilter.maxRuggedLaunches) {
|
|
1100
|
+
return miss(
|
|
1101
|
+
`deployer has ${record.ruggedLaunches} drained pools, max is ${reputationFilter.maxRuggedLaunches}`
|
|
1102
|
+
);
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
if (reputationFilter.requireLpLocked === true) {
|
|
1106
|
+
const positionId = eventPositionId(event);
|
|
1107
|
+
if (positionId === null) return miss("event carries no LP position to check for a lock");
|
|
1108
|
+
if (!context.isLpLocked) {
|
|
1109
|
+
return miss("rule requires an LP lock check but no reputation provider was configured");
|
|
1110
|
+
}
|
|
1111
|
+
if (!await context.isLpLocked(positionId)) {
|
|
1112
|
+
return miss(`LP position ${positionId} is not held by the launchpad locker`);
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
return matchOk(rule);
|
|
1117
|
+
}
|
|
1118
|
+
async function matchRules(rules, event, context = {}) {
|
|
1119
|
+
const results = await Promise.all(
|
|
1120
|
+
rules.map(async (rule) => {
|
|
1121
|
+
try {
|
|
1122
|
+
return { rule, result: await evaluateRule(rule, event, context) };
|
|
1123
|
+
} catch (error) {
|
|
1124
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1125
|
+
return { rule, result: miss(`evaluation failed: ${message}`) };
|
|
1126
|
+
}
|
|
1127
|
+
})
|
|
1128
|
+
);
|
|
1129
|
+
return results.filter((entry) => entry.result.matched);
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
// src/rules/ratelimit.ts
|
|
1133
|
+
var ALLOWED = { allowed: true, reason: null, retryAtMs: null };
|
|
1134
|
+
async function checkRateLimit(store, key, config, nowMs) {
|
|
1135
|
+
if (!config) return ALLOWED;
|
|
1136
|
+
if (config.minIntervalSeconds !== void 0) {
|
|
1137
|
+
const last = await store.lastAt(key);
|
|
1138
|
+
if (last !== null) {
|
|
1139
|
+
const nextAllowed = last + config.minIntervalSeconds * 1e3;
|
|
1140
|
+
if (nowMs < nextAllowed) {
|
|
1141
|
+
return {
|
|
1142
|
+
allowed: false,
|
|
1143
|
+
reason: `minIntervalSeconds=${config.minIntervalSeconds} not elapsed`,
|
|
1144
|
+
retryAtMs: nextAllowed
|
|
1145
|
+
};
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
if (config.maxPerHour !== void 0) {
|
|
1150
|
+
const windowStart = nowMs - 36e5;
|
|
1151
|
+
const used = await store.countSince(key, windowStart);
|
|
1152
|
+
if (used >= config.maxPerHour) {
|
|
1153
|
+
return {
|
|
1154
|
+
allowed: false,
|
|
1155
|
+
reason: `maxPerHour=${config.maxPerHour} reached (${used} in the last hour)`,
|
|
1156
|
+
retryAtMs: windowStart + 36e5
|
|
1157
|
+
};
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
return ALLOWED;
|
|
1161
|
+
}
|
|
1162
|
+
function rateLimitKey(subscriptionId, ruleId) {
|
|
1163
|
+
return `${subscriptionId}:${ruleId}`;
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
// src/notifiers/format.ts
|
|
1167
|
+
var import_viem4 = require("viem");
|
|
1168
|
+
|
|
1169
|
+
// src/notifiers/escape.ts
|
|
1170
|
+
var MARKDOWN_V2_RESERVED = "_*[]()~`>#+-=|{}.!";
|
|
1171
|
+
function escapeMarkdownV2(text) {
|
|
1172
|
+
let out = "";
|
|
1173
|
+
for (const char of text) {
|
|
1174
|
+
if (MARKDOWN_V2_RESERVED.includes(char)) out += "\\";
|
|
1175
|
+
out += char;
|
|
1176
|
+
}
|
|
1177
|
+
return out;
|
|
1178
|
+
}
|
|
1179
|
+
function escapeMarkdownV2Url(url) {
|
|
1180
|
+
return url.replace(/[\\)]/g, (char) => `\\${char}`);
|
|
1181
|
+
}
|
|
1182
|
+
function markdownV2Link(label, url) {
|
|
1183
|
+
return `[${escapeMarkdownV2(label)}](${escapeMarkdownV2Url(url)})`;
|
|
1184
|
+
}
|
|
1185
|
+
function escapeHtml(text) {
|
|
1186
|
+
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
1187
|
+
}
|
|
1188
|
+
function escapeHtmlAttribute(value) {
|
|
1189
|
+
return escapeHtml(value).replace(/"/g, """);
|
|
1190
|
+
}
|
|
1191
|
+
function htmlLink(label, url) {
|
|
1192
|
+
return `<a href="${escapeHtmlAttribute(url)}">${escapeHtml(label)}</a>`;
|
|
1193
|
+
}
|
|
1194
|
+
var DISCORD_RESERVED = /[\\*_~`|>[\]()]/g;
|
|
1195
|
+
function escapeDiscordMarkdown(text) {
|
|
1196
|
+
return text.replace(DISCORD_RESERVED, (char) => `\\${char}`);
|
|
1197
|
+
}
|
|
1198
|
+
function truncate(text, max) {
|
|
1199
|
+
if (max <= 0) return "";
|
|
1200
|
+
const chars = [...text];
|
|
1201
|
+
if (chars.length <= max) return text;
|
|
1202
|
+
if (max === 1) return "\u2026";
|
|
1203
|
+
return `${chars.slice(0, max - 1).join("")}\u2026`;
|
|
1204
|
+
}
|
|
1205
|
+
function displayLabel(symbol, address) {
|
|
1206
|
+
const trimmed = symbol?.trim();
|
|
1207
|
+
if (trimmed) return truncate(trimmed, 40);
|
|
1208
|
+
return shortAddress(address);
|
|
1209
|
+
}
|
|
1210
|
+
function shortAddress(address) {
|
|
1211
|
+
if (address.length <= 12) return address;
|
|
1212
|
+
return `${address.slice(0, 6)}\u2026${address.slice(-4)}`;
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
// src/notifiers/format.ts
|
|
1216
|
+
var EMBED_COLORS = {
|
|
1217
|
+
launch: 2278750,
|
|
1218
|
+
curve_trade: 3718648,
|
|
1219
|
+
graduation: 11032055,
|
|
1220
|
+
whale_trade: 16096779
|
|
1221
|
+
};
|
|
1222
|
+
var KIND_EMOJI = {
|
|
1223
|
+
launch: "\u{1F680}",
|
|
1224
|
+
curve_trade: "\u{1F4C8}",
|
|
1225
|
+
graduation: "\u{1F393}",
|
|
1226
|
+
whale_trade: "\u{1F40B}"
|
|
1227
|
+
};
|
|
1228
|
+
var LAUNCHPAD_NAMES = { noxa: "NOXA", odyssey: "The Odyssey" };
|
|
1229
|
+
var usdFormatter = new Intl.NumberFormat("en-US", {
|
|
1230
|
+
style: "currency",
|
|
1231
|
+
currency: "USD",
|
|
1232
|
+
maximumFractionDigits: 2
|
|
1233
|
+
});
|
|
1234
|
+
function formatUsd(value) {
|
|
1235
|
+
if (value === null || !Number.isFinite(value)) return "unknown";
|
|
1236
|
+
const abs = Math.abs(value);
|
|
1237
|
+
if (abs >= 1e6) return `$${(value / 1e6).toFixed(2)}M`;
|
|
1238
|
+
if (abs >= 1e4) return `$${(value / 1e3).toFixed(1)}K`;
|
|
1239
|
+
return usdFormatter.format(value);
|
|
1240
|
+
}
|
|
1241
|
+
function formatAmount(value) {
|
|
1242
|
+
const abs = Math.abs(value);
|
|
1243
|
+
if (abs === 0) return "0";
|
|
1244
|
+
if (abs >= 1e9) return `${(value / 1e9).toFixed(2)}B`;
|
|
1245
|
+
if (abs >= 1e6) return `${(value / 1e6).toFixed(2)}M`;
|
|
1246
|
+
if (abs >= 1e3) return `${(value / 1e3).toFixed(2)}K`;
|
|
1247
|
+
if (abs >= 1) return value.toFixed(4).replace(/\.?0+$/, "");
|
|
1248
|
+
return value.toPrecision(4);
|
|
1249
|
+
}
|
|
1250
|
+
function formatEth(wei) {
|
|
1251
|
+
return `${formatAmount(Number((0, import_viem4.formatUnits)(wei < 0n ? -wei : wei, 18)))} ETH`;
|
|
1252
|
+
}
|
|
1253
|
+
function headline(event) {
|
|
1254
|
+
const label = displayLabel(event.symbol, event.token);
|
|
1255
|
+
const pad = LAUNCHPAD_NAMES[event.launchpad];
|
|
1256
|
+
switch (event.kind) {
|
|
1257
|
+
case "launch":
|
|
1258
|
+
return `${KIND_EMOJI.launch} New launch: ${label} on ${pad}`;
|
|
1259
|
+
case "curve_trade":
|
|
1260
|
+
return `${KIND_EMOJI.curve_trade} Curve ${event.side}: ${label} for ${formatUsd(event.usdValue)}`;
|
|
1261
|
+
case "graduation":
|
|
1262
|
+
return `${KIND_EMOJI.graduation} Graduated: ${label} filled its curve`;
|
|
1263
|
+
case "whale_trade":
|
|
1264
|
+
return `${KIND_EMOJI.whale_trade} Whale ${event.side}: ${formatUsd(event.usdValue)} of ${label}`;
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
function detailRows(event) {
|
|
1268
|
+
const rows = [];
|
|
1269
|
+
rows.push({ label: "Launchpad", value: LAUNCHPAD_NAMES[event.launchpad], inline: true });
|
|
1270
|
+
if (event.name) rows.push({ label: "Name", value: truncate(event.name, 64), inline: true });
|
|
1271
|
+
rows.push({ label: "Token", value: shortAddress(event.token), inline: true });
|
|
1272
|
+
switch (event.kind) {
|
|
1273
|
+
case "launch": {
|
|
1274
|
+
rows.push({
|
|
1275
|
+
label: "Listing",
|
|
1276
|
+
value: event.instantListing ? "Instant (live Uniswap v3 pool, no curve)" : "Bonding curve (graduates when filled)",
|
|
1277
|
+
inline: true
|
|
1278
|
+
});
|
|
1279
|
+
rows.push({ label: "Deployer", value: shortAddress(event.actor), inline: true });
|
|
1280
|
+
if (event.initialBuyAmount > 0n) {
|
|
1281
|
+
rows.push({ label: "Initial buy", value: formatUsd(event.usdValue), inline: true });
|
|
1282
|
+
}
|
|
1283
|
+
break;
|
|
1284
|
+
}
|
|
1285
|
+
case "curve_trade": {
|
|
1286
|
+
rows.push({ label: "Side", value: event.side === "buy" ? "Buy" : "Sell", inline: true });
|
|
1287
|
+
rows.push({ label: "Size", value: formatEth(event.quoteAmountWei), inline: true });
|
|
1288
|
+
rows.push({ label: "Value", value: formatUsd(event.usdValue), inline: true });
|
|
1289
|
+
rows.push({
|
|
1290
|
+
label: "Tokens",
|
|
1291
|
+
value: formatAmount(Number((0, import_viem4.formatUnits)(event.tokenAmount, 18))),
|
|
1292
|
+
inline: true
|
|
1293
|
+
});
|
|
1294
|
+
rows.push({ label: "Trader", value: shortAddress(event.actor), inline: true });
|
|
1295
|
+
break;
|
|
1296
|
+
}
|
|
1297
|
+
case "graduation": {
|
|
1298
|
+
rows.push({ label: "Pool", value: shortAddress(event.pool), inline: true });
|
|
1299
|
+
rows.push({ label: "Seeded", value: formatUsd(event.usdValue), inline: true });
|
|
1300
|
+
rows.push({ label: "LP position", value: `#${event.positionId}`, inline: true });
|
|
1301
|
+
break;
|
|
1302
|
+
}
|
|
1303
|
+
case "whale_trade": {
|
|
1304
|
+
rows.push({ label: "Side", value: event.side === "buy" ? "Buy" : "Sell", inline: true });
|
|
1305
|
+
rows.push({ label: "Value", value: formatUsd(event.usdValue), inline: true });
|
|
1306
|
+
rows.push({
|
|
1307
|
+
label: "Size",
|
|
1308
|
+
value: `${formatAmount(event.tokenAmount)} for ${formatAmount(event.quoteAmount)} ${event.quoteSymbol}`,
|
|
1309
|
+
inline: true
|
|
1310
|
+
});
|
|
1311
|
+
rows.push({ label: "Price", value: `${formatAmount(event.price)} ${event.quoteSymbol}`, inline: true });
|
|
1312
|
+
rows.push({ label: "Fee tier", value: `${event.feeTier / 1e4}%`, inline: true });
|
|
1313
|
+
rows.push({ label: "Trader", value: shortAddress(event.actor), inline: true });
|
|
1314
|
+
break;
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
return rows;
|
|
1318
|
+
}
|
|
1319
|
+
function renderAlert(event, options = {}) {
|
|
1320
|
+
const parseMode = options.parseMode ?? "HTML";
|
|
1321
|
+
const title = headline(event);
|
|
1322
|
+
const rows = detailRows(event);
|
|
1323
|
+
const label = displayLabel(event.symbol, event.token);
|
|
1324
|
+
const linkPairs = [
|
|
1325
|
+
{ label: "Transaction", url: event.explorer.tx },
|
|
1326
|
+
{ label: "Token", url: event.explorer.token }
|
|
1327
|
+
];
|
|
1328
|
+
if (event.explorer.pool) linkPairs.push({ label: "Pool", url: event.explorer.pool });
|
|
1329
|
+
linkPairs.push({ label: "Actor", url: event.explorer.actor });
|
|
1330
|
+
const text = parseMode === "HTML" ? [
|
|
1331
|
+
`<b>${escapeHtml(title)}</b>`,
|
|
1332
|
+
"",
|
|
1333
|
+
...rows.map((row) => `${escapeHtml(row.label)}: <b>${escapeHtml(row.value)}</b>`),
|
|
1334
|
+
`Contract: <code>${escapeHtml(event.token)}</code>`,
|
|
1335
|
+
"",
|
|
1336
|
+
linkPairs.map((link) => htmlLink(link.label, link.url)).join(" \xB7 "),
|
|
1337
|
+
...options.footer ? ["", `<i>${escapeHtml(options.footer)}</i>`] : []
|
|
1338
|
+
].join("\n") : [
|
|
1339
|
+
`*${escapeMarkdownV2(title)}*`,
|
|
1340
|
+
"",
|
|
1341
|
+
...rows.map((row) => `${escapeMarkdownV2(row.label)}: *${escapeMarkdownV2(row.value)}*`),
|
|
1342
|
+
`Contract: \`${event.token}\``,
|
|
1343
|
+
"",
|
|
1344
|
+
linkPairs.map((link) => markdownV2Link(link.label, link.url)).join(" \xB7 "),
|
|
1345
|
+
...options.footer ? ["", `_${escapeMarkdownV2(options.footer)}_`] : []
|
|
1346
|
+
].join("\n");
|
|
1347
|
+
const fields = rows.map((row) => ({
|
|
1348
|
+
name: truncate(row.label, 256),
|
|
1349
|
+
value: truncate(escapeDiscordMarkdown(row.value), 1024),
|
|
1350
|
+
inline: row.inline ?? false
|
|
1351
|
+
}));
|
|
1352
|
+
fields.push({
|
|
1353
|
+
name: "Links",
|
|
1354
|
+
value: linkPairs.map((link) => `[${link.label}](${link.url})`).join(" \xB7 "),
|
|
1355
|
+
inline: false
|
|
1356
|
+
});
|
|
1357
|
+
const embed = {
|
|
1358
|
+
title: truncate(escapeDiscordMarkdown(title), 256),
|
|
1359
|
+
url: event.explorer.tx,
|
|
1360
|
+
description: `\`${event.token}\``,
|
|
1361
|
+
color: EMBED_COLORS[event.kind],
|
|
1362
|
+
fields,
|
|
1363
|
+
...options.footer ? { footer: { text: truncate(options.footer, 2048) } } : {},
|
|
1364
|
+
...event.timestampMs !== null ? { timestamp: new Date(event.timestampMs).toISOString() } : {}
|
|
1365
|
+
};
|
|
1366
|
+
const plain = `${title} | ${label} ${event.token} | block ${event.blockNumber} | ${event.explorer.tx}`;
|
|
1367
|
+
return { text, parseMode, embed, plain };
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
// src/service/dispatcher.ts
|
|
1371
|
+
function createDispatcher(options) {
|
|
1372
|
+
const {
|
|
1373
|
+
store,
|
|
1374
|
+
sources,
|
|
1375
|
+
gate,
|
|
1376
|
+
rateLimits,
|
|
1377
|
+
notifierFor,
|
|
1378
|
+
getBlockNumber,
|
|
1379
|
+
logger
|
|
1380
|
+
} = options;
|
|
1381
|
+
const confirmations = options.confirmations ?? 2n;
|
|
1382
|
+
const chunkSize = options.chunkSize ?? 2000n;
|
|
1383
|
+
const initialLookback = options.initialLookbackBlocks ?? 5000n;
|
|
1384
|
+
const flushBatchSize = options.flushBatchSize ?? 50;
|
|
1385
|
+
const maxAttempts = options.maxDeliveryAttempts ?? 6;
|
|
1386
|
+
const retryBackoffMs = options.retryBackoffMs ?? 3e4;
|
|
1387
|
+
const now = options.now ?? Date.now;
|
|
1388
|
+
const evaluation = options.evaluation ?? {};
|
|
1389
|
+
async function fanOut(event, subscriptions) {
|
|
1390
|
+
let enqueued = 0;
|
|
1391
|
+
let deduped = 0;
|
|
1392
|
+
let suppressed = 0;
|
|
1393
|
+
for (const subscription of subscriptions) {
|
|
1394
|
+
const matches = await matchRules(subscription.rules, event, evaluation);
|
|
1395
|
+
if (matches.length === 0) continue;
|
|
1396
|
+
const { policy } = await gate.resolve(subscription.subscriberId);
|
|
1397
|
+
for (const { rule } of matches) {
|
|
1398
|
+
if (!policy.allowedKinds.includes(event.kind)) {
|
|
1399
|
+
suppressed += 1;
|
|
1400
|
+
continue;
|
|
1401
|
+
}
|
|
1402
|
+
const key = rateLimitKey(subscription.id, rule.id);
|
|
1403
|
+
const limit = await checkRateLimit(rateLimits, key, rule.rateLimit, now());
|
|
1404
|
+
if (!limit.allowed) {
|
|
1405
|
+
suppressed += 1;
|
|
1406
|
+
logger.debug("rate limited", { subscription: subscription.id, rule: rule.id, reason: limit.reason });
|
|
1407
|
+
continue;
|
|
1408
|
+
}
|
|
1409
|
+
const usedThisHour = await rateLimits.countForSubscriberSince(
|
|
1410
|
+
subscription.subscriberId,
|
|
1411
|
+
now() - 36e5
|
|
1412
|
+
);
|
|
1413
|
+
const throughput = await gate.checkThroughput(subscription.subscriberId, usedThisHour);
|
|
1414
|
+
if (!throughput.allowed) {
|
|
1415
|
+
suppressed += 1;
|
|
1416
|
+
logger.debug("throughput capped", {
|
|
1417
|
+
subscriber: subscription.subscriberId,
|
|
1418
|
+
reason: throughput.reason
|
|
1419
|
+
});
|
|
1420
|
+
continue;
|
|
1421
|
+
}
|
|
1422
|
+
const id = AlertStore.deliveryKey(event.id, subscription.id, rule.id);
|
|
1423
|
+
const created = store.enqueue({
|
|
1424
|
+
id,
|
|
1425
|
+
subscriptionId: subscription.id,
|
|
1426
|
+
subscriberId: subscription.subscriberId,
|
|
1427
|
+
ruleId: rule.id,
|
|
1428
|
+
eventId: event.id,
|
|
1429
|
+
eventJson: encodeEvent(event),
|
|
1430
|
+
platform: subscription.platform,
|
|
1431
|
+
target: subscription.target,
|
|
1432
|
+
notBeforeMs: now() + policy.deliveryDelayMs
|
|
1433
|
+
});
|
|
1434
|
+
if (!created) {
|
|
1435
|
+
deduped += 1;
|
|
1436
|
+
continue;
|
|
1437
|
+
}
|
|
1438
|
+
enqueued += 1;
|
|
1439
|
+
await rateLimits.record(key, subscription.subscriberId, now());
|
|
1440
|
+
}
|
|
1441
|
+
}
|
|
1442
|
+
return { enqueued, deduped, suppressed };
|
|
1443
|
+
}
|
|
1444
|
+
return {
|
|
1445
|
+
recover() {
|
|
1446
|
+
const recovered = store.recoverInFlight();
|
|
1447
|
+
if (recovered > 0) logger.warn("recovered in-flight deliveries after restart", { recovered });
|
|
1448
|
+
return recovered;
|
|
1449
|
+
},
|
|
1450
|
+
async pollOnce() {
|
|
1451
|
+
const stats = {
|
|
1452
|
+
ranges: [],
|
|
1453
|
+
eventsDecoded: 0,
|
|
1454
|
+
enqueued: 0,
|
|
1455
|
+
deduped: 0,
|
|
1456
|
+
suppressed: 0,
|
|
1457
|
+
errors: []
|
|
1458
|
+
};
|
|
1459
|
+
const head = await getBlockNumber();
|
|
1460
|
+
const safeHead = head > confirmations ? head - confirmations : 0n;
|
|
1461
|
+
const subscriptions = store.listEnabledSubscriptions();
|
|
1462
|
+
for (const source of sources) {
|
|
1463
|
+
const persisted = store.getCursor(source.id);
|
|
1464
|
+
const fallback = safeHead > initialLookback ? safeHead - initialLookback : source.startBlock;
|
|
1465
|
+
const from = persisted ?? (fallback > source.startBlock ? fallback : source.startBlock);
|
|
1466
|
+
if (from > safeHead) continue;
|
|
1467
|
+
const to = from + chunkSize - 1n > safeHead ? safeHead : from + chunkSize - 1n;
|
|
1468
|
+
try {
|
|
1469
|
+
const events = await source.poll(from, to);
|
|
1470
|
+
stats.eventsDecoded += events.length;
|
|
1471
|
+
for (const event of events) {
|
|
1472
|
+
const result = await fanOut(event, subscriptions);
|
|
1473
|
+
stats.enqueued += result.enqueued;
|
|
1474
|
+
stats.deduped += result.deduped;
|
|
1475
|
+
stats.suppressed += result.suppressed;
|
|
1476
|
+
}
|
|
1477
|
+
store.setCursor(source.id, to + 1n);
|
|
1478
|
+
stats.ranges.push({ sourceId: source.id, from, to });
|
|
1479
|
+
} catch (error) {
|
|
1480
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1481
|
+
stats.errors.push({ sourceId: source.id, error: message });
|
|
1482
|
+
logger.error("source poll failed, cursor not advanced", {
|
|
1483
|
+
source: source.id,
|
|
1484
|
+
from,
|
|
1485
|
+
to,
|
|
1486
|
+
error: message
|
|
1487
|
+
});
|
|
1488
|
+
}
|
|
1489
|
+
}
|
|
1490
|
+
return stats;
|
|
1491
|
+
},
|
|
1492
|
+
async flushOnce() {
|
|
1493
|
+
const stats = { claimed: 0, sent: 0, retried: 0, dead: 0 };
|
|
1494
|
+
const rows = store.claimDue(now(), flushBatchSize);
|
|
1495
|
+
stats.claimed = rows.length;
|
|
1496
|
+
for (const row of rows) {
|
|
1497
|
+
const outcome = await deliver2(row);
|
|
1498
|
+
if (outcome === "sent") stats.sent += 1;
|
|
1499
|
+
else if (outcome === "retry") stats.retried += 1;
|
|
1500
|
+
else stats.dead += 1;
|
|
1501
|
+
}
|
|
1502
|
+
return stats;
|
|
1503
|
+
}
|
|
1504
|
+
};
|
|
1505
|
+
async function deliver2(row) {
|
|
1506
|
+
const subscription = store.getSubscription(row.subscriptionId);
|
|
1507
|
+
if (!subscription) {
|
|
1508
|
+
store.markDead(row.id, row.attempts + 1, "subscription no longer exists");
|
|
1509
|
+
return "dead";
|
|
1510
|
+
}
|
|
1511
|
+
const notifier = notifierFor(subscription);
|
|
1512
|
+
if (!notifier) {
|
|
1513
|
+
store.markDead(row.id, row.attempts + 1, `no notifier configured for platform ${row.platform}`);
|
|
1514
|
+
return "dead";
|
|
1515
|
+
}
|
|
1516
|
+
let event;
|
|
1517
|
+
try {
|
|
1518
|
+
event = decodeEvent(row.eventJson);
|
|
1519
|
+
} catch (error) {
|
|
1520
|
+
store.markDead(row.id, row.attempts + 1, `corrupt event payload: ${String(error)}`);
|
|
1521
|
+
return "dead";
|
|
1522
|
+
}
|
|
1523
|
+
const alert = renderAlert(event, {
|
|
1524
|
+
...options.render,
|
|
1525
|
+
footer: options.render?.footer ?? `rule: ${row.ruleId}`
|
|
1526
|
+
});
|
|
1527
|
+
const result = await notifier.send(row.target, alert);
|
|
1528
|
+
const attempts = row.attempts + 1;
|
|
1529
|
+
if (result.ok) {
|
|
1530
|
+
store.markSent(row.id);
|
|
1531
|
+
logger.info("alert delivered", {
|
|
1532
|
+
id: row.id,
|
|
1533
|
+
platform: notifier.platform,
|
|
1534
|
+
kind: event.kind,
|
|
1535
|
+
token: event.token,
|
|
1536
|
+
attempts: result.attempts
|
|
1537
|
+
});
|
|
1538
|
+
return "sent";
|
|
1539
|
+
}
|
|
1540
|
+
if (!result.retryable || attempts >= maxAttempts) {
|
|
1541
|
+
store.markDead(row.id, attempts, result.error ?? "delivery failed");
|
|
1542
|
+
logger.error("alert dead-lettered", {
|
|
1543
|
+
id: row.id,
|
|
1544
|
+
platform: notifier.platform,
|
|
1545
|
+
attempts,
|
|
1546
|
+
retryable: result.retryable,
|
|
1547
|
+
error: result.error
|
|
1548
|
+
});
|
|
1549
|
+
return "dead";
|
|
1550
|
+
}
|
|
1551
|
+
const nextAttemptAt = now() + retryBackoffMs * 2 ** (attempts - 1);
|
|
1552
|
+
store.markFailed(row.id, attempts, result.error ?? "delivery failed", nextAttemptAt);
|
|
1553
|
+
logger.warn("alert delivery failed, will retry", {
|
|
1554
|
+
id: row.id,
|
|
1555
|
+
attempts,
|
|
1556
|
+
nextAttemptAt,
|
|
1557
|
+
error: result.error
|
|
1558
|
+
});
|
|
1559
|
+
return "retry";
|
|
1560
|
+
}
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
// src/service/http.ts
|
|
1564
|
+
var import_node_http = require("http");
|
|
1565
|
+
function json(res, status, body) {
|
|
1566
|
+
const payload = JSON.stringify(
|
|
1567
|
+
body,
|
|
1568
|
+
(_key, value) => typeof value === "bigint" ? value.toString() : value
|
|
1569
|
+
);
|
|
1570
|
+
res.writeHead(status, { "content-type": "application/json; charset=utf-8" });
|
|
1571
|
+
res.end(payload);
|
|
1572
|
+
}
|
|
1573
|
+
async function readBody(req, limitBytes) {
|
|
1574
|
+
const chunks = [];
|
|
1575
|
+
let size = 0;
|
|
1576
|
+
for await (const chunk of req) {
|
|
1577
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
|
|
1578
|
+
size += buffer.length;
|
|
1579
|
+
if (size > limitBytes) return null;
|
|
1580
|
+
chunks.push(buffer);
|
|
1581
|
+
}
|
|
1582
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
1583
|
+
}
|
|
1584
|
+
function renderMetrics(store, health, nowMs) {
|
|
1585
|
+
const metrics = store.metrics();
|
|
1586
|
+
const lines = [
|
|
1587
|
+
"# HELP hood_alerts_subscriptions Subscriptions by state.",
|
|
1588
|
+
"# TYPE hood_alerts_subscriptions gauge",
|
|
1589
|
+
`hood_alerts_subscriptions{state="total"} ${metrics.subscriptions}`,
|
|
1590
|
+
`hood_alerts_subscriptions{state="enabled"} ${metrics.enabledSubscriptions}`,
|
|
1591
|
+
"# HELP hood_alerts_outbox Deliveries by outbox status.",
|
|
1592
|
+
"# TYPE hood_alerts_outbox gauge"
|
|
1593
|
+
];
|
|
1594
|
+
for (const [status, count] of Object.entries(metrics.outbox)) {
|
|
1595
|
+
lines.push(`hood_alerts_outbox{status="${status}"} ${count}`);
|
|
1596
|
+
}
|
|
1597
|
+
lines.push(
|
|
1598
|
+
"# HELP hood_alerts_deliveries_last_hour Alerts delivered in the last rolling hour.",
|
|
1599
|
+
"# TYPE hood_alerts_deliveries_last_hour gauge",
|
|
1600
|
+
`hood_alerts_deliveries_last_hour ${metrics.deliveriesLastHour}`,
|
|
1601
|
+
"# HELP hood_alerts_pools Memecoin pools tracked for whale trades.",
|
|
1602
|
+
"# TYPE hood_alerts_pools gauge",
|
|
1603
|
+
`hood_alerts_pools ${metrics.pools}`,
|
|
1604
|
+
"# HELP hood_alerts_cursor_block Next unprocessed block per source.",
|
|
1605
|
+
"# TYPE hood_alerts_cursor_block gauge"
|
|
1606
|
+
);
|
|
1607
|
+
for (const cursor of metrics.cursors) {
|
|
1608
|
+
lines.push(`hood_alerts_cursor_block{source="${cursor.sourceId}"} ${cursor.nextBlock}`);
|
|
1609
|
+
}
|
|
1610
|
+
lines.push(
|
|
1611
|
+
"# HELP hood_alerts_head_block Chain head block last observed.",
|
|
1612
|
+
"# TYPE hood_alerts_head_block gauge",
|
|
1613
|
+
`hood_alerts_head_block ${health.headBlock ?? 0}`,
|
|
1614
|
+
"# HELP hood_alerts_lag_blocks How far each source trails the chain head.",
|
|
1615
|
+
"# TYPE hood_alerts_lag_blocks gauge"
|
|
1616
|
+
);
|
|
1617
|
+
if (health.headBlock !== null) {
|
|
1618
|
+
for (const cursor of metrics.cursors) {
|
|
1619
|
+
const lag = health.headBlock > cursor.nextBlock ? health.headBlock - cursor.nextBlock : 0n;
|
|
1620
|
+
lines.push(`hood_alerts_lag_blocks{source="${cursor.sourceId}"} ${lag}`);
|
|
1621
|
+
}
|
|
1622
|
+
}
|
|
1623
|
+
lines.push(
|
|
1624
|
+
"# HELP hood_alerts_last_poll_age_seconds Seconds since the last successful poll.",
|
|
1625
|
+
"# TYPE hood_alerts_last_poll_age_seconds gauge",
|
|
1626
|
+
`hood_alerts_last_poll_age_seconds ${health.lastPollAtMs === null ? -1 : Math.round((nowMs - health.lastPollAtMs) / 1e3)}`,
|
|
1627
|
+
"# HELP hood_alerts_uptime_seconds Process uptime.",
|
|
1628
|
+
"# TYPE hood_alerts_uptime_seconds counter",
|
|
1629
|
+
`hood_alerts_uptime_seconds ${Math.round((nowMs - health.startedAtMs) / 1e3)}`,
|
|
1630
|
+
""
|
|
1631
|
+
);
|
|
1632
|
+
return lines.join("\n");
|
|
1633
|
+
}
|
|
1634
|
+
function createHttpServer(options) {
|
|
1635
|
+
const now = options.now ?? Date.now;
|
|
1636
|
+
const stalenessMs = options.readinessStalenessMs ?? 12e4;
|
|
1637
|
+
return (0, import_node_http.createServer)((req, res) => {
|
|
1638
|
+
void (async () => {
|
|
1639
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
1640
|
+
const path = url.pathname;
|
|
1641
|
+
try {
|
|
1642
|
+
if (req.method === "GET" && (path === "/health" || path === "/healthz")) {
|
|
1643
|
+
const health = options.health();
|
|
1644
|
+
json(res, 200, {
|
|
1645
|
+
ok: true,
|
|
1646
|
+
version: health.version,
|
|
1647
|
+
uptimeSeconds: Math.round((now() - health.startedAtMs) / 1e3),
|
|
1648
|
+
headBlock: health.headBlock,
|
|
1649
|
+
lastPollAtMs: health.lastPollAtMs,
|
|
1650
|
+
lastError: health.lastError,
|
|
1651
|
+
platforms: health.platforms
|
|
1652
|
+
});
|
|
1653
|
+
return;
|
|
1654
|
+
}
|
|
1655
|
+
if (req.method === "GET" && path === "/ready") {
|
|
1656
|
+
const health = options.health();
|
|
1657
|
+
const fresh = health.lastPollAtMs !== null && now() - health.lastPollAtMs <= stalenessMs;
|
|
1658
|
+
json(res, fresh ? 200 : 503, {
|
|
1659
|
+
ready: fresh,
|
|
1660
|
+
lastPollAtMs: health.lastPollAtMs,
|
|
1661
|
+
lastError: health.lastError
|
|
1662
|
+
});
|
|
1663
|
+
return;
|
|
1664
|
+
}
|
|
1665
|
+
if (req.method === "GET" && path === "/metrics") {
|
|
1666
|
+
const body = renderMetrics(options.store, options.health(), now());
|
|
1667
|
+
res.writeHead(200, { "content-type": "text/plain; version=0.0.4; charset=utf-8" });
|
|
1668
|
+
res.end(body);
|
|
1669
|
+
return;
|
|
1670
|
+
}
|
|
1671
|
+
if (options.discord && req.method === "POST" && path === options.discord.path) {
|
|
1672
|
+
const rawBody = await readBody(req, 256 * 1024);
|
|
1673
|
+
if (rawBody === null) {
|
|
1674
|
+
json(res, 413, { error: "request body too large" });
|
|
1675
|
+
return;
|
|
1676
|
+
}
|
|
1677
|
+
const answer = await options.discord.handle({
|
|
1678
|
+
signature: req.headers["x-signature-ed25519"] ?? null,
|
|
1679
|
+
timestamp: req.headers["x-signature-timestamp"] ?? null,
|
|
1680
|
+
rawBody
|
|
1681
|
+
});
|
|
1682
|
+
json(res, answer.status, answer.body);
|
|
1683
|
+
return;
|
|
1684
|
+
}
|
|
1685
|
+
json(res, 404, { error: `no route for ${req.method ?? "GET"} ${path}` });
|
|
1686
|
+
} catch (error) {
|
|
1687
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1688
|
+
options.logger.error("http handler failed", { path, error: message });
|
|
1689
|
+
if (!res.headersSent) json(res, 500, { error: "internal error" });
|
|
1690
|
+
else res.end();
|
|
1691
|
+
}
|
|
1692
|
+
})();
|
|
1693
|
+
});
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
// src/service/logger.ts
|
|
1697
|
+
var LEVELS = ["debug", "info", "warn", "error"];
|
|
1698
|
+
function safeStringify(value) {
|
|
1699
|
+
return JSON.stringify(value, (_key, raw) => {
|
|
1700
|
+
if (typeof raw === "bigint") return raw.toString();
|
|
1701
|
+
if (raw instanceof Error) return { name: raw.name, message: raw.message, stack: raw.stack };
|
|
1702
|
+
return raw;
|
|
1703
|
+
});
|
|
1704
|
+
}
|
|
1705
|
+
function createLogger(options = {}) {
|
|
1706
|
+
const minimum = LEVELS.indexOf(options.level ?? "info");
|
|
1707
|
+
const write = options.write ?? ((line) => console.log(line));
|
|
1708
|
+
const now = options.now ?? Date.now;
|
|
1709
|
+
const base = options.base ?? {};
|
|
1710
|
+
const emit = (level, message, fields) => {
|
|
1711
|
+
if (LEVELS.indexOf(level) < minimum) return;
|
|
1712
|
+
write(
|
|
1713
|
+
safeStringify({
|
|
1714
|
+
time: new Date(now()).toISOString(),
|
|
1715
|
+
level,
|
|
1716
|
+
message,
|
|
1717
|
+
...base,
|
|
1718
|
+
...fields ?? {}
|
|
1719
|
+
})
|
|
1720
|
+
);
|
|
1721
|
+
};
|
|
1722
|
+
return {
|
|
1723
|
+
debug: (message, fields) => emit("debug", message, fields),
|
|
1724
|
+
info: (message, fields) => emit("info", message, fields),
|
|
1725
|
+
warn: (message, fields) => emit("warn", message, fields),
|
|
1726
|
+
error: (message, fields) => emit("error", message, fields),
|
|
1727
|
+
child(fields) {
|
|
1728
|
+
return createLogger({ ...options, base: { ...base, ...fields } });
|
|
1729
|
+
}
|
|
1730
|
+
};
|
|
1731
|
+
}
|
|
1732
|
+
function createSilentLogger() {
|
|
1733
|
+
const silent = {
|
|
1734
|
+
debug: () => void 0,
|
|
1735
|
+
info: () => void 0,
|
|
1736
|
+
warn: () => void 0,
|
|
1737
|
+
error: () => void 0,
|
|
1738
|
+
child: () => silent
|
|
1739
|
+
};
|
|
1740
|
+
return silent;
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
// src/service/runner.ts
|
|
1744
|
+
var import_hoodchain7 = require("hoodchain");
|
|
1745
|
+
|
|
1746
|
+
// src/events/sources.ts
|
|
1747
|
+
var import_viem5 = require("viem");
|
|
1748
|
+
var import_hoodchain3 = require("hoodchain");
|
|
1749
|
+
var import_hoodkit = require("hoodkit");
|
|
1750
|
+
|
|
1751
|
+
// src/events/explorer.ts
|
|
1752
|
+
var MAINNET_EXPLORER = "https://robinhoodchain.blockscout.com";
|
|
1753
|
+
var TESTNET_EXPLORER = "https://explorer.testnet.chain.robinhood.com";
|
|
1754
|
+
function explorerBase(network) {
|
|
1755
|
+
return network === "testnet" ? TESTNET_EXPLORER : MAINNET_EXPLORER;
|
|
1756
|
+
}
|
|
1757
|
+
function txUrl(base, hash) {
|
|
1758
|
+
return `${base}/tx/${hash}`;
|
|
1759
|
+
}
|
|
1760
|
+
function addressUrl(base, address) {
|
|
1761
|
+
return `${base}/address/${address}`;
|
|
1762
|
+
}
|
|
1763
|
+
function tokenUrl(base, token) {
|
|
1764
|
+
return `${base}/token/${token}`;
|
|
1765
|
+
}
|
|
1766
|
+
|
|
1767
|
+
// src/events/blocktime.ts
|
|
1768
|
+
function createBlockTimeReader(client, maxEntries = 2e3) {
|
|
1769
|
+
const cache = /* @__PURE__ */ new Map();
|
|
1770
|
+
function remember(block, ts) {
|
|
1771
|
+
if (cache.size >= maxEntries) {
|
|
1772
|
+
const oldest = cache.keys().next();
|
|
1773
|
+
if (!oldest.done) cache.delete(oldest.value);
|
|
1774
|
+
}
|
|
1775
|
+
cache.set(block.toString(), ts);
|
|
1776
|
+
}
|
|
1777
|
+
async function get(blockNumber) {
|
|
1778
|
+
const hit = cache.get(blockNumber.toString());
|
|
1779
|
+
if (hit !== void 0) return hit;
|
|
1780
|
+
try {
|
|
1781
|
+
const block = await client.public.getBlock({ blockNumber, includeTransactions: false });
|
|
1782
|
+
const ms = Number(block.timestamp) * 1e3;
|
|
1783
|
+
remember(blockNumber, ms);
|
|
1784
|
+
return ms;
|
|
1785
|
+
} catch {
|
|
1786
|
+
return null;
|
|
1787
|
+
}
|
|
1788
|
+
}
|
|
1789
|
+
return {
|
|
1790
|
+
get,
|
|
1791
|
+
async getMany(blocks, maxLookups) {
|
|
1792
|
+
const unique = [...new Set(blocks.map((b) => b.toString()))].map((b) => BigInt(b));
|
|
1793
|
+
const out = /* @__PURE__ */ new Map();
|
|
1794
|
+
let budget = maxLookups;
|
|
1795
|
+
for (const block of unique) {
|
|
1796
|
+
const cached = cache.get(block.toString());
|
|
1797
|
+
if (cached !== void 0) {
|
|
1798
|
+
out.set(block, cached);
|
|
1799
|
+
continue;
|
|
1800
|
+
}
|
|
1801
|
+
if (budget <= 0) {
|
|
1802
|
+
out.set(block, null);
|
|
1803
|
+
continue;
|
|
1804
|
+
}
|
|
1805
|
+
budget -= 1;
|
|
1806
|
+
out.set(block, await get(block));
|
|
1807
|
+
}
|
|
1808
|
+
return out;
|
|
1809
|
+
}
|
|
1810
|
+
};
|
|
1811
|
+
}
|
|
1812
|
+
|
|
1813
|
+
// src/events/sources.ts
|
|
1814
|
+
var ODYSSEY_FACTORIES2 = [
|
|
1815
|
+
import_hoodchain3.ODYSSEY_ADDRESSES.bondingCurveFactory,
|
|
1816
|
+
import_hoodchain3.ODYSSEY_ADDRESSES.reflectionFactory,
|
|
1817
|
+
import_hoodchain3.ODYSSEY_ADDRESSES.instantFactory
|
|
1818
|
+
];
|
|
1819
|
+
function eventId(kind, transactionHash, logIndex) {
|
|
1820
|
+
return `${kind}:${transactionHash.toLowerCase()}:${logIndex}`;
|
|
1821
|
+
}
|
|
1822
|
+
function links(base, token, actor, hash, pool) {
|
|
1823
|
+
return {
|
|
1824
|
+
tx: txUrl(base, hash),
|
|
1825
|
+
token: tokenUrl(base, token),
|
|
1826
|
+
actor: addressUrl(base, actor),
|
|
1827
|
+
...pool ? { pool: addressUrl(base, pool) } : {}
|
|
1828
|
+
};
|
|
1829
|
+
}
|
|
1830
|
+
async function valueQuote(ctx, quoteToken, raw) {
|
|
1831
|
+
const { weth, usdg } = quoteTokens(ctx.client);
|
|
1832
|
+
const token = quoteToken.toLowerCase();
|
|
1833
|
+
if (token === usdg.toLowerCase()) return ctx.oracle.usdgToUsd(raw);
|
|
1834
|
+
if (token === weth.toLowerCase()) return ctx.oracle.weiToUsd(raw);
|
|
1835
|
+
return null;
|
|
1836
|
+
}
|
|
1837
|
+
function assertMainnet(client) {
|
|
1838
|
+
if (client.network !== "mainnet") {
|
|
1839
|
+
throw new Error(
|
|
1840
|
+
'hood-alerts: the NOXA and Odyssey launchpads are deployed on Robinhood Chain mainnet (chain 4663) only. Create the client with { chain: "mainnet" } (the default) to watch them.'
|
|
1841
|
+
);
|
|
1842
|
+
}
|
|
1843
|
+
}
|
|
1844
|
+
function createEventSources(ctx) {
|
|
1845
|
+
assertMainnet(ctx.client);
|
|
1846
|
+
return [
|
|
1847
|
+
createNoxaLaunchSource(ctx),
|
|
1848
|
+
createOdysseyLaunchSource(ctx),
|
|
1849
|
+
createOdysseyCurveTradeSource(ctx),
|
|
1850
|
+
createOdysseyGraduationSource(ctx),
|
|
1851
|
+
createWhaleTradeSource(ctx)
|
|
1852
|
+
];
|
|
1853
|
+
}
|
|
1854
|
+
function blockTimes(ctx) {
|
|
1855
|
+
ctx.blockTimes ??= createBlockTimeReader(ctx.client);
|
|
1856
|
+
return ctx.blockTimes;
|
|
1857
|
+
}
|
|
1858
|
+
async function stampTimes(ctx, events) {
|
|
1859
|
+
if (events.length === 0) return events;
|
|
1860
|
+
const reader = blockTimes(ctx);
|
|
1861
|
+
const times = await reader.getMany(
|
|
1862
|
+
events.map((e) => e.blockNumber),
|
|
1863
|
+
ctx.maxBlockTimeLookupsPerPoll ?? 25
|
|
1864
|
+
);
|
|
1865
|
+
for (const event of events) event.timestampMs = times.get(event.blockNumber) ?? null;
|
|
1866
|
+
return events;
|
|
1867
|
+
}
|
|
1868
|
+
function createNoxaLaunchSource(ctx) {
|
|
1869
|
+
const base = explorerBase(ctx.client.network);
|
|
1870
|
+
return {
|
|
1871
|
+
id: "noxa:launch",
|
|
1872
|
+
label: "NOXA launches",
|
|
1873
|
+
startBlock: import_hoodchain3.NOXA_ADDRESSES.deployBlock,
|
|
1874
|
+
async poll(from, to) {
|
|
1875
|
+
const logs = await fetchLogRange(
|
|
1876
|
+
(lo, hi) => ctx.client.public.getLogs({
|
|
1877
|
+
address: import_hoodchain3.NOXA_ADDRESSES.launchFactory,
|
|
1878
|
+
event: import_hoodchain3.noxaTokenLaunchedEvent,
|
|
1879
|
+
fromBlock: lo,
|
|
1880
|
+
toBlock: hi
|
|
1881
|
+
}),
|
|
1882
|
+
from,
|
|
1883
|
+
to,
|
|
1884
|
+
ctx.logOptions ?? {}
|
|
1885
|
+
);
|
|
1886
|
+
const events = [];
|
|
1887
|
+
for (const log of logs) {
|
|
1888
|
+
const token = log.args.token;
|
|
1889
|
+
const deployer = log.args.deployer;
|
|
1890
|
+
if (!token || !deployer) continue;
|
|
1891
|
+
const pool = log.args.pool ?? null;
|
|
1892
|
+
const pairToken = log.args.pairToken ?? null;
|
|
1893
|
+
const initialBuyAmount = log.args.initialBuyAmount ?? 0n;
|
|
1894
|
+
if (pool && pairToken) {
|
|
1895
|
+
await ctx.registry.record({
|
|
1896
|
+
pool: (0, import_viem5.getAddress)(pool),
|
|
1897
|
+
token: (0, import_viem5.getAddress)(token),
|
|
1898
|
+
quoteToken: (0, import_viem5.getAddress)(pairToken),
|
|
1899
|
+
launchpad: "noxa",
|
|
1900
|
+
createdBlock: log.blockNumber
|
|
1901
|
+
});
|
|
1902
|
+
}
|
|
1903
|
+
const meta = await ctx.tokens.get(token);
|
|
1904
|
+
const usdValue = pairToken && initialBuyAmount > 0n ? await valueQuote(ctx, pairToken, initialBuyAmount) : null;
|
|
1905
|
+
events.push({
|
|
1906
|
+
id: eventId("launch", log.transactionHash, log.logIndex),
|
|
1907
|
+
kind: "launch",
|
|
1908
|
+
launchpad: "noxa",
|
|
1909
|
+
token: (0, import_viem5.getAddress)(token),
|
|
1910
|
+
symbol: meta.symbol,
|
|
1911
|
+
name: meta.name,
|
|
1912
|
+
actor: (0, import_viem5.getAddress)(deployer),
|
|
1913
|
+
blockNumber: log.blockNumber,
|
|
1914
|
+
logIndex: log.logIndex,
|
|
1915
|
+
transactionHash: log.transactionHash,
|
|
1916
|
+
timestampMs: null,
|
|
1917
|
+
usdValue,
|
|
1918
|
+
explorer: links(base, token, deployer, log.transactionHash, pool ?? void 0),
|
|
1919
|
+
pool: pool ? (0, import_viem5.getAddress)(pool) : null,
|
|
1920
|
+
pairToken: pairToken ? (0, import_viem5.getAddress)(pairToken) : null,
|
|
1921
|
+
initialBuyAmount,
|
|
1922
|
+
positionId: log.args.positionId ?? null,
|
|
1923
|
+
// NOXA has no bonding curve at all: a launch is already a listing.
|
|
1924
|
+
instantListing: true
|
|
1925
|
+
});
|
|
1926
|
+
}
|
|
1927
|
+
return stampTimes(ctx, events);
|
|
1928
|
+
}
|
|
1929
|
+
};
|
|
1930
|
+
}
|
|
1931
|
+
function createOdysseyLaunchSource(ctx) {
|
|
1932
|
+
const base = explorerBase(ctx.client.network);
|
|
1933
|
+
return {
|
|
1934
|
+
id: "odyssey:launch",
|
|
1935
|
+
label: "Odyssey launches",
|
|
1936
|
+
startBlock: import_hoodchain3.NOXA_ADDRESSES.deployBlock,
|
|
1937
|
+
async poll(from, to) {
|
|
1938
|
+
const logs = await fetchLogRange(
|
|
1939
|
+
(lo, hi) => ctx.client.public.getLogs({
|
|
1940
|
+
address: ODYSSEY_FACTORIES2,
|
|
1941
|
+
event: import_hoodchain3.odysseyTokenCreatedEvent,
|
|
1942
|
+
fromBlock: lo,
|
|
1943
|
+
toBlock: hi
|
|
1944
|
+
}),
|
|
1945
|
+
from,
|
|
1946
|
+
to,
|
|
1947
|
+
ctx.logOptions ?? {}
|
|
1948
|
+
);
|
|
1949
|
+
const events = [];
|
|
1950
|
+
for (const log of logs) {
|
|
1951
|
+
const token = log.args.token;
|
|
1952
|
+
const creator = log.args.creator;
|
|
1953
|
+
if (!token || !creator) continue;
|
|
1954
|
+
const meta = await ctx.tokens.get(token);
|
|
1955
|
+
events.push({
|
|
1956
|
+
id: eventId("launch", log.transactionHash, log.logIndex),
|
|
1957
|
+
kind: "launch",
|
|
1958
|
+
launchpad: "odyssey",
|
|
1959
|
+
token: (0, import_viem5.getAddress)(token),
|
|
1960
|
+
symbol: meta.symbol,
|
|
1961
|
+
name: meta.name,
|
|
1962
|
+
actor: (0, import_viem5.getAddress)(creator),
|
|
1963
|
+
blockNumber: log.blockNumber,
|
|
1964
|
+
logIndex: log.logIndex,
|
|
1965
|
+
transactionHash: log.transactionHash,
|
|
1966
|
+
timestampMs: null,
|
|
1967
|
+
// The curve holds liquidity until graduation, so a new Odyssey token
|
|
1968
|
+
// has no pool and no priceable notional at creation time.
|
|
1969
|
+
usdValue: null,
|
|
1970
|
+
explorer: links(base, token, creator, log.transactionHash),
|
|
1971
|
+
pool: null,
|
|
1972
|
+
pairToken: null,
|
|
1973
|
+
initialBuyAmount: 0n,
|
|
1974
|
+
positionId: null,
|
|
1975
|
+
instantListing: log.address.toLowerCase() === import_hoodchain3.ODYSSEY_ADDRESSES.instantFactory.toLowerCase()
|
|
1976
|
+
});
|
|
1977
|
+
}
|
|
1978
|
+
return stampTimes(ctx, events);
|
|
1979
|
+
}
|
|
1980
|
+
};
|
|
1981
|
+
}
|
|
1982
|
+
function createOdysseyCurveTradeSource(ctx) {
|
|
1983
|
+
const base = explorerBase(ctx.client.network);
|
|
1984
|
+
return {
|
|
1985
|
+
id: "odyssey:curve_trade",
|
|
1986
|
+
label: "Odyssey curve trades",
|
|
1987
|
+
startBlock: import_hoodchain3.NOXA_ADDRESSES.deployBlock,
|
|
1988
|
+
async poll(from, to) {
|
|
1989
|
+
const logs = await fetchLogRange(
|
|
1990
|
+
(lo, hi) => ctx.client.public.getLogs({
|
|
1991
|
+
address: ODYSSEY_FACTORIES2,
|
|
1992
|
+
event: import_hoodchain3.odysseyTradedEvent,
|
|
1993
|
+
fromBlock: lo,
|
|
1994
|
+
toBlock: hi
|
|
1995
|
+
}),
|
|
1996
|
+
from,
|
|
1997
|
+
to,
|
|
1998
|
+
ctx.logOptions ?? {}
|
|
1999
|
+
);
|
|
2000
|
+
const events = [];
|
|
2001
|
+
for (const log of logs) {
|
|
2002
|
+
const token = log.args.token;
|
|
2003
|
+
const trader = log.args.trader;
|
|
2004
|
+
if (!token || !trader) continue;
|
|
2005
|
+
const tokenAmount = log.args.tokenAmount ?? 0n;
|
|
2006
|
+
const quoteAmountWei = log.args.quoteAmount ?? 0n;
|
|
2007
|
+
const meta = await ctx.tokens.get(token);
|
|
2008
|
+
const usdValue = await ctx.oracle.weiToUsd(quoteAmountWei);
|
|
2009
|
+
const priceEth = tokenAmount > 0n ? Number(quoteAmountWei) / Number(tokenAmount) : null;
|
|
2010
|
+
events.push({
|
|
2011
|
+
id: eventId("curve_trade", log.transactionHash, log.logIndex),
|
|
2012
|
+
kind: "curve_trade",
|
|
2013
|
+
launchpad: "odyssey",
|
|
2014
|
+
token: (0, import_viem5.getAddress)(token),
|
|
2015
|
+
symbol: meta.symbol,
|
|
2016
|
+
name: meta.name,
|
|
2017
|
+
actor: (0, import_viem5.getAddress)(trader),
|
|
2018
|
+
blockNumber: log.blockNumber,
|
|
2019
|
+
logIndex: log.logIndex,
|
|
2020
|
+
transactionHash: log.transactionHash,
|
|
2021
|
+
timestampMs: null,
|
|
2022
|
+
usdValue,
|
|
2023
|
+
explorer: links(base, token, trader, log.transactionHash),
|
|
2024
|
+
side: log.args.isBuy ? "buy" : "sell",
|
|
2025
|
+
tokenAmount,
|
|
2026
|
+
quoteAmountWei,
|
|
2027
|
+
feeWei: log.args.fee ?? 0n,
|
|
2028
|
+
virtualQuoteWei: log.args.virtualQuote ?? 0n,
|
|
2029
|
+
virtualTokenAmount: log.args.virtualToken ?? 0n,
|
|
2030
|
+
priceEth
|
|
2031
|
+
});
|
|
2032
|
+
}
|
|
2033
|
+
return stampTimes(ctx, events);
|
|
2034
|
+
}
|
|
2035
|
+
};
|
|
2036
|
+
}
|
|
2037
|
+
function createOdysseyGraduationSource(ctx) {
|
|
2038
|
+
const base = explorerBase(ctx.client.network);
|
|
2039
|
+
return {
|
|
2040
|
+
id: "odyssey:graduation",
|
|
2041
|
+
label: "Odyssey graduations",
|
|
2042
|
+
startBlock: import_hoodchain3.NOXA_ADDRESSES.deployBlock,
|
|
2043
|
+
async poll(from, to) {
|
|
2044
|
+
const logs = await fetchLogRange(
|
|
2045
|
+
(lo, hi) => ctx.client.public.getLogs({
|
|
2046
|
+
address: ODYSSEY_FACTORIES2,
|
|
2047
|
+
event: import_hoodchain3.odysseyPoolMigratedEvent,
|
|
2048
|
+
fromBlock: lo,
|
|
2049
|
+
toBlock: hi
|
|
2050
|
+
}),
|
|
2051
|
+
from,
|
|
2052
|
+
to,
|
|
2053
|
+
ctx.logOptions ?? {}
|
|
2054
|
+
);
|
|
2055
|
+
const events = [];
|
|
2056
|
+
for (const log of logs) {
|
|
2057
|
+
const token = log.args.token;
|
|
2058
|
+
const pool = log.args.pool;
|
|
2059
|
+
if (!token || !pool) continue;
|
|
2060
|
+
const quoteUsed = log.args.usdcUsed ?? 0n;
|
|
2061
|
+
let quoteToken = null;
|
|
2062
|
+
try {
|
|
2063
|
+
const info = await (0, import_hoodkit.loadPoolInfo)(ctx.client, pool);
|
|
2064
|
+
quoteToken = info.token0.toLowerCase() === token.toLowerCase() ? info.token1 : info.token0;
|
|
2065
|
+
await ctx.registry.record({
|
|
2066
|
+
pool: (0, import_viem5.getAddress)(pool),
|
|
2067
|
+
token: (0, import_viem5.getAddress)(token),
|
|
2068
|
+
quoteToken,
|
|
2069
|
+
launchpad: "odyssey",
|
|
2070
|
+
createdBlock: log.blockNumber
|
|
2071
|
+
});
|
|
2072
|
+
} catch {
|
|
2073
|
+
quoteToken = null;
|
|
2074
|
+
}
|
|
2075
|
+
const meta = await ctx.tokens.get(token);
|
|
2076
|
+
const usdValue = quoteToken ? await valueQuote(ctx, quoteToken, quoteUsed) : null;
|
|
2077
|
+
events.push({
|
|
2078
|
+
id: eventId("graduation", log.transactionHash, log.logIndex),
|
|
2079
|
+
kind: "graduation",
|
|
2080
|
+
launchpad: "odyssey",
|
|
2081
|
+
token: (0, import_viem5.getAddress)(token),
|
|
2082
|
+
symbol: meta.symbol,
|
|
2083
|
+
name: meta.name,
|
|
2084
|
+
// A migration is executed by the factory on behalf of the curve, so
|
|
2085
|
+
// the closest thing to an actor is the factory that emitted it.
|
|
2086
|
+
actor: (0, import_viem5.getAddress)(log.address),
|
|
2087
|
+
blockNumber: log.blockNumber,
|
|
2088
|
+
logIndex: log.logIndex,
|
|
2089
|
+
transactionHash: log.transactionHash,
|
|
2090
|
+
timestampMs: null,
|
|
2091
|
+
usdValue,
|
|
2092
|
+
explorer: links(base, token, log.address, log.transactionHash, pool),
|
|
2093
|
+
pool: (0, import_viem5.getAddress)(pool),
|
|
2094
|
+
positionId: log.args.tokenId ?? 0n,
|
|
2095
|
+
liquidity: log.args.liquidity ?? 0n,
|
|
2096
|
+
tokenUsed: log.args.tokenUsed ?? 0n,
|
|
2097
|
+
quoteUsed
|
|
2098
|
+
});
|
|
2099
|
+
}
|
|
2100
|
+
return stampTimes(ctx, events);
|
|
2101
|
+
}
|
|
2102
|
+
};
|
|
2103
|
+
}
|
|
2104
|
+
function createWhaleTradeSource(ctx) {
|
|
2105
|
+
const base = explorerBase(ctx.client.network);
|
|
2106
|
+
const poolInfoCache = /* @__PURE__ */ new Map();
|
|
2107
|
+
const minUsd = ctx.whaleMinUsd ?? 1e3;
|
|
2108
|
+
async function poolInfo(pool) {
|
|
2109
|
+
const key = pool.toLowerCase();
|
|
2110
|
+
const hit = poolInfoCache.get(key);
|
|
2111
|
+
if (hit) return hit;
|
|
2112
|
+
try {
|
|
2113
|
+
const info = await (0, import_hoodkit.loadPoolInfo)(ctx.client, pool);
|
|
2114
|
+
poolInfoCache.set(key, info);
|
|
2115
|
+
return info;
|
|
2116
|
+
} catch {
|
|
2117
|
+
return null;
|
|
2118
|
+
}
|
|
2119
|
+
}
|
|
2120
|
+
return {
|
|
2121
|
+
id: "uniswap:whale_trade",
|
|
2122
|
+
label: "Whale trades",
|
|
2123
|
+
startBlock: import_hoodchain3.NOXA_ADDRESSES.deployBlock,
|
|
2124
|
+
async poll(from, to) {
|
|
2125
|
+
const pinned = ctx.pinnedTokens ? await ctx.pinnedTokens() : [];
|
|
2126
|
+
const entries = await ctx.registry.active(ctx.whalePoolLimit ?? 400, pinned);
|
|
2127
|
+
if (entries.length === 0) return [];
|
|
2128
|
+
const byPool = new Map(entries.map((entry) => [entry.pool.toLowerCase(), entry]));
|
|
2129
|
+
const batches = batchAddresses(
|
|
2130
|
+
entries.map((entry) => entry.pool),
|
|
2131
|
+
ctx.whalePoolBatchSize ?? 200
|
|
2132
|
+
);
|
|
2133
|
+
const logs = (await Promise.all(
|
|
2134
|
+
batches.map(
|
|
2135
|
+
(batch) => fetchLogRange(
|
|
2136
|
+
(lo, hi) => ctx.client.public.getLogs({
|
|
2137
|
+
address: batch,
|
|
2138
|
+
event: import_hoodkit.uniswapV3SwapEvent,
|
|
2139
|
+
fromBlock: lo,
|
|
2140
|
+
toBlock: hi
|
|
2141
|
+
}),
|
|
2142
|
+
from,
|
|
2143
|
+
to,
|
|
2144
|
+
ctx.logOptions ?? {}
|
|
2145
|
+
)
|
|
2146
|
+
)
|
|
2147
|
+
)).flat();
|
|
2148
|
+
const events = [];
|
|
2149
|
+
for (const log of logs) {
|
|
2150
|
+
const entry = byPool.get(log.address.toLowerCase());
|
|
2151
|
+
if (!entry) continue;
|
|
2152
|
+
const info = await poolInfo(entry.pool);
|
|
2153
|
+
if (!info) continue;
|
|
2154
|
+
const amount0 = log.args.amount0 ?? 0n;
|
|
2155
|
+
const amount1 = log.args.amount1 ?? 0n;
|
|
2156
|
+
const quoteIsToken0 = info.token0.toLowerCase() === entry.quoteToken.toLowerCase();
|
|
2157
|
+
const quoteRaw = quoteIsToken0 ? amount0 : amount1;
|
|
2158
|
+
const tokenRaw = quoteIsToken0 ? amount1 : amount0;
|
|
2159
|
+
if (quoteRaw === 0n || tokenRaw === 0n) continue;
|
|
2160
|
+
const usdValue = await valueQuote(ctx, entry.quoteToken, quoteRaw);
|
|
2161
|
+
if (usdValue === null || usdValue < minUsd) continue;
|
|
2162
|
+
const quoteDecimals = quoteIsToken0 ? info.decimals0 : info.decimals1;
|
|
2163
|
+
const tokenDecimals = quoteIsToken0 ? info.decimals1 : info.decimals0;
|
|
2164
|
+
const quoteAmount = Math.abs(Number(quoteRaw)) / 10 ** quoteDecimals;
|
|
2165
|
+
const tokenAmount = Math.abs(Number(tokenRaw)) / 10 ** tokenDecimals;
|
|
2166
|
+
const [tokenMeta, quoteMeta] = await Promise.all([
|
|
2167
|
+
ctx.tokens.get(entry.token),
|
|
2168
|
+
ctx.tokens.get(entry.quoteToken)
|
|
2169
|
+
]);
|
|
2170
|
+
const trader = log.args.recipient ?? log.args.sender;
|
|
2171
|
+
events.push({
|
|
2172
|
+
id: eventId("whale_trade", log.transactionHash, log.logIndex),
|
|
2173
|
+
kind: "whale_trade",
|
|
2174
|
+
launchpad: entry.launchpad,
|
|
2175
|
+
token: entry.token,
|
|
2176
|
+
symbol: tokenMeta.symbol,
|
|
2177
|
+
name: tokenMeta.name,
|
|
2178
|
+
actor: (0, import_viem5.getAddress)(trader),
|
|
2179
|
+
blockNumber: log.blockNumber,
|
|
2180
|
+
logIndex: log.logIndex,
|
|
2181
|
+
transactionHash: log.transactionHash,
|
|
2182
|
+
timestampMs: null,
|
|
2183
|
+
usdValue,
|
|
2184
|
+
explorer: links(base, entry.token, trader, log.transactionHash, entry.pool),
|
|
2185
|
+
pool: entry.pool,
|
|
2186
|
+
quoteToken: entry.quoteToken,
|
|
2187
|
+
quoteSymbol: quoteMeta.symbol ?? "quote",
|
|
2188
|
+
// The memecoin left the pool (negative delta) means the trader bought it.
|
|
2189
|
+
side: tokenRaw < 0n ? "buy" : "sell",
|
|
2190
|
+
tokenAmount,
|
|
2191
|
+
quoteAmount,
|
|
2192
|
+
price: tokenAmount > 0 ? quoteAmount / tokenAmount : 0,
|
|
2193
|
+
feeTier: info.fee
|
|
2194
|
+
});
|
|
2195
|
+
}
|
|
2196
|
+
return stampTimes(ctx, events);
|
|
2197
|
+
}
|
|
2198
|
+
};
|
|
2199
|
+
}
|
|
2200
|
+
|
|
2201
|
+
// src/events/liquidity.ts
|
|
2202
|
+
var import_viem6 = require("viem");
|
|
2203
|
+
var import_hoodchain4 = require("hoodchain");
|
|
2204
|
+
function createLiquidityReader(client, oracle, registry, options = {}) {
|
|
2205
|
+
const ttlMs = options.ttlMs ?? 6e4;
|
|
2206
|
+
const now = options.now ?? Date.now;
|
|
2207
|
+
const cache = /* @__PURE__ */ new Map();
|
|
2208
|
+
const { weth, usdg } = quoteTokens(client);
|
|
2209
|
+
async function readPool(pool, quoteToken) {
|
|
2210
|
+
const quote = quoteToken.toLowerCase();
|
|
2211
|
+
if (quote !== weth.toLowerCase() && quote !== usdg.toLowerCase()) return null;
|
|
2212
|
+
const balance = await client.public.readContract({
|
|
2213
|
+
address: quoteToken,
|
|
2214
|
+
abi: import_hoodchain4.erc20Abi,
|
|
2215
|
+
functionName: "balanceOf",
|
|
2216
|
+
args: [pool]
|
|
2217
|
+
});
|
|
2218
|
+
if (quote === usdg.toLowerCase()) {
|
|
2219
|
+
return Number((0, import_viem6.formatUnits)(balance, import_hoodchain4.USDG_DECIMALS)) * 2;
|
|
2220
|
+
}
|
|
2221
|
+
const usd = await oracle.weiToUsd(balance);
|
|
2222
|
+
return usd === null ? null : usd * 2;
|
|
2223
|
+
}
|
|
2224
|
+
async function poolLiquidityUsd(pool, quoteToken) {
|
|
2225
|
+
const key = pool.toLowerCase();
|
|
2226
|
+
const hit = cache.get(key);
|
|
2227
|
+
if (hit && now() - hit.at < ttlMs) return hit.value;
|
|
2228
|
+
let value;
|
|
2229
|
+
try {
|
|
2230
|
+
value = await readPool(pool, quoteToken);
|
|
2231
|
+
} catch {
|
|
2232
|
+
value = null;
|
|
2233
|
+
}
|
|
2234
|
+
cache.set(key, { value, at: now() });
|
|
2235
|
+
return value;
|
|
2236
|
+
}
|
|
2237
|
+
return {
|
|
2238
|
+
poolLiquidityUsd,
|
|
2239
|
+
async tokenLiquidityUsd(token) {
|
|
2240
|
+
const pools = await registry.poolsFor(token);
|
|
2241
|
+
if (pools.length === 0) return null;
|
|
2242
|
+
const values = await Promise.all(
|
|
2243
|
+
pools.map((entry) => poolLiquidityUsd(entry.pool, entry.quoteToken))
|
|
2244
|
+
);
|
|
2245
|
+
const known = values.filter((v) => v !== null);
|
|
2246
|
+
return known.length === 0 ? null : Math.max(...known);
|
|
2247
|
+
}
|
|
2248
|
+
};
|
|
2249
|
+
}
|
|
2250
|
+
|
|
2251
|
+
// src/events/tokens.ts
|
|
2252
|
+
var import_viem7 = require("viem");
|
|
2253
|
+
var import_hoodchain5 = require("hoodchain");
|
|
2254
|
+
function createTokenMetaReader(client, maxEntries = 5e3) {
|
|
2255
|
+
const cache = /* @__PURE__ */ new Map();
|
|
2256
|
+
const inflight = /* @__PURE__ */ new Map();
|
|
2257
|
+
function remember(meta) {
|
|
2258
|
+
if (cache.size >= maxEntries) {
|
|
2259
|
+
const oldest = cache.keys().next();
|
|
2260
|
+
if (!oldest.done) cache.delete(oldest.value);
|
|
2261
|
+
}
|
|
2262
|
+
cache.set(meta.address.toLowerCase(), meta);
|
|
2263
|
+
}
|
|
2264
|
+
async function load(token) {
|
|
2265
|
+
const results = await client.public.multicall({
|
|
2266
|
+
contracts: [
|
|
2267
|
+
{ address: token, abi: import_hoodchain5.erc20Abi, functionName: "symbol" },
|
|
2268
|
+
{ address: token, abi: import_hoodchain5.erc20Abi, functionName: "name" },
|
|
2269
|
+
{ address: token, abi: import_hoodchain5.erc20Abi, functionName: "decimals" }
|
|
2270
|
+
],
|
|
2271
|
+
allowFailure: true
|
|
2272
|
+
});
|
|
2273
|
+
const [symbolResult, nameResult, decimalsResult] = results;
|
|
2274
|
+
const meta = {
|
|
2275
|
+
address: (0, import_viem7.getAddress)(token),
|
|
2276
|
+
symbol: symbolResult && symbolResult.status === "success" ? String(symbolResult.result) : null,
|
|
2277
|
+
name: nameResult && nameResult.status === "success" ? String(nameResult.result) : null,
|
|
2278
|
+
decimals: decimalsResult && decimalsResult.status === "success" ? Number(decimalsResult.result) : 18
|
|
2279
|
+
};
|
|
2280
|
+
remember(meta);
|
|
2281
|
+
return meta;
|
|
2282
|
+
}
|
|
2283
|
+
return {
|
|
2284
|
+
async get(token) {
|
|
2285
|
+
const key = token.toLowerCase();
|
|
2286
|
+
const hit = cache.get(key);
|
|
2287
|
+
if (hit) return hit;
|
|
2288
|
+
const pending = inflight.get(key);
|
|
2289
|
+
if (pending) return pending;
|
|
2290
|
+
const promise = load(token).catch(() => {
|
|
2291
|
+
const fallback = { address: (0, import_viem7.getAddress)(token), symbol: null, name: null, decimals: 18 };
|
|
2292
|
+
remember(fallback);
|
|
2293
|
+
return fallback;
|
|
2294
|
+
}).finally(() => {
|
|
2295
|
+
inflight.delete(key);
|
|
2296
|
+
});
|
|
2297
|
+
inflight.set(key, promise);
|
|
2298
|
+
return promise;
|
|
2299
|
+
},
|
|
2300
|
+
prime(meta) {
|
|
2301
|
+
remember(meta);
|
|
2302
|
+
}
|
|
2303
|
+
};
|
|
2304
|
+
}
|
|
2305
|
+
|
|
2306
|
+
// src/bot/commands.ts
|
|
2307
|
+
var import_node_crypto = require("crypto");
|
|
2308
|
+
var import_viem10 = require("viem");
|
|
2309
|
+
|
|
2310
|
+
// src/tiers/entitlements.ts
|
|
2311
|
+
var import_viem8 = require("viem");
|
|
2312
|
+
var import_hoodchain6 = require("hoodchain");
|
|
2313
|
+
var import_viem9 = require("viem");
|
|
2314
|
+
var free = (subscriberId, source) => ({
|
|
2315
|
+
subscriberId,
|
|
2316
|
+
tier: "free",
|
|
2317
|
+
expiresAtMs: null,
|
|
2318
|
+
source
|
|
2319
|
+
});
|
|
2320
|
+
function createStaticEntitlementProvider(options) {
|
|
2321
|
+
const premium = new Set((options.premiumSubscribers ?? []).map((id) => id.trim()).filter(Boolean));
|
|
2322
|
+
return {
|
|
2323
|
+
async get(subscriberId) {
|
|
2324
|
+
if (options.allPremium === true || premium.has(subscriberId)) {
|
|
2325
|
+
return { subscriberId, tier: "premium", expiresAtMs: null, source: "static" };
|
|
2326
|
+
}
|
|
2327
|
+
return free(subscriberId, "static");
|
|
2328
|
+
}
|
|
2329
|
+
};
|
|
2330
|
+
}
|
|
2331
|
+
var transferEvent = {
|
|
2332
|
+
type: "event",
|
|
2333
|
+
name: "Transfer",
|
|
2334
|
+
inputs: [
|
|
2335
|
+
{ name: "from", type: "address", indexed: true },
|
|
2336
|
+
{ name: "to", type: "address", indexed: true },
|
|
2337
|
+
{ name: "value", type: "uint256", indexed: false }
|
|
2338
|
+
]
|
|
2339
|
+
};
|
|
2340
|
+
function accrueExpiry(payments, pricePerPeriodUsdg, periodDays) {
|
|
2341
|
+
if (pricePerPeriodUsdg <= 0) return null;
|
|
2342
|
+
const periodMs = periodDays * 864e5;
|
|
2343
|
+
const ordered = [...payments].sort((a, b) => a.atMs - b.atMs);
|
|
2344
|
+
let expiry = null;
|
|
2345
|
+
for (const payment of ordered) {
|
|
2346
|
+
const periods = Math.floor(payment.amountUsdg / pricePerPeriodUsdg);
|
|
2347
|
+
if (periods < 1) continue;
|
|
2348
|
+
const start = expiry !== null && expiry > payment.atMs ? expiry : payment.atMs;
|
|
2349
|
+
expiry = start + periods * periodMs;
|
|
2350
|
+
}
|
|
2351
|
+
return expiry;
|
|
2352
|
+
}
|
|
2353
|
+
function createUsdgEntitlementProvider(options) {
|
|
2354
|
+
const ttlMs = options.ttlMs ?? 3e5;
|
|
2355
|
+
const now = options.now ?? Date.now;
|
|
2356
|
+
const periodDays = options.periodDays ?? 30;
|
|
2357
|
+
const usdg = options.client.network === "testnet" ? "0x7E955252E15c84f5768B83c41a71F9eba181802F" : "0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168";
|
|
2358
|
+
const cache = /* @__PURE__ */ new Map();
|
|
2359
|
+
async function resolve(subscriberId) {
|
|
2360
|
+
const wallet = await options.links.walletOf(subscriberId);
|
|
2361
|
+
if (!wallet) return free(subscriberId, "usdg:no-linked-wallet");
|
|
2362
|
+
const head = await options.client.public.getBlockNumber();
|
|
2363
|
+
const logs = await fetchLogRange(
|
|
2364
|
+
(from, to) => options.client.public.getLogs({
|
|
2365
|
+
address: usdg,
|
|
2366
|
+
event: transferEvent,
|
|
2367
|
+
args: { from: wallet, to: (0, import_viem8.getAddress)(options.receiver) },
|
|
2368
|
+
fromBlock: from,
|
|
2369
|
+
toBlock: to
|
|
2370
|
+
}),
|
|
2371
|
+
options.fromBlock ?? 0n,
|
|
2372
|
+
head,
|
|
2373
|
+
options.logOptions ?? {}
|
|
2374
|
+
);
|
|
2375
|
+
if (logs.length === 0) return free(subscriberId, "usdg:no-payments");
|
|
2376
|
+
const payments = [];
|
|
2377
|
+
for (const log of logs) {
|
|
2378
|
+
const value = log.args.value;
|
|
2379
|
+
if (value === void 0 || value === 0n) continue;
|
|
2380
|
+
const block = await options.client.public.getBlock({
|
|
2381
|
+
blockNumber: log.blockNumber,
|
|
2382
|
+
includeTransactions: false
|
|
2383
|
+
});
|
|
2384
|
+
payments.push({
|
|
2385
|
+
atMs: Number(block.timestamp) * 1e3,
|
|
2386
|
+
amountUsdg: Number((0, import_viem9.formatUnits)(value, import_hoodchain6.USDG_DECIMALS))
|
|
2387
|
+
});
|
|
2388
|
+
}
|
|
2389
|
+
const expiresAtMs = accrueExpiry(payments, options.pricePerPeriodUsdg, periodDays);
|
|
2390
|
+
if (expiresAtMs === null || expiresAtMs <= now()) {
|
|
2391
|
+
return { subscriberId, tier: "free", expiresAtMs, source: "usdg:lapsed" };
|
|
2392
|
+
}
|
|
2393
|
+
return { subscriberId, tier: "premium", expiresAtMs, source: "usdg" };
|
|
2394
|
+
}
|
|
2395
|
+
return {
|
|
2396
|
+
async get(subscriberId) {
|
|
2397
|
+
const hit = cache.get(subscriberId);
|
|
2398
|
+
if (hit && now() - hit.at < ttlMs) return hit.entitlement;
|
|
2399
|
+
try {
|
|
2400
|
+
const entitlement = await resolve(subscriberId);
|
|
2401
|
+
cache.set(subscriberId, { entitlement, at: now() });
|
|
2402
|
+
return entitlement;
|
|
2403
|
+
} catch (error) {
|
|
2404
|
+
if (hit) return hit.entitlement;
|
|
2405
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2406
|
+
return free(subscriberId, `usdg:unavailable (${message})`);
|
|
2407
|
+
}
|
|
2408
|
+
}
|
|
2409
|
+
};
|
|
2410
|
+
}
|
|
2411
|
+
function chainEntitlementProviders(...providers) {
|
|
2412
|
+
return {
|
|
2413
|
+
async get(subscriberId) {
|
|
2414
|
+
let last = free(subscriberId, "chain:empty");
|
|
2415
|
+
for (const provider of providers) {
|
|
2416
|
+
const entitlement = await provider.get(subscriberId);
|
|
2417
|
+
if (entitlement.tier === "premium") return entitlement;
|
|
2418
|
+
last = entitlement;
|
|
2419
|
+
}
|
|
2420
|
+
return last;
|
|
2421
|
+
}
|
|
2422
|
+
};
|
|
2423
|
+
}
|
|
2424
|
+
function linkMessage(subscriberId, nonce) {
|
|
2425
|
+
return `hood-alerts wallet link
|
|
2426
|
+
subscriber: ${subscriberId}
|
|
2427
|
+
nonce: ${nonce}`;
|
|
2428
|
+
}
|
|
2429
|
+
async function verifyWalletLink(subscriberId, nonce, address, signature) {
|
|
2430
|
+
if (!/^0x[0-9a-fA-F]{130}$/.test(signature)) return null;
|
|
2431
|
+
if (!/^0x[0-9a-fA-F]{40}$/.test(address)) return null;
|
|
2432
|
+
try {
|
|
2433
|
+
const valid = await (0, import_viem8.verifyMessage)({
|
|
2434
|
+
address: (0, import_viem8.getAddress)(address),
|
|
2435
|
+
message: linkMessage(subscriberId, nonce),
|
|
2436
|
+
signature
|
|
2437
|
+
});
|
|
2438
|
+
return valid ? (0, import_viem8.getAddress)(address) : null;
|
|
2439
|
+
} catch {
|
|
2440
|
+
return null;
|
|
2441
|
+
}
|
|
2442
|
+
}
|
|
2443
|
+
|
|
2444
|
+
// src/bot/commands.ts
|
|
2445
|
+
var reply = (text) => ({ html: escapeHtml(text), text });
|
|
2446
|
+
var html = (htmlText, plain) => ({ html: htmlText, text: plain });
|
|
2447
|
+
var COMMANDS = [
|
|
2448
|
+
{ name: "start", description: "Set up alerts in this chat", usage: "/start" },
|
|
2449
|
+
{ name: "help", description: "Show every command", usage: "/help" },
|
|
2450
|
+
{
|
|
2451
|
+
name: "subscribe",
|
|
2452
|
+
description: "Send alerts here, with a starter rule set",
|
|
2453
|
+
usage: "/subscribe [target]",
|
|
2454
|
+
options: [
|
|
2455
|
+
{
|
|
2456
|
+
name: "target",
|
|
2457
|
+
description: "Discord webhook URL or channel id. Defaults to this channel.",
|
|
2458
|
+
required: false
|
|
2459
|
+
}
|
|
2460
|
+
]
|
|
2461
|
+
},
|
|
2462
|
+
{
|
|
2463
|
+
name: "unsubscribe",
|
|
2464
|
+
description: "Stop alerts for a subscription",
|
|
2465
|
+
usage: "/unsubscribe [subscription id]",
|
|
2466
|
+
options: [{ name: "id", description: "Subscription id from /status", required: false }]
|
|
2467
|
+
},
|
|
2468
|
+
{ name: "rules", description: "List your rules", usage: "/rules" },
|
|
2469
|
+
{
|
|
2470
|
+
name: "rule",
|
|
2471
|
+
description: "Add, remove, enable or disable a rule",
|
|
2472
|
+
usage: "/rule add <json> | /rule rm <id> | /rule on <id> | /rule off <id>",
|
|
2473
|
+
options: [{ name: "input", description: "add <json> | rm <id> | on <id> | off <id>", required: true }]
|
|
2474
|
+
},
|
|
2475
|
+
{
|
|
2476
|
+
name: "threshold",
|
|
2477
|
+
description: "Set a rule minimum USD value",
|
|
2478
|
+
usage: "/threshold <rule id> <usd>",
|
|
2479
|
+
options: [
|
|
2480
|
+
{ name: "rule", description: "Rule id", required: true },
|
|
2481
|
+
{ name: "usd", description: "Minimum USD value", required: true }
|
|
2482
|
+
]
|
|
2483
|
+
},
|
|
2484
|
+
{
|
|
2485
|
+
name: "watch",
|
|
2486
|
+
description: "Add a token to a rule watchlist",
|
|
2487
|
+
usage: "/watch <token address> [rule id]",
|
|
2488
|
+
options: [
|
|
2489
|
+
{ name: "token", description: "Token contract address", required: true },
|
|
2490
|
+
{ name: "rule", description: "Rule id (defaults to the first rule)", required: false }
|
|
2491
|
+
]
|
|
2492
|
+
},
|
|
2493
|
+
{
|
|
2494
|
+
name: "unwatch",
|
|
2495
|
+
description: "Remove a token from a rule watchlist",
|
|
2496
|
+
usage: "/unwatch <token address> [rule id]",
|
|
2497
|
+
options: [
|
|
2498
|
+
{ name: "token", description: "Token contract address", required: true },
|
|
2499
|
+
{ name: "rule", description: "Rule id (defaults to the first rule)", required: false }
|
|
2500
|
+
]
|
|
2501
|
+
},
|
|
2502
|
+
{ name: "tier", description: "Show your tier and its limits", usage: "/tier" },
|
|
2503
|
+
{ name: "status", description: "Show your subscriptions and delivery state", usage: "/status" },
|
|
2504
|
+
{
|
|
2505
|
+
name: "link",
|
|
2506
|
+
description: "Link a paying wallet (signature verified)",
|
|
2507
|
+
usage: "/link [address] [signature]",
|
|
2508
|
+
options: [
|
|
2509
|
+
{ name: "address", description: "Wallet address that pays", required: false },
|
|
2510
|
+
{ name: "signature", description: "Signature of the link message", required: false }
|
|
2511
|
+
]
|
|
2512
|
+
},
|
|
2513
|
+
{ name: "upgrade", description: "How to go premium", usage: "/upgrade" }
|
|
2514
|
+
];
|
|
2515
|
+
function ruleSummary(rule) {
|
|
2516
|
+
const parts = [
|
|
2517
|
+
`${rule.enabled ? "on " : "off"} ${rule.id}`,
|
|
2518
|
+
rule.name ? `"${rule.name}"` : null,
|
|
2519
|
+
`kinds=${rule.kinds.join("/")}`,
|
|
2520
|
+
`pads=${rule.launchpads.join("/")}`,
|
|
2521
|
+
rule.minUsd !== void 0 ? `minUsd=${rule.minUsd}` : null,
|
|
2522
|
+
rule.maxUsd !== void 0 ? `maxUsd=${rule.maxUsd}` : null,
|
|
2523
|
+
rule.minLiquidityUsd !== void 0 ? `minLiq=${rule.minLiquidityUsd}` : null,
|
|
2524
|
+
rule.maxLiquidityUsd !== void 0 ? `maxLiq=${rule.maxLiquidityUsd}` : null,
|
|
2525
|
+
rule.side !== "any" ? `side=${rule.side}` : null,
|
|
2526
|
+
rule.tokens.length > 0 ? `tokens=${rule.tokens.length}` : null,
|
|
2527
|
+
rule.deployers.length > 0 ? `deployers=${rule.deployers.length}` : null,
|
|
2528
|
+
rule.rateLimit ? `rate=${JSON.stringify(rule.rateLimit)}` : null,
|
|
2529
|
+
`filters=${ruleComplexity(rule)}`
|
|
2530
|
+
].filter((part) => part !== null);
|
|
2531
|
+
return parts.join(" ");
|
|
2532
|
+
}
|
|
2533
|
+
function createCommandRouter(deps) {
|
|
2534
|
+
const now = deps.now ?? Date.now;
|
|
2535
|
+
const newId = deps.newId ?? import_node_crypto.randomUUID;
|
|
2536
|
+
function subscriptionsOf(context) {
|
|
2537
|
+
return deps.store.listSubscriptionsFor(context.subscriberId);
|
|
2538
|
+
}
|
|
2539
|
+
function firstSubscription(context) {
|
|
2540
|
+
return subscriptionsOf(context)[0] ?? null;
|
|
2541
|
+
}
|
|
2542
|
+
async function handleSubscribe(context) {
|
|
2543
|
+
const target = context.args[0]?.trim() || context.defaultTarget;
|
|
2544
|
+
if (!target) {
|
|
2545
|
+
return reply(
|
|
2546
|
+
"I could not work out where to send alerts. Pass a target: /subscribe <discord webhook URL or channel id>"
|
|
2547
|
+
);
|
|
2548
|
+
}
|
|
2549
|
+
const existing = subscriptionsOf(context);
|
|
2550
|
+
if (existing.some((sub) => sub.target === target)) {
|
|
2551
|
+
return reply(`This target is already subscribed. Use /rules to edit it, or /unsubscribe to stop.`);
|
|
2552
|
+
}
|
|
2553
|
+
const decision = await deps.gate.checkAddSubscription(context.subscriberId, existing.length);
|
|
2554
|
+
if (!decision.allowed) return reply(`Cannot subscribe: ${decision.reason}`);
|
|
2555
|
+
const { policy } = await deps.gate.resolve(context.subscriberId);
|
|
2556
|
+
const rules = defaultRules().filter(
|
|
2557
|
+
(rule) => rule.kinds.every((kind) => policy.allowedKinds.includes(kind))
|
|
2558
|
+
);
|
|
2559
|
+
const subscription = parseSubscription({
|
|
2560
|
+
id: newId(),
|
|
2561
|
+
subscriberId: context.subscriberId,
|
|
2562
|
+
platform: context.platform,
|
|
2563
|
+
target,
|
|
2564
|
+
enabled: true,
|
|
2565
|
+
rules: rules.slice(0, policy.maxRulesPerSubscription),
|
|
2566
|
+
createdAtMs: now()
|
|
2567
|
+
});
|
|
2568
|
+
deps.store.saveSubscription(subscription);
|
|
2569
|
+
return html(
|
|
2570
|
+
[
|
|
2571
|
+
`<b>Subscribed.</b> Alerts will arrive here.`,
|
|
2572
|
+
`Subscription id: <code>${escapeHtml(subscription.id)}</code>`,
|
|
2573
|
+
`Tier: <b>${policy.tier}</b> (delivery delay ${policy.deliveryDelayMs / 1e3}s, ${policy.maxAlertsPerHour} alerts/hour)`,
|
|
2574
|
+
"",
|
|
2575
|
+
"Starter rules:",
|
|
2576
|
+
...subscription.rules.map((rule) => `\u2022 <code>${escapeHtml(ruleSummary(rule))}</code>`),
|
|
2577
|
+
"",
|
|
2578
|
+
"Edit them with /threshold, /watch and /rule. See /help."
|
|
2579
|
+
].join("\n"),
|
|
2580
|
+
`Subscribed. id=${subscription.id} tier=${policy.tier}`
|
|
2581
|
+
);
|
|
2582
|
+
}
|
|
2583
|
+
async function handleUnsubscribe(context) {
|
|
2584
|
+
const subs = subscriptionsOf(context);
|
|
2585
|
+
if (subs.length === 0) return reply("You have no subscriptions. Use /subscribe to start one.");
|
|
2586
|
+
const id = context.args[0]?.trim();
|
|
2587
|
+
if (!id) {
|
|
2588
|
+
if (subs.length > 1) {
|
|
2589
|
+
return html(
|
|
2590
|
+
[
|
|
2591
|
+
"You have several subscriptions. Name the one to remove:",
|
|
2592
|
+
...subs.map((sub) => `\u2022 <code>${escapeHtml(sub.id)}</code> \u2192 ${escapeHtml(sub.target)}`),
|
|
2593
|
+
"",
|
|
2594
|
+
"Usage: /unsubscribe <subscription id>"
|
|
2595
|
+
].join("\n"),
|
|
2596
|
+
"Name the subscription to remove: " + subs.map((sub) => sub.id).join(", ")
|
|
2597
|
+
);
|
|
2598
|
+
}
|
|
2599
|
+
const only = subs[0];
|
|
2600
|
+
deps.store.deleteSubscription(only.id);
|
|
2601
|
+
return reply("Unsubscribed. No more alerts will be delivered here.");
|
|
2602
|
+
}
|
|
2603
|
+
const match = subs.find((sub) => sub.id === id);
|
|
2604
|
+
if (!match) return reply(`No subscription with id "${id}". Use /status to list yours.`);
|
|
2605
|
+
deps.store.deleteSubscription(match.id);
|
|
2606
|
+
return reply(`Unsubscribed ${match.id}.`);
|
|
2607
|
+
}
|
|
2608
|
+
function handleRules(context) {
|
|
2609
|
+
const subs = subscriptionsOf(context);
|
|
2610
|
+
if (subs.length === 0) return reply("You have no subscriptions yet. Use /subscribe to start one.");
|
|
2611
|
+
const lines = [];
|
|
2612
|
+
for (const sub of subs) {
|
|
2613
|
+
lines.push(`<b>${escapeHtml(sub.target)}</b> (<code>${escapeHtml(sub.id)}</code>)`);
|
|
2614
|
+
if (sub.rules.length === 0) lines.push(" no rules: nothing will be delivered");
|
|
2615
|
+
for (const rule of sub.rules) lines.push(` \u2022 <code>${escapeHtml(ruleSummary(rule))}</code>`);
|
|
2616
|
+
}
|
|
2617
|
+
return html(lines.join("\n"), lines.join("\n").replace(/<[^>]+>/g, ""));
|
|
2618
|
+
}
|
|
2619
|
+
async function handleRule(context) {
|
|
2620
|
+
const action = context.args[0]?.toLowerCase();
|
|
2621
|
+
const subscription = firstSubscription(context);
|
|
2622
|
+
if (!subscription) return reply("Subscribe first with /subscribe, then add rules.");
|
|
2623
|
+
if (action === "add") {
|
|
2624
|
+
const json2 = context.rest.slice(context.rest.indexOf("add") + 3).trim();
|
|
2625
|
+
if (!json2) {
|
|
2626
|
+
return html(
|
|
2627
|
+
[
|
|
2628
|
+
'Usage: <code>/rule add {"id":"big-whales","kinds":["whale_trade"],"minUsd":25000}</code>',
|
|
2629
|
+
"",
|
|
2630
|
+
"Fields: id, name, enabled, kinds, launchpads, minUsd, maxUsd, minLiquidityUsd,",
|
|
2631
|
+
"maxLiquidityUsd, tokens, deployers, excludeDeployers, side, reputation, rateLimit."
|
|
2632
|
+
].join("\n"),
|
|
2633
|
+
'Usage: /rule add {"id":"big-whales","kinds":["whale_trade"],"minUsd":25000}'
|
|
2634
|
+
);
|
|
2635
|
+
}
|
|
2636
|
+
let parsed;
|
|
2637
|
+
try {
|
|
2638
|
+
parsed = JSON.parse(json2);
|
|
2639
|
+
} catch {
|
|
2640
|
+
return reply('That is not valid JSON. Example: /rule add {"id":"whales","kinds":["whale_trade"],"minUsd":5000}');
|
|
2641
|
+
}
|
|
2642
|
+
const result = safeParseRule(parsed);
|
|
2643
|
+
if (!result.success) return reply(`Rule rejected: ${formatValidationError(result.error)}`);
|
|
2644
|
+
const rule = result.data;
|
|
2645
|
+
if (subscription.rules.some((existing) => existing.id === rule.id)) {
|
|
2646
|
+
return reply(`A rule with id "${rule.id}" already exists. Remove it first with /rule rm ${rule.id}.`);
|
|
2647
|
+
}
|
|
2648
|
+
const decision = await deps.gate.checkAddRule(context.subscriberId, rule, subscription.rules.length);
|
|
2649
|
+
if (!decision.allowed) return reply(`Rule rejected: ${decision.reason}`);
|
|
2650
|
+
subscription.rules.push(rule);
|
|
2651
|
+
deps.store.saveSubscription(subscription);
|
|
2652
|
+
return html(
|
|
2653
|
+
`Rule added: <code>${escapeHtml(ruleSummary(rule))}</code>`,
|
|
2654
|
+
`Rule added: ${ruleSummary(rule)}`
|
|
2655
|
+
);
|
|
2656
|
+
}
|
|
2657
|
+
if (action === "rm" || action === "remove" || action === "delete") {
|
|
2658
|
+
const id = context.args[1]?.trim();
|
|
2659
|
+
if (!id) return reply("Usage: /rule rm <rule id>. List them with /rules.");
|
|
2660
|
+
const index = subscription.rules.findIndex((rule) => rule.id === id);
|
|
2661
|
+
if (index === -1) return reply(`No rule with id "${id}". List them with /rules.`);
|
|
2662
|
+
subscription.rules.splice(index, 1);
|
|
2663
|
+
deps.store.saveSubscription(subscription);
|
|
2664
|
+
return reply(`Rule ${id} removed.`);
|
|
2665
|
+
}
|
|
2666
|
+
if (action === "on" || action === "off") {
|
|
2667
|
+
const id = context.args[1]?.trim();
|
|
2668
|
+
if (!id) return reply(`Usage: /rule ${action} <rule id>. List them with /rules.`);
|
|
2669
|
+
const rule = subscription.rules.find((entry) => entry.id === id);
|
|
2670
|
+
if (!rule) return reply(`No rule with id "${id}". List them with /rules.`);
|
|
2671
|
+
rule.enabled = action === "on";
|
|
2672
|
+
deps.store.saveSubscription(subscription);
|
|
2673
|
+
return reply(`Rule ${id} is now ${rule.enabled ? "enabled" : "disabled"}.`);
|
|
2674
|
+
}
|
|
2675
|
+
return html(
|
|
2676
|
+
[
|
|
2677
|
+
"Usage:",
|
|
2678
|
+
"\u2022 <code>/rule add {json}</code>",
|
|
2679
|
+
"\u2022 <code>/rule rm <id></code>",
|
|
2680
|
+
"\u2022 <code>/rule on <id></code>",
|
|
2681
|
+
"\u2022 <code>/rule off <id></code>"
|
|
2682
|
+
].join("\n"),
|
|
2683
|
+
"Usage: /rule add {json} | /rule rm <id> | /rule on <id> | /rule off <id>"
|
|
2684
|
+
);
|
|
2685
|
+
}
|
|
2686
|
+
async function handleThreshold(context) {
|
|
2687
|
+
const subscription = firstSubscription(context);
|
|
2688
|
+
if (!subscription) return reply("Subscribe first with /subscribe.");
|
|
2689
|
+
const [ruleId, rawUsd] = context.args;
|
|
2690
|
+
if (!ruleId || !rawUsd) {
|
|
2691
|
+
return reply("Usage: /threshold <rule id> <usd>. Example: /threshold whales 5000");
|
|
2692
|
+
}
|
|
2693
|
+
const rule = subscription.rules.find((entry) => entry.id === ruleId);
|
|
2694
|
+
if (!rule) return reply(`No rule with id "${ruleId}". List them with /rules.`);
|
|
2695
|
+
const usd = Number(rawUsd);
|
|
2696
|
+
if (!Number.isFinite(usd) || usd < 0) {
|
|
2697
|
+
return reply(`"${rawUsd}" is not a valid amount. Use a positive number, for example /threshold ${ruleId} 5000`);
|
|
2698
|
+
}
|
|
2699
|
+
if (rule.maxUsd !== void 0 && usd > rule.maxUsd) {
|
|
2700
|
+
return reply(`minUsd ${usd} would exceed this rule's maxUsd ${rule.maxUsd}. Lower it or raise maxUsd first.`);
|
|
2701
|
+
}
|
|
2702
|
+
rule.minUsd = usd;
|
|
2703
|
+
const decision = await deps.gate.checkRule(context.subscriberId, rule);
|
|
2704
|
+
if (!decision.allowed) return reply(`Change rejected: ${decision.reason}`);
|
|
2705
|
+
deps.store.saveSubscription(subscription);
|
|
2706
|
+
return reply(`Rule ${ruleId} now requires at least $${usd} of value.`);
|
|
2707
|
+
}
|
|
2708
|
+
async function handleWatch(context, add) {
|
|
2709
|
+
const subscription = firstSubscription(context);
|
|
2710
|
+
if (!subscription) return reply("Subscribe first with /subscribe.");
|
|
2711
|
+
const rawToken = context.args[0]?.trim();
|
|
2712
|
+
if (!rawToken) {
|
|
2713
|
+
return reply(`Usage: /${add ? "watch" : "unwatch"} <token address> [rule id]`);
|
|
2714
|
+
}
|
|
2715
|
+
if (!/^0x[0-9a-fA-F]{40}$/.test(rawToken)) {
|
|
2716
|
+
return reply(`"${rawToken}" is not a token address. Pass the 0x contract address from the alert.`);
|
|
2717
|
+
}
|
|
2718
|
+
const token = (0, import_viem10.getAddress)(rawToken).toLowerCase();
|
|
2719
|
+
const ruleId = context.args[1]?.trim();
|
|
2720
|
+
const rule = ruleId ? subscription.rules.find((entry) => entry.id === ruleId) : subscription.rules[0];
|
|
2721
|
+
if (!rule) {
|
|
2722
|
+
return reply(
|
|
2723
|
+
ruleId ? `No rule with id "${ruleId}". List them with /rules.` : "This subscription has no rules yet. Add one with /rule add."
|
|
2724
|
+
);
|
|
2725
|
+
}
|
|
2726
|
+
if (add) {
|
|
2727
|
+
if (rule.tokens.includes(token)) return reply(`${rawToken} is already on rule ${rule.id}.`);
|
|
2728
|
+
rule.tokens.push(token);
|
|
2729
|
+
const decision = await deps.gate.checkRule(context.subscriberId, rule);
|
|
2730
|
+
if (!decision.allowed) {
|
|
2731
|
+
rule.tokens.pop();
|
|
2732
|
+
return reply(`Cannot add: ${decision.reason}`);
|
|
2733
|
+
}
|
|
2734
|
+
deps.store.saveSubscription(subscription);
|
|
2735
|
+
return reply(`Watching ${rawToken} on rule ${rule.id} (${rule.tokens.length} tokens).`);
|
|
2736
|
+
}
|
|
2737
|
+
const index = rule.tokens.indexOf(token);
|
|
2738
|
+
if (index === -1) return reply(`${rawToken} is not on rule ${rule.id}.`);
|
|
2739
|
+
rule.tokens.splice(index, 1);
|
|
2740
|
+
deps.store.saveSubscription(subscription);
|
|
2741
|
+
return reply(`Stopped watching ${rawToken} on rule ${rule.id}.`);
|
|
2742
|
+
}
|
|
2743
|
+
async function handleTier(context) {
|
|
2744
|
+
const { entitlement, policy } = await deps.gate.resolve(context.subscriberId);
|
|
2745
|
+
const expiry = entitlement.expiresAtMs === null ? "no expiry" : `expires ${new Date(entitlement.expiresAtMs).toISOString()}`;
|
|
2746
|
+
const lines = [
|
|
2747
|
+
`<b>Tier: ${escapeHtml(policy.tier)}</b> (${escapeHtml(expiry)}, source ${escapeHtml(entitlement.source)})`,
|
|
2748
|
+
`Subscriptions: up to ${policy.maxSubscriptions}`,
|
|
2749
|
+
`Rules per subscription: up to ${policy.maxRulesPerSubscription}`,
|
|
2750
|
+
`Filters per rule: up to ${policy.maxRuleComplexity}`,
|
|
2751
|
+
`Watchlist size: up to ${policy.maxWatchlistTokens}`,
|
|
2752
|
+
`Alerts per hour: up to ${policy.maxAlertsPerHour}`,
|
|
2753
|
+
`Delivery delay: ${policy.deliveryDelayMs / 1e3}s`,
|
|
2754
|
+
`Liquidity and reputation filters: ${policy.allowOnChainLookupFilters ? "included" : "premium only"}`,
|
|
2755
|
+
`Event kinds: ${policy.allowedKinds.join(", ")}`
|
|
2756
|
+
];
|
|
2757
|
+
return html(lines.join("\n"), lines.join("\n").replace(/<[^>]+>/g, ""));
|
|
2758
|
+
}
|
|
2759
|
+
async function handleStatus(context) {
|
|
2760
|
+
const subs = subscriptionsOf(context);
|
|
2761
|
+
if (subs.length === 0) return reply("No subscriptions. Use /subscribe to start one.");
|
|
2762
|
+
const delivered2 = deps.store.countDeliveriesForSubscriber(context.subscriberId, now() - 36e5);
|
|
2763
|
+
const { policy } = await deps.gate.resolve(context.subscriberId);
|
|
2764
|
+
const lines = [
|
|
2765
|
+
`<b>${subs.length} subscription${subs.length === 1 ? "" : "s"}</b>, ${delivered2} alerts in the last hour (cap ${policy.maxAlertsPerHour})`,
|
|
2766
|
+
...subs.map(
|
|
2767
|
+
(sub) => `\u2022 <code>${escapeHtml(sub.id)}</code> \u2192 ${escapeHtml(sub.target)} (${sub.rules.length} rules, ${sub.enabled ? "enabled" : "disabled"})`
|
|
2768
|
+
)
|
|
2769
|
+
];
|
|
2770
|
+
return html(lines.join("\n"), lines.join("\n").replace(/<[^>]+>/g, ""));
|
|
2771
|
+
}
|
|
2772
|
+
async function handleLink(context) {
|
|
2773
|
+
const [address, signature] = context.args;
|
|
2774
|
+
if (!address) {
|
|
2775
|
+
const nonce2 = deps.store.issueLinkNonce(context.subscriberId, newId());
|
|
2776
|
+
const message = linkMessage(context.subscriberId, nonce2);
|
|
2777
|
+
return html(
|
|
2778
|
+
[
|
|
2779
|
+
"Sign this exact message with the wallet that pays, then send",
|
|
2780
|
+
`<code>/link <address> <signature></code>`,
|
|
2781
|
+
"",
|
|
2782
|
+
`<pre>${escapeHtml(message)}</pre>`
|
|
2783
|
+
].join("\n"),
|
|
2784
|
+
`Sign this message and reply with /link <address> <signature>:
|
|
2785
|
+
${message}`
|
|
2786
|
+
);
|
|
2787
|
+
}
|
|
2788
|
+
if (!signature) {
|
|
2789
|
+
return reply("Usage: /link <address> <signature>. Send /link on its own to get the message to sign.");
|
|
2790
|
+
}
|
|
2791
|
+
const nonce = deps.store.getLinkNonce(context.subscriberId);
|
|
2792
|
+
if (!nonce) {
|
|
2793
|
+
return reply("No pending link request. Send /link on its own first to get a fresh message to sign.");
|
|
2794
|
+
}
|
|
2795
|
+
const verified = await verifyWalletLink(context.subscriberId, nonce, address, signature);
|
|
2796
|
+
if (!verified) {
|
|
2797
|
+
return reply("That signature does not match. Sign the exact message from /link with the wallet you named.");
|
|
2798
|
+
}
|
|
2799
|
+
deps.store.linkWallet(context.subscriberId, verified);
|
|
2800
|
+
deps.store.clearLinkNonce(context.subscriberId);
|
|
2801
|
+
return reply(`Wallet ${verified} linked. Payments from it now count towards your subscription.`);
|
|
2802
|
+
}
|
|
2803
|
+
function handleUpgrade() {
|
|
2804
|
+
if (!deps.upgradeInstructions) {
|
|
2805
|
+
return reply(
|
|
2806
|
+
"This deployment does not sell premium access. The operator grants it through configuration; ask them."
|
|
2807
|
+
);
|
|
2808
|
+
}
|
|
2809
|
+
return html(escapeHtml(deps.upgradeInstructions), deps.upgradeInstructions);
|
|
2810
|
+
}
|
|
2811
|
+
function handleHelp() {
|
|
2812
|
+
const header = "hood-alerts: Robinhood Chain memecoin alerts.";
|
|
2813
|
+
const kinds = "Event kinds: launch, curve_trade, graduation, whale_trade.";
|
|
2814
|
+
const lifecycle = "NOXA lists instantly (no bonding curve), so it never produces curve_trade or graduation. The Odyssey produces all four.";
|
|
2815
|
+
return html(
|
|
2816
|
+
[
|
|
2817
|
+
`<b>${escapeHtml(header)}</b>`,
|
|
2818
|
+
"",
|
|
2819
|
+
...COMMANDS.map(
|
|
2820
|
+
(command) => `<code>${escapeHtml(command.usage)}</code> - ${escapeHtml(command.description)}`
|
|
2821
|
+
),
|
|
2822
|
+
"",
|
|
2823
|
+
escapeHtml(kinds),
|
|
2824
|
+
escapeHtml(lifecycle)
|
|
2825
|
+
].join("\n"),
|
|
2826
|
+
[
|
|
2827
|
+
header,
|
|
2828
|
+
"",
|
|
2829
|
+
...COMMANDS.map((command) => `${command.usage} - ${command.description}`),
|
|
2830
|
+
"",
|
|
2831
|
+
kinds,
|
|
2832
|
+
lifecycle
|
|
2833
|
+
].join("\n")
|
|
2834
|
+
);
|
|
2835
|
+
}
|
|
2836
|
+
return {
|
|
2837
|
+
commands: COMMANDS,
|
|
2838
|
+
async handle(command, context) {
|
|
2839
|
+
const name = command.replace(/^\//, "").split("@")[0]?.toLowerCase() ?? "";
|
|
2840
|
+
try {
|
|
2841
|
+
switch (name) {
|
|
2842
|
+
case "start":
|
|
2843
|
+
case "help":
|
|
2844
|
+
return handleHelp();
|
|
2845
|
+
case "subscribe":
|
|
2846
|
+
return await handleSubscribe(context);
|
|
2847
|
+
case "unsubscribe":
|
|
2848
|
+
return await handleUnsubscribe(context);
|
|
2849
|
+
case "rules":
|
|
2850
|
+
return handleRules(context);
|
|
2851
|
+
case "rule":
|
|
2852
|
+
return await handleRule(context);
|
|
2853
|
+
case "threshold":
|
|
2854
|
+
return await handleThreshold(context);
|
|
2855
|
+
case "watch":
|
|
2856
|
+
return await handleWatch(context, true);
|
|
2857
|
+
case "unwatch":
|
|
2858
|
+
return await handleWatch(context, false);
|
|
2859
|
+
case "tier":
|
|
2860
|
+
return await handleTier(context);
|
|
2861
|
+
case "status":
|
|
2862
|
+
return await handleStatus(context);
|
|
2863
|
+
case "link":
|
|
2864
|
+
return await handleLink(context);
|
|
2865
|
+
case "upgrade":
|
|
2866
|
+
return handleUpgrade();
|
|
2867
|
+
default:
|
|
2868
|
+
return reply(`Unknown command "${name}". Send /help for the list.`);
|
|
2869
|
+
}
|
|
2870
|
+
} catch (error) {
|
|
2871
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2872
|
+
return reply(`That command failed: ${message}. Try /help, or report this if it keeps happening.`);
|
|
2873
|
+
}
|
|
2874
|
+
}
|
|
2875
|
+
};
|
|
2876
|
+
}
|
|
2877
|
+
|
|
2878
|
+
// src/bot/discord-bot.ts
|
|
2879
|
+
var import_node_crypto2 = require("crypto");
|
|
2880
|
+
|
|
2881
|
+
// src/notifiers/http.ts
|
|
2882
|
+
var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, Math.max(0, ms)));
|
|
2883
|
+
function resolveFetch(injected) {
|
|
2884
|
+
if (injected) return injected;
|
|
2885
|
+
if (typeof globalThis.fetch !== "function") {
|
|
2886
|
+
throw new Error("hood-alerts: global fetch is unavailable (Node 20+ required), and none was injected");
|
|
2887
|
+
}
|
|
2888
|
+
return globalThis.fetch;
|
|
2889
|
+
}
|
|
2890
|
+
async function postJson(fetchImpl, url, payload, headers = {}) {
|
|
2891
|
+
const response = await fetchImpl(url, {
|
|
2892
|
+
method: "POST",
|
|
2893
|
+
headers: { "content-type": "application/json", ...headers },
|
|
2894
|
+
body: JSON.stringify(payload)
|
|
2895
|
+
});
|
|
2896
|
+
const raw = await response.text();
|
|
2897
|
+
let body = null;
|
|
2898
|
+
if (raw) {
|
|
2899
|
+
try {
|
|
2900
|
+
body = JSON.parse(raw);
|
|
2901
|
+
} catch {
|
|
2902
|
+
body = null;
|
|
2903
|
+
}
|
|
2904
|
+
}
|
|
2905
|
+
return { status: response.status, ok: response.ok, headers: response.headers, body, raw };
|
|
2906
|
+
}
|
|
2907
|
+
function numericHeader(headers, name) {
|
|
2908
|
+
const value = headers.get(name);
|
|
2909
|
+
if (value === null) return null;
|
|
2910
|
+
const parsed = Number(value);
|
|
2911
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
2912
|
+
}
|
|
2913
|
+
function pick(body, ...path) {
|
|
2914
|
+
let current = body;
|
|
2915
|
+
for (const key of path) {
|
|
2916
|
+
if (typeof current !== "object" || current === null) return void 0;
|
|
2917
|
+
current = current[key];
|
|
2918
|
+
}
|
|
2919
|
+
return current;
|
|
2920
|
+
}
|
|
2921
|
+
|
|
2922
|
+
// src/bot/discord-bot.ts
|
|
2923
|
+
var INTERACTION_TYPE = { PING: 1, APPLICATION_COMMAND: 2 };
|
|
2924
|
+
var CALLBACK_TYPE = { PONG: 1, CHANNEL_MESSAGE_WITH_SOURCE: 4 };
|
|
2925
|
+
var EPHEMERAL_FLAG = 1 << 6;
|
|
2926
|
+
var ED25519_SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex");
|
|
2927
|
+
function verifyDiscordSignature(publicKey, signature, timestamp, rawBody) {
|
|
2928
|
+
if (!/^[0-9a-fA-F]{64}$/.test(publicKey)) return false;
|
|
2929
|
+
if (!/^[0-9a-fA-F]{128}$/.test(signature)) return false;
|
|
2930
|
+
if (!timestamp) return false;
|
|
2931
|
+
try {
|
|
2932
|
+
const key = (0, import_node_crypto2.createPublicKey)({
|
|
2933
|
+
key: Buffer.concat([ED25519_SPKI_PREFIX, Buffer.from(publicKey, "hex")]),
|
|
2934
|
+
format: "der",
|
|
2935
|
+
type: "spki"
|
|
2936
|
+
});
|
|
2937
|
+
return (0, import_node_crypto2.verify)(
|
|
2938
|
+
null,
|
|
2939
|
+
Buffer.from(timestamp + rawBody, "utf8"),
|
|
2940
|
+
key,
|
|
2941
|
+
Buffer.from(signature, "hex")
|
|
2942
|
+
);
|
|
2943
|
+
} catch {
|
|
2944
|
+
return false;
|
|
2945
|
+
}
|
|
2946
|
+
}
|
|
2947
|
+
function createDiscordInteractionHandler(options) {
|
|
2948
|
+
const ephemeral = options.ephemeral ?? true;
|
|
2949
|
+
return {
|
|
2950
|
+
async handle(request) {
|
|
2951
|
+
if (!request.signature || !request.timestamp || !verifyDiscordSignature(options.publicKey, request.signature, request.timestamp, request.rawBody)) {
|
|
2952
|
+
return { status: 401, body: { error: "invalid request signature" } };
|
|
2953
|
+
}
|
|
2954
|
+
let interaction;
|
|
2955
|
+
try {
|
|
2956
|
+
interaction = JSON.parse(request.rawBody);
|
|
2957
|
+
} catch {
|
|
2958
|
+
return { status: 400, body: { error: "body is not JSON" } };
|
|
2959
|
+
}
|
|
2960
|
+
if (interaction.type === INTERACTION_TYPE.PING) {
|
|
2961
|
+
return { status: 200, body: { type: CALLBACK_TYPE.PONG } };
|
|
2962
|
+
}
|
|
2963
|
+
if (interaction.type !== INTERACTION_TYPE.APPLICATION_COMMAND) {
|
|
2964
|
+
return { status: 400, body: { error: `unsupported interaction type ${interaction.type}` } };
|
|
2965
|
+
}
|
|
2966
|
+
const name = interaction.data?.name;
|
|
2967
|
+
if (!name) return { status: 400, body: { error: "interaction carried no command name" } };
|
|
2968
|
+
const values = (interaction.data?.options ?? []).map((option) => option.value === void 0 ? "" : String(option.value)).filter((value) => value.length > 0);
|
|
2969
|
+
const rest = values.join(" ");
|
|
2970
|
+
const args = values.length === 1 ? values[0].split(/\s+/) : values;
|
|
2971
|
+
const userId = interaction.member?.user?.id ?? interaction.user?.id ?? "";
|
|
2972
|
+
const context = {
|
|
2973
|
+
// A guild pays once for all its channels; a DM is billed to the user.
|
|
2974
|
+
subscriberId: `discord:${interaction.guild_id ?? userId}`,
|
|
2975
|
+
platform: "discord",
|
|
2976
|
+
defaultTarget: interaction.channel_id ?? "",
|
|
2977
|
+
args,
|
|
2978
|
+
rest: `${name} ${rest}`.trim()
|
|
2979
|
+
};
|
|
2980
|
+
const reply2 = await options.router.handle(name, context);
|
|
2981
|
+
return {
|
|
2982
|
+
status: 200,
|
|
2983
|
+
body: {
|
|
2984
|
+
type: CALLBACK_TYPE.CHANNEL_MESSAGE_WITH_SOURCE,
|
|
2985
|
+
data: {
|
|
2986
|
+
// Discord hard-caps message content at 2000 characters.
|
|
2987
|
+
content: truncate(reply2.text, 1900),
|
|
2988
|
+
...ephemeral ? { flags: EPHEMERAL_FLAG } : {}
|
|
2989
|
+
}
|
|
2990
|
+
}
|
|
2991
|
+
};
|
|
2992
|
+
}
|
|
2993
|
+
};
|
|
2994
|
+
}
|
|
2995
|
+
|
|
2996
|
+
// src/notifiers/types.ts
|
|
2997
|
+
function delivered(status, attempts, messageId) {
|
|
2998
|
+
return { ok: true, status, attempts, messageId, error: null, retryable: false };
|
|
2999
|
+
}
|
|
3000
|
+
function failed(status, attempts, error, retryable) {
|
|
3001
|
+
return { ok: false, status, attempts, messageId: null, error, retryable };
|
|
3002
|
+
}
|
|
3003
|
+
|
|
3004
|
+
// src/notifiers/telegram.ts
|
|
3005
|
+
var PERMANENT_STATUSES = /* @__PURE__ */ new Set([400, 401, 403, 404]);
|
|
3006
|
+
function createTelegramClient(options) {
|
|
3007
|
+
if (!/^\d+:[A-Za-z0-9_-]+$/.test(options.botToken)) {
|
|
3008
|
+
throw new Error('hood-alerts: TELEGRAM_BOT_TOKEN must look like "<bot id>:<secret>"');
|
|
3009
|
+
}
|
|
3010
|
+
const fetchImpl = resolveFetch(options.fetch);
|
|
3011
|
+
const apiBase = (options.apiBase ?? "https://api.telegram.org").replace(/\/+$/, "");
|
|
3012
|
+
const maxAttempts = options.maxAttempts ?? 4;
|
|
3013
|
+
const backoffMs = options.backoffMs ?? 500;
|
|
3014
|
+
const maxWaitMs = options.maxWaitMs ?? 6e4;
|
|
3015
|
+
const sleep2 = options.sleep ?? sleep;
|
|
3016
|
+
let lastAttempts = 0;
|
|
3017
|
+
return {
|
|
3018
|
+
get lastAttempts() {
|
|
3019
|
+
return lastAttempts;
|
|
3020
|
+
},
|
|
3021
|
+
async call(method, params) {
|
|
3022
|
+
const url = `${apiBase}/bot${options.botToken}/${method}`;
|
|
3023
|
+
let attempt = 0;
|
|
3024
|
+
let last = {
|
|
3025
|
+
ok: false,
|
|
3026
|
+
status: 0,
|
|
3027
|
+
result: null,
|
|
3028
|
+
description: "no attempt was made",
|
|
3029
|
+
retryAfterSeconds: null
|
|
3030
|
+
};
|
|
3031
|
+
while (attempt < maxAttempts) {
|
|
3032
|
+
attempt += 1;
|
|
3033
|
+
lastAttempts = attempt;
|
|
3034
|
+
let response;
|
|
3035
|
+
try {
|
|
3036
|
+
response = await postJson(fetchImpl, url, params);
|
|
3037
|
+
} catch (error) {
|
|
3038
|
+
last = {
|
|
3039
|
+
ok: false,
|
|
3040
|
+
status: 0,
|
|
3041
|
+
result: null,
|
|
3042
|
+
description: error instanceof Error ? error.message : String(error),
|
|
3043
|
+
retryAfterSeconds: null
|
|
3044
|
+
};
|
|
3045
|
+
if (attempt >= maxAttempts) break;
|
|
3046
|
+
await sleep2(backoffMs * 2 ** (attempt - 1));
|
|
3047
|
+
continue;
|
|
3048
|
+
}
|
|
3049
|
+
const description = typeof pick(response.body, "description") === "string" ? pick(response.body, "description") : null;
|
|
3050
|
+
const retryAfterField = pick(response.body, "parameters", "retry_after");
|
|
3051
|
+
const retryAfterSeconds = typeof retryAfterField === "number" ? retryAfterField : numericHeader(response.headers, "retry-after");
|
|
3052
|
+
last = {
|
|
3053
|
+
ok: response.status === 200 && pick(response.body, "ok") === true,
|
|
3054
|
+
status: response.status,
|
|
3055
|
+
result: pick(response.body, "result") ?? null,
|
|
3056
|
+
description,
|
|
3057
|
+
retryAfterSeconds
|
|
3058
|
+
};
|
|
3059
|
+
if (last.ok) return last;
|
|
3060
|
+
if (response.status === 429) {
|
|
3061
|
+
const waitMs = (retryAfterSeconds ?? backoffMs / 1e3) * 1e3;
|
|
3062
|
+
if (waitMs > maxWaitMs || attempt >= maxAttempts) return last;
|
|
3063
|
+
await sleep2(waitMs);
|
|
3064
|
+
continue;
|
|
3065
|
+
}
|
|
3066
|
+
if (PERMANENT_STATUSES.has(response.status)) return last;
|
|
3067
|
+
if (attempt >= maxAttempts) return last;
|
|
3068
|
+
await sleep2(backoffMs * 2 ** (attempt - 1));
|
|
3069
|
+
}
|
|
3070
|
+
return last;
|
|
3071
|
+
}
|
|
3072
|
+
};
|
|
3073
|
+
}
|
|
3074
|
+
function createTelegramNotifier(options) {
|
|
3075
|
+
const client = options.client ?? createTelegramClient(options);
|
|
3076
|
+
const disableWebPagePreview = options.disableWebPagePreview ?? true;
|
|
3077
|
+
const disableNotification = options.disableNotification ?? false;
|
|
3078
|
+
return {
|
|
3079
|
+
platform: "telegram",
|
|
3080
|
+
kind: "bot",
|
|
3081
|
+
async send(target, alert) {
|
|
3082
|
+
if (!/^-?\d+$|^@[A-Za-z][A-Za-z0-9_]{4,}$/.test(target)) {
|
|
3083
|
+
return failed(
|
|
3084
|
+
null,
|
|
3085
|
+
0,
|
|
3086
|
+
`invalid Telegram chat id "${target}": expected a numeric id or an @username`,
|
|
3087
|
+
false
|
|
3088
|
+
);
|
|
3089
|
+
}
|
|
3090
|
+
const response = await client.call("sendMessage", {
|
|
3091
|
+
chat_id: /^-?\d+$/.test(target) ? Number(target) : target,
|
|
3092
|
+
text: alert.text,
|
|
3093
|
+
parse_mode: alert.parseMode,
|
|
3094
|
+
link_preview_options: { is_disabled: disableWebPagePreview },
|
|
3095
|
+
disable_notification: disableNotification
|
|
3096
|
+
});
|
|
3097
|
+
const attempts = client.lastAttempts;
|
|
3098
|
+
if (response.ok) {
|
|
3099
|
+
const messageId = pick(response.result, "message_id");
|
|
3100
|
+
return delivered(response.status, attempts, messageId === void 0 ? null : String(messageId));
|
|
3101
|
+
}
|
|
3102
|
+
const retryable = response.status === 429 || response.status >= 500 || response.status === 0;
|
|
3103
|
+
return failed(
|
|
3104
|
+
response.status || null,
|
|
3105
|
+
attempts,
|
|
3106
|
+
response.description ?? `Telegram sendMessage failed with status ${response.status}`,
|
|
3107
|
+
retryable
|
|
3108
|
+
);
|
|
3109
|
+
}
|
|
3110
|
+
};
|
|
3111
|
+
}
|
|
3112
|
+
|
|
3113
|
+
// src/bot/telegram-bot.ts
|
|
3114
|
+
function parseCommand(text) {
|
|
3115
|
+
const trimmed = text.trim();
|
|
3116
|
+
if (!trimmed.startsWith("/")) return null;
|
|
3117
|
+
const [head, ...tail] = trimmed.split(/\s+/);
|
|
3118
|
+
if (!head) return null;
|
|
3119
|
+
return { command: head, args: tail, rest: trimmed.slice(head.length).trim() };
|
|
3120
|
+
}
|
|
3121
|
+
function createTelegramBot(options) {
|
|
3122
|
+
const client = options.client ?? createTelegramClient(options);
|
|
3123
|
+
const logger = options.logger ?? createSilentLogger();
|
|
3124
|
+
const pollTimeout = options.pollTimeoutSeconds ?? 30;
|
|
3125
|
+
let offset = options.offsetStore?.get() ?? 0;
|
|
3126
|
+
let running = false;
|
|
3127
|
+
async function handleUpdate(update) {
|
|
3128
|
+
const message = update.message;
|
|
3129
|
+
const text = message?.text;
|
|
3130
|
+
const chatId = message?.chat?.id;
|
|
3131
|
+
const fromId = message?.from?.id;
|
|
3132
|
+
if (!text || chatId === void 0 || fromId === void 0 || message?.from?.is_bot === true) return;
|
|
3133
|
+
const parsed = parseCommand(text);
|
|
3134
|
+
if (!parsed) return;
|
|
3135
|
+
const context = {
|
|
3136
|
+
subscriberId: `telegram:${fromId}`,
|
|
3137
|
+
platform: "telegram",
|
|
3138
|
+
defaultTarget: String(chatId),
|
|
3139
|
+
args: parsed.args,
|
|
3140
|
+
rest: parsed.rest
|
|
3141
|
+
};
|
|
3142
|
+
const reply2 = await options.router.handle(parsed.command, context);
|
|
3143
|
+
const response = await client.call("sendMessage", {
|
|
3144
|
+
chat_id: chatId,
|
|
3145
|
+
text: reply2.html,
|
|
3146
|
+
parse_mode: "HTML",
|
|
3147
|
+
link_preview_options: { is_disabled: true }
|
|
3148
|
+
});
|
|
3149
|
+
if (!response.ok) {
|
|
3150
|
+
logger.warn("telegram reply failed", {
|
|
3151
|
+
chatId,
|
|
3152
|
+
command: parsed.command,
|
|
3153
|
+
error: response.description
|
|
3154
|
+
});
|
|
3155
|
+
}
|
|
3156
|
+
}
|
|
3157
|
+
return {
|
|
3158
|
+
async publishCommands() {
|
|
3159
|
+
const response = await client.call("setMyCommands", {
|
|
3160
|
+
commands: options.router.commands.map((command) => ({
|
|
3161
|
+
command: command.name,
|
|
3162
|
+
description: command.description.slice(0, 256)
|
|
3163
|
+
}))
|
|
3164
|
+
});
|
|
3165
|
+
if (!response.ok) logger.warn("setMyCommands failed", { error: response.description });
|
|
3166
|
+
return response.ok;
|
|
3167
|
+
},
|
|
3168
|
+
async pollOnce() {
|
|
3169
|
+
const response = await client.call("getUpdates", {
|
|
3170
|
+
...offset > 0 ? { offset } : {},
|
|
3171
|
+
timeout: pollTimeout,
|
|
3172
|
+
allowed_updates: ["message"]
|
|
3173
|
+
});
|
|
3174
|
+
if (!response.ok) {
|
|
3175
|
+
logger.warn("getUpdates failed", { error: response.description });
|
|
3176
|
+
return 0;
|
|
3177
|
+
}
|
|
3178
|
+
const updates = Array.isArray(response.result) ? response.result : [];
|
|
3179
|
+
for (const update of updates) {
|
|
3180
|
+
try {
|
|
3181
|
+
await handleUpdate(update);
|
|
3182
|
+
} catch (error) {
|
|
3183
|
+
logger.error("telegram update handler threw", {
|
|
3184
|
+
updateId: update.update_id,
|
|
3185
|
+
error: error instanceof Error ? error.message : String(error)
|
|
3186
|
+
});
|
|
3187
|
+
}
|
|
3188
|
+
offset = update.update_id + 1;
|
|
3189
|
+
options.offsetStore?.set(offset);
|
|
3190
|
+
}
|
|
3191
|
+
return updates.length;
|
|
3192
|
+
},
|
|
3193
|
+
async start() {
|
|
3194
|
+
running = true;
|
|
3195
|
+
logger.info("telegram bot started", { offset });
|
|
3196
|
+
while (running) {
|
|
3197
|
+
try {
|
|
3198
|
+
await this.pollOnce();
|
|
3199
|
+
} catch (error) {
|
|
3200
|
+
logger.error("telegram poll failed", {
|
|
3201
|
+
error: error instanceof Error ? error.message : String(error)
|
|
3202
|
+
});
|
|
3203
|
+
await new Promise((resolve) => setTimeout(resolve, 2e3));
|
|
3204
|
+
}
|
|
3205
|
+
}
|
|
3206
|
+
logger.info("telegram bot stopped");
|
|
3207
|
+
},
|
|
3208
|
+
stop() {
|
|
3209
|
+
running = false;
|
|
3210
|
+
}
|
|
3211
|
+
};
|
|
3212
|
+
}
|
|
3213
|
+
|
|
3214
|
+
// src/notifiers/capture.ts
|
|
3215
|
+
function createCaptureNotifier(platform, onSend, now = Date.now) {
|
|
3216
|
+
const sent = [];
|
|
3217
|
+
return {
|
|
3218
|
+
platform,
|
|
3219
|
+
kind: "capture",
|
|
3220
|
+
sent,
|
|
3221
|
+
clear() {
|
|
3222
|
+
sent.length = 0;
|
|
3223
|
+
},
|
|
3224
|
+
async send(target, alert) {
|
|
3225
|
+
const delivery = { platform, target, alert, atMs: now() };
|
|
3226
|
+
sent.push(delivery);
|
|
3227
|
+
onSend?.(delivery);
|
|
3228
|
+
return delivered(200, 1, `capture-${sent.length}`);
|
|
3229
|
+
}
|
|
3230
|
+
};
|
|
3231
|
+
}
|
|
3232
|
+
|
|
3233
|
+
// src/notifiers/discord.ts
|
|
3234
|
+
function createDiscordRateLimiter(now = Date.now, sleep2 = sleep) {
|
|
3235
|
+
const buckets = /* @__PURE__ */ new Map();
|
|
3236
|
+
let globalResetAtMs = 0;
|
|
3237
|
+
return {
|
|
3238
|
+
async acquire(route) {
|
|
3239
|
+
const globalWait = globalResetAtMs - now();
|
|
3240
|
+
if (globalWait > 0) await sleep2(globalWait);
|
|
3241
|
+
const bucket = buckets.get(route);
|
|
3242
|
+
if (!bucket) return;
|
|
3243
|
+
if (bucket.remaining > 0) return;
|
|
3244
|
+
const wait = bucket.resetAtMs - now();
|
|
3245
|
+
if (wait > 0) await sleep2(wait);
|
|
3246
|
+
},
|
|
3247
|
+
update(route, headers) {
|
|
3248
|
+
const remaining = numericHeader(headers, "x-ratelimit-remaining");
|
|
3249
|
+
const resetAfter = numericHeader(headers, "x-ratelimit-reset-after");
|
|
3250
|
+
if (remaining === null && resetAfter === null) return;
|
|
3251
|
+
buckets.set(route, {
|
|
3252
|
+
remaining: remaining ?? 1,
|
|
3253
|
+
resetAtMs: now() + (resetAfter ?? 0) * 1e3
|
|
3254
|
+
});
|
|
3255
|
+
},
|
|
3256
|
+
penalise(route, retryAfterMs, global) {
|
|
3257
|
+
const resetAtMs = now() + retryAfterMs;
|
|
3258
|
+
if (global) globalResetAtMs = Math.max(globalResetAtMs, resetAtMs);
|
|
3259
|
+
buckets.set(route, { remaining: 0, resetAtMs });
|
|
3260
|
+
}
|
|
3261
|
+
};
|
|
3262
|
+
}
|
|
3263
|
+
var PERMANENT_STATUSES2 = /* @__PURE__ */ new Set([400, 401, 403, 404, 405]);
|
|
3264
|
+
async function deliver(args, options, fetchImpl, limiter) {
|
|
3265
|
+
const maxAttempts = options.maxAttempts ?? 4;
|
|
3266
|
+
const backoffMs = options.backoffMs ?? 500;
|
|
3267
|
+
const maxWaitMs = options.maxWaitMs ?? 6e4;
|
|
3268
|
+
const sleep2 = options.sleep ?? sleep;
|
|
3269
|
+
let attempt = 0;
|
|
3270
|
+
let lastStatus = null;
|
|
3271
|
+
let lastError = "no attempt was made";
|
|
3272
|
+
let retryable = true;
|
|
3273
|
+
while (attempt < maxAttempts) {
|
|
3274
|
+
attempt += 1;
|
|
3275
|
+
await limiter.acquire(args.route);
|
|
3276
|
+
let response;
|
|
3277
|
+
try {
|
|
3278
|
+
response = await postJson(fetchImpl, args.url, args.payload, args.headers);
|
|
3279
|
+
} catch (error) {
|
|
3280
|
+
lastStatus = null;
|
|
3281
|
+
lastError = error instanceof Error ? error.message : String(error);
|
|
3282
|
+
retryable = true;
|
|
3283
|
+
if (attempt >= maxAttempts) break;
|
|
3284
|
+
await sleep2(backoffMs * 2 ** (attempt - 1));
|
|
3285
|
+
continue;
|
|
3286
|
+
}
|
|
3287
|
+
limiter.update(args.route, response.headers);
|
|
3288
|
+
lastStatus = response.status;
|
|
3289
|
+
if (response.status === 429) {
|
|
3290
|
+
const retryAfterField = pick(response.body, "retry_after");
|
|
3291
|
+
const retryAfterSeconds = typeof retryAfterField === "number" ? retryAfterField : numericHeader(response.headers, "retry-after") ?? backoffMs / 1e3;
|
|
3292
|
+
const isGlobal = pick(response.body, "global") === true || response.headers.get("x-ratelimit-global") === "true";
|
|
3293
|
+
const waitMs = retryAfterSeconds * 1e3;
|
|
3294
|
+
limiter.penalise(args.route, waitMs, isGlobal);
|
|
3295
|
+
lastError = `rate limited, retry after ${retryAfterSeconds}s${isGlobal ? " (global)" : ""}`;
|
|
3296
|
+
retryable = true;
|
|
3297
|
+
if (waitMs > maxWaitMs || attempt >= maxAttempts) break;
|
|
3298
|
+
await sleep2(waitMs);
|
|
3299
|
+
continue;
|
|
3300
|
+
}
|
|
3301
|
+
if (response.ok) {
|
|
3302
|
+
const messageId = pick(response.body, "id");
|
|
3303
|
+
return delivered(response.status, attempt, messageId === void 0 ? null : String(messageId));
|
|
3304
|
+
}
|
|
3305
|
+
const message = (typeof pick(response.body, "message") === "string" ? pick(response.body, "message") : null) ?? response.raw.slice(0, 200);
|
|
3306
|
+
lastError = message || `Discord request failed with status ${response.status}`;
|
|
3307
|
+
if (PERMANENT_STATUSES2.has(response.status)) {
|
|
3308
|
+
retryable = false;
|
|
3309
|
+
break;
|
|
3310
|
+
}
|
|
3311
|
+
retryable = true;
|
|
3312
|
+
if (attempt >= maxAttempts) break;
|
|
3313
|
+
await sleep2(backoffMs * 2 ** (attempt - 1));
|
|
3314
|
+
}
|
|
3315
|
+
return failed(lastStatus, attempt, lastError, retryable);
|
|
3316
|
+
}
|
|
3317
|
+
var WEBHOOK_PATTERN = /^https:\/\/(?:\w+\.)?discord(?:app)?\.com\/api(?:\/v\d+)?\/webhooks\/\d+\/[\w-]+$/;
|
|
3318
|
+
function createDiscordWebhookNotifier(options = {}) {
|
|
3319
|
+
const fetchImpl = resolveFetch(options.fetch);
|
|
3320
|
+
const limiter = options.limiter ?? createDiscordRateLimiter(options.now ?? Date.now, options.sleep ?? sleep);
|
|
3321
|
+
return {
|
|
3322
|
+
platform: "discord",
|
|
3323
|
+
kind: "webhook",
|
|
3324
|
+
async send(target, alert) {
|
|
3325
|
+
const url = target || options.webhookUrl;
|
|
3326
|
+
if (!url || !WEBHOOK_PATTERN.test(url)) {
|
|
3327
|
+
return failed(
|
|
3328
|
+
null,
|
|
3329
|
+
0,
|
|
3330
|
+
`invalid Discord webhook URL "${target}": expected https://discord.com/api/webhooks/<id>/<token>`,
|
|
3331
|
+
false
|
|
3332
|
+
);
|
|
3333
|
+
}
|
|
3334
|
+
return deliver(
|
|
3335
|
+
{
|
|
3336
|
+
url: `${url}?wait=true`,
|
|
3337
|
+
// Discord buckets webhook routes per webhook id.
|
|
3338
|
+
route: `webhook:${url.split("/").slice(-2, -1)[0] ?? url}`,
|
|
3339
|
+
payload: {
|
|
3340
|
+
embeds: [alert.embed],
|
|
3341
|
+
...options.username ? { username: options.username } : {}
|
|
3342
|
+
},
|
|
3343
|
+
headers: {}
|
|
3344
|
+
},
|
|
3345
|
+
options,
|
|
3346
|
+
fetchImpl,
|
|
3347
|
+
limiter
|
|
3348
|
+
);
|
|
3349
|
+
}
|
|
3350
|
+
};
|
|
3351
|
+
}
|
|
3352
|
+
function createDiscordBotNotifier(options) {
|
|
3353
|
+
if (!options.botToken.trim()) {
|
|
3354
|
+
throw new Error("hood-alerts: DISCORD_BOT_TOKEN is required for the Discord bot notifier");
|
|
3355
|
+
}
|
|
3356
|
+
const fetchImpl = resolveFetch(options.fetch);
|
|
3357
|
+
const apiBase = (options.apiBase ?? "https://discord.com/api/v10").replace(/\/+$/, "");
|
|
3358
|
+
const limiter = options.limiter ?? createDiscordRateLimiter(options.now ?? Date.now, options.sleep ?? sleep);
|
|
3359
|
+
return {
|
|
3360
|
+
platform: "discord",
|
|
3361
|
+
kind: "bot",
|
|
3362
|
+
async send(target, alert) {
|
|
3363
|
+
if (!/^\d{5,25}$/.test(target)) {
|
|
3364
|
+
return failed(null, 0, `invalid Discord channel id "${target}": expected a numeric snowflake`, false);
|
|
3365
|
+
}
|
|
3366
|
+
return deliver(
|
|
3367
|
+
{
|
|
3368
|
+
url: `${apiBase}/channels/${target}/messages`,
|
|
3369
|
+
route: `channel:${target}`,
|
|
3370
|
+
payload: { embeds: [alert.embed] },
|
|
3371
|
+
headers: { authorization: `Bot ${options.botToken}` }
|
|
3372
|
+
},
|
|
3373
|
+
options,
|
|
3374
|
+
fetchImpl,
|
|
3375
|
+
limiter
|
|
3376
|
+
);
|
|
3377
|
+
}
|
|
3378
|
+
};
|
|
3379
|
+
}
|
|
3380
|
+
|
|
3381
|
+
// src/tiers/policy.ts
|
|
3382
|
+
var TIER_POLICIES = {
|
|
3383
|
+
free: {
|
|
3384
|
+
tier: "free",
|
|
3385
|
+
maxSubscriptions: 1,
|
|
3386
|
+
maxRulesPerSubscription: 3,
|
|
3387
|
+
maxRuleComplexity: 3,
|
|
3388
|
+
maxWatchlistTokens: 5,
|
|
3389
|
+
maxAlertsPerHour: 30,
|
|
3390
|
+
deliveryDelayMs: 6e4,
|
|
3391
|
+
allowOnChainLookupFilters: false,
|
|
3392
|
+
allowedKinds: ["launch", "graduation", "whale_trade"]
|
|
3393
|
+
},
|
|
3394
|
+
premium: {
|
|
3395
|
+
tier: "premium",
|
|
3396
|
+
maxSubscriptions: 10,
|
|
3397
|
+
maxRulesPerSubscription: 50,
|
|
3398
|
+
maxRuleComplexity: 24,
|
|
3399
|
+
maxWatchlistTokens: 500,
|
|
3400
|
+
maxAlertsPerHour: 2e3,
|
|
3401
|
+
deliveryDelayMs: 0,
|
|
3402
|
+
allowOnChainLookupFilters: true,
|
|
3403
|
+
allowedKinds: ["launch", "curve_trade", "graduation", "whale_trade"]
|
|
3404
|
+
}
|
|
3405
|
+
};
|
|
3406
|
+
var OK = { allowed: true, reason: null };
|
|
3407
|
+
var deny = (reason) => ({ allowed: false, reason });
|
|
3408
|
+
function canAddSubscription(policy, currentCount) {
|
|
3409
|
+
if (currentCount >= policy.maxSubscriptions) {
|
|
3410
|
+
return deny(
|
|
3411
|
+
`the ${policy.tier} tier allows ${policy.maxSubscriptions} subscription${policy.maxSubscriptions === 1 ? "" : "s"} and you have ${currentCount}. Upgrade with /upgrade, or remove one with /unsubscribe.`
|
|
3412
|
+
);
|
|
3413
|
+
}
|
|
3414
|
+
return OK;
|
|
3415
|
+
}
|
|
3416
|
+
function canAddRule(policy, rule, currentRuleCount) {
|
|
3417
|
+
if (currentRuleCount >= policy.maxRulesPerSubscription) {
|
|
3418
|
+
return deny(
|
|
3419
|
+
`the ${policy.tier} tier allows ${policy.maxRulesPerSubscription} rules per subscription and you have ${currentRuleCount}.`
|
|
3420
|
+
);
|
|
3421
|
+
}
|
|
3422
|
+
return isRuleAllowed(policy, rule);
|
|
3423
|
+
}
|
|
3424
|
+
function isRuleAllowed(policy, rule) {
|
|
3425
|
+
const disallowedKinds = rule.kinds.filter((kind) => !policy.allowedKinds.includes(kind));
|
|
3426
|
+
if (disallowedKinds.length > 0) {
|
|
3427
|
+
return deny(
|
|
3428
|
+
`the ${policy.tier} tier does not include ${disallowedKinds.join(", ")} alerts. Allowed: ${policy.allowedKinds.join(", ")}.`
|
|
3429
|
+
);
|
|
3430
|
+
}
|
|
3431
|
+
const complexity = ruleComplexity(rule);
|
|
3432
|
+
if (complexity > policy.maxRuleComplexity) {
|
|
3433
|
+
return deny(
|
|
3434
|
+
`this rule uses ${complexity} filters and the ${policy.tier} tier allows ${policy.maxRuleComplexity}.`
|
|
3435
|
+
);
|
|
3436
|
+
}
|
|
3437
|
+
if (rule.tokens.length > policy.maxWatchlistTokens) {
|
|
3438
|
+
return deny(
|
|
3439
|
+
`the ${policy.tier} tier allows ${policy.maxWatchlistTokens} watchlist tokens and this rule has ${rule.tokens.length}.`
|
|
3440
|
+
);
|
|
3441
|
+
}
|
|
3442
|
+
if (rule.deployers.length + rule.excludeDeployers.length > policy.maxWatchlistTokens) {
|
|
3443
|
+
return deny(
|
|
3444
|
+
`the ${policy.tier} tier allows ${policy.maxWatchlistTokens} watched deployers per rule.`
|
|
3445
|
+
);
|
|
3446
|
+
}
|
|
3447
|
+
if (!policy.allowOnChainLookupFilters && usesOnChainLookups(rule)) {
|
|
3448
|
+
return deny(
|
|
3449
|
+
`liquidity and deployer-reputation filters are a premium feature: each one costs an on-chain read per candidate event.`
|
|
3450
|
+
);
|
|
3451
|
+
}
|
|
3452
|
+
return OK;
|
|
3453
|
+
}
|
|
3454
|
+
function policyFor(tier, overrides) {
|
|
3455
|
+
const base = TIER_POLICIES[tier];
|
|
3456
|
+
const override = overrides?.[tier];
|
|
3457
|
+
return override ? { ...base, ...override, tier } : base;
|
|
3458
|
+
}
|
|
3459
|
+
|
|
3460
|
+
// src/tiers/enforce.ts
|
|
3461
|
+
function createEntitlementGate(options) {
|
|
3462
|
+
async function resolve(subscriberId) {
|
|
3463
|
+
const entitlement = await options.provider.get(subscriberId);
|
|
3464
|
+
return { entitlement, policy: policyFor(entitlement.tier, options.policyOverrides) };
|
|
3465
|
+
}
|
|
3466
|
+
return {
|
|
3467
|
+
resolve,
|
|
3468
|
+
async checkAddSubscription(subscriberId, currentCount) {
|
|
3469
|
+
const { policy } = await resolve(subscriberId);
|
|
3470
|
+
return canAddSubscription(policy, currentCount);
|
|
3471
|
+
},
|
|
3472
|
+
async checkAddRule(subscriberId, rule, currentRuleCount) {
|
|
3473
|
+
const { policy } = await resolve(subscriberId);
|
|
3474
|
+
return canAddRule(policy, rule, currentRuleCount);
|
|
3475
|
+
},
|
|
3476
|
+
async checkRule(subscriberId, rule) {
|
|
3477
|
+
const { policy } = await resolve(subscriberId);
|
|
3478
|
+
return isRuleAllowed(policy, rule);
|
|
3479
|
+
},
|
|
3480
|
+
async deliveryDelayMs(subscriberId) {
|
|
3481
|
+
const { policy } = await resolve(subscriberId);
|
|
3482
|
+
return policy.deliveryDelayMs;
|
|
3483
|
+
},
|
|
3484
|
+
async checkThroughput(subscriberId, deliveredInLastHour) {
|
|
3485
|
+
const { policy } = await resolve(subscriberId);
|
|
3486
|
+
if (deliveredInLastHour >= policy.maxAlertsPerHour) {
|
|
3487
|
+
return {
|
|
3488
|
+
allowed: false,
|
|
3489
|
+
reason: `hourly alert cap reached (${policy.maxAlertsPerHour} on the ${policy.tier} tier)`
|
|
3490
|
+
};
|
|
3491
|
+
}
|
|
3492
|
+
return { allowed: true, reason: null };
|
|
3493
|
+
}
|
|
3494
|
+
};
|
|
3495
|
+
}
|
|
3496
|
+
|
|
3497
|
+
// src/service/runner.ts
|
|
3498
|
+
var VERSION = "0.1.0";
|
|
3499
|
+
function buildEntitlements(config, client, store) {
|
|
3500
|
+
const staticProvider = createStaticEntitlementProvider({
|
|
3501
|
+
premiumSubscribers: config.premiumSubscribers,
|
|
3502
|
+
allPremium: config.allPremium
|
|
3503
|
+
});
|
|
3504
|
+
if (config.entitlements === "static") return staticProvider;
|
|
3505
|
+
const usdgProvider = createUsdgEntitlementProvider({
|
|
3506
|
+
client,
|
|
3507
|
+
receiver: config.usdgReceiver,
|
|
3508
|
+
pricePerPeriodUsdg: config.premiumPriceUsdg,
|
|
3509
|
+
periodDays: config.premiumPeriodDays,
|
|
3510
|
+
links: store.walletLinks(),
|
|
3511
|
+
fromBlock: config.paymentsFromBlock
|
|
3512
|
+
});
|
|
3513
|
+
if (config.entitlements === "usdg") return usdgProvider;
|
|
3514
|
+
return chainEntitlementProviders(staticProvider, usdgProvider);
|
|
3515
|
+
}
|
|
3516
|
+
function createService(options) {
|
|
3517
|
+
const config = options.config;
|
|
3518
|
+
const logger = options.logger ?? createLogger({ level: config.logLevel, base: { service: "hood-alerts" } });
|
|
3519
|
+
const client = options.client ?? (0, import_hoodchain7.createHoodClient)({
|
|
3520
|
+
chain: config.network,
|
|
3521
|
+
...config.rpcUrl ? { rpcUrl: config.rpcUrl } : {}
|
|
3522
|
+
});
|
|
3523
|
+
const store = options.store ?? new AlertStore(config.dbPath);
|
|
3524
|
+
const oracle = createPriceOracle(client);
|
|
3525
|
+
const tokens = createTokenMetaReader(client);
|
|
3526
|
+
const registry = store.poolRegistry();
|
|
3527
|
+
const liquidity = createLiquidityReader(client, oracle, registry);
|
|
3528
|
+
const reputation = createRpcReputationProvider(client, oracle);
|
|
3529
|
+
const sources = createEventSources({
|
|
3530
|
+
client,
|
|
3531
|
+
oracle,
|
|
3532
|
+
tokens,
|
|
3533
|
+
registry,
|
|
3534
|
+
pinnedTokens: async () => store.watchlistTokens(),
|
|
3535
|
+
whaleMinUsd: config.whaleMinUsd,
|
|
3536
|
+
whalePoolLimit: config.whalePoolLimit
|
|
3537
|
+
});
|
|
3538
|
+
const entitlements = buildEntitlements(config, client, store);
|
|
3539
|
+
const gate = createEntitlementGate({ provider: entitlements });
|
|
3540
|
+
const platforms = [];
|
|
3541
|
+
let telegramNotifier = null;
|
|
3542
|
+
let discordWebhookNotifier = null;
|
|
3543
|
+
let discordBotNotifier = null;
|
|
3544
|
+
const telegramClient = config.telegramBotToken ? createTelegramClient({ botToken: config.telegramBotToken }) : null;
|
|
3545
|
+
if (config.dryRun) {
|
|
3546
|
+
telegramNotifier = createCaptureNotifier(
|
|
3547
|
+
"telegram",
|
|
3548
|
+
(delivery) => logger.info("dry run: telegram alert", { target: delivery.target, text: delivery.alert.plain })
|
|
3549
|
+
);
|
|
3550
|
+
discordWebhookNotifier = createCaptureNotifier(
|
|
3551
|
+
"discord",
|
|
3552
|
+
(delivery) => logger.info("dry run: discord alert", { target: delivery.target, text: delivery.alert.plain })
|
|
3553
|
+
);
|
|
3554
|
+
discordBotNotifier = discordWebhookNotifier;
|
|
3555
|
+
platforms.push("dry-run");
|
|
3556
|
+
} else {
|
|
3557
|
+
if (telegramClient) {
|
|
3558
|
+
telegramNotifier = createTelegramNotifier({
|
|
3559
|
+
botToken: config.telegramBotToken,
|
|
3560
|
+
client: telegramClient
|
|
3561
|
+
});
|
|
3562
|
+
platforms.push("telegram");
|
|
3563
|
+
}
|
|
3564
|
+
discordWebhookNotifier = createDiscordWebhookNotifier({});
|
|
3565
|
+
platforms.push("discord-webhook");
|
|
3566
|
+
if (config.discordBotToken) {
|
|
3567
|
+
discordBotNotifier = createDiscordBotNotifier({ botToken: config.discordBotToken });
|
|
3568
|
+
platforms.push("discord-bot");
|
|
3569
|
+
}
|
|
3570
|
+
}
|
|
3571
|
+
function notifierFor(subscription) {
|
|
3572
|
+
if (subscription.platform === "telegram") return telegramNotifier;
|
|
3573
|
+
if (subscription.target.startsWith("https://")) return discordWebhookNotifier;
|
|
3574
|
+
return discordBotNotifier;
|
|
3575
|
+
}
|
|
3576
|
+
const dispatcher = createDispatcher({
|
|
3577
|
+
store,
|
|
3578
|
+
sources,
|
|
3579
|
+
gate,
|
|
3580
|
+
rateLimits: store.rateLimitStore(),
|
|
3581
|
+
notifierFor,
|
|
3582
|
+
getBlockNumber: () => client.public.getBlockNumber(),
|
|
3583
|
+
logger,
|
|
3584
|
+
evaluation: {
|
|
3585
|
+
liquidityUsd: (event) => liquidity.tokenLiquidityUsd(event.token),
|
|
3586
|
+
reputation: (deployer) => reputation.get(deployer),
|
|
3587
|
+
isLpLocked: (positionId) => reputation.isLpLocked(positionId)
|
|
3588
|
+
},
|
|
3589
|
+
confirmations: config.confirmations,
|
|
3590
|
+
chunkSize: config.chunkSize,
|
|
3591
|
+
initialLookbackBlocks: config.initialLookbackBlocks,
|
|
3592
|
+
flushBatchSize: config.flushBatchSize,
|
|
3593
|
+
maxDeliveryAttempts: config.maxDeliveryAttempts,
|
|
3594
|
+
retryBackoffMs: config.retryBackoffMs
|
|
3595
|
+
});
|
|
3596
|
+
const router = createCommandRouter({
|
|
3597
|
+
store,
|
|
3598
|
+
gate,
|
|
3599
|
+
...config.upgradeInstructions ? { upgradeInstructions: config.upgradeInstructions } : {}
|
|
3600
|
+
});
|
|
3601
|
+
let telegramBot = null;
|
|
3602
|
+
if (telegramClient && !config.dryRun) {
|
|
3603
|
+
telegramBot = createTelegramBot({
|
|
3604
|
+
botToken: config.telegramBotToken,
|
|
3605
|
+
client: telegramClient,
|
|
3606
|
+
router,
|
|
3607
|
+
logger: logger.child({ component: "telegram-bot" }),
|
|
3608
|
+
offsetStore: {
|
|
3609
|
+
get: () => {
|
|
3610
|
+
const raw = store.getMeta("telegram:offset");
|
|
3611
|
+
return raw === null ? null : Number(raw);
|
|
3612
|
+
},
|
|
3613
|
+
set: (offset) => store.setMeta("telegram:offset", String(offset))
|
|
3614
|
+
}
|
|
3615
|
+
});
|
|
3616
|
+
}
|
|
3617
|
+
const discordHandler = config.discordPublicKey ? createDiscordInteractionHandler({ router, publicKey: config.discordPublicKey }) : null;
|
|
3618
|
+
const startedAtMs = Date.now();
|
|
3619
|
+
let headBlock = null;
|
|
3620
|
+
let lastPollAtMs = null;
|
|
3621
|
+
let lastError = null;
|
|
3622
|
+
const health = () => ({
|
|
3623
|
+
headBlock,
|
|
3624
|
+
lastPollAtMs,
|
|
3625
|
+
lastError,
|
|
3626
|
+
platforms,
|
|
3627
|
+
version: VERSION,
|
|
3628
|
+
startedAtMs
|
|
3629
|
+
});
|
|
3630
|
+
const server = createHttpServer({
|
|
3631
|
+
store,
|
|
3632
|
+
logger: logger.child({ component: "http" }),
|
|
3633
|
+
health,
|
|
3634
|
+
...discordHandler ? { discord: { path: config.discordInteractionsPath, handle: discordHandler.handle } } : {}
|
|
3635
|
+
});
|
|
3636
|
+
let running = false;
|
|
3637
|
+
const loops = [];
|
|
3638
|
+
let stopping = null;
|
|
3639
|
+
const waiters = /* @__PURE__ */ new Set();
|
|
3640
|
+
function delay(ms) {
|
|
3641
|
+
return new Promise((resolve) => {
|
|
3642
|
+
const timer = setTimeout(() => {
|
|
3643
|
+
waiters.delete(wake);
|
|
3644
|
+
resolve();
|
|
3645
|
+
}, ms);
|
|
3646
|
+
const wake = () => {
|
|
3647
|
+
clearTimeout(timer);
|
|
3648
|
+
waiters.delete(wake);
|
|
3649
|
+
resolve();
|
|
3650
|
+
};
|
|
3651
|
+
waiters.add(wake);
|
|
3652
|
+
});
|
|
3653
|
+
}
|
|
3654
|
+
async function pollLoop() {
|
|
3655
|
+
while (running) {
|
|
3656
|
+
try {
|
|
3657
|
+
headBlock = await client.public.getBlockNumber();
|
|
3658
|
+
const stats = await dispatcher.pollOnce();
|
|
3659
|
+
lastPollAtMs = Date.now();
|
|
3660
|
+
lastError = stats.errors.length > 0 ? stats.errors[0]?.error ?? null : null;
|
|
3661
|
+
if (stats.eventsDecoded > 0 || stats.enqueued > 0) {
|
|
3662
|
+
logger.info("poll complete", {
|
|
3663
|
+
head: headBlock,
|
|
3664
|
+
events: stats.eventsDecoded,
|
|
3665
|
+
enqueued: stats.enqueued,
|
|
3666
|
+
deduped: stats.deduped,
|
|
3667
|
+
suppressed: stats.suppressed,
|
|
3668
|
+
errors: stats.errors.length
|
|
3669
|
+
});
|
|
3670
|
+
}
|
|
3671
|
+
} catch (error) {
|
|
3672
|
+
lastError = error instanceof Error ? error.message : String(error);
|
|
3673
|
+
logger.error("poll loop error", { error: lastError });
|
|
3674
|
+
}
|
|
3675
|
+
await delay(config.pollIntervalMs);
|
|
3676
|
+
}
|
|
3677
|
+
}
|
|
3678
|
+
async function flushLoop() {
|
|
3679
|
+
while (running) {
|
|
3680
|
+
try {
|
|
3681
|
+
const stats = await dispatcher.flushOnce();
|
|
3682
|
+
if (stats.claimed > 0) logger.debug("flush complete", { ...stats });
|
|
3683
|
+
} catch (error) {
|
|
3684
|
+
logger.error("flush loop error", {
|
|
3685
|
+
error: error instanceof Error ? error.message : String(error)
|
|
3686
|
+
});
|
|
3687
|
+
}
|
|
3688
|
+
await delay(config.flushIntervalMs);
|
|
3689
|
+
}
|
|
3690
|
+
}
|
|
3691
|
+
async function pruneLoop() {
|
|
3692
|
+
while (running) {
|
|
3693
|
+
await delay(36e5);
|
|
3694
|
+
if (!running) break;
|
|
3695
|
+
try {
|
|
3696
|
+
const cutoff = Date.now() - config.outboxRetentionHours * 36e5;
|
|
3697
|
+
const outbox = store.pruneOutbox(cutoff);
|
|
3698
|
+
const deliveries = store.pruneDeliveries(Date.now() - 25 * 36e5);
|
|
3699
|
+
if (outbox > 0 || deliveries > 0) logger.info("pruned", { outbox, deliveries });
|
|
3700
|
+
} catch (error) {
|
|
3701
|
+
logger.error("prune failed", {
|
|
3702
|
+
error: error instanceof Error ? error.message : String(error)
|
|
3703
|
+
});
|
|
3704
|
+
}
|
|
3705
|
+
}
|
|
3706
|
+
}
|
|
3707
|
+
return {
|
|
3708
|
+
config,
|
|
3709
|
+
store,
|
|
3710
|
+
dispatcher,
|
|
3711
|
+
logger,
|
|
3712
|
+
health,
|
|
3713
|
+
async start() {
|
|
3714
|
+
if (running) return;
|
|
3715
|
+
running = true;
|
|
3716
|
+
dispatcher.recover();
|
|
3717
|
+
await new Promise((resolve, reject) => {
|
|
3718
|
+
const onError = (error) => reject(error);
|
|
3719
|
+
server.once("error", onError);
|
|
3720
|
+
server.listen(config.port, () => {
|
|
3721
|
+
server.removeListener("error", onError);
|
|
3722
|
+
resolve();
|
|
3723
|
+
});
|
|
3724
|
+
});
|
|
3725
|
+
logger.info("service started", {
|
|
3726
|
+
port: config.port,
|
|
3727
|
+
network: config.network,
|
|
3728
|
+
dryRun: config.dryRun,
|
|
3729
|
+
platforms,
|
|
3730
|
+
entitlements: config.entitlements,
|
|
3731
|
+
sources: sources.map((source) => source.id)
|
|
3732
|
+
});
|
|
3733
|
+
loops.push(pollLoop(), flushLoop(), pruneLoop());
|
|
3734
|
+
if (telegramBot) {
|
|
3735
|
+
await telegramBot.publishCommands();
|
|
3736
|
+
loops.push(telegramBot.start());
|
|
3737
|
+
}
|
|
3738
|
+
},
|
|
3739
|
+
async stop() {
|
|
3740
|
+
stopping ??= (async () => {
|
|
3741
|
+
logger.info("service stopping");
|
|
3742
|
+
running = false;
|
|
3743
|
+
telegramBot?.stop();
|
|
3744
|
+
for (const wake of [...waiters]) wake();
|
|
3745
|
+
await new Promise((resolve) => {
|
|
3746
|
+
server.close(() => resolve());
|
|
3747
|
+
server.closeAllConnections?.();
|
|
3748
|
+
});
|
|
3749
|
+
await Promise.allSettled(loops);
|
|
3750
|
+
store.close();
|
|
3751
|
+
logger.info("service stopped");
|
|
3752
|
+
})();
|
|
3753
|
+
return stopping;
|
|
3754
|
+
}
|
|
3755
|
+
};
|
|
3756
|
+
}
|
|
3757
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
3758
|
+
0 && (module.exports = {
|
|
3759
|
+
AlertStore,
|
|
3760
|
+
LEVELS,
|
|
3761
|
+
VERSION,
|
|
3762
|
+
createDispatcher,
|
|
3763
|
+
createHttpServer,
|
|
3764
|
+
createLogger,
|
|
3765
|
+
createService,
|
|
3766
|
+
createSilentLogger,
|
|
3767
|
+
decode,
|
|
3768
|
+
decodeEvent,
|
|
3769
|
+
encode,
|
|
3770
|
+
encodeEvent,
|
|
3771
|
+
loadConfig,
|
|
3772
|
+
renderMetrics
|
|
3773
|
+
});
|
|
3774
|
+
//# sourceMappingURL=index.cjs.map
|