@heightproof/shared 0.0.0-stage → 0.1.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.
@@ -0,0 +1,287 @@
1
+ import { chainBlockTimeMs } from "./rpc.js";
2
+ export const DEFAULT_DETECTOR_THRESHOLDS = {
3
+ staleHeadBlockTimeMultiplier: 3,
4
+ maxHeadLagBlocks: 3,
5
+ minHeadObservations: 3,
6
+ pageLimitMinHits: 3,
7
+ reorgAlertMinDepth: 2,
8
+ reorgConvergenceLagMs: 6_000,
9
+ throttleRateThreshold: 0.05,
10
+ minThrottleSample: 20,
11
+ softThrottleLatencyMultiplier: 3,
12
+ softThrottleDegradationMultiplier: 2
13
+ };
14
+ /** Block tags whose results are expected to lag the chain head, and must not trip D1. */
15
+ const LAGGING_BLOCK_TAGS = new Set(["finalized", "safe", "earliest"]);
16
+ const RECEIPT_METHODS = new Set(["eth_getTransactionReceipt", "eth_getTransactionByHash"]);
17
+ const CALL_METHODS = new Set(["eth_call", "eth_getCode"]);
18
+ const LOG_METHODS = new Set(["eth_getLogs", "eth_getFilterLogs"]);
19
+ /**
20
+ * D1 — stale head.
21
+ *
22
+ * Two independent conditions: a head that has stopped advancing while the endpoint is
23
+ * still answering, and a head that trails the fastest endpoint on the same chain.
24
+ */
25
+ export function detectStaleHead(event, context = {}, thresholds = DEFAULT_DETECTOR_THRESHOLDS) {
26
+ if (event.kind !== "evm_rpc" || !event.evmRpc)
27
+ return [];
28
+ // Verification calls (D2) are pinned to a height chosen by the verifier, usually behind the
29
+ // head by design. They say nothing about how current the endpoint is.
30
+ if (event.evmRpc.shadowOfTraceId)
31
+ return [];
32
+ // D1 judges providers that are answering with data. A failed call reports no head at all, so
33
+ // the endpoint's last good head would read as lag; D3 and D5 own errors and throttling.
34
+ if (event.evmRpc.resultShape === "error_in_body" || event.response.statusCode >= 400)
35
+ return [];
36
+ // A call pinned to a past block, or explicitly asking for finalized state, is supposed
37
+ // to return old data. Only head-tracking calls say anything about staleness.
38
+ const tag = event.evmRpc.blockTag;
39
+ if (tag && (LAGGING_BLOCK_TAGS.has(tag) || tag.startsWith("0x")))
40
+ return [];
41
+ // Sparse traffic produces sparse samples; judging an endpoint on one or two
42
+ // observations produces noise rather than findings.
43
+ if ((context.endpointHeadObservations ?? 0) < thresholds.minHeadObservations)
44
+ return [];
45
+ const signals = [];
46
+ const blockTime = chainBlockTimeMs(event.evmRpc.chainId) ?? context.measuredBlockTimeMs;
47
+ if (blockTime && context.endpointHeadStalledMs !== undefined) {
48
+ const limit = blockTime * thresholds.staleHeadBlockTimeMultiplier;
49
+ if (context.endpointHeadStalledMs > limit) {
50
+ signals.push({
51
+ name: "stale_head_stalled",
52
+ weight: 30,
53
+ reason: `Provider is still answering but its chain head has not advanced for ${Math.round(context.endpointHeadStalledMs / 1000)}s, over ${thresholds.staleHeadBlockTimeMultiplier}x this chain's block time`
54
+ });
55
+ }
56
+ }
57
+ const head = context.endpointHeadBlockNumber;
58
+ const chainHead = context.chainHeadBlockNumber;
59
+ if (head !== undefined && chainHead !== undefined) {
60
+ const lag = chainHead - head;
61
+ if (lag > thresholds.maxHeadLagBlocks) {
62
+ signals.push({
63
+ name: "stale_head_lagging",
64
+ weight: 26,
65
+ reason: `Provider's chain head is ${lag} blocks behind the furthest-ahead endpoint on this chain`
66
+ });
67
+ }
68
+ }
69
+ return signals;
70
+ }
71
+ /**
72
+ * D3 — silent failure.
73
+ *
74
+ * Responses that arrive as successes but carry no usable answer. Every rule except the
75
+ * explicit in-body error is comparative, because emptiness on its own is usually correct.
76
+ */
77
+ export function detectSilentFailure(event, context = {}, thresholds = DEFAULT_DETECTOR_THRESHOLDS) {
78
+ if (event.kind !== "evm_rpc" || !event.evmRpc)
79
+ return [];
80
+ const { method, resultShape, resultCount: count } = event.evmRpc;
81
+ const signals = [];
82
+ // Not comparative: a JSON-RPC error object is the provider stating it failed, while the
83
+ // transport reported success. Nothing that reads status codes will ever see this.
84
+ if (resultShape === "error_in_body") {
85
+ const code = event.evmRpc.rpcErrorCode;
86
+ signals.push({
87
+ name: "rpc_error_in_body",
88
+ weight: 28,
89
+ reason: `Provider returned HTTP ${event.response.statusCode} carrying a JSON-RPC error${code === undefined ? "" : ` (code ${code})`}`
90
+ });
91
+ }
92
+ if (RECEIPT_METHODS.has(method) && resultShape === "null" && context.priorNonNullForSameTarget) {
93
+ signals.push({
94
+ name: "receipt_disappeared",
95
+ weight: 32,
96
+ reason: `${method} now returns null for a transaction this same provider previously reported as mined`
97
+ });
98
+ }
99
+ if (CALL_METHODS.has(method) && resultShape === "empty_data" && context.priorDataForSameCall) {
100
+ signals.push({
101
+ name: "empty_data_regression",
102
+ weight: 26,
103
+ reason: `${method} returned empty data for a call that previously returned data at an earlier block`
104
+ });
105
+ }
106
+ if (LOG_METHODS.has(method) &&
107
+ count !== undefined &&
108
+ count > 0 &&
109
+ context.endpointMaxResultCount === count &&
110
+ (context.endpointMaxResultCountHits ?? 0) >= thresholds.pageLimitMinHits) {
111
+ signals.push({
112
+ name: "suspected_log_truncation",
113
+ weight: 20,
114
+ reason: `${method} returned exactly ${count} results, a ceiling this provider has hit ${context.endpointMaxResultCountHits} times — likely a silently truncated page`
115
+ });
116
+ }
117
+ return signals;
118
+ }
119
+ /**
120
+ * D6 — cost and waste.
121
+ *
122
+ * Waste is a billing problem, not a threat, so these signals carry zero weight: they are
123
+ * recorded against the event and aggregated for reporting without inflating a threat score
124
+ * or manufacturing an incident.
125
+ */
126
+ export function detectCostWaste(event, context = {}) {
127
+ if (event.kind !== "evm_rpc" || !event.evmRpc)
128
+ return [];
129
+ // Only provable at a pinned height. Two identical calls either side of a block boundary
130
+ // are legitimate polling, not waste.
131
+ if (!event.evmRpc.blockNumber)
132
+ return [];
133
+ const duplicates = context.duplicateCallCount ?? 0;
134
+ if (duplicates < 1)
135
+ return [];
136
+ const spent = context.duplicateCostUnits;
137
+ return [
138
+ {
139
+ name: "duplicate_rpc_call",
140
+ weight: 0,
141
+ reason: `Identical ${event.evmRpc.method} at block ${event.evmRpc.blockNumber} has already been made ${duplicates} time${duplicates === 1 ? "" : "s"}${spent ? `, costing ${spent} compute units` : ""}`
142
+ }
143
+ ];
144
+ }
145
+ /** Methods whose disagreement almost always means truncation rather than a wrong answer. */
146
+ const DISAGREEMENT_LOG_METHODS = new Set(["eth_getLogs", "eth_getFilterLogs"]);
147
+ /**
148
+ * D2 — cross-provider disagreement.
149
+ *
150
+ * At equal block height a mismatch is either a provider bug or an unpropagated reorg.
151
+ * Both are worth raising, and neither is visible to anything that only watches status
152
+ * codes and latency.
153
+ *
154
+ * The caller is responsible for only ever supplying peer results from block-pinned calls.
155
+ * A peer that cannot serve the pinned height is not a disagreement — that is a stale head,
156
+ * and D1 is what reports it.
157
+ */
158
+ export function detectDisagreement(event, context = {}) {
159
+ if (event.kind !== "evm_rpc" || !event.evmRpc)
160
+ return [];
161
+ const { method, resultHash, blockNumber } = event.evmRpc;
162
+ if (!resultHash || !blockNumber)
163
+ return [];
164
+ const peers = context.peerResults ?? [];
165
+ const disagreeing = peers.filter((peer) => peer.resultHash !== resultHash);
166
+ if (disagreeing.length === 0)
167
+ return [];
168
+ const labels = disagreeing.map((peer) => peer.provider ?? peer.endpointHash ?? "an unnamed endpoint");
169
+ const evidence = attestedEvidence(event, disagreeing);
170
+ if (DISAGREEMENT_LOG_METHODS.has(method)) {
171
+ return [
172
+ {
173
+ name: "log_range_disagreement",
174
+ weight: 20,
175
+ reason: `${method} at block ${blockNumber} returned a different set of logs than ${labels.join(", ")} — most often a silently truncated range rather than a wrong answer${evidence}`
176
+ }
177
+ ];
178
+ }
179
+ return [
180
+ {
181
+ name: "provider_disagreement",
182
+ weight: 40,
183
+ reason: `${method} at block ${blockNumber} returned a different result than ${labels.join(", ")}; at equal height this is a provider bug or an unpropagated reorg${evidence}`
184
+ }
185
+ ];
186
+ }
187
+ /**
188
+ * Names the signed answers on either side of a disagreement. A signature changes no weight:
189
+ * it makes the finding independently checkable, it does not make one side right.
190
+ */
191
+ function attestedEvidence(event, disagreeing) {
192
+ const signers = new Set();
193
+ const own = event.evmRpc?.attestation;
194
+ if (own?.signatureValid && own.requestBound)
195
+ signers.add(own.signer);
196
+ for (const peer of disagreeing)
197
+ if (peer.attestedBy)
198
+ signers.add(peer.attestedBy);
199
+ if (signers.size === 0)
200
+ return "";
201
+ return `. Airnode-signed evidence from ${[...signers].join(", ")} can be verified independently`;
202
+ }
203
+ /**
204
+ * D4 — reorg lag.
205
+ *
206
+ * A hash changing at a height that was already observed is a reorg. Single-block reorgs
207
+ * are ordinary on mainnet and are recorded rather than alerted; what matters is depth, and
208
+ * whether this endpoint is still serving a chain its peers have already abandoned.
209
+ */
210
+ export function detectReorgLag(event, context = {}, thresholds = DEFAULT_DETECTOR_THRESHOLDS) {
211
+ if (event.kind !== "evm_rpc" || !event.evmRpc)
212
+ return [];
213
+ const { blockHash, blockNumber } = event.evmRpc;
214
+ if (!blockHash || !blockNumber)
215
+ return [];
216
+ const signals = [];
217
+ const prior = context.priorBlockHashAtHeight;
218
+ if (prior && prior !== blockHash) {
219
+ const depth = context.reorgDepth ?? 1;
220
+ // A one-block reorg is normal chain behaviour. Record it as a signal so it lands in
221
+ // the data, but give it no weight so it cannot manufacture an incident on its own.
222
+ // Kept below the incident threshold on purpose. A reorg is the chain's behaviour, not
223
+ // the provider's fault, so it is recorded and scored but does not raise an incident on
224
+ // its own. The actionable finding is `reorg_lag` below: a provider still serving a
225
+ // chain its peers have already abandoned.
226
+ const alerting = depth >= thresholds.reorgAlertMinDepth;
227
+ signals.push({
228
+ name: "reorg_detected",
229
+ weight: alerting ? 24 : 0,
230
+ reason: `Block ${blockNumber} changed hash${depth > 1 ? `, part of a ${depth}-block reorg` : " in a single-block reorg"}`
231
+ });
232
+ }
233
+ if (context.supersededByPeer && (context.peerConvergenceLagMs ?? 0) > thresholds.reorgConvergenceLagMs) {
234
+ signals.push({
235
+ name: "reorg_lag",
236
+ weight: 28,
237
+ reason: `Provider is still serving block ${blockNumber} at a hash its peers abandoned ${Math.round((context.peerConvergenceLagMs ?? 0) / 1000)}s ago`
238
+ });
239
+ }
240
+ return signals;
241
+ }
242
+ /**
243
+ * D5 — throttling as success.
244
+ *
245
+ * The clean case is a 429. The case that matters is a provider quietly degrading under
246
+ * load: latency steps up and results start coming back empty, with every response still
247
+ * reporting success.
248
+ *
249
+ * The soft rule deliberately requires **both** latency degradation and result degradation.
250
+ * A latency cliff on its own is already reported by `provider_degradation`, and firing here
251
+ * as well would just restate it under a second name.
252
+ */
253
+ export function detectThrottling(event, context = {}, thresholds = DEFAULT_DETECTOR_THRESHOLDS) {
254
+ if (event.kind !== "evm_rpc" || !event.evmRpc)
255
+ return [];
256
+ const sample = context.recentRequestCount ?? 0;
257
+ if (sample < thresholds.minThrottleSample)
258
+ return [];
259
+ const throttleRate = context.recentThrottleRate ?? 0;
260
+ if (throttleRate > thresholds.throttleRateThreshold) {
261
+ return [
262
+ {
263
+ name: "provider_throttling",
264
+ weight: 22,
265
+ reason: `${Math.round(throttleRate * 100)}% of recent requests to this provider were rate limited`
266
+ }
267
+ ];
268
+ }
269
+ // Soft throttling: neither half is conclusive alone, so both are required.
270
+ const recentLatency = context.recentP95LatencyMs ?? 0;
271
+ const baselineLatency = context.baselineP95LatencyMs ?? 0;
272
+ const latencyCliff = baselineLatency > 0 && recentLatency > baselineLatency * thresholds.softThrottleLatencyMultiplier;
273
+ const recentDegraded = context.recentDegradedResultRate ?? 0;
274
+ const baselineDegraded = context.baselineDegradedResultRate ?? 0;
275
+ const resultsDegraded = recentDegraded > 0 &&
276
+ recentDegraded > baselineDegraded * thresholds.softThrottleDegradationMultiplier;
277
+ if (latencyCliff && resultsDegraded) {
278
+ return [
279
+ {
280
+ name: "soft_throttling",
281
+ weight: 26,
282
+ reason: `Provider latency rose to ${Math.round(recentLatency)}ms against a ${Math.round(baselineLatency)}ms baseline while empty or errored results rose to ${Math.round(recentDegraded * 100)}% — the signature of throttling delivered as success`
283
+ }
284
+ ];
285
+ }
286
+ return [];
287
+ }