lovewebconnection 0.0.0-stage → 0.2.1
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/.env.example +38 -0
- package/LICENSE +21 -0
- package/README.md +1937 -2
- package/dist/alert-queue.d.ts +72 -0
- package/dist/alert-queue.js +155 -0
- package/dist/alert-store.d.ts +29 -0
- package/dist/alert-store.js +75 -0
- package/dist/balances.d.ts +221 -0
- package/dist/balances.js +1232 -0
- package/dist/cache.d.ts +37 -0
- package/dist/cache.js +128 -0
- package/dist/call.d.ts +46 -0
- package/dist/call.js +377 -0
- package/dist/chain.d.ts +97 -0
- package/dist/chain.js +419 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +1011 -0
- package/dist/compare-history.d.ts +82 -0
- package/dist/compare-history.js +340 -0
- package/dist/compare.d.ts +98 -0
- package/dist/compare.js +492 -0
- package/dist/contract.d.ts +107 -0
- package/dist/contract.js +162 -0
- package/dist/default-abi.d.ts +53 -0
- package/dist/default-abi.js +85 -0
- package/dist/history.d.ts +83 -0
- package/dist/history.js +422 -0
- package/dist/index.d.ts +54 -0
- package/dist/index.js +27 -0
- package/dist/market-page.d.ts +8 -0
- package/dist/market-page.js +554 -0
- package/dist/market-server.d.ts +46 -0
- package/dist/market-server.js +473 -0
- package/dist/market.d.ts +323 -0
- package/dist/market.js +835 -0
- package/dist/metadata-cache.d.ts +72 -0
- package/dist/metadata-cache.js +197 -0
- package/dist/metrics.d.ts +64 -0
- package/dist/metrics.js +138 -0
- package/dist/plot.d.ts +60 -0
- package/dist/plot.js +317 -0
- package/dist/presets-history.d.ts +372 -0
- package/dist/presets-history.js +1243 -0
- package/dist/presets-summary.d.ts +17 -0
- package/dist/presets-summary.js +55 -0
- package/dist/presets.d.ts +59 -0
- package/dist/presets.js +181 -0
- package/dist/prices.d.ts +63 -0
- package/dist/prices.js +154 -0
- package/dist/rotate.d.ts +19 -0
- package/dist/rotate.js +34 -0
- package/dist/scan.d.ts +81 -0
- package/dist/scan.js +501 -0
- package/dist/sessions.d.ts +104 -0
- package/dist/sessions.js +515 -0
- package/dist/signatures.d.ts +13 -0
- package/dist/signatures.js +43 -0
- package/dist/tokenfile.d.ts +50 -0
- package/dist/tokenfile.js +218 -0
- package/dist/views.d.ts +63 -0
- package/dist/views.js +415 -0
- package/dist/watch.d.ts +306 -0
- package/dist/watch.js +1121 -0
- package/dist/webhook.d.ts +17 -0
- package/dist/webhook.js +35 -0
- package/examples/erc20.abi.json +57 -0
- package/examples/xlayer.tokens.json +4 -0
- package/package.json +77 -4
package/dist/watch.js
ADDED
|
@@ -0,0 +1,1121 @@
|
|
|
1
|
+
import { formatGwei, getAddress, isAddress } from "viem";
|
|
2
|
+
import { formatTokenAmount, loadTokenList, MAX_SUB_CALLS, readBalances, } from "./balances.js";
|
|
3
|
+
import { MetadataCache } from "./metadata-cache.js";
|
|
4
|
+
import { DEFAULT_METRICS_WINDOW, computeMetrics, computeSessionMetrics, createMetricsState, formatMetrics, formatSeconds, recordBlockTime, recordFailure, recordLatency, } from "./metrics.js";
|
|
5
|
+
import { PriceSource, priceChainSlug, usdValue } from "./prices.js";
|
|
6
|
+
export const DEFAULT_WATCH_OPTIONS = {
|
|
7
|
+
intervalMs: 3000,
|
|
8
|
+
stallAfterSeconds: 30,
|
|
9
|
+
maxSamples: 0,
|
|
10
|
+
gasSurgePercent: 50,
|
|
11
|
+
windowSize: DEFAULT_METRICS_WINDOW,
|
|
12
|
+
reportEvery: 0,
|
|
13
|
+
logPath: null,
|
|
14
|
+
logMaxBytes: 0,
|
|
15
|
+
detach: false,
|
|
16
|
+
alertLatencyMs: null,
|
|
17
|
+
alertBlockTimeSeconds: null,
|
|
18
|
+
alertBalancePercent: null,
|
|
19
|
+
webhookUrl: null,
|
|
20
|
+
alertAuthHeader: null,
|
|
21
|
+
alertQueuePath: null,
|
|
22
|
+
owner: null,
|
|
23
|
+
tokens: [],
|
|
24
|
+
labels: new Map(),
|
|
25
|
+
portfolioEvery: 1,
|
|
26
|
+
batchSize: null,
|
|
27
|
+
maxSubCalls: null,
|
|
28
|
+
verbose: false,
|
|
29
|
+
usd: false,
|
|
30
|
+
};
|
|
31
|
+
const MAX_INTERVAL_SECONDS = 300;
|
|
32
|
+
const MAX_STALL_SECONDS = 3600;
|
|
33
|
+
const MAX_SAMPLES = 100_000;
|
|
34
|
+
const MAX_GAS_SURGE_PERCENT = 10_000;
|
|
35
|
+
const MAX_WINDOW = 1000;
|
|
36
|
+
const MAX_REPORT_EVERY = 10_000;
|
|
37
|
+
const MAX_ALERT_MS = 600_000;
|
|
38
|
+
const MAX_ALERT_BLOCK_SECONDS = 3600;
|
|
39
|
+
const MAX_ALERT_BALANCE_PERCENT = 10_000;
|
|
40
|
+
const MAX_BATCH_SIZE = 100;
|
|
41
|
+
const MIN_LOG_MAX_BYTES = 1024;
|
|
42
|
+
const MAX_LOG_MAX_BYTES = 10 * 1024 ** 3;
|
|
43
|
+
const MAX_CONSECUTIVE_ERRORS = 5;
|
|
44
|
+
const GAS_WINDOW = 10;
|
|
45
|
+
export class WatchArgError extends Error {
|
|
46
|
+
constructor(message) {
|
|
47
|
+
super(message);
|
|
48
|
+
this.name = "WatchArgError";
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
function parseInteger(raw, flag, min, max) {
|
|
52
|
+
if (!/^\d+$/.test(raw)) {
|
|
53
|
+
throw new WatchArgError(`${flag} must be a whole number, got "${raw}".`);
|
|
54
|
+
}
|
|
55
|
+
const value = Number(raw);
|
|
56
|
+
if (value < min || value > max) {
|
|
57
|
+
throw new WatchArgError(`${flag} must be between ${min} and ${max}.`);
|
|
58
|
+
}
|
|
59
|
+
return value;
|
|
60
|
+
}
|
|
61
|
+
const SIZE_UNITS = { b: 1, kb: 1024, mb: 1024 ** 2, gb: 1024 ** 3 };
|
|
62
|
+
/** `512kb`, `10mb`, `1gb`, or a bare number of megabytes. */
|
|
63
|
+
export function parseLogSize(raw) {
|
|
64
|
+
const match = /^(\d+(?:\.\d+)?)(b|kb|mb|gb)?$/i.exec(raw.trim());
|
|
65
|
+
if (!match) {
|
|
66
|
+
throw new WatchArgError(`--log-max-size must look like "512kb" or "10mb", got "${raw}".`);
|
|
67
|
+
}
|
|
68
|
+
const amount = Number(match[1]);
|
|
69
|
+
const unit = (match[2] ?? "mb").toLowerCase();
|
|
70
|
+
const bytes = Math.round(amount * (SIZE_UNITS[unit] ?? 1));
|
|
71
|
+
if (!Number.isFinite(bytes) || bytes < MIN_LOG_MAX_BYTES) {
|
|
72
|
+
throw new WatchArgError(`--log-max-size must be at least ${MIN_LOG_MAX_BYTES} bytes.`);
|
|
73
|
+
}
|
|
74
|
+
if (bytes > MAX_LOG_MAX_BYTES) {
|
|
75
|
+
throw new WatchArgError(`--log-max-size must be at most ${MAX_LOG_MAX_BYTES / 1024 ** 3}gb.`);
|
|
76
|
+
}
|
|
77
|
+
return bytes;
|
|
78
|
+
}
|
|
79
|
+
export function parseWatchArgs(argv, env = process.env) {
|
|
80
|
+
const options = { ...DEFAULT_WATCH_OPTIONS };
|
|
81
|
+
// .env is loaded before this runs, so the variable is the normal source and
|
|
82
|
+
// the flag is only an explicit override.
|
|
83
|
+
options.alertAuthHeader = env.LWC_WEBHOOK_AUTH?.trim() || null;
|
|
84
|
+
let authSource = "LWC_WEBHOOK_AUTH";
|
|
85
|
+
let sawBatchSize = false;
|
|
86
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
87
|
+
const arg = argv[index] ?? "";
|
|
88
|
+
const readValue = (flag) => {
|
|
89
|
+
const value = argv[index + 1];
|
|
90
|
+
if (!value || value.startsWith("--")) {
|
|
91
|
+
throw new WatchArgError(`${flag} requires a value.`);
|
|
92
|
+
}
|
|
93
|
+
index += 1;
|
|
94
|
+
return value;
|
|
95
|
+
};
|
|
96
|
+
if (arg === "--interval") {
|
|
97
|
+
options.intervalMs =
|
|
98
|
+
parseInteger(readValue("--interval"), "--interval", 1, MAX_INTERVAL_SECONDS) * 1000;
|
|
99
|
+
}
|
|
100
|
+
else if (arg === "--stall-after") {
|
|
101
|
+
options.stallAfterSeconds = parseInteger(readValue("--stall-after"), "--stall-after", 2, MAX_STALL_SECONDS);
|
|
102
|
+
}
|
|
103
|
+
else if (arg === "--samples") {
|
|
104
|
+
options.maxSamples = parseInteger(readValue("--samples"), "--samples", 0, MAX_SAMPLES);
|
|
105
|
+
}
|
|
106
|
+
else if (arg === "--gas-surge") {
|
|
107
|
+
options.gasSurgePercent = parseInteger(readValue("--gas-surge"), "--gas-surge", 1, MAX_GAS_SURGE_PERCENT);
|
|
108
|
+
}
|
|
109
|
+
else if (arg === "--window") {
|
|
110
|
+
options.windowSize = parseInteger(readValue("--window"), "--window", 5, MAX_WINDOW);
|
|
111
|
+
}
|
|
112
|
+
else if (arg === "--report-every") {
|
|
113
|
+
options.reportEvery = parseInteger(readValue("--report-every"), "--report-every", 0, MAX_REPORT_EVERY);
|
|
114
|
+
}
|
|
115
|
+
else if (arg === "--alert-latency") {
|
|
116
|
+
options.alertLatencyMs = parseInteger(readValue("--alert-latency"), "--alert-latency", 1, MAX_ALERT_MS);
|
|
117
|
+
}
|
|
118
|
+
else if (arg === "--alert-block-time") {
|
|
119
|
+
options.alertBlockTimeSeconds = parseInteger(readValue("--alert-block-time"), "--alert-block-time", 1, MAX_ALERT_BLOCK_SECONDS);
|
|
120
|
+
}
|
|
121
|
+
else if (arg === "--alert-balance" || arg.startsWith("--alert-balance=")) {
|
|
122
|
+
const value = arg.startsWith("--alert-balance=")
|
|
123
|
+
? arg.slice("--alert-balance=".length)
|
|
124
|
+
: readValue("--alert-balance");
|
|
125
|
+
options.alertBalancePercent = parseInteger(value, "--alert-balance", 1, MAX_ALERT_BALANCE_PERCENT);
|
|
126
|
+
}
|
|
127
|
+
else if (arg === "--alert-webhook-auth") {
|
|
128
|
+
const value = readValue("--alert-webhook-auth").trim();
|
|
129
|
+
if (!value)
|
|
130
|
+
throw new WatchArgError("--alert-webhook-auth requires a header value.");
|
|
131
|
+
options.alertAuthHeader = value;
|
|
132
|
+
authSource = "--alert-webhook-auth";
|
|
133
|
+
}
|
|
134
|
+
else if (arg.startsWith("--alert-webhook-auth=")) {
|
|
135
|
+
const value = arg.slice("--alert-webhook-auth=".length).trim();
|
|
136
|
+
if (!value)
|
|
137
|
+
throw new WatchArgError("--alert-webhook-auth requires a header value.");
|
|
138
|
+
options.alertAuthHeader = value;
|
|
139
|
+
authSource = "--alert-webhook-auth";
|
|
140
|
+
}
|
|
141
|
+
else if (arg === "--alert-queue" || arg.startsWith("--alert-queue=")) {
|
|
142
|
+
const value = arg.startsWith("--alert-queue=")
|
|
143
|
+
? arg.slice("--alert-queue=".length)
|
|
144
|
+
: readValue("--alert-queue");
|
|
145
|
+
if (!value.trim())
|
|
146
|
+
throw new WatchArgError("--alert-queue requires a file path.");
|
|
147
|
+
options.alertQueuePath = value;
|
|
148
|
+
}
|
|
149
|
+
else if (arg === "--alert-webhook") {
|
|
150
|
+
options.webhookUrl = readValue("--alert-webhook");
|
|
151
|
+
}
|
|
152
|
+
else if (arg.startsWith("--alert-webhook=")) {
|
|
153
|
+
const value = arg.slice("--alert-webhook=".length);
|
|
154
|
+
if (!value)
|
|
155
|
+
throw new WatchArgError("--alert-webhook requires a URL.");
|
|
156
|
+
options.webhookUrl = value;
|
|
157
|
+
}
|
|
158
|
+
else if (arg === "--owner" || arg.startsWith("--owner=")) {
|
|
159
|
+
const value = arg.startsWith("--owner=")
|
|
160
|
+
? arg.slice("--owner=".length)
|
|
161
|
+
: readValue("--owner");
|
|
162
|
+
if (!isAddress(value)) {
|
|
163
|
+
throw new WatchArgError(`"${value}" is not a valid owner address.`);
|
|
164
|
+
}
|
|
165
|
+
options.owner = getAddress(value);
|
|
166
|
+
}
|
|
167
|
+
else if (arg === "--tokens-file" || arg.startsWith("--tokens-file=")) {
|
|
168
|
+
const value = arg.startsWith("--tokens-file=")
|
|
169
|
+
? arg.slice("--tokens-file=".length)
|
|
170
|
+
: readValue("--tokens-file");
|
|
171
|
+
if (!value)
|
|
172
|
+
throw new WatchArgError("--tokens-file requires a path.");
|
|
173
|
+
try {
|
|
174
|
+
const entries = loadTokenList(value);
|
|
175
|
+
options.tokens = entries.map((entry) => entry.address);
|
|
176
|
+
options.labels = new Map(entries.flatMap((entry) => entry.label ? [[entry.address.toLowerCase(), entry.label]] : []));
|
|
177
|
+
}
|
|
178
|
+
catch (error) {
|
|
179
|
+
throw new WatchArgError(error instanceof Error ? error.message : String(error));
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
else if (arg === "--portfolio-every") {
|
|
183
|
+
options.portfolioEvery = parseInteger(readValue("--portfolio-every"), "--portfolio-every", 1, MAX_SAMPLES);
|
|
184
|
+
}
|
|
185
|
+
else if (arg === "--batch-size" || arg.startsWith("--batch-size=")) {
|
|
186
|
+
if (options.maxSubCalls !== null) {
|
|
187
|
+
throw new WatchArgError("use either --batch-size or --max-subcalls, not both.");
|
|
188
|
+
}
|
|
189
|
+
const value = arg.startsWith("--batch-size=")
|
|
190
|
+
? arg.slice("--batch-size=".length)
|
|
191
|
+
: readValue("--batch-size");
|
|
192
|
+
options.batchSize = parseInteger(value, "--batch-size", 1, MAX_BATCH_SIZE);
|
|
193
|
+
sawBatchSize = true;
|
|
194
|
+
}
|
|
195
|
+
else if (arg === "--max-subcalls" || arg.startsWith("--max-subcalls=")) {
|
|
196
|
+
if (sawBatchSize) {
|
|
197
|
+
throw new WatchArgError("use either --batch-size or --max-subcalls, not both.");
|
|
198
|
+
}
|
|
199
|
+
const value = arg.startsWith("--max-subcalls=")
|
|
200
|
+
? arg.slice("--max-subcalls=".length)
|
|
201
|
+
: readValue("--max-subcalls");
|
|
202
|
+
options.maxSubCalls = parseInteger(value, "--max-subcalls", 1, MAX_SUB_CALLS);
|
|
203
|
+
}
|
|
204
|
+
else if (arg === "--verbose") {
|
|
205
|
+
options.verbose = true;
|
|
206
|
+
}
|
|
207
|
+
else if (arg === "--usd") {
|
|
208
|
+
options.usd = true;
|
|
209
|
+
}
|
|
210
|
+
else if (arg === "--log") {
|
|
211
|
+
options.logPath = readValue("--log");
|
|
212
|
+
}
|
|
213
|
+
else if (arg.startsWith("--log=")) {
|
|
214
|
+
const value = arg.slice("--log=".length);
|
|
215
|
+
if (!value)
|
|
216
|
+
throw new WatchArgError("--log requires a file path.");
|
|
217
|
+
options.logPath = value;
|
|
218
|
+
}
|
|
219
|
+
else if (arg === "--log-max-size") {
|
|
220
|
+
options.logMaxBytes = parseLogSize(readValue("--log-max-size"));
|
|
221
|
+
}
|
|
222
|
+
else if (arg.startsWith("--log-max-size=")) {
|
|
223
|
+
const value = arg.slice("--log-max-size=".length);
|
|
224
|
+
if (!value)
|
|
225
|
+
throw new WatchArgError("--log-max-size requires a size, e.g. 10mb.");
|
|
226
|
+
options.logMaxBytes = parseLogSize(value);
|
|
227
|
+
}
|
|
228
|
+
else if (arg === "--detach") {
|
|
229
|
+
options.detach = true;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
if (options.detach && !options.logPath) {
|
|
233
|
+
throw new WatchArgError("--detach requires --log <path> so the output is not lost.");
|
|
234
|
+
}
|
|
235
|
+
if (options.logMaxBytes > 0 && options.logPath === null) {
|
|
236
|
+
throw new WatchArgError("--log-max-size needs --log <path> so it has a file to rotate.");
|
|
237
|
+
}
|
|
238
|
+
if (options.webhookUrl !== null) {
|
|
239
|
+
if (!/^https?:\/\/\S+$/.test(options.webhookUrl)) {
|
|
240
|
+
throw new WatchArgError(`--alert-webhook must be an http(s) URL, got "${options.webhookUrl}".`);
|
|
241
|
+
}
|
|
242
|
+
if (options.alertLatencyMs === null &&
|
|
243
|
+
options.alertBlockTimeSeconds === null &&
|
|
244
|
+
options.alertBalancePercent === null) {
|
|
245
|
+
throw new WatchArgError("--alert-webhook needs --alert-latency, --alert-block-time or --alert-balance so it knows when to fire.");
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
if (options.alertAuthHeader !== null) {
|
|
249
|
+
if (/[\r\n]/.test(options.alertAuthHeader)) {
|
|
250
|
+
throw new WatchArgError(`${authSource} must be a single line.`);
|
|
251
|
+
}
|
|
252
|
+
// The variable is global to the shell, so it is simply unused when there is
|
|
253
|
+
// no webhook; an explicit flag without one is still a mistake.
|
|
254
|
+
if (options.webhookUrl === null && authSource === "--alert-webhook-auth") {
|
|
255
|
+
throw new WatchArgError("--alert-webhook-auth needs --alert-webhook <url>.");
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
if (options.alertBalancePercent !== null &&
|
|
259
|
+
(options.owner === null || options.tokens.length === 0)) {
|
|
260
|
+
throw new WatchArgError("--alert-balance needs --owner and --tokens-file so it has balances to compare.");
|
|
261
|
+
}
|
|
262
|
+
if (options.usd && (options.owner === null || options.tokens.length === 0)) {
|
|
263
|
+
throw new WatchArgError("--usd needs --owner and --tokens-file so it has a portfolio to value.");
|
|
264
|
+
}
|
|
265
|
+
if (options.owner === null && options.tokens.length > 0) {
|
|
266
|
+
throw new WatchArgError("--tokens-file needs --owner <address> so it knows whose balances to read.");
|
|
267
|
+
}
|
|
268
|
+
if (options.owner !== null && options.tokens.length === 0) {
|
|
269
|
+
throw new WatchArgError("--owner needs --tokens-file with at least one token.");
|
|
270
|
+
}
|
|
271
|
+
if (options.stallAfterSeconds * 1000 < options.intervalMs) {
|
|
272
|
+
throw new WatchArgError(`--stall-after (${options.stallAfterSeconds}s) must be at least the poll interval (${options.intervalMs / 1000}s).`);
|
|
273
|
+
}
|
|
274
|
+
return options;
|
|
275
|
+
}
|
|
276
|
+
export function createTracker() {
|
|
277
|
+
return {
|
|
278
|
+
lastBlock: null,
|
|
279
|
+
lastChainId: null,
|
|
280
|
+
lastAdvanceAt: 0,
|
|
281
|
+
stalledSince: null,
|
|
282
|
+
gasHistory: [],
|
|
283
|
+
gasSurge: false,
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
function median(values) {
|
|
287
|
+
if (values.length === 0)
|
|
288
|
+
return null;
|
|
289
|
+
const sorted = [...values].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
|
290
|
+
const middle = Math.floor(sorted.length / 2);
|
|
291
|
+
if (sorted.length % 2 === 1)
|
|
292
|
+
return sorted[middle] ?? null;
|
|
293
|
+
const lower = sorted[middle - 1];
|
|
294
|
+
const upper = sorted[middle];
|
|
295
|
+
if (lower === undefined || upper === undefined)
|
|
296
|
+
return null;
|
|
297
|
+
return (lower + upper) / 2n;
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* Pure state machine: given the previous state and a fresh sample, decide which
|
|
301
|
+
* events happened. Chain drift is reported when the observed id differs from the
|
|
302
|
+
* configured one and from the previous observation. Gas surges compare the
|
|
303
|
+
* current price against the median of the last readings, with hysteresis so a
|
|
304
|
+
* price hovering at the threshold does not flap.
|
|
305
|
+
*/
|
|
306
|
+
export function evaluateSample(state, sample, options, expectedChainId) {
|
|
307
|
+
const events = [];
|
|
308
|
+
if (sample.chainId !== expectedChainId && sample.chainId !== state.lastChainId) {
|
|
309
|
+
events.push({
|
|
310
|
+
type: "chain-drift",
|
|
311
|
+
at: sample.at,
|
|
312
|
+
expected: expectedChainId,
|
|
313
|
+
previous: state.lastChainId,
|
|
314
|
+
actual: sample.chainId,
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
let lastAdvanceAt = state.lastAdvanceAt;
|
|
318
|
+
let stalledSince = state.stalledSince;
|
|
319
|
+
if (state.lastBlock === null || sample.blockNumber > state.lastBlock) {
|
|
320
|
+
const delta = state.lastBlock === null ? 0n : sample.blockNumber - state.lastBlock;
|
|
321
|
+
if (stalledSince !== null) {
|
|
322
|
+
events.push({
|
|
323
|
+
type: "recovered",
|
|
324
|
+
at: sample.at,
|
|
325
|
+
from: state.lastBlock ?? sample.blockNumber,
|
|
326
|
+
to: sample.blockNumber,
|
|
327
|
+
stalledForSeconds: Math.max(0, Math.round((sample.at - stalledSince) / 1000)),
|
|
328
|
+
});
|
|
329
|
+
stalledSince = null;
|
|
330
|
+
}
|
|
331
|
+
lastAdvanceAt = sample.at;
|
|
332
|
+
events.push({
|
|
333
|
+
type: "advance",
|
|
334
|
+
at: sample.at,
|
|
335
|
+
blockNumber: sample.blockNumber,
|
|
336
|
+
delta,
|
|
337
|
+
gasPriceWei: sample.gasPriceWei,
|
|
338
|
+
latencyMs: sample.latencyMs,
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
else {
|
|
342
|
+
const staleSeconds = Math.max(0, Math.floor((sample.at - lastAdvanceAt) / 1000));
|
|
343
|
+
if (staleSeconds >= options.stallAfterSeconds && stalledSince === null) {
|
|
344
|
+
stalledSince = lastAdvanceAt;
|
|
345
|
+
events.push({
|
|
346
|
+
type: "stall",
|
|
347
|
+
at: sample.at,
|
|
348
|
+
blockNumber: state.lastBlock ?? sample.blockNumber,
|
|
349
|
+
staleSeconds,
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
else if (stalledSince === null) {
|
|
353
|
+
events.push({
|
|
354
|
+
type: "hold",
|
|
355
|
+
at: sample.at,
|
|
356
|
+
blockNumber: state.lastBlock ?? sample.blockNumber,
|
|
357
|
+
staleSeconds,
|
|
358
|
+
gasPriceWei: sample.gasPriceWei,
|
|
359
|
+
latencyMs: sample.latencyMs,
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
let gasHistory = state.gasHistory;
|
|
364
|
+
let gasSurge = state.gasSurge;
|
|
365
|
+
if (sample.gasPriceWei !== null) {
|
|
366
|
+
const baseline = median(gasHistory);
|
|
367
|
+
const current = sample.gasPriceWei;
|
|
368
|
+
if (baseline !== null && baseline > 0n) {
|
|
369
|
+
const increasePercent = Math.round((Number(current - baseline) * 100) / Number(baseline));
|
|
370
|
+
const threshold = options.gasSurgePercent;
|
|
371
|
+
if (!gasSurge && increasePercent >= threshold) {
|
|
372
|
+
gasSurge = true;
|
|
373
|
+
events.push({
|
|
374
|
+
type: "gas-surge",
|
|
375
|
+
at: sample.at,
|
|
376
|
+
gasPriceWei: current,
|
|
377
|
+
baselineWei: baseline,
|
|
378
|
+
increasePercent,
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
else if (gasSurge && increasePercent < threshold / 2) {
|
|
382
|
+
gasSurge = false;
|
|
383
|
+
events.push({
|
|
384
|
+
type: "gas-normal",
|
|
385
|
+
at: sample.at,
|
|
386
|
+
gasPriceWei: current,
|
|
387
|
+
baselineWei: baseline,
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
gasHistory = [...gasHistory, current].slice(-GAS_WINDOW);
|
|
392
|
+
}
|
|
393
|
+
return {
|
|
394
|
+
state: {
|
|
395
|
+
lastBlock: state.lastBlock === null || sample.blockNumber > state.lastBlock
|
|
396
|
+
? sample.blockNumber
|
|
397
|
+
: state.lastBlock,
|
|
398
|
+
lastChainId: sample.chainId,
|
|
399
|
+
lastAdvanceAt,
|
|
400
|
+
stalledSince,
|
|
401
|
+
gasHistory,
|
|
402
|
+
gasSurge,
|
|
403
|
+
},
|
|
404
|
+
events,
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
export function createAlertState() {
|
|
408
|
+
return { latencyBreaching: false, blockTimeBreaching: false };
|
|
409
|
+
}
|
|
410
|
+
/**
|
|
411
|
+
* Edge-triggered thresholds: one alert when a metric rises above its limit and
|
|
412
|
+
* one when it falls back under it, so a sustained breach does not repeat on
|
|
413
|
+
* every sample.
|
|
414
|
+
*/
|
|
415
|
+
export function evaluateAlerts(metrics, thresholds, state) {
|
|
416
|
+
const alerts = [];
|
|
417
|
+
const next = { ...state };
|
|
418
|
+
if (thresholds.alertLatencyMs !== null && metrics.latency.count > 0) {
|
|
419
|
+
const value = metrics.latency.p95;
|
|
420
|
+
const threshold = thresholds.alertLatencyMs;
|
|
421
|
+
if (!next.latencyBreaching && value > threshold) {
|
|
422
|
+
next.latencyBreaching = true;
|
|
423
|
+
alerts.push({ metric: "latency", direction: "breach", value, threshold });
|
|
424
|
+
}
|
|
425
|
+
else if (next.latencyBreaching && value <= threshold) {
|
|
426
|
+
next.latencyBreaching = false;
|
|
427
|
+
alerts.push({ metric: "latency", direction: "clear", value, threshold });
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
if (thresholds.alertBlockTimeSeconds !== null && metrics.blockTime.count > 0) {
|
|
431
|
+
const value = metrics.blockTime.medianSeconds;
|
|
432
|
+
const threshold = thresholds.alertBlockTimeSeconds;
|
|
433
|
+
if (!next.blockTimeBreaching && value > threshold) {
|
|
434
|
+
next.blockTimeBreaching = true;
|
|
435
|
+
alerts.push({ metric: "block-time", direction: "breach", value, threshold });
|
|
436
|
+
}
|
|
437
|
+
else if (next.blockTimeBreaching && value <= threshold) {
|
|
438
|
+
next.blockTimeBreaching = false;
|
|
439
|
+
alerts.push({ metric: "block-time", direction: "clear", value, threshold });
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
return { state: next, alerts };
|
|
443
|
+
}
|
|
444
|
+
const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
445
|
+
function describeWatchError(error) {
|
|
446
|
+
if (error instanceof Error) {
|
|
447
|
+
const short = error.shortMessage;
|
|
448
|
+
if (typeof short === "string" && short.length > 0)
|
|
449
|
+
return short;
|
|
450
|
+
return error.message;
|
|
451
|
+
}
|
|
452
|
+
return String(error);
|
|
453
|
+
}
|
|
454
|
+
/**
|
|
455
|
+
* Compare two readings of the same balance. Returns null unless the move is
|
|
456
|
+
* strictly larger than `thresholdPercent`. A balance that was zero has no
|
|
457
|
+
* percentage baseline, so any move away from zero alerts.
|
|
458
|
+
*/
|
|
459
|
+
export function describeBalanceChange(previous, balance, thresholdPercent) {
|
|
460
|
+
const delta = balance - previous;
|
|
461
|
+
if (delta === 0n)
|
|
462
|
+
return null;
|
|
463
|
+
const direction = delta > 0n ? "up" : "down";
|
|
464
|
+
if (previous === 0n)
|
|
465
|
+
return { delta, changePercent: null, direction };
|
|
466
|
+
const percent = (Number(delta) * 100) / Number(previous);
|
|
467
|
+
if (Math.abs(percent) <= thresholdPercent)
|
|
468
|
+
return null;
|
|
469
|
+
return { delta, changePercent: Math.round(percent), direction };
|
|
470
|
+
}
|
|
471
|
+
export async function watchChain(client, config, options, onEvent, signal, deps = {}) {
|
|
472
|
+
const now = deps.now ?? (() => Date.now());
|
|
473
|
+
const sleep = deps.sleep ?? defaultSleep;
|
|
474
|
+
const summary = {
|
|
475
|
+
samples: 0,
|
|
476
|
+
errors: 0,
|
|
477
|
+
firstBlock: null,
|
|
478
|
+
lastBlock: null,
|
|
479
|
+
advancedBy: 0n,
|
|
480
|
+
chainDrift: false,
|
|
481
|
+
stalls: 0,
|
|
482
|
+
gasSurges: 0,
|
|
483
|
+
alerts: 0,
|
|
484
|
+
lastGasWei: null,
|
|
485
|
+
peakGasWei: null,
|
|
486
|
+
metrics: computeMetrics(createMetricsState(options.windowSize)),
|
|
487
|
+
session: computeSessionMetrics(createMetricsState(options.windowSize)),
|
|
488
|
+
durationMs: 0,
|
|
489
|
+
aborted: false,
|
|
490
|
+
portfolio: null,
|
|
491
|
+
};
|
|
492
|
+
const startedAt = now();
|
|
493
|
+
let tracker = createTracker();
|
|
494
|
+
let alertState = createAlertState();
|
|
495
|
+
let metricsState = createMetricsState(options.windowSize);
|
|
496
|
+
let consecutiveErrors = 0;
|
|
497
|
+
let previousAdvanceAt = null;
|
|
498
|
+
const portfolioState = new Map();
|
|
499
|
+
let portfolioSamples = 0;
|
|
500
|
+
let lastBalances = null;
|
|
501
|
+
let lastNative = null;
|
|
502
|
+
let firstNative = null;
|
|
503
|
+
let nativeMeta = null;
|
|
504
|
+
const metadataCache = new MetadataCache({ chainId: config.chainId, persist: false });
|
|
505
|
+
const readPortfolio = deps.readPortfolio ??
|
|
506
|
+
(() => readBalances(client, config, options.owner, options.tokens, {
|
|
507
|
+
cache: metadataCache,
|
|
508
|
+
labels: options.labels,
|
|
509
|
+
batchSize: options.batchSize ?? undefined,
|
|
510
|
+
maxSubCalls: options.maxSubCalls ?? undefined,
|
|
511
|
+
onChunkProgress: deps.onChunkProgress,
|
|
512
|
+
}));
|
|
513
|
+
const priceSlug = options.usd ? priceChainSlug(config.chainId) : null;
|
|
514
|
+
const priceSource = priceSlug === null ? null : new PriceSource({ chainSlug: priceSlug });
|
|
515
|
+
const readPrices = deps.readPrices ??
|
|
516
|
+
(priceSource === null
|
|
517
|
+
? undefined
|
|
518
|
+
: (tokens) => priceSource.prices(tokens));
|
|
519
|
+
while (!signal?.aborted) {
|
|
520
|
+
if (options.maxSamples > 0 && summary.samples >= options.maxSamples)
|
|
521
|
+
break;
|
|
522
|
+
const callStarted = now();
|
|
523
|
+
try {
|
|
524
|
+
const [blockNumber, gasPriceWei, chainId] = await Promise.all([
|
|
525
|
+
client.getBlockNumber(),
|
|
526
|
+
client.getGasPrice().catch(() => null),
|
|
527
|
+
client.getChainId(),
|
|
528
|
+
]);
|
|
529
|
+
const at = now();
|
|
530
|
+
const latencyMs = Math.max(0, at - callStarted);
|
|
531
|
+
const evaluated = evaluateSample(tracker, { at, blockNumber, chainId, gasPriceWei, latencyMs }, options, config.chainId);
|
|
532
|
+
tracker = evaluated.state;
|
|
533
|
+
metricsState = recordLatency(metricsState, latencyMs);
|
|
534
|
+
for (const event of evaluated.events) {
|
|
535
|
+
if (event.type === "stall")
|
|
536
|
+
summary.stalls += 1;
|
|
537
|
+
if (event.type === "chain-drift")
|
|
538
|
+
summary.chainDrift = true;
|
|
539
|
+
if (event.type === "gas-surge")
|
|
540
|
+
summary.gasSurges += 1;
|
|
541
|
+
if (event.type === "advance") {
|
|
542
|
+
if (event.delta > 0n && previousAdvanceAt !== null) {
|
|
543
|
+
metricsState = recordBlockTime(metricsState, Math.max(0, (event.at - previousAdvanceAt) / 1000));
|
|
544
|
+
}
|
|
545
|
+
previousAdvanceAt = event.at;
|
|
546
|
+
}
|
|
547
|
+
await onEvent(event);
|
|
548
|
+
}
|
|
549
|
+
const currentMetrics = computeMetrics(metricsState);
|
|
550
|
+
const cumulativeMetrics = computeSessionMetrics(metricsState);
|
|
551
|
+
const evaluatedAlerts = evaluateAlerts(currentMetrics, options, alertState);
|
|
552
|
+
alertState = evaluatedAlerts.state;
|
|
553
|
+
for (const alert of evaluatedAlerts.alerts) {
|
|
554
|
+
summary.alerts += 1;
|
|
555
|
+
await onEvent({
|
|
556
|
+
type: "alert",
|
|
557
|
+
at,
|
|
558
|
+
...alert,
|
|
559
|
+
metrics: currentMetrics,
|
|
560
|
+
cumulative: cumulativeMetrics,
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
const sampleIndex = summary.samples + 1;
|
|
564
|
+
if (options.owner !== null &&
|
|
565
|
+
options.tokens.length > 0 &&
|
|
566
|
+
sampleIndex % options.portfolioEvery === 0) {
|
|
567
|
+
try {
|
|
568
|
+
const report = await readPortfolio();
|
|
569
|
+
portfolioSamples += 1;
|
|
570
|
+
// A chunk that needed a sub-batch retry or an individual fallback is
|
|
571
|
+
// reported before the sample it belongs to, so a log reader sees the
|
|
572
|
+
// recovery ahead of the balances it recovered.
|
|
573
|
+
for (const chunk of report.chunks) {
|
|
574
|
+
if (chunk.status === "batched")
|
|
575
|
+
continue;
|
|
576
|
+
await onEvent({
|
|
577
|
+
type: "chunk-recovery",
|
|
578
|
+
at: now(),
|
|
579
|
+
owner: report.owner,
|
|
580
|
+
chunk: chunk.index,
|
|
581
|
+
chunks: chunk.total,
|
|
582
|
+
tokens: chunk.tokens,
|
|
583
|
+
status: chunk.status,
|
|
584
|
+
error: chunk.error,
|
|
585
|
+
individual: chunk.individual,
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
let priceBook = null;
|
|
589
|
+
let priceError = null;
|
|
590
|
+
if (options.usd) {
|
|
591
|
+
if (readPrices === undefined) {
|
|
592
|
+
priceError = `no price feed for chain ${config.chainId}`;
|
|
593
|
+
}
|
|
594
|
+
else {
|
|
595
|
+
try {
|
|
596
|
+
priceBook = await readPrices(report.tokens.map((token) => token.address));
|
|
597
|
+
priceError = priceBook.error;
|
|
598
|
+
}
|
|
599
|
+
catch (error) {
|
|
600
|
+
priceError = describeWatchError(error);
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
const balanceAlerts = [];
|
|
605
|
+
const tokens = report.tokens.map((token) => {
|
|
606
|
+
const key = token.address.toLowerCase();
|
|
607
|
+
const before = lastBalances?.get(key) ?? null;
|
|
608
|
+
const delta = before !== null && token.balance !== null ? token.balance - before : null;
|
|
609
|
+
const known = portfolioState.get(key);
|
|
610
|
+
portfolioState.set(key, {
|
|
611
|
+
address: token.address,
|
|
612
|
+
symbol: token.symbol,
|
|
613
|
+
label: token.label,
|
|
614
|
+
decimals: token.decimals,
|
|
615
|
+
first: known?.first ?? token.balance,
|
|
616
|
+
last: token.balance,
|
|
617
|
+
});
|
|
618
|
+
if (options.alertBalancePercent !== null &&
|
|
619
|
+
before !== null &&
|
|
620
|
+
token.balance !== null) {
|
|
621
|
+
const change = describeBalanceChange(before, token.balance, options.alertBalancePercent);
|
|
622
|
+
if (change !== null) {
|
|
623
|
+
balanceAlerts.push({
|
|
624
|
+
type: "balance-alert",
|
|
625
|
+
at: now(),
|
|
626
|
+
asset: "token",
|
|
627
|
+
address: token.address,
|
|
628
|
+
symbol: token.symbol,
|
|
629
|
+
label: token.label,
|
|
630
|
+
decimals: token.decimals,
|
|
631
|
+
previous: before,
|
|
632
|
+
balance: token.balance,
|
|
633
|
+
delta: change.delta,
|
|
634
|
+
changePercent: change.changePercent,
|
|
635
|
+
thresholdPercent: options.alertBalancePercent,
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
return {
|
|
640
|
+
address: token.address,
|
|
641
|
+
symbol: token.symbol,
|
|
642
|
+
label: token.label,
|
|
643
|
+
decimals: token.decimals,
|
|
644
|
+
balance: token.balance,
|
|
645
|
+
delta,
|
|
646
|
+
usd: usdValue(token.balance, token.decimals, priceBook?.tokens.get(key)?.price ?? null),
|
|
647
|
+
};
|
|
648
|
+
});
|
|
649
|
+
lastBalances = new Map(report.tokens.map((token) => [token.address.toLowerCase(), token.balance]));
|
|
650
|
+
const previousNative = lastNative;
|
|
651
|
+
const nativeDelta = previousNative === null ? null : report.native.balance - previousNative;
|
|
652
|
+
lastNative = report.native.balance;
|
|
653
|
+
if (options.alertBalancePercent !== null && previousNative !== null) {
|
|
654
|
+
const change = describeBalanceChange(previousNative, report.native.balance, options.alertBalancePercent);
|
|
655
|
+
if (change !== null) {
|
|
656
|
+
balanceAlerts.push({
|
|
657
|
+
type: "balance-alert",
|
|
658
|
+
at: now(),
|
|
659
|
+
asset: "native",
|
|
660
|
+
address: null,
|
|
661
|
+
symbol: report.native.symbol,
|
|
662
|
+
label: null,
|
|
663
|
+
decimals: report.native.decimals,
|
|
664
|
+
previous: previousNative,
|
|
665
|
+
balance: report.native.balance,
|
|
666
|
+
delta: change.delta,
|
|
667
|
+
changePercent: change.changePercent,
|
|
668
|
+
thresholdPercent: options.alertBalancePercent,
|
|
669
|
+
});
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
if (firstNative === null)
|
|
673
|
+
firstNative = report.native.balance;
|
|
674
|
+
nativeMeta = { symbol: report.native.symbol, decimals: report.native.decimals };
|
|
675
|
+
const nativeUsd = usdValue(report.native.balance, report.native.decimals, priceBook?.native?.price ?? null);
|
|
676
|
+
const priced = [...tokens.map((token) => token.usd), nativeUsd].filter((value) => value !== null);
|
|
677
|
+
const usdTotal = priced.length === 0 ? null : priced.reduce((sum, value) => sum + value, 0);
|
|
678
|
+
await onEvent({
|
|
679
|
+
type: "portfolio",
|
|
680
|
+
at: now(),
|
|
681
|
+
blockNumber: report.blockNumber,
|
|
682
|
+
batchSize: report.batchSize,
|
|
683
|
+
chunkCount: report.chunkCount,
|
|
684
|
+
native: {
|
|
685
|
+
symbol: report.native.symbol,
|
|
686
|
+
decimals: report.native.decimals,
|
|
687
|
+
balance: report.native.balance,
|
|
688
|
+
delta: nativeDelta,
|
|
689
|
+
usd: nativeUsd,
|
|
690
|
+
},
|
|
691
|
+
tokens,
|
|
692
|
+
usdTotal,
|
|
693
|
+
priceError,
|
|
694
|
+
error: null,
|
|
695
|
+
});
|
|
696
|
+
for (const alert of balanceAlerts) {
|
|
697
|
+
summary.alerts += 1;
|
|
698
|
+
await onEvent(alert);
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
catch (error) {
|
|
702
|
+
await onEvent({
|
|
703
|
+
type: "portfolio",
|
|
704
|
+
at: now(),
|
|
705
|
+
blockNumber: null,
|
|
706
|
+
batchSize: null,
|
|
707
|
+
chunkCount: null,
|
|
708
|
+
native: null,
|
|
709
|
+
tokens: [],
|
|
710
|
+
usdTotal: null,
|
|
711
|
+
priceError: null,
|
|
712
|
+
error: describeWatchError(error),
|
|
713
|
+
});
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
summary.samples += 1;
|
|
717
|
+
consecutiveErrors = 0;
|
|
718
|
+
if (summary.firstBlock === null)
|
|
719
|
+
summary.firstBlock = blockNumber;
|
|
720
|
+
summary.lastBlock = blockNumber;
|
|
721
|
+
if (gasPriceWei !== null) {
|
|
722
|
+
summary.lastGasWei = gasPriceWei;
|
|
723
|
+
if (summary.peakGasWei === null || gasPriceWei > summary.peakGasWei) {
|
|
724
|
+
summary.peakGasWei = gasPriceWei;
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
if (options.reportEvery > 0 && summary.samples % options.reportEvery === 0) {
|
|
728
|
+
summary.metrics = currentMetrics;
|
|
729
|
+
await onEvent({
|
|
730
|
+
type: "metrics",
|
|
731
|
+
at,
|
|
732
|
+
metrics: currentMetrics,
|
|
733
|
+
cumulative: cumulativeMetrics,
|
|
734
|
+
});
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
catch (error) {
|
|
738
|
+
consecutiveErrors += 1;
|
|
739
|
+
summary.errors += 1;
|
|
740
|
+
metricsState = recordFailure(metricsState);
|
|
741
|
+
await onEvent({ type: "error", at: now(), message: describeWatchError(error) });
|
|
742
|
+
if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS)
|
|
743
|
+
break;
|
|
744
|
+
}
|
|
745
|
+
if (options.maxSamples > 0 && summary.samples >= options.maxSamples)
|
|
746
|
+
break;
|
|
747
|
+
if (signal?.aborted)
|
|
748
|
+
break;
|
|
749
|
+
await sleep(options.intervalMs);
|
|
750
|
+
}
|
|
751
|
+
summary.aborted = signal?.aborted ?? false;
|
|
752
|
+
summary.durationMs = Math.max(0, now() - startedAt);
|
|
753
|
+
if (summary.firstBlock !== null && summary.lastBlock !== null) {
|
|
754
|
+
summary.advancedBy = summary.lastBlock - summary.firstBlock;
|
|
755
|
+
}
|
|
756
|
+
summary.metrics = computeMetrics(metricsState);
|
|
757
|
+
summary.session = computeSessionMetrics(metricsState);
|
|
758
|
+
if (options.owner !== null && options.tokens.length > 0) {
|
|
759
|
+
summary.portfolio = {
|
|
760
|
+
samples: portfolioSamples,
|
|
761
|
+
native: nativeMeta && firstNative !== null && lastNative !== null
|
|
762
|
+
? {
|
|
763
|
+
symbol: nativeMeta.symbol,
|
|
764
|
+
decimals: nativeMeta.decimals,
|
|
765
|
+
first: firstNative,
|
|
766
|
+
last: lastNative,
|
|
767
|
+
delta: lastNative - firstNative,
|
|
768
|
+
}
|
|
769
|
+
: null,
|
|
770
|
+
tokens: [...portfolioState.values()].map((entry) => ({
|
|
771
|
+
address: entry.address,
|
|
772
|
+
symbol: entry.symbol,
|
|
773
|
+
label: entry.label,
|
|
774
|
+
decimals: entry.decimals,
|
|
775
|
+
first: entry.first,
|
|
776
|
+
last: entry.last,
|
|
777
|
+
delta: entry.first !== null && entry.last !== null ? entry.last - entry.first : null,
|
|
778
|
+
})),
|
|
779
|
+
};
|
|
780
|
+
}
|
|
781
|
+
return summary;
|
|
782
|
+
}
|
|
783
|
+
function clockTime(at) {
|
|
784
|
+
return new Date(at).toTimeString().slice(0, 8);
|
|
785
|
+
}
|
|
786
|
+
function formatDuration(ms) {
|
|
787
|
+
const seconds = Math.round(ms / 1000);
|
|
788
|
+
if (seconds < 60)
|
|
789
|
+
return `${seconds}s`;
|
|
790
|
+
const minutes = Math.floor(seconds / 60);
|
|
791
|
+
const rest = seconds % 60;
|
|
792
|
+
return `${minutes}m${rest.toString().padStart(2, "0")}s`;
|
|
793
|
+
}
|
|
794
|
+
function formatGas(wei) {
|
|
795
|
+
return wei === null ? "gas n/a" : `${formatGwei(wei)} gwei`;
|
|
796
|
+
}
|
|
797
|
+
function formatUsd(value) {
|
|
798
|
+
return `$${value.toLocaleString("en-US", {
|
|
799
|
+
minimumFractionDigits: 2,
|
|
800
|
+
maximumFractionDigits: 2,
|
|
801
|
+
})}`;
|
|
802
|
+
}
|
|
803
|
+
function formatDelta(delta, decimals) {
|
|
804
|
+
if (delta === null)
|
|
805
|
+
return "";
|
|
806
|
+
if (delta === 0n)
|
|
807
|
+
return " (no change)";
|
|
808
|
+
const text = decimals === null ? delta.toString() : formatTokenAmount(delta, decimals);
|
|
809
|
+
return ` (${delta > 0n ? "+" : ""}${text})`;
|
|
810
|
+
}
|
|
811
|
+
export function formatWatchEvent(event, config) {
|
|
812
|
+
const stamp = clockTime(event.at);
|
|
813
|
+
switch (event.type) {
|
|
814
|
+
case "advance":
|
|
815
|
+
return `${stamp} block ${event.blockNumber} (+${event.delta}) ${formatGas(event.gasPriceWei)} ${event.latencyMs} ms`;
|
|
816
|
+
case "hold":
|
|
817
|
+
return `${stamp} block ${event.blockNumber} (unchanged ${event.staleSeconds}s) ${formatGas(event.gasPriceWei)} ${event.latencyMs} ms`;
|
|
818
|
+
case "stall":
|
|
819
|
+
return `${stamp} STALL: block ${event.blockNumber} has not advanced for ${event.staleSeconds}s`;
|
|
820
|
+
case "recovered":
|
|
821
|
+
return `${stamp} RECOVERED: ${event.from} → ${event.to} after ${event.stalledForSeconds}s`;
|
|
822
|
+
case "chain-drift":
|
|
823
|
+
return `${stamp} CHAIN DRIFT: expected ${event.expected} (${config.name}), RPC answered ${event.actual}`;
|
|
824
|
+
case "gas-surge":
|
|
825
|
+
return `${stamp} GAS SURGE: ${formatGwei(event.gasPriceWei)} gwei, +${event.increasePercent}% over ${formatGwei(event.baselineWei)} gwei baseline`;
|
|
826
|
+
case "gas-normal":
|
|
827
|
+
return `${stamp} GAS NORMAL: ${formatGwei(event.gasPriceWei)} gwei (baseline ${formatGwei(event.baselineWei)} gwei)`;
|
|
828
|
+
case "alert": {
|
|
829
|
+
const label = event.metric === "latency" ? "latency p95" : "block time";
|
|
830
|
+
const unit = event.metric === "latency" ? "ms" : "s";
|
|
831
|
+
return event.direction === "breach"
|
|
832
|
+
? `${stamp} ALERT: ${label} ${event.value} ${unit} is above ${event.threshold} ${unit}`
|
|
833
|
+
: `${stamp} ALERT CLEARED: ${label} ${event.value} ${unit} back under ${event.threshold} ${unit}`;
|
|
834
|
+
}
|
|
835
|
+
case "balance-alert": {
|
|
836
|
+
const name = event.label ? `${event.symbol} [${event.label}]` : event.symbol;
|
|
837
|
+
const amount = (value) => event.decimals === null ? value.toString() : formatTokenAmount(value, event.decimals);
|
|
838
|
+
const move = event.changePercent === null
|
|
839
|
+
? `${amount(event.previous)} → ${amount(event.balance)} (from zero)`
|
|
840
|
+
: `${event.changePercent > 0 ? "+" : ""}${event.changePercent}% (${amount(event.previous)} → ${amount(event.balance)})`;
|
|
841
|
+
return `${stamp} BALANCE ALERT: ${name} ${move} exceeds ${event.thresholdPercent}%`;
|
|
842
|
+
}
|
|
843
|
+
case "chunk-recovery": {
|
|
844
|
+
const status = event.status === "recovered"
|
|
845
|
+
? "recovered"
|
|
846
|
+
: event.status === "partial"
|
|
847
|
+
? "partly recovered"
|
|
848
|
+
: "failed";
|
|
849
|
+
const individual = event.individual.length > 0
|
|
850
|
+
? `, ${event.individual.length} read individually`
|
|
851
|
+
: "";
|
|
852
|
+
const reason = event.error === null ? "" : ` ${event.error}`;
|
|
853
|
+
return `${stamp} PORTFOLIO CHUNK ${event.chunk}/${event.chunks} (${event.tokens} token${event.tokens === 1 ? "" : "s"}) ${status}${individual}${reason}`;
|
|
854
|
+
}
|
|
855
|
+
case "portfolio": {
|
|
856
|
+
const head = `${stamp} PORTFOLIO block ${event.blockNumber ?? "n/a"}${event.usdTotal === null ? "" : ` total ${formatUsd(event.usdTotal)}`}`;
|
|
857
|
+
if (event.error)
|
|
858
|
+
return `${head}\n error: ${event.error}`;
|
|
859
|
+
const rows = [];
|
|
860
|
+
if (event.native) {
|
|
861
|
+
rows.push(` native ${formatTokenAmount(event.native.balance, event.native.decimals)} ${event.native.symbol}${formatDelta(event.native.delta, event.native.decimals)}${event.native.usd === null ? "" : ` ≈ ${formatUsd(event.native.usd)}`}`);
|
|
862
|
+
}
|
|
863
|
+
for (const token of event.tokens) {
|
|
864
|
+
const name = token.label ? `${token.symbol} [${token.label}]` : token.symbol;
|
|
865
|
+
const value = token.balance === null
|
|
866
|
+
? "unreadable"
|
|
867
|
+
: token.decimals === null
|
|
868
|
+
? `${token.balance} raw`
|
|
869
|
+
: formatTokenAmount(token.balance, token.decimals);
|
|
870
|
+
rows.push(` ${name} ${value}${formatDelta(token.delta, token.decimals)}${token.usd === null ? "" : ` ≈ ${formatUsd(token.usd)}`}`);
|
|
871
|
+
}
|
|
872
|
+
if (event.priceError)
|
|
873
|
+
rows.push(` prices ${event.priceError}`);
|
|
874
|
+
return [head, ...rows].join("\n");
|
|
875
|
+
}
|
|
876
|
+
case "metrics":
|
|
877
|
+
return `${stamp} METRICS: ${formatMetrics(event.metrics, event.cumulative)}`;
|
|
878
|
+
case "error":
|
|
879
|
+
return `${stamp} error: ${event.message}`;
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
export function formatWatchSummary(summary) {
|
|
883
|
+
const lines = [
|
|
884
|
+
"lwc — watch summary",
|
|
885
|
+
"",
|
|
886
|
+
` samples ${summary.samples}`,
|
|
887
|
+
` duration ${formatDuration(summary.durationMs)}`,
|
|
888
|
+
];
|
|
889
|
+
if (summary.firstBlock !== null && summary.lastBlock !== null) {
|
|
890
|
+
lines.push(` block ${summary.firstBlock} → ${summary.lastBlock} (+${summary.advancedBy})`);
|
|
891
|
+
}
|
|
892
|
+
if (summary.lastGasWei !== null) {
|
|
893
|
+
const peak = summary.peakGasWei === null ? "n/a" : `${formatGwei(summary.peakGasWei)} gwei`;
|
|
894
|
+
lines.push(` gas ${formatGwei(summary.lastGasWei)} gwei (peak ${peak})`);
|
|
895
|
+
}
|
|
896
|
+
const { latency, blockTime } = summary.metrics;
|
|
897
|
+
const session = summary.session;
|
|
898
|
+
if (latency.count > 0) {
|
|
899
|
+
lines.push(` ${"latency".padEnd(14)}p50 ${latency.p50} ms, p95 ${latency.p95} ms, max ${latency.max} ms (${latency.count} samples)`);
|
|
900
|
+
if (session.latency.count > 0) {
|
|
901
|
+
lines.push(` ${"".padEnd(14)}session p50 ${session.latency.p50} ms, p95 ${session.latency.p95} ms, max ${session.latency.max} ms (${session.latency.count} samples)`);
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
if (blockTime.count > 0) {
|
|
905
|
+
lines.push(` ${"block time".padEnd(14)}median ${formatSeconds(blockTime.medianSeconds)}, min ${formatSeconds(blockTime.minSeconds)}, max ${formatSeconds(blockTime.maxSeconds)} (${blockTime.count} intervals)`);
|
|
906
|
+
if (session.blockTime.count > 0) {
|
|
907
|
+
lines.push(` ${"".padEnd(14)}session median ${formatSeconds(session.blockTime.medianSeconds)}, min ${formatSeconds(session.blockTime.minSeconds)}, max ${formatSeconds(session.blockTime.maxSeconds)} (${session.blockTime.count} intervals)`);
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
if (summary.metrics.windowPolls > 0) {
|
|
911
|
+
lines.push(` ${"failures".padEnd(14)}${summary.metrics.windowFailures} of ${summary.metrics.windowPolls} polls (${Math.round(summary.metrics.errorRate * 100)}%)`);
|
|
912
|
+
if (session.polls > 0) {
|
|
913
|
+
lines.push(` ${"".padEnd(14)}session ${session.failures} of ${session.polls} polls (${Math.round(session.errorRate * 1000) / 10}%)`);
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
if (summary.portfolio) {
|
|
917
|
+
lines.push(` ${"portfolio".padEnd(14)}${summary.portfolio.samples} samples`);
|
|
918
|
+
if (summary.portfolio.native) {
|
|
919
|
+
const native = summary.portfolio.native;
|
|
920
|
+
lines.push(` ${"".padEnd(14)}${native.symbol} ${formatTokenAmount(native.last, native.decimals)}${formatDelta(native.delta, native.decimals)}`);
|
|
921
|
+
}
|
|
922
|
+
for (const token of summary.portfolio.tokens) {
|
|
923
|
+
const value = token.last === null
|
|
924
|
+
? "unreadable"
|
|
925
|
+
: token.decimals === null
|
|
926
|
+
? `${token.last} raw`
|
|
927
|
+
: formatTokenAmount(token.last, token.decimals);
|
|
928
|
+
lines.push(` ${"".padEnd(14)}${token.symbol} ${value}${formatDelta(token.delta, token.decimals)}`);
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
lines.push(` gas surges ${summary.gasSurges}`);
|
|
932
|
+
lines.push(` ${"alerts".padEnd(14)}${summary.alerts}`);
|
|
933
|
+
lines.push(` stalls ${summary.stalls}`);
|
|
934
|
+
lines.push(` chain drift ${summary.chainDrift ? "detected" : "none"}`);
|
|
935
|
+
lines.push(` errors ${summary.errors}`);
|
|
936
|
+
return lines.join("\n");
|
|
937
|
+
}
|
|
938
|
+
export function toWatchEventJson(event) {
|
|
939
|
+
const base = { at: new Date(event.at).toISOString() };
|
|
940
|
+
switch (event.type) {
|
|
941
|
+
case "advance":
|
|
942
|
+
return {
|
|
943
|
+
...base,
|
|
944
|
+
event: "advance",
|
|
945
|
+
blockNumber: event.blockNumber.toString(),
|
|
946
|
+
delta: event.delta.toString(),
|
|
947
|
+
gasPriceWei: event.gasPriceWei === null ? null : event.gasPriceWei.toString(),
|
|
948
|
+
latencyMs: event.latencyMs,
|
|
949
|
+
};
|
|
950
|
+
case "hold":
|
|
951
|
+
return {
|
|
952
|
+
...base,
|
|
953
|
+
event: "hold",
|
|
954
|
+
blockNumber: event.blockNumber.toString(),
|
|
955
|
+
staleSeconds: event.staleSeconds,
|
|
956
|
+
gasPriceWei: event.gasPriceWei === null ? null : event.gasPriceWei.toString(),
|
|
957
|
+
latencyMs: event.latencyMs,
|
|
958
|
+
};
|
|
959
|
+
case "stall":
|
|
960
|
+
return {
|
|
961
|
+
...base,
|
|
962
|
+
event: "stall",
|
|
963
|
+
blockNumber: event.blockNumber.toString(),
|
|
964
|
+
staleSeconds: event.staleSeconds,
|
|
965
|
+
};
|
|
966
|
+
case "recovered":
|
|
967
|
+
return {
|
|
968
|
+
...base,
|
|
969
|
+
event: "recovered",
|
|
970
|
+
from: event.from.toString(),
|
|
971
|
+
to: event.to.toString(),
|
|
972
|
+
stalledForSeconds: event.stalledForSeconds,
|
|
973
|
+
};
|
|
974
|
+
case "chain-drift":
|
|
975
|
+
return {
|
|
976
|
+
...base,
|
|
977
|
+
event: "chain-drift",
|
|
978
|
+
expected: event.expected,
|
|
979
|
+
previous: event.previous,
|
|
980
|
+
actual: event.actual,
|
|
981
|
+
};
|
|
982
|
+
case "gas-surge":
|
|
983
|
+
return {
|
|
984
|
+
...base,
|
|
985
|
+
event: "gas-surge",
|
|
986
|
+
gasPriceWei: event.gasPriceWei.toString(),
|
|
987
|
+
baselineWei: event.baselineWei.toString(),
|
|
988
|
+
increasePercent: event.increasePercent,
|
|
989
|
+
};
|
|
990
|
+
case "gas-normal":
|
|
991
|
+
return {
|
|
992
|
+
...base,
|
|
993
|
+
event: "gas-normal",
|
|
994
|
+
gasPriceWei: event.gasPriceWei.toString(),
|
|
995
|
+
baselineWei: event.baselineWei.toString(),
|
|
996
|
+
};
|
|
997
|
+
case "alert":
|
|
998
|
+
return {
|
|
999
|
+
...base,
|
|
1000
|
+
event: "alert",
|
|
1001
|
+
metric: event.metric,
|
|
1002
|
+
direction: event.direction,
|
|
1003
|
+
value: event.value,
|
|
1004
|
+
threshold: event.threshold,
|
|
1005
|
+
metrics: event.metrics,
|
|
1006
|
+
cumulative: event.cumulative,
|
|
1007
|
+
};
|
|
1008
|
+
case "balance-alert":
|
|
1009
|
+
return {
|
|
1010
|
+
...base,
|
|
1011
|
+
event: "balance-alert",
|
|
1012
|
+
asset: event.asset,
|
|
1013
|
+
address: event.address,
|
|
1014
|
+
symbol: event.symbol,
|
|
1015
|
+
label: event.label,
|
|
1016
|
+
decimals: event.decimals,
|
|
1017
|
+
previous: event.previous.toString(),
|
|
1018
|
+
balance: event.balance.toString(),
|
|
1019
|
+
delta: event.delta.toString(),
|
|
1020
|
+
changePercent: event.changePercent,
|
|
1021
|
+
thresholdPercent: event.thresholdPercent,
|
|
1022
|
+
};
|
|
1023
|
+
case "chunk-recovery":
|
|
1024
|
+
return {
|
|
1025
|
+
...base,
|
|
1026
|
+
event: "chunk-recovery",
|
|
1027
|
+
owner: event.owner,
|
|
1028
|
+
chunk: event.chunk,
|
|
1029
|
+
chunks: event.chunks,
|
|
1030
|
+
tokens: event.tokens,
|
|
1031
|
+
status: event.status,
|
|
1032
|
+
error: event.error,
|
|
1033
|
+
individual: event.individual,
|
|
1034
|
+
};
|
|
1035
|
+
case "portfolio":
|
|
1036
|
+
return {
|
|
1037
|
+
...base,
|
|
1038
|
+
event: "portfolio",
|
|
1039
|
+
blockNumber: event.blockNumber === null ? null : event.blockNumber.toString(),
|
|
1040
|
+
batchSize: event.batchSize,
|
|
1041
|
+
chunkCount: event.chunkCount,
|
|
1042
|
+
native: event.native === null
|
|
1043
|
+
? null
|
|
1044
|
+
: {
|
|
1045
|
+
symbol: event.native.symbol,
|
|
1046
|
+
decimals: event.native.decimals,
|
|
1047
|
+
balance: event.native.balance.toString(),
|
|
1048
|
+
delta: event.native.delta === null ? null : event.native.delta.toString(),
|
|
1049
|
+
usd: event.native.usd,
|
|
1050
|
+
},
|
|
1051
|
+
tokens: event.tokens.map((token) => ({
|
|
1052
|
+
address: token.address,
|
|
1053
|
+
symbol: token.symbol,
|
|
1054
|
+
label: token.label,
|
|
1055
|
+
decimals: token.decimals,
|
|
1056
|
+
balance: token.balance === null ? null : token.balance.toString(),
|
|
1057
|
+
delta: token.delta === null ? null : token.delta.toString(),
|
|
1058
|
+
usd: token.usd,
|
|
1059
|
+
})),
|
|
1060
|
+
usdTotal: event.usdTotal,
|
|
1061
|
+
priceError: event.priceError,
|
|
1062
|
+
error: event.error,
|
|
1063
|
+
};
|
|
1064
|
+
case "metrics":
|
|
1065
|
+
return {
|
|
1066
|
+
...base,
|
|
1067
|
+
event: "metrics",
|
|
1068
|
+
metrics: event.metrics,
|
|
1069
|
+
cumulative: event.cumulative,
|
|
1070
|
+
};
|
|
1071
|
+
case "error":
|
|
1072
|
+
return { ...base, event: "error", message: event.message };
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
export function toWatchSummaryJson(summary) {
|
|
1076
|
+
return {
|
|
1077
|
+
ok: summary.errors === 0 &&
|
|
1078
|
+
!summary.chainDrift &&
|
|
1079
|
+
summary.stalls === 0 &&
|
|
1080
|
+
summary.gasSurges === 0,
|
|
1081
|
+
event: "summary",
|
|
1082
|
+
samples: summary.samples,
|
|
1083
|
+
errors: summary.errors,
|
|
1084
|
+
firstBlock: summary.firstBlock === null ? null : summary.firstBlock.toString(),
|
|
1085
|
+
lastBlock: summary.lastBlock === null ? null : summary.lastBlock.toString(),
|
|
1086
|
+
advancedBy: summary.advancedBy.toString(),
|
|
1087
|
+
chainDrift: summary.chainDrift,
|
|
1088
|
+
stalls: summary.stalls,
|
|
1089
|
+
gasSurges: summary.gasSurges,
|
|
1090
|
+
alerts: summary.alerts,
|
|
1091
|
+
lastGasWei: summary.lastGasWei === null ? null : summary.lastGasWei.toString(),
|
|
1092
|
+
peakGasWei: summary.peakGasWei === null ? null : summary.peakGasWei.toString(),
|
|
1093
|
+
metrics: summary.metrics,
|
|
1094
|
+
session: summary.session,
|
|
1095
|
+
durationMs: summary.durationMs,
|
|
1096
|
+
aborted: summary.aborted,
|
|
1097
|
+
portfolio: summary.portfolio === null
|
|
1098
|
+
? null
|
|
1099
|
+
: {
|
|
1100
|
+
samples: summary.portfolio.samples,
|
|
1101
|
+
native: summary.portfolio.native === null
|
|
1102
|
+
? null
|
|
1103
|
+
: {
|
|
1104
|
+
symbol: summary.portfolio.native.symbol,
|
|
1105
|
+
decimals: summary.portfolio.native.decimals,
|
|
1106
|
+
first: summary.portfolio.native.first.toString(),
|
|
1107
|
+
last: summary.portfolio.native.last.toString(),
|
|
1108
|
+
delta: summary.portfolio.native.delta.toString(),
|
|
1109
|
+
},
|
|
1110
|
+
tokens: summary.portfolio.tokens.map((token) => ({
|
|
1111
|
+
address: token.address,
|
|
1112
|
+
symbol: token.symbol,
|
|
1113
|
+
label: token.label,
|
|
1114
|
+
decimals: token.decimals,
|
|
1115
|
+
first: token.first === null ? null : token.first.toString(),
|
|
1116
|
+
last: token.last === null ? null : token.last.toString(),
|
|
1117
|
+
delta: token.delta === null ? null : token.delta.toString(),
|
|
1118
|
+
})),
|
|
1119
|
+
},
|
|
1120
|
+
};
|
|
1121
|
+
}
|