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,1216 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createEventSources,
|
|
3
|
+
createLiquidityReader,
|
|
4
|
+
createTokenMetaReader
|
|
5
|
+
} from "./chunk-CBNZ6AZT.js";
|
|
6
|
+
import {
|
|
7
|
+
checkRateLimit,
|
|
8
|
+
createRpcReputationProvider,
|
|
9
|
+
matchRules,
|
|
10
|
+
rateLimitKey
|
|
11
|
+
} from "./chunk-Y2M3USWZ.js";
|
|
12
|
+
import {
|
|
13
|
+
createPriceOracle
|
|
14
|
+
} from "./chunk-6JQK5G3Z.js";
|
|
15
|
+
import {
|
|
16
|
+
createCaptureNotifier,
|
|
17
|
+
createDiscordBotNotifier,
|
|
18
|
+
createDiscordWebhookNotifier,
|
|
19
|
+
renderAlert
|
|
20
|
+
} from "./chunk-SOBZ2FPP.js";
|
|
21
|
+
import {
|
|
22
|
+
createCommandRouter,
|
|
23
|
+
createDiscordInteractionHandler,
|
|
24
|
+
createLogger,
|
|
25
|
+
createTelegramBot
|
|
26
|
+
} from "./chunk-32GS6XVE.js";
|
|
27
|
+
import {
|
|
28
|
+
createTelegramClient,
|
|
29
|
+
createTelegramNotifier
|
|
30
|
+
} from "./chunk-NBMINJ5E.js";
|
|
31
|
+
import {
|
|
32
|
+
createEntitlementGate
|
|
33
|
+
} from "./chunk-RSTLW7XH.js";
|
|
34
|
+
import {
|
|
35
|
+
chainEntitlementProviders,
|
|
36
|
+
createStaticEntitlementProvider,
|
|
37
|
+
createUsdgEntitlementProvider
|
|
38
|
+
} from "./chunk-W2GUAWUR.js";
|
|
39
|
+
import {
|
|
40
|
+
parseSubscription
|
|
41
|
+
} from "./chunk-VC5MKEY2.js";
|
|
42
|
+
|
|
43
|
+
// src/service/config.ts
|
|
44
|
+
import { z } from "zod";
|
|
45
|
+
var bool = (defaultValue) => z.string().optional().transform((value) => {
|
|
46
|
+
if (value === void 0 || value.trim() === "") return defaultValue;
|
|
47
|
+
return /^(1|true|yes|on)$/i.test(value.trim());
|
|
48
|
+
});
|
|
49
|
+
var int = (defaultValue, min, max) => z.string().optional().transform((value) => value === void 0 || value.trim() === "" ? defaultValue : Number(value)).refine((value) => Number.isFinite(value) && value >= min && value <= max, {
|
|
50
|
+
message: `must be a number between ${min} and ${max}`
|
|
51
|
+
});
|
|
52
|
+
var bigintValue = (defaultValue) => z.string().optional().transform((value) => value === void 0 || value.trim() === "" ? defaultValue : BigInt(value.trim()));
|
|
53
|
+
var csv = z.string().optional().transform(
|
|
54
|
+
(value) => (value ?? "").split(",").map((entry) => entry.trim()).filter(Boolean)
|
|
55
|
+
);
|
|
56
|
+
var addressValue = z.string().regex(/^0x[0-9a-fA-F]{40}$/, "must be a 0x-prefixed address").optional();
|
|
57
|
+
var envSchema = z.object({
|
|
58
|
+
NETWORK: z.enum(["mainnet", "testnet"]).optional().default("mainnet"),
|
|
59
|
+
ROBINHOOD_RPC_URL: z.string().url().optional(),
|
|
60
|
+
DB_PATH: z.string().optional().default("./data/hood-alerts.sqlite"),
|
|
61
|
+
PORT: int(8080, 1, 65535),
|
|
62
|
+
LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).optional().default("info"),
|
|
63
|
+
DRY_RUN: bool(false),
|
|
64
|
+
TELEGRAM_BOT_TOKEN: z.string().optional(),
|
|
65
|
+
DISCORD_BOT_TOKEN: z.string().optional(),
|
|
66
|
+
DISCORD_APPLICATION_ID: z.string().optional(),
|
|
67
|
+
DISCORD_PUBLIC_KEY: z.string().optional(),
|
|
68
|
+
DISCORD_INTERACTIONS_PATH: z.string().optional().default("/discord/interactions"),
|
|
69
|
+
POLL_INTERVAL_MS: int(3e3, 250, 6e5),
|
|
70
|
+
FLUSH_INTERVAL_MS: int(1e3, 100, 6e5),
|
|
71
|
+
CONFIRMATIONS: bigintValue(2n),
|
|
72
|
+
CHUNK_SIZE: bigintValue(2000n),
|
|
73
|
+
INITIAL_LOOKBACK_BLOCKS: bigintValue(5000n),
|
|
74
|
+
WHALE_MIN_USD: int(1e3, 0, 1e9),
|
|
75
|
+
WHALE_POOL_LIMIT: int(400, 1, 5e3),
|
|
76
|
+
FLUSH_BATCH_SIZE: int(50, 1, 1e3),
|
|
77
|
+
MAX_DELIVERY_ATTEMPTS: int(6, 1, 50),
|
|
78
|
+
RETRY_BACKOFF_MS: int(3e4, 100, 36e5),
|
|
79
|
+
OUTBOX_RETENTION_HOURS: int(72, 1, 8760),
|
|
80
|
+
ENTITLEMENTS: z.enum(["static", "usdg", "both"]).optional().default("static"),
|
|
81
|
+
PREMIUM_SUBSCRIBERS: csv,
|
|
82
|
+
ALL_PREMIUM: bool(false),
|
|
83
|
+
USDG_RECEIVER: addressValue,
|
|
84
|
+
PREMIUM_PRICE_USDG: int(25, 1, 1e6),
|
|
85
|
+
PREMIUM_PERIOD_DAYS: int(30, 1, 3650),
|
|
86
|
+
PAYMENTS_FROM_BLOCK: bigintValue(0n),
|
|
87
|
+
UPGRADE_INSTRUCTIONS: z.string().optional()
|
|
88
|
+
});
|
|
89
|
+
function loadConfig(env = process.env) {
|
|
90
|
+
const parsed = envSchema.safeParse(env);
|
|
91
|
+
if (!parsed.success) {
|
|
92
|
+
const detail = parsed.error.issues.map((issue) => `${issue.path.join(".") || "env"}: ${issue.message}`).join("\n ");
|
|
93
|
+
throw new Error(`hood-alerts: invalid configuration
|
|
94
|
+
${detail}`);
|
|
95
|
+
}
|
|
96
|
+
const value = parsed.data;
|
|
97
|
+
if (value.ENTITLEMENTS !== "static" && !value.USDG_RECEIVER) {
|
|
98
|
+
throw new Error(
|
|
99
|
+
"hood-alerts: ENTITLEMENTS=usdg (or both) needs USDG_RECEIVER, the address subscribers pay."
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
if (value.DISCORD_APPLICATION_ID && !value.DISCORD_PUBLIC_KEY) {
|
|
103
|
+
throw new Error(
|
|
104
|
+
"hood-alerts: DISCORD_APPLICATION_ID is set but DISCORD_PUBLIC_KEY is not. Interactions cannot be verified without it."
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
return {
|
|
108
|
+
network: value.NETWORK,
|
|
109
|
+
rpcUrl: value.ROBINHOOD_RPC_URL,
|
|
110
|
+
dbPath: value.DB_PATH,
|
|
111
|
+
port: value.PORT,
|
|
112
|
+
logLevel: value.LOG_LEVEL,
|
|
113
|
+
dryRun: value.DRY_RUN,
|
|
114
|
+
telegramBotToken: value.TELEGRAM_BOT_TOKEN,
|
|
115
|
+
discordBotToken: value.DISCORD_BOT_TOKEN,
|
|
116
|
+
discordApplicationId: value.DISCORD_APPLICATION_ID,
|
|
117
|
+
discordPublicKey: value.DISCORD_PUBLIC_KEY,
|
|
118
|
+
discordInteractionsPath: value.DISCORD_INTERACTIONS_PATH,
|
|
119
|
+
pollIntervalMs: value.POLL_INTERVAL_MS,
|
|
120
|
+
flushIntervalMs: value.FLUSH_INTERVAL_MS,
|
|
121
|
+
confirmations: value.CONFIRMATIONS,
|
|
122
|
+
chunkSize: value.CHUNK_SIZE,
|
|
123
|
+
initialLookbackBlocks: value.INITIAL_LOOKBACK_BLOCKS,
|
|
124
|
+
whaleMinUsd: value.WHALE_MIN_USD,
|
|
125
|
+
whalePoolLimit: value.WHALE_POOL_LIMIT,
|
|
126
|
+
flushBatchSize: value.FLUSH_BATCH_SIZE,
|
|
127
|
+
maxDeliveryAttempts: value.MAX_DELIVERY_ATTEMPTS,
|
|
128
|
+
retryBackoffMs: value.RETRY_BACKOFF_MS,
|
|
129
|
+
outboxRetentionHours: value.OUTBOX_RETENTION_HOURS,
|
|
130
|
+
entitlements: value.ENTITLEMENTS,
|
|
131
|
+
premiumSubscribers: value.PREMIUM_SUBSCRIBERS,
|
|
132
|
+
allPremium: value.ALL_PREMIUM,
|
|
133
|
+
usdgReceiver: value.USDG_RECEIVER,
|
|
134
|
+
premiumPriceUsdg: value.PREMIUM_PRICE_USDG,
|
|
135
|
+
premiumPeriodDays: value.PREMIUM_PERIOD_DAYS,
|
|
136
|
+
paymentsFromBlock: value.PAYMENTS_FROM_BLOCK,
|
|
137
|
+
upgradeInstructions: value.UPGRADE_INSTRUCTIONS
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// src/service/codec.ts
|
|
142
|
+
var BIGINT_TAG = "$bigint";
|
|
143
|
+
function encode(value) {
|
|
144
|
+
return JSON.stringify(
|
|
145
|
+
value,
|
|
146
|
+
(_key, raw) => typeof raw === "bigint" ? { [BIGINT_TAG]: raw.toString() } : raw
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
function decode(text) {
|
|
150
|
+
return JSON.parse(text, (_key, raw) => {
|
|
151
|
+
if (typeof raw === "object" && raw !== null && Object.keys(raw).length === 1 && typeof raw[BIGINT_TAG] === "string") {
|
|
152
|
+
return BigInt(raw[BIGINT_TAG]);
|
|
153
|
+
}
|
|
154
|
+
return raw;
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
function encodeEvent(event) {
|
|
158
|
+
return encode(event);
|
|
159
|
+
}
|
|
160
|
+
function decodeEvent(text) {
|
|
161
|
+
return decode(text);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// src/service/store.ts
|
|
165
|
+
import { createRequire } from "module";
|
|
166
|
+
import { mkdirSync } from "fs";
|
|
167
|
+
import { dirname } from "path";
|
|
168
|
+
import { getAddress } from "viem";
|
|
169
|
+
var requireFromHere = createRequire(
|
|
170
|
+
import.meta.url ?? `${process.cwd()}/`
|
|
171
|
+
);
|
|
172
|
+
var { DatabaseSync } = requireFromHere("node:sqlite");
|
|
173
|
+
var SCHEMA = `
|
|
174
|
+
CREATE TABLE IF NOT EXISTS subscriptions (
|
|
175
|
+
id TEXT PRIMARY KEY,
|
|
176
|
+
subscriber_id TEXT NOT NULL,
|
|
177
|
+
platform TEXT NOT NULL,
|
|
178
|
+
target TEXT NOT NULL,
|
|
179
|
+
enabled INTEGER NOT NULL DEFAULT 1,
|
|
180
|
+
rules TEXT NOT NULL,
|
|
181
|
+
created_at INTEGER NOT NULL,
|
|
182
|
+
updated_at INTEGER NOT NULL
|
|
183
|
+
);
|
|
184
|
+
CREATE INDEX IF NOT EXISTS idx_subscriptions_subscriber ON subscriptions(subscriber_id);
|
|
185
|
+
|
|
186
|
+
CREATE TABLE IF NOT EXISTS cursors (
|
|
187
|
+
source_id TEXT PRIMARY KEY,
|
|
188
|
+
next_block TEXT NOT NULL,
|
|
189
|
+
updated_at INTEGER NOT NULL
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
CREATE TABLE IF NOT EXISTS outbox (
|
|
193
|
+
id TEXT PRIMARY KEY,
|
|
194
|
+
subscription_id TEXT NOT NULL,
|
|
195
|
+
subscriber_id TEXT NOT NULL,
|
|
196
|
+
rule_id TEXT NOT NULL,
|
|
197
|
+
event_id TEXT NOT NULL,
|
|
198
|
+
event_json TEXT NOT NULL,
|
|
199
|
+
platform TEXT NOT NULL,
|
|
200
|
+
target TEXT NOT NULL,
|
|
201
|
+
status TEXT NOT NULL,
|
|
202
|
+
not_before INTEGER NOT NULL,
|
|
203
|
+
attempts INTEGER NOT NULL DEFAULT 0,
|
|
204
|
+
last_error TEXT,
|
|
205
|
+
created_at INTEGER NOT NULL,
|
|
206
|
+
updated_at INTEGER NOT NULL
|
|
207
|
+
);
|
|
208
|
+
CREATE INDEX IF NOT EXISTS idx_outbox_due ON outbox(status, not_before);
|
|
209
|
+
CREATE INDEX IF NOT EXISTS idx_outbox_event ON outbox(event_id);
|
|
210
|
+
|
|
211
|
+
CREATE TABLE IF NOT EXISTS deliveries (
|
|
212
|
+
key TEXT NOT NULL,
|
|
213
|
+
subscriber_id TEXT NOT NULL,
|
|
214
|
+
at INTEGER NOT NULL
|
|
215
|
+
);
|
|
216
|
+
CREATE INDEX IF NOT EXISTS idx_deliveries_key ON deliveries(key, at);
|
|
217
|
+
CREATE INDEX IF NOT EXISTS idx_deliveries_subscriber ON deliveries(subscriber_id, at);
|
|
218
|
+
|
|
219
|
+
CREATE TABLE IF NOT EXISTS pools (
|
|
220
|
+
pool TEXT PRIMARY KEY,
|
|
221
|
+
token TEXT NOT NULL,
|
|
222
|
+
quote_token TEXT NOT NULL,
|
|
223
|
+
launchpad TEXT NOT NULL,
|
|
224
|
+
created_block TEXT NOT NULL
|
|
225
|
+
);
|
|
226
|
+
CREATE INDEX IF NOT EXISTS idx_pools_token ON pools(token);
|
|
227
|
+
CREATE INDEX IF NOT EXISTS idx_pools_created ON pools(created_block);
|
|
228
|
+
|
|
229
|
+
CREATE TABLE IF NOT EXISTS wallet_links (
|
|
230
|
+
subscriber_id TEXT PRIMARY KEY,
|
|
231
|
+
address TEXT NOT NULL,
|
|
232
|
+
linked_at INTEGER NOT NULL
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
CREATE TABLE IF NOT EXISTS link_nonces (
|
|
236
|
+
subscriber_id TEXT PRIMARY KEY,
|
|
237
|
+
nonce TEXT NOT NULL,
|
|
238
|
+
issued_at INTEGER NOT NULL
|
|
239
|
+
);
|
|
240
|
+
|
|
241
|
+
CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
|
|
242
|
+
`;
|
|
243
|
+
var AlertStore = class _AlertStore {
|
|
244
|
+
db;
|
|
245
|
+
clock;
|
|
246
|
+
constructor(path, clock = () => Date.now()) {
|
|
247
|
+
if (path !== ":memory:") mkdirSync(dirname(path), { recursive: true });
|
|
248
|
+
this.db = new DatabaseSync(path);
|
|
249
|
+
this.clock = clock;
|
|
250
|
+
this.db.exec("PRAGMA journal_mode = WAL;");
|
|
251
|
+
this.db.exec("PRAGMA foreign_keys = ON;");
|
|
252
|
+
this.db.exec(SCHEMA);
|
|
253
|
+
}
|
|
254
|
+
/** Deterministic outbox key. The whole dedupe guarantee rests on this. */
|
|
255
|
+
static deliveryKey(eventId, subscriptionId, ruleId) {
|
|
256
|
+
return `${eventId}|${subscriptionId}|${ruleId}`;
|
|
257
|
+
}
|
|
258
|
+
// ---- cursors -----------------------------------------------------------
|
|
259
|
+
/** The next unprocessed block for a source, or `null` when never persisted. */
|
|
260
|
+
getCursor(sourceId) {
|
|
261
|
+
const row = this.db.prepare("SELECT next_block FROM cursors WHERE source_id = ?").get(sourceId);
|
|
262
|
+
return row ? BigInt(row.next_block) : null;
|
|
263
|
+
}
|
|
264
|
+
/** Persist the next unprocessed block. Call only after the range is enqueued. */
|
|
265
|
+
setCursor(sourceId, nextBlock) {
|
|
266
|
+
this.db.prepare(
|
|
267
|
+
`INSERT INTO cursors (source_id, next_block, updated_at) VALUES (?, ?, ?)
|
|
268
|
+
ON CONFLICT(source_id) DO UPDATE SET next_block = excluded.next_block, updated_at = excluded.updated_at`
|
|
269
|
+
).run(sourceId, nextBlock.toString(), this.clock());
|
|
270
|
+
}
|
|
271
|
+
/** Every persisted cursor, for `/metrics` and for operator inspection. */
|
|
272
|
+
listCursors() {
|
|
273
|
+
const rows = this.db.prepare("SELECT source_id, next_block FROM cursors ORDER BY source_id").all();
|
|
274
|
+
return rows.map((row) => ({ sourceId: row.source_id, nextBlock: BigInt(row.next_block) }));
|
|
275
|
+
}
|
|
276
|
+
// ---- subscriptions -----------------------------------------------------
|
|
277
|
+
/** Insert or replace a subscription. */
|
|
278
|
+
saveSubscription(subscription) {
|
|
279
|
+
const now = this.clock();
|
|
280
|
+
this.db.prepare(
|
|
281
|
+
`INSERT INTO subscriptions (id, subscriber_id, platform, target, enabled, rules, created_at, updated_at)
|
|
282
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
283
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
284
|
+
subscriber_id = excluded.subscriber_id,
|
|
285
|
+
platform = excluded.platform,
|
|
286
|
+
target = excluded.target,
|
|
287
|
+
enabled = excluded.enabled,
|
|
288
|
+
rules = excluded.rules,
|
|
289
|
+
updated_at = excluded.updated_at`
|
|
290
|
+
).run(
|
|
291
|
+
subscription.id,
|
|
292
|
+
subscription.subscriberId,
|
|
293
|
+
subscription.platform,
|
|
294
|
+
subscription.target,
|
|
295
|
+
subscription.enabled ? 1 : 0,
|
|
296
|
+
encode(subscription.rules),
|
|
297
|
+
subscription.createdAtMs,
|
|
298
|
+
now
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
hydrate(row) {
|
|
302
|
+
return parseSubscription({
|
|
303
|
+
id: row.id,
|
|
304
|
+
subscriberId: row.subscriber_id,
|
|
305
|
+
platform: row.platform,
|
|
306
|
+
target: row.target,
|
|
307
|
+
enabled: row.enabled === 1,
|
|
308
|
+
rules: decode(row.rules),
|
|
309
|
+
createdAtMs: row.created_at
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
/** One subscription by id. */
|
|
313
|
+
getSubscription(id) {
|
|
314
|
+
const row = this.db.prepare("SELECT * FROM subscriptions WHERE id = ?").get(id);
|
|
315
|
+
return row ? this.hydrate(row) : null;
|
|
316
|
+
}
|
|
317
|
+
/** Every subscription owned by a subscriber. */
|
|
318
|
+
listSubscriptionsFor(subscriberId) {
|
|
319
|
+
const rows = this.db.prepare("SELECT * FROM subscriptions WHERE subscriber_id = ? ORDER BY created_at").all(subscriberId);
|
|
320
|
+
return rows.map((row) => this.hydrate(row));
|
|
321
|
+
}
|
|
322
|
+
/** Every enabled subscription, for the dispatch fan-out. */
|
|
323
|
+
listEnabledSubscriptions() {
|
|
324
|
+
const rows = this.db.prepare("SELECT * FROM subscriptions WHERE enabled = 1 ORDER BY created_at").all();
|
|
325
|
+
return rows.map((row) => this.hydrate(row));
|
|
326
|
+
}
|
|
327
|
+
/** Remove a subscription. Returns `true` when a row was deleted. */
|
|
328
|
+
deleteSubscription(id) {
|
|
329
|
+
const result = this.db.prepare("DELETE FROM subscriptions WHERE id = ?").run(id);
|
|
330
|
+
return Number(result.changes) > 0;
|
|
331
|
+
}
|
|
332
|
+
/** Every token on any enabled subscription's watchlist, deduped. */
|
|
333
|
+
watchlistTokens() {
|
|
334
|
+
const seen = /* @__PURE__ */ new Set();
|
|
335
|
+
for (const subscription of this.listEnabledSubscriptions()) {
|
|
336
|
+
for (const rule of subscription.rules) {
|
|
337
|
+
for (const token of rule.tokens) seen.add(token.toLowerCase());
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
return [...seen].map((token) => getAddress(token));
|
|
341
|
+
}
|
|
342
|
+
// ---- outbox ------------------------------------------------------------
|
|
343
|
+
/**
|
|
344
|
+
* Queue a delivery.
|
|
345
|
+
*
|
|
346
|
+
* @returns `true` when this call created the row, `false` when it already
|
|
347
|
+
* existed (a replayed block range, a duplicate log). The boolean is the
|
|
348
|
+
* dedupe signal the dispatcher counts.
|
|
349
|
+
*/
|
|
350
|
+
enqueue(row) {
|
|
351
|
+
const now = this.clock();
|
|
352
|
+
try {
|
|
353
|
+
this.db.prepare(
|
|
354
|
+
`INSERT INTO outbox (id, subscription_id, subscriber_id, rule_id, event_id, event_json,
|
|
355
|
+
platform, target, status, not_before, attempts, last_error, created_at, updated_at)
|
|
356
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, 0, NULL, ?, ?)`
|
|
357
|
+
).run(
|
|
358
|
+
row.id,
|
|
359
|
+
row.subscriptionId,
|
|
360
|
+
row.subscriberId,
|
|
361
|
+
row.ruleId,
|
|
362
|
+
row.eventId,
|
|
363
|
+
row.eventJson,
|
|
364
|
+
row.platform,
|
|
365
|
+
row.target,
|
|
366
|
+
row.notBeforeMs,
|
|
367
|
+
now,
|
|
368
|
+
now
|
|
369
|
+
);
|
|
370
|
+
return true;
|
|
371
|
+
} catch (error) {
|
|
372
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
373
|
+
if (/UNIQUE|PRIMARY KEY/i.test(message)) return false;
|
|
374
|
+
throw error;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
static toOutboxRow(raw) {
|
|
378
|
+
return {
|
|
379
|
+
id: raw.id,
|
|
380
|
+
subscriptionId: raw.subscription_id,
|
|
381
|
+
subscriberId: raw.subscriber_id,
|
|
382
|
+
ruleId: raw.rule_id,
|
|
383
|
+
eventId: raw.event_id,
|
|
384
|
+
eventJson: raw.event_json,
|
|
385
|
+
platform: raw.platform,
|
|
386
|
+
target: raw.target,
|
|
387
|
+
status: raw.status,
|
|
388
|
+
notBeforeMs: raw.not_before,
|
|
389
|
+
attempts: raw.attempts,
|
|
390
|
+
lastError: raw.last_error,
|
|
391
|
+
createdAtMs: raw.created_at,
|
|
392
|
+
updatedAtMs: raw.updated_at
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
/**
|
|
396
|
+
* Claim due deliveries, moving them to `sending` in one transaction so a
|
|
397
|
+
* second flush pass cannot pick up the same rows.
|
|
398
|
+
*/
|
|
399
|
+
claimDue(nowMs, limit) {
|
|
400
|
+
const rows = this.db.prepare(
|
|
401
|
+
`SELECT * FROM outbox
|
|
402
|
+
WHERE status IN ('pending', 'failed') AND not_before <= ?
|
|
403
|
+
ORDER BY not_before, created_at
|
|
404
|
+
LIMIT ?`
|
|
405
|
+
).all(nowMs, limit);
|
|
406
|
+
if (rows.length === 0) return [];
|
|
407
|
+
const update = this.db.prepare(`UPDATE outbox SET status = 'sending', updated_at = ? WHERE id = ?`);
|
|
408
|
+
this.db.exec("BEGIN");
|
|
409
|
+
try {
|
|
410
|
+
for (const row of rows) update.run(nowMs, row.id);
|
|
411
|
+
this.db.exec("COMMIT");
|
|
412
|
+
} catch (error) {
|
|
413
|
+
this.db.exec("ROLLBACK");
|
|
414
|
+
throw error;
|
|
415
|
+
}
|
|
416
|
+
return rows.map((row) => _AlertStore.toOutboxRow(row));
|
|
417
|
+
}
|
|
418
|
+
/** Mark a delivery sent. */
|
|
419
|
+
markSent(id) {
|
|
420
|
+
this.db.prepare(`UPDATE outbox SET status = 'sent', last_error = NULL, updated_at = ? WHERE id = ?`).run(this.clock(), id);
|
|
421
|
+
}
|
|
422
|
+
/** Record a retryable failure and schedule the next attempt. */
|
|
423
|
+
markFailed(id, attempts, error, nextAttemptAtMs) {
|
|
424
|
+
this.db.prepare(
|
|
425
|
+
`UPDATE outbox SET status = 'failed', attempts = ?, last_error = ?, not_before = ?, updated_at = ? WHERE id = ?`
|
|
426
|
+
).run(attempts, error, nextAttemptAtMs, this.clock(), id);
|
|
427
|
+
}
|
|
428
|
+
/** Give up on a delivery (permanent rejection, or attempts exhausted). */
|
|
429
|
+
markDead(id, attempts, error) {
|
|
430
|
+
this.db.prepare(`UPDATE outbox SET status = 'dead', attempts = ?, last_error = ?, updated_at = ? WHERE id = ?`).run(attempts, error, this.clock(), id);
|
|
431
|
+
}
|
|
432
|
+
/**
|
|
433
|
+
* Return rows abandoned mid-flight by a crash to the queue.
|
|
434
|
+
*
|
|
435
|
+
* @returns how many rows were recovered.
|
|
436
|
+
*/
|
|
437
|
+
recoverInFlight() {
|
|
438
|
+
const result = this.db.prepare(`UPDATE outbox SET status = 'pending', updated_at = ? WHERE status = 'sending'`).run(this.clock());
|
|
439
|
+
return Number(result.changes);
|
|
440
|
+
}
|
|
441
|
+
/** Delete delivered rows older than `beforeMs`, keeping the file bounded. */
|
|
442
|
+
pruneOutbox(beforeMs) {
|
|
443
|
+
const result = this.db.prepare(`DELETE FROM outbox WHERE status = 'sent' AND updated_at < ?`).run(beforeMs);
|
|
444
|
+
return Number(result.changes);
|
|
445
|
+
}
|
|
446
|
+
/** Delete delivery history older than `beforeMs`. */
|
|
447
|
+
pruneDeliveries(beforeMs) {
|
|
448
|
+
const result = this.db.prepare("DELETE FROM deliveries WHERE at < ?").run(beforeMs);
|
|
449
|
+
return Number(result.changes);
|
|
450
|
+
}
|
|
451
|
+
// ---- rate limiting and throughput --------------------------------------
|
|
452
|
+
/** A {@link RateLimitStore} backed by the `deliveries` table. */
|
|
453
|
+
rateLimitStore() {
|
|
454
|
+
return {
|
|
455
|
+
countSince: async (key, sinceMs) => {
|
|
456
|
+
const row = this.db.prepare("SELECT COUNT(*) AS n FROM deliveries WHERE key = ? AND at >= ?").get(key, sinceMs);
|
|
457
|
+
return Number(row.n);
|
|
458
|
+
},
|
|
459
|
+
lastAt: async (key) => {
|
|
460
|
+
const row = this.db.prepare("SELECT MAX(at) AS last FROM deliveries WHERE key = ?").get(key);
|
|
461
|
+
return row.last ?? null;
|
|
462
|
+
},
|
|
463
|
+
record: async (key, subscriberId, atMs) => {
|
|
464
|
+
this.recordDelivery(key, subscriberId, atMs);
|
|
465
|
+
},
|
|
466
|
+
countForSubscriberSince: async (subscriberId, sinceMs) => {
|
|
467
|
+
return this.countDeliveriesForSubscriber(subscriberId, sinceMs);
|
|
468
|
+
}
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
/** Record a delivery for both rate limiting and throughput accounting. */
|
|
472
|
+
recordDelivery(key, subscriberId, atMs) {
|
|
473
|
+
this.db.prepare("INSERT INTO deliveries (key, subscriber_id, at) VALUES (?, ?, ?)").run(key, subscriberId, atMs);
|
|
474
|
+
}
|
|
475
|
+
/** How many alerts a subscriber received since `sinceMs`. */
|
|
476
|
+
countDeliveriesForSubscriber(subscriberId, sinceMs) {
|
|
477
|
+
const row = this.db.prepare("SELECT COUNT(*) AS n FROM deliveries WHERE subscriber_id = ? AND at >= ?").get(subscriberId, sinceMs);
|
|
478
|
+
return Number(row.n);
|
|
479
|
+
}
|
|
480
|
+
// ---- pool registry -----------------------------------------------------
|
|
481
|
+
/** A {@link PoolRegistry} backed by the `pools` table. */
|
|
482
|
+
poolRegistry() {
|
|
483
|
+
return {
|
|
484
|
+
record: async (entry) => {
|
|
485
|
+
this.db.prepare(
|
|
486
|
+
`INSERT INTO pools (pool, token, quote_token, launchpad, created_block) VALUES (?, ?, ?, ?, ?)
|
|
487
|
+
ON CONFLICT(pool) DO UPDATE SET
|
|
488
|
+
token = excluded.token, quote_token = excluded.quote_token,
|
|
489
|
+
launchpad = excluded.launchpad, created_block = excluded.created_block`
|
|
490
|
+
).run(
|
|
491
|
+
getAddress(entry.pool),
|
|
492
|
+
getAddress(entry.token),
|
|
493
|
+
getAddress(entry.quoteToken),
|
|
494
|
+
entry.launchpad,
|
|
495
|
+
entry.createdBlock.toString()
|
|
496
|
+
);
|
|
497
|
+
},
|
|
498
|
+
active: async (limit, pinned = []) => {
|
|
499
|
+
const picked = /* @__PURE__ */ new Map();
|
|
500
|
+
if (pinned.length > 0) {
|
|
501
|
+
const placeholders = pinned.map(() => "?").join(",");
|
|
502
|
+
const rows2 = this.db.prepare(`SELECT * FROM pools WHERE lower(token) IN (${placeholders})`).all(...pinned.map((token) => token.toLowerCase()));
|
|
503
|
+
for (const row of rows2) {
|
|
504
|
+
const entry = _AlertStore.toPoolEntry(row);
|
|
505
|
+
picked.set(entry.pool.toLowerCase(), entry);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
const rows = this.db.prepare(
|
|
509
|
+
// created_block is stored as text to keep bigint precision, so it
|
|
510
|
+
// is ordered numerically by length first, then lexically.
|
|
511
|
+
"SELECT * FROM pools ORDER BY length(created_block) DESC, created_block DESC LIMIT ?"
|
|
512
|
+
).all(limit);
|
|
513
|
+
for (const row of rows) {
|
|
514
|
+
if (picked.size >= limit + pinned.length) break;
|
|
515
|
+
const entry = _AlertStore.toPoolEntry(row);
|
|
516
|
+
if (!picked.has(entry.pool.toLowerCase())) picked.set(entry.pool.toLowerCase(), entry);
|
|
517
|
+
}
|
|
518
|
+
return [...picked.values()];
|
|
519
|
+
},
|
|
520
|
+
originOf: async (token) => {
|
|
521
|
+
const row = this.db.prepare("SELECT launchpad FROM pools WHERE lower(token) = ? LIMIT 1").get(token.toLowerCase());
|
|
522
|
+
return row ? row.launchpad : null;
|
|
523
|
+
},
|
|
524
|
+
poolsFor: async (token) => {
|
|
525
|
+
const rows = this.db.prepare("SELECT * FROM pools WHERE lower(token) = ?").all(token.toLowerCase());
|
|
526
|
+
return rows.map((row) => _AlertStore.toPoolEntry(row));
|
|
527
|
+
},
|
|
528
|
+
size: async () => {
|
|
529
|
+
const row = this.db.prepare("SELECT COUNT(*) AS n FROM pools").get();
|
|
530
|
+
return Number(row.n);
|
|
531
|
+
}
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
static toPoolEntry(row) {
|
|
535
|
+
return {
|
|
536
|
+
pool: getAddress(row["pool"]),
|
|
537
|
+
token: getAddress(row["token"]),
|
|
538
|
+
quoteToken: getAddress(row["quote_token"]),
|
|
539
|
+
launchpad: row["launchpad"],
|
|
540
|
+
createdBlock: BigInt(row["created_block"])
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
// ---- wallet links ------------------------------------------------------
|
|
544
|
+
/** Store a signature-verified wallet link. */
|
|
545
|
+
linkWallet(subscriberId, address) {
|
|
546
|
+
this.db.prepare(
|
|
547
|
+
`INSERT INTO wallet_links (subscriber_id, address, linked_at) VALUES (?, ?, ?)
|
|
548
|
+
ON CONFLICT(subscriber_id) DO UPDATE SET address = excluded.address, linked_at = excluded.linked_at`
|
|
549
|
+
).run(subscriberId, getAddress(address), this.clock());
|
|
550
|
+
}
|
|
551
|
+
/** A {@link WalletLinkStore} view of the links table. */
|
|
552
|
+
walletLinks() {
|
|
553
|
+
return {
|
|
554
|
+
walletOf: async (subscriberId) => {
|
|
555
|
+
const row = this.db.prepare("SELECT address FROM wallet_links WHERE subscriber_id = ?").get(subscriberId);
|
|
556
|
+
return row ? getAddress(row.address) : null;
|
|
557
|
+
}
|
|
558
|
+
};
|
|
559
|
+
}
|
|
560
|
+
/** Issue (or reissue) the nonce a subscriber must sign to link a wallet. */
|
|
561
|
+
issueLinkNonce(subscriberId, nonce) {
|
|
562
|
+
this.db.prepare(
|
|
563
|
+
`INSERT INTO link_nonces (subscriber_id, nonce, issued_at) VALUES (?, ?, ?)
|
|
564
|
+
ON CONFLICT(subscriber_id) DO UPDATE SET nonce = excluded.nonce, issued_at = excluded.issued_at`
|
|
565
|
+
).run(subscriberId, nonce, this.clock());
|
|
566
|
+
return nonce;
|
|
567
|
+
}
|
|
568
|
+
/** The outstanding link nonce for a subscriber, or `null`. */
|
|
569
|
+
getLinkNonce(subscriberId) {
|
|
570
|
+
const row = this.db.prepare("SELECT nonce FROM link_nonces WHERE subscriber_id = ?").get(subscriberId);
|
|
571
|
+
return row?.nonce ?? null;
|
|
572
|
+
}
|
|
573
|
+
/** Consume a link nonce so a signature cannot be replayed. */
|
|
574
|
+
clearLinkNonce(subscriberId) {
|
|
575
|
+
this.db.prepare("DELETE FROM link_nonces WHERE subscriber_id = ?").run(subscriberId);
|
|
576
|
+
}
|
|
577
|
+
// ---- meta and metrics --------------------------------------------------
|
|
578
|
+
/** Read a free-form service setting. */
|
|
579
|
+
getMeta(key) {
|
|
580
|
+
const row = this.db.prepare("SELECT value FROM meta WHERE key = ?").get(key);
|
|
581
|
+
return row?.value ?? null;
|
|
582
|
+
}
|
|
583
|
+
/** Write a free-form service setting. */
|
|
584
|
+
setMeta(key, value) {
|
|
585
|
+
this.db.prepare(
|
|
586
|
+
`INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value`
|
|
587
|
+
).run(key, value);
|
|
588
|
+
}
|
|
589
|
+
/** Aggregate counters for `/metrics`. */
|
|
590
|
+
metrics() {
|
|
591
|
+
const outbox = { pending: 0, sending: 0, sent: 0, failed: 0, dead: 0 };
|
|
592
|
+
const statusRows = this.db.prepare("SELECT status, COUNT(*) AS n FROM outbox GROUP BY status").all();
|
|
593
|
+
for (const row of statusRows) outbox[row.status] = Number(row.n);
|
|
594
|
+
const subs = this.db.prepare("SELECT COUNT(*) AS total, SUM(enabled) AS enabled FROM subscriptions").get();
|
|
595
|
+
const pools = this.db.prepare("SELECT COUNT(*) AS n FROM pools").get();
|
|
596
|
+
const deliveries = this.db.prepare("SELECT COUNT(*) AS n FROM deliveries WHERE at >= ?").get(this.clock() - 36e5);
|
|
597
|
+
return {
|
|
598
|
+
subscriptions: Number(subs.total),
|
|
599
|
+
enabledSubscriptions: Number(subs.enabled ?? 0),
|
|
600
|
+
outbox,
|
|
601
|
+
pools: Number(pools.n),
|
|
602
|
+
deliveriesLastHour: Number(deliveries.n),
|
|
603
|
+
cursors: this.listCursors()
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
/** Close the database. Safe to call twice. */
|
|
607
|
+
close() {
|
|
608
|
+
try {
|
|
609
|
+
this.db.close();
|
|
610
|
+
} catch {
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
};
|
|
614
|
+
|
|
615
|
+
// src/service/dispatcher.ts
|
|
616
|
+
function createDispatcher(options) {
|
|
617
|
+
const {
|
|
618
|
+
store,
|
|
619
|
+
sources,
|
|
620
|
+
gate,
|
|
621
|
+
rateLimits,
|
|
622
|
+
notifierFor,
|
|
623
|
+
getBlockNumber,
|
|
624
|
+
logger
|
|
625
|
+
} = options;
|
|
626
|
+
const confirmations = options.confirmations ?? 2n;
|
|
627
|
+
const chunkSize = options.chunkSize ?? 2000n;
|
|
628
|
+
const initialLookback = options.initialLookbackBlocks ?? 5000n;
|
|
629
|
+
const flushBatchSize = options.flushBatchSize ?? 50;
|
|
630
|
+
const maxAttempts = options.maxDeliveryAttempts ?? 6;
|
|
631
|
+
const retryBackoffMs = options.retryBackoffMs ?? 3e4;
|
|
632
|
+
const now = options.now ?? Date.now;
|
|
633
|
+
const evaluation = options.evaluation ?? {};
|
|
634
|
+
async function fanOut(event, subscriptions) {
|
|
635
|
+
let enqueued = 0;
|
|
636
|
+
let deduped = 0;
|
|
637
|
+
let suppressed = 0;
|
|
638
|
+
for (const subscription of subscriptions) {
|
|
639
|
+
const matches = await matchRules(subscription.rules, event, evaluation);
|
|
640
|
+
if (matches.length === 0) continue;
|
|
641
|
+
const { policy } = await gate.resolve(subscription.subscriberId);
|
|
642
|
+
for (const { rule } of matches) {
|
|
643
|
+
if (!policy.allowedKinds.includes(event.kind)) {
|
|
644
|
+
suppressed += 1;
|
|
645
|
+
continue;
|
|
646
|
+
}
|
|
647
|
+
const key = rateLimitKey(subscription.id, rule.id);
|
|
648
|
+
const limit = await checkRateLimit(rateLimits, key, rule.rateLimit, now());
|
|
649
|
+
if (!limit.allowed) {
|
|
650
|
+
suppressed += 1;
|
|
651
|
+
logger.debug("rate limited", { subscription: subscription.id, rule: rule.id, reason: limit.reason });
|
|
652
|
+
continue;
|
|
653
|
+
}
|
|
654
|
+
const usedThisHour = await rateLimits.countForSubscriberSince(
|
|
655
|
+
subscription.subscriberId,
|
|
656
|
+
now() - 36e5
|
|
657
|
+
);
|
|
658
|
+
const throughput = await gate.checkThroughput(subscription.subscriberId, usedThisHour);
|
|
659
|
+
if (!throughput.allowed) {
|
|
660
|
+
suppressed += 1;
|
|
661
|
+
logger.debug("throughput capped", {
|
|
662
|
+
subscriber: subscription.subscriberId,
|
|
663
|
+
reason: throughput.reason
|
|
664
|
+
});
|
|
665
|
+
continue;
|
|
666
|
+
}
|
|
667
|
+
const id = AlertStore.deliveryKey(event.id, subscription.id, rule.id);
|
|
668
|
+
const created = store.enqueue({
|
|
669
|
+
id,
|
|
670
|
+
subscriptionId: subscription.id,
|
|
671
|
+
subscriberId: subscription.subscriberId,
|
|
672
|
+
ruleId: rule.id,
|
|
673
|
+
eventId: event.id,
|
|
674
|
+
eventJson: encodeEvent(event),
|
|
675
|
+
platform: subscription.platform,
|
|
676
|
+
target: subscription.target,
|
|
677
|
+
notBeforeMs: now() + policy.deliveryDelayMs
|
|
678
|
+
});
|
|
679
|
+
if (!created) {
|
|
680
|
+
deduped += 1;
|
|
681
|
+
continue;
|
|
682
|
+
}
|
|
683
|
+
enqueued += 1;
|
|
684
|
+
await rateLimits.record(key, subscription.subscriberId, now());
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
return { enqueued, deduped, suppressed };
|
|
688
|
+
}
|
|
689
|
+
return {
|
|
690
|
+
recover() {
|
|
691
|
+
const recovered = store.recoverInFlight();
|
|
692
|
+
if (recovered > 0) logger.warn("recovered in-flight deliveries after restart", { recovered });
|
|
693
|
+
return recovered;
|
|
694
|
+
},
|
|
695
|
+
async pollOnce() {
|
|
696
|
+
const stats = {
|
|
697
|
+
ranges: [],
|
|
698
|
+
eventsDecoded: 0,
|
|
699
|
+
enqueued: 0,
|
|
700
|
+
deduped: 0,
|
|
701
|
+
suppressed: 0,
|
|
702
|
+
errors: []
|
|
703
|
+
};
|
|
704
|
+
const head = await getBlockNumber();
|
|
705
|
+
const safeHead = head > confirmations ? head - confirmations : 0n;
|
|
706
|
+
const subscriptions = store.listEnabledSubscriptions();
|
|
707
|
+
for (const source of sources) {
|
|
708
|
+
const persisted = store.getCursor(source.id);
|
|
709
|
+
const fallback = safeHead > initialLookback ? safeHead - initialLookback : source.startBlock;
|
|
710
|
+
const from = persisted ?? (fallback > source.startBlock ? fallback : source.startBlock);
|
|
711
|
+
if (from > safeHead) continue;
|
|
712
|
+
const to = from + chunkSize - 1n > safeHead ? safeHead : from + chunkSize - 1n;
|
|
713
|
+
try {
|
|
714
|
+
const events = await source.poll(from, to);
|
|
715
|
+
stats.eventsDecoded += events.length;
|
|
716
|
+
for (const event of events) {
|
|
717
|
+
const result = await fanOut(event, subscriptions);
|
|
718
|
+
stats.enqueued += result.enqueued;
|
|
719
|
+
stats.deduped += result.deduped;
|
|
720
|
+
stats.suppressed += result.suppressed;
|
|
721
|
+
}
|
|
722
|
+
store.setCursor(source.id, to + 1n);
|
|
723
|
+
stats.ranges.push({ sourceId: source.id, from, to });
|
|
724
|
+
} catch (error) {
|
|
725
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
726
|
+
stats.errors.push({ sourceId: source.id, error: message });
|
|
727
|
+
logger.error("source poll failed, cursor not advanced", {
|
|
728
|
+
source: source.id,
|
|
729
|
+
from,
|
|
730
|
+
to,
|
|
731
|
+
error: message
|
|
732
|
+
});
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
return stats;
|
|
736
|
+
},
|
|
737
|
+
async flushOnce() {
|
|
738
|
+
const stats = { claimed: 0, sent: 0, retried: 0, dead: 0 };
|
|
739
|
+
const rows = store.claimDue(now(), flushBatchSize);
|
|
740
|
+
stats.claimed = rows.length;
|
|
741
|
+
for (const row of rows) {
|
|
742
|
+
const outcome = await deliver(row);
|
|
743
|
+
if (outcome === "sent") stats.sent += 1;
|
|
744
|
+
else if (outcome === "retry") stats.retried += 1;
|
|
745
|
+
else stats.dead += 1;
|
|
746
|
+
}
|
|
747
|
+
return stats;
|
|
748
|
+
}
|
|
749
|
+
};
|
|
750
|
+
async function deliver(row) {
|
|
751
|
+
const subscription = store.getSubscription(row.subscriptionId);
|
|
752
|
+
if (!subscription) {
|
|
753
|
+
store.markDead(row.id, row.attempts + 1, "subscription no longer exists");
|
|
754
|
+
return "dead";
|
|
755
|
+
}
|
|
756
|
+
const notifier = notifierFor(subscription);
|
|
757
|
+
if (!notifier) {
|
|
758
|
+
store.markDead(row.id, row.attempts + 1, `no notifier configured for platform ${row.platform}`);
|
|
759
|
+
return "dead";
|
|
760
|
+
}
|
|
761
|
+
let event;
|
|
762
|
+
try {
|
|
763
|
+
event = decodeEvent(row.eventJson);
|
|
764
|
+
} catch (error) {
|
|
765
|
+
store.markDead(row.id, row.attempts + 1, `corrupt event payload: ${String(error)}`);
|
|
766
|
+
return "dead";
|
|
767
|
+
}
|
|
768
|
+
const alert = renderAlert(event, {
|
|
769
|
+
...options.render,
|
|
770
|
+
footer: options.render?.footer ?? `rule: ${row.ruleId}`
|
|
771
|
+
});
|
|
772
|
+
const result = await notifier.send(row.target, alert);
|
|
773
|
+
const attempts = row.attempts + 1;
|
|
774
|
+
if (result.ok) {
|
|
775
|
+
store.markSent(row.id);
|
|
776
|
+
logger.info("alert delivered", {
|
|
777
|
+
id: row.id,
|
|
778
|
+
platform: notifier.platform,
|
|
779
|
+
kind: event.kind,
|
|
780
|
+
token: event.token,
|
|
781
|
+
attempts: result.attempts
|
|
782
|
+
});
|
|
783
|
+
return "sent";
|
|
784
|
+
}
|
|
785
|
+
if (!result.retryable || attempts >= maxAttempts) {
|
|
786
|
+
store.markDead(row.id, attempts, result.error ?? "delivery failed");
|
|
787
|
+
logger.error("alert dead-lettered", {
|
|
788
|
+
id: row.id,
|
|
789
|
+
platform: notifier.platform,
|
|
790
|
+
attempts,
|
|
791
|
+
retryable: result.retryable,
|
|
792
|
+
error: result.error
|
|
793
|
+
});
|
|
794
|
+
return "dead";
|
|
795
|
+
}
|
|
796
|
+
const nextAttemptAt = now() + retryBackoffMs * 2 ** (attempts - 1);
|
|
797
|
+
store.markFailed(row.id, attempts, result.error ?? "delivery failed", nextAttemptAt);
|
|
798
|
+
logger.warn("alert delivery failed, will retry", {
|
|
799
|
+
id: row.id,
|
|
800
|
+
attempts,
|
|
801
|
+
nextAttemptAt,
|
|
802
|
+
error: result.error
|
|
803
|
+
});
|
|
804
|
+
return "retry";
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
// src/service/http.ts
|
|
809
|
+
import { createServer } from "http";
|
|
810
|
+
function json(res, status, body) {
|
|
811
|
+
const payload = JSON.stringify(
|
|
812
|
+
body,
|
|
813
|
+
(_key, value) => typeof value === "bigint" ? value.toString() : value
|
|
814
|
+
);
|
|
815
|
+
res.writeHead(status, { "content-type": "application/json; charset=utf-8" });
|
|
816
|
+
res.end(payload);
|
|
817
|
+
}
|
|
818
|
+
async function readBody(req, limitBytes) {
|
|
819
|
+
const chunks = [];
|
|
820
|
+
let size = 0;
|
|
821
|
+
for await (const chunk of req) {
|
|
822
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
|
|
823
|
+
size += buffer.length;
|
|
824
|
+
if (size > limitBytes) return null;
|
|
825
|
+
chunks.push(buffer);
|
|
826
|
+
}
|
|
827
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
828
|
+
}
|
|
829
|
+
function renderMetrics(store, health, nowMs) {
|
|
830
|
+
const metrics = store.metrics();
|
|
831
|
+
const lines = [
|
|
832
|
+
"# HELP hood_alerts_subscriptions Subscriptions by state.",
|
|
833
|
+
"# TYPE hood_alerts_subscriptions gauge",
|
|
834
|
+
`hood_alerts_subscriptions{state="total"} ${metrics.subscriptions}`,
|
|
835
|
+
`hood_alerts_subscriptions{state="enabled"} ${metrics.enabledSubscriptions}`,
|
|
836
|
+
"# HELP hood_alerts_outbox Deliveries by outbox status.",
|
|
837
|
+
"# TYPE hood_alerts_outbox gauge"
|
|
838
|
+
];
|
|
839
|
+
for (const [status, count] of Object.entries(metrics.outbox)) {
|
|
840
|
+
lines.push(`hood_alerts_outbox{status="${status}"} ${count}`);
|
|
841
|
+
}
|
|
842
|
+
lines.push(
|
|
843
|
+
"# HELP hood_alerts_deliveries_last_hour Alerts delivered in the last rolling hour.",
|
|
844
|
+
"# TYPE hood_alerts_deliveries_last_hour gauge",
|
|
845
|
+
`hood_alerts_deliveries_last_hour ${metrics.deliveriesLastHour}`,
|
|
846
|
+
"# HELP hood_alerts_pools Memecoin pools tracked for whale trades.",
|
|
847
|
+
"# TYPE hood_alerts_pools gauge",
|
|
848
|
+
`hood_alerts_pools ${metrics.pools}`,
|
|
849
|
+
"# HELP hood_alerts_cursor_block Next unprocessed block per source.",
|
|
850
|
+
"# TYPE hood_alerts_cursor_block gauge"
|
|
851
|
+
);
|
|
852
|
+
for (const cursor of metrics.cursors) {
|
|
853
|
+
lines.push(`hood_alerts_cursor_block{source="${cursor.sourceId}"} ${cursor.nextBlock}`);
|
|
854
|
+
}
|
|
855
|
+
lines.push(
|
|
856
|
+
"# HELP hood_alerts_head_block Chain head block last observed.",
|
|
857
|
+
"# TYPE hood_alerts_head_block gauge",
|
|
858
|
+
`hood_alerts_head_block ${health.headBlock ?? 0}`,
|
|
859
|
+
"# HELP hood_alerts_lag_blocks How far each source trails the chain head.",
|
|
860
|
+
"# TYPE hood_alerts_lag_blocks gauge"
|
|
861
|
+
);
|
|
862
|
+
if (health.headBlock !== null) {
|
|
863
|
+
for (const cursor of metrics.cursors) {
|
|
864
|
+
const lag = health.headBlock > cursor.nextBlock ? health.headBlock - cursor.nextBlock : 0n;
|
|
865
|
+
lines.push(`hood_alerts_lag_blocks{source="${cursor.sourceId}"} ${lag}`);
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
lines.push(
|
|
869
|
+
"# HELP hood_alerts_last_poll_age_seconds Seconds since the last successful poll.",
|
|
870
|
+
"# TYPE hood_alerts_last_poll_age_seconds gauge",
|
|
871
|
+
`hood_alerts_last_poll_age_seconds ${health.lastPollAtMs === null ? -1 : Math.round((nowMs - health.lastPollAtMs) / 1e3)}`,
|
|
872
|
+
"# HELP hood_alerts_uptime_seconds Process uptime.",
|
|
873
|
+
"# TYPE hood_alerts_uptime_seconds counter",
|
|
874
|
+
`hood_alerts_uptime_seconds ${Math.round((nowMs - health.startedAtMs) / 1e3)}`,
|
|
875
|
+
""
|
|
876
|
+
);
|
|
877
|
+
return lines.join("\n");
|
|
878
|
+
}
|
|
879
|
+
function createHttpServer(options) {
|
|
880
|
+
const now = options.now ?? Date.now;
|
|
881
|
+
const stalenessMs = options.readinessStalenessMs ?? 12e4;
|
|
882
|
+
return createServer((req, res) => {
|
|
883
|
+
void (async () => {
|
|
884
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
885
|
+
const path = url.pathname;
|
|
886
|
+
try {
|
|
887
|
+
if (req.method === "GET" && (path === "/health" || path === "/healthz")) {
|
|
888
|
+
const health = options.health();
|
|
889
|
+
json(res, 200, {
|
|
890
|
+
ok: true,
|
|
891
|
+
version: health.version,
|
|
892
|
+
uptimeSeconds: Math.round((now() - health.startedAtMs) / 1e3),
|
|
893
|
+
headBlock: health.headBlock,
|
|
894
|
+
lastPollAtMs: health.lastPollAtMs,
|
|
895
|
+
lastError: health.lastError,
|
|
896
|
+
platforms: health.platforms
|
|
897
|
+
});
|
|
898
|
+
return;
|
|
899
|
+
}
|
|
900
|
+
if (req.method === "GET" && path === "/ready") {
|
|
901
|
+
const health = options.health();
|
|
902
|
+
const fresh = health.lastPollAtMs !== null && now() - health.lastPollAtMs <= stalenessMs;
|
|
903
|
+
json(res, fresh ? 200 : 503, {
|
|
904
|
+
ready: fresh,
|
|
905
|
+
lastPollAtMs: health.lastPollAtMs,
|
|
906
|
+
lastError: health.lastError
|
|
907
|
+
});
|
|
908
|
+
return;
|
|
909
|
+
}
|
|
910
|
+
if (req.method === "GET" && path === "/metrics") {
|
|
911
|
+
const body = renderMetrics(options.store, options.health(), now());
|
|
912
|
+
res.writeHead(200, { "content-type": "text/plain; version=0.0.4; charset=utf-8" });
|
|
913
|
+
res.end(body);
|
|
914
|
+
return;
|
|
915
|
+
}
|
|
916
|
+
if (options.discord && req.method === "POST" && path === options.discord.path) {
|
|
917
|
+
const rawBody = await readBody(req, 256 * 1024);
|
|
918
|
+
if (rawBody === null) {
|
|
919
|
+
json(res, 413, { error: "request body too large" });
|
|
920
|
+
return;
|
|
921
|
+
}
|
|
922
|
+
const answer = await options.discord.handle({
|
|
923
|
+
signature: req.headers["x-signature-ed25519"] ?? null,
|
|
924
|
+
timestamp: req.headers["x-signature-timestamp"] ?? null,
|
|
925
|
+
rawBody
|
|
926
|
+
});
|
|
927
|
+
json(res, answer.status, answer.body);
|
|
928
|
+
return;
|
|
929
|
+
}
|
|
930
|
+
json(res, 404, { error: `no route for ${req.method ?? "GET"} ${path}` });
|
|
931
|
+
} catch (error) {
|
|
932
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
933
|
+
options.logger.error("http handler failed", { path, error: message });
|
|
934
|
+
if (!res.headersSent) json(res, 500, { error: "internal error" });
|
|
935
|
+
else res.end();
|
|
936
|
+
}
|
|
937
|
+
})();
|
|
938
|
+
});
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
// src/service/runner.ts
|
|
942
|
+
import { createHoodClient } from "hoodchain";
|
|
943
|
+
var VERSION = "0.1.0";
|
|
944
|
+
function buildEntitlements(config, client, store) {
|
|
945
|
+
const staticProvider = createStaticEntitlementProvider({
|
|
946
|
+
premiumSubscribers: config.premiumSubscribers,
|
|
947
|
+
allPremium: config.allPremium
|
|
948
|
+
});
|
|
949
|
+
if (config.entitlements === "static") return staticProvider;
|
|
950
|
+
const usdgProvider = createUsdgEntitlementProvider({
|
|
951
|
+
client,
|
|
952
|
+
receiver: config.usdgReceiver,
|
|
953
|
+
pricePerPeriodUsdg: config.premiumPriceUsdg,
|
|
954
|
+
periodDays: config.premiumPeriodDays,
|
|
955
|
+
links: store.walletLinks(),
|
|
956
|
+
fromBlock: config.paymentsFromBlock
|
|
957
|
+
});
|
|
958
|
+
if (config.entitlements === "usdg") return usdgProvider;
|
|
959
|
+
return chainEntitlementProviders(staticProvider, usdgProvider);
|
|
960
|
+
}
|
|
961
|
+
function createService(options) {
|
|
962
|
+
const config = options.config;
|
|
963
|
+
const logger = options.logger ?? createLogger({ level: config.logLevel, base: { service: "hood-alerts" } });
|
|
964
|
+
const client = options.client ?? createHoodClient({
|
|
965
|
+
chain: config.network,
|
|
966
|
+
...config.rpcUrl ? { rpcUrl: config.rpcUrl } : {}
|
|
967
|
+
});
|
|
968
|
+
const store = options.store ?? new AlertStore(config.dbPath);
|
|
969
|
+
const oracle = createPriceOracle(client);
|
|
970
|
+
const tokens = createTokenMetaReader(client);
|
|
971
|
+
const registry = store.poolRegistry();
|
|
972
|
+
const liquidity = createLiquidityReader(client, oracle, registry);
|
|
973
|
+
const reputation = createRpcReputationProvider(client, oracle);
|
|
974
|
+
const sources = createEventSources({
|
|
975
|
+
client,
|
|
976
|
+
oracle,
|
|
977
|
+
tokens,
|
|
978
|
+
registry,
|
|
979
|
+
pinnedTokens: async () => store.watchlistTokens(),
|
|
980
|
+
whaleMinUsd: config.whaleMinUsd,
|
|
981
|
+
whalePoolLimit: config.whalePoolLimit
|
|
982
|
+
});
|
|
983
|
+
const entitlements = buildEntitlements(config, client, store);
|
|
984
|
+
const gate = createEntitlementGate({ provider: entitlements });
|
|
985
|
+
const platforms = [];
|
|
986
|
+
let telegramNotifier = null;
|
|
987
|
+
let discordWebhookNotifier = null;
|
|
988
|
+
let discordBotNotifier = null;
|
|
989
|
+
const telegramClient = config.telegramBotToken ? createTelegramClient({ botToken: config.telegramBotToken }) : null;
|
|
990
|
+
if (config.dryRun) {
|
|
991
|
+
telegramNotifier = createCaptureNotifier(
|
|
992
|
+
"telegram",
|
|
993
|
+
(delivery) => logger.info("dry run: telegram alert", { target: delivery.target, text: delivery.alert.plain })
|
|
994
|
+
);
|
|
995
|
+
discordWebhookNotifier = createCaptureNotifier(
|
|
996
|
+
"discord",
|
|
997
|
+
(delivery) => logger.info("dry run: discord alert", { target: delivery.target, text: delivery.alert.plain })
|
|
998
|
+
);
|
|
999
|
+
discordBotNotifier = discordWebhookNotifier;
|
|
1000
|
+
platforms.push("dry-run");
|
|
1001
|
+
} else {
|
|
1002
|
+
if (telegramClient) {
|
|
1003
|
+
telegramNotifier = createTelegramNotifier({
|
|
1004
|
+
botToken: config.telegramBotToken,
|
|
1005
|
+
client: telegramClient
|
|
1006
|
+
});
|
|
1007
|
+
platforms.push("telegram");
|
|
1008
|
+
}
|
|
1009
|
+
discordWebhookNotifier = createDiscordWebhookNotifier({});
|
|
1010
|
+
platforms.push("discord-webhook");
|
|
1011
|
+
if (config.discordBotToken) {
|
|
1012
|
+
discordBotNotifier = createDiscordBotNotifier({ botToken: config.discordBotToken });
|
|
1013
|
+
platforms.push("discord-bot");
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
function notifierFor(subscription) {
|
|
1017
|
+
if (subscription.platform === "telegram") return telegramNotifier;
|
|
1018
|
+
if (subscription.target.startsWith("https://")) return discordWebhookNotifier;
|
|
1019
|
+
return discordBotNotifier;
|
|
1020
|
+
}
|
|
1021
|
+
const dispatcher = createDispatcher({
|
|
1022
|
+
store,
|
|
1023
|
+
sources,
|
|
1024
|
+
gate,
|
|
1025
|
+
rateLimits: store.rateLimitStore(),
|
|
1026
|
+
notifierFor,
|
|
1027
|
+
getBlockNumber: () => client.public.getBlockNumber(),
|
|
1028
|
+
logger,
|
|
1029
|
+
evaluation: {
|
|
1030
|
+
liquidityUsd: (event) => liquidity.tokenLiquidityUsd(event.token),
|
|
1031
|
+
reputation: (deployer) => reputation.get(deployer),
|
|
1032
|
+
isLpLocked: (positionId) => reputation.isLpLocked(positionId)
|
|
1033
|
+
},
|
|
1034
|
+
confirmations: config.confirmations,
|
|
1035
|
+
chunkSize: config.chunkSize,
|
|
1036
|
+
initialLookbackBlocks: config.initialLookbackBlocks,
|
|
1037
|
+
flushBatchSize: config.flushBatchSize,
|
|
1038
|
+
maxDeliveryAttempts: config.maxDeliveryAttempts,
|
|
1039
|
+
retryBackoffMs: config.retryBackoffMs
|
|
1040
|
+
});
|
|
1041
|
+
const router = createCommandRouter({
|
|
1042
|
+
store,
|
|
1043
|
+
gate,
|
|
1044
|
+
...config.upgradeInstructions ? { upgradeInstructions: config.upgradeInstructions } : {}
|
|
1045
|
+
});
|
|
1046
|
+
let telegramBot = null;
|
|
1047
|
+
if (telegramClient && !config.dryRun) {
|
|
1048
|
+
telegramBot = createTelegramBot({
|
|
1049
|
+
botToken: config.telegramBotToken,
|
|
1050
|
+
client: telegramClient,
|
|
1051
|
+
router,
|
|
1052
|
+
logger: logger.child({ component: "telegram-bot" }),
|
|
1053
|
+
offsetStore: {
|
|
1054
|
+
get: () => {
|
|
1055
|
+
const raw = store.getMeta("telegram:offset");
|
|
1056
|
+
return raw === null ? null : Number(raw);
|
|
1057
|
+
},
|
|
1058
|
+
set: (offset) => store.setMeta("telegram:offset", String(offset))
|
|
1059
|
+
}
|
|
1060
|
+
});
|
|
1061
|
+
}
|
|
1062
|
+
const discordHandler = config.discordPublicKey ? createDiscordInteractionHandler({ router, publicKey: config.discordPublicKey }) : null;
|
|
1063
|
+
const startedAtMs = Date.now();
|
|
1064
|
+
let headBlock = null;
|
|
1065
|
+
let lastPollAtMs = null;
|
|
1066
|
+
let lastError = null;
|
|
1067
|
+
const health = () => ({
|
|
1068
|
+
headBlock,
|
|
1069
|
+
lastPollAtMs,
|
|
1070
|
+
lastError,
|
|
1071
|
+
platforms,
|
|
1072
|
+
version: VERSION,
|
|
1073
|
+
startedAtMs
|
|
1074
|
+
});
|
|
1075
|
+
const server = createHttpServer({
|
|
1076
|
+
store,
|
|
1077
|
+
logger: logger.child({ component: "http" }),
|
|
1078
|
+
health,
|
|
1079
|
+
...discordHandler ? { discord: { path: config.discordInteractionsPath, handle: discordHandler.handle } } : {}
|
|
1080
|
+
});
|
|
1081
|
+
let running = false;
|
|
1082
|
+
const loops = [];
|
|
1083
|
+
let stopping = null;
|
|
1084
|
+
const waiters = /* @__PURE__ */ new Set();
|
|
1085
|
+
function delay(ms) {
|
|
1086
|
+
return new Promise((resolve) => {
|
|
1087
|
+
const timer = setTimeout(() => {
|
|
1088
|
+
waiters.delete(wake);
|
|
1089
|
+
resolve();
|
|
1090
|
+
}, ms);
|
|
1091
|
+
const wake = () => {
|
|
1092
|
+
clearTimeout(timer);
|
|
1093
|
+
waiters.delete(wake);
|
|
1094
|
+
resolve();
|
|
1095
|
+
};
|
|
1096
|
+
waiters.add(wake);
|
|
1097
|
+
});
|
|
1098
|
+
}
|
|
1099
|
+
async function pollLoop() {
|
|
1100
|
+
while (running) {
|
|
1101
|
+
try {
|
|
1102
|
+
headBlock = await client.public.getBlockNumber();
|
|
1103
|
+
const stats = await dispatcher.pollOnce();
|
|
1104
|
+
lastPollAtMs = Date.now();
|
|
1105
|
+
lastError = stats.errors.length > 0 ? stats.errors[0]?.error ?? null : null;
|
|
1106
|
+
if (stats.eventsDecoded > 0 || stats.enqueued > 0) {
|
|
1107
|
+
logger.info("poll complete", {
|
|
1108
|
+
head: headBlock,
|
|
1109
|
+
events: stats.eventsDecoded,
|
|
1110
|
+
enqueued: stats.enqueued,
|
|
1111
|
+
deduped: stats.deduped,
|
|
1112
|
+
suppressed: stats.suppressed,
|
|
1113
|
+
errors: stats.errors.length
|
|
1114
|
+
});
|
|
1115
|
+
}
|
|
1116
|
+
} catch (error) {
|
|
1117
|
+
lastError = error instanceof Error ? error.message : String(error);
|
|
1118
|
+
logger.error("poll loop error", { error: lastError });
|
|
1119
|
+
}
|
|
1120
|
+
await delay(config.pollIntervalMs);
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
async function flushLoop() {
|
|
1124
|
+
while (running) {
|
|
1125
|
+
try {
|
|
1126
|
+
const stats = await dispatcher.flushOnce();
|
|
1127
|
+
if (stats.claimed > 0) logger.debug("flush complete", { ...stats });
|
|
1128
|
+
} catch (error) {
|
|
1129
|
+
logger.error("flush loop error", {
|
|
1130
|
+
error: error instanceof Error ? error.message : String(error)
|
|
1131
|
+
});
|
|
1132
|
+
}
|
|
1133
|
+
await delay(config.flushIntervalMs);
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
async function pruneLoop() {
|
|
1137
|
+
while (running) {
|
|
1138
|
+
await delay(36e5);
|
|
1139
|
+
if (!running) break;
|
|
1140
|
+
try {
|
|
1141
|
+
const cutoff = Date.now() - config.outboxRetentionHours * 36e5;
|
|
1142
|
+
const outbox = store.pruneOutbox(cutoff);
|
|
1143
|
+
const deliveries = store.pruneDeliveries(Date.now() - 25 * 36e5);
|
|
1144
|
+
if (outbox > 0 || deliveries > 0) logger.info("pruned", { outbox, deliveries });
|
|
1145
|
+
} catch (error) {
|
|
1146
|
+
logger.error("prune failed", {
|
|
1147
|
+
error: error instanceof Error ? error.message : String(error)
|
|
1148
|
+
});
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
return {
|
|
1153
|
+
config,
|
|
1154
|
+
store,
|
|
1155
|
+
dispatcher,
|
|
1156
|
+
logger,
|
|
1157
|
+
health,
|
|
1158
|
+
async start() {
|
|
1159
|
+
if (running) return;
|
|
1160
|
+
running = true;
|
|
1161
|
+
dispatcher.recover();
|
|
1162
|
+
await new Promise((resolve, reject) => {
|
|
1163
|
+
const onError = (error) => reject(error);
|
|
1164
|
+
server.once("error", onError);
|
|
1165
|
+
server.listen(config.port, () => {
|
|
1166
|
+
server.removeListener("error", onError);
|
|
1167
|
+
resolve();
|
|
1168
|
+
});
|
|
1169
|
+
});
|
|
1170
|
+
logger.info("service started", {
|
|
1171
|
+
port: config.port,
|
|
1172
|
+
network: config.network,
|
|
1173
|
+
dryRun: config.dryRun,
|
|
1174
|
+
platforms,
|
|
1175
|
+
entitlements: config.entitlements,
|
|
1176
|
+
sources: sources.map((source) => source.id)
|
|
1177
|
+
});
|
|
1178
|
+
loops.push(pollLoop(), flushLoop(), pruneLoop());
|
|
1179
|
+
if (telegramBot) {
|
|
1180
|
+
await telegramBot.publishCommands();
|
|
1181
|
+
loops.push(telegramBot.start());
|
|
1182
|
+
}
|
|
1183
|
+
},
|
|
1184
|
+
async stop() {
|
|
1185
|
+
stopping ??= (async () => {
|
|
1186
|
+
logger.info("service stopping");
|
|
1187
|
+
running = false;
|
|
1188
|
+
telegramBot?.stop();
|
|
1189
|
+
for (const wake of [...waiters]) wake();
|
|
1190
|
+
await new Promise((resolve) => {
|
|
1191
|
+
server.close(() => resolve());
|
|
1192
|
+
server.closeAllConnections?.();
|
|
1193
|
+
});
|
|
1194
|
+
await Promise.allSettled(loops);
|
|
1195
|
+
store.close();
|
|
1196
|
+
logger.info("service stopped");
|
|
1197
|
+
})();
|
|
1198
|
+
return stopping;
|
|
1199
|
+
}
|
|
1200
|
+
};
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
export {
|
|
1204
|
+
loadConfig,
|
|
1205
|
+
encode,
|
|
1206
|
+
decode,
|
|
1207
|
+
encodeEvent,
|
|
1208
|
+
decodeEvent,
|
|
1209
|
+
AlertStore,
|
|
1210
|
+
createDispatcher,
|
|
1211
|
+
renderMetrics,
|
|
1212
|
+
createHttpServer,
|
|
1213
|
+
VERSION,
|
|
1214
|
+
createService
|
|
1215
|
+
};
|
|
1216
|
+
//# sourceMappingURL=chunk-Z4DJPESQ.js.map
|