@heightproof/shared 0.0.0-stage → 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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jayces
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,14 @@
1
- # Temporary Holding Version
1
+ # @heightproof/shared
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Internal building blocks shared by the [Heightproof](https://github.com/macjayz/heightproof)
4
+ SDKs and services: the `HeightproofEvent` schema (Zod), JSON-RPC result normalization and
5
+ hashing, redaction helpers, and the content-aware RPC reliability detectors.
6
+
7
+ You normally do not install this directly; it is a dependency of
8
+ [`@heightproof/web3`](https://www.npmjs.com/package/@heightproof/web3) and
9
+ [`@heightproof/sdk-node`](https://www.npmjs.com/package/@heightproof/sdk-node). Its API follows
10
+ those packages and may change between minor versions while Heightproof is pre-1.0.
11
+
12
+ ## License
13
+
14
+ MIT
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Airnode attestations.
3
+ *
4
+ * An Airnode signs every response it serves: an EIP-191 signature over
5
+ * keccak256(requestHash ‖ uint256 timestamp ‖ body bytes). A verification observation that
6
+ * carries one is no longer just "a third provider said so" — anyone holding the signed body
7
+ * can check, offline and without trusting Heightproof, which address answered which request
8
+ * at which time.
9
+ *
10
+ * What a signature does not prove is that the answer is correct. It is attribution, not
11
+ * truth: an attested reference can be stale like any other endpoint, and detectors treat it
12
+ * as one more comparable peer, never as an oracle.
13
+ *
14
+ * Spec: https://airnodehub-docs.api3.org/airnode/attestation
15
+ */
16
+ export type AirnodeAttestation = {
17
+ /** The address the response claims to be signed by (X-Airnode-Address). */
18
+ signer: string;
19
+ requestHash: string;
20
+ /** Unix seconds at which the response was signed. */
21
+ timestamp: string;
22
+ signature: string;
23
+ };
24
+ export type AirnodeAttestationCheck = {
25
+ /** The signature recovers to `signer` over exactly this body, request and time. */
26
+ signatureValid: boolean;
27
+ /** The request hash is the one this exact request derives, so the answer is ours. */
28
+ requestBound: boolean;
29
+ };
30
+ /** Reads the four attestation headers. Undefined unless all four are present and well formed. */
31
+ export declare function readAirnodeAttestation(headers: Pick<Headers, "get">): AirnodeAttestation | undefined;
32
+ /**
33
+ * The request hash an Airnode derives for a passthrough request.
34
+ *
35
+ * keccak256(utf8(JSON.stringify(["passthrough", METHOD, path, queryEntries, headerEntries, body])))
36
+ * where `path` excludes the `/api` prefix and query entries keep their sent order with every
37
+ * `x-airnode-*` parameter removed.
38
+ */
39
+ export declare function airnodePassthroughRequestHash(input: {
40
+ method: string;
41
+ /** Full request URL, including the `/api` prefix and the query. */
42
+ url: string;
43
+ body: string;
44
+ headerEntries?: [string, string][];
45
+ }): string | undefined;
46
+ /** Recovers the address that signed `body` under this attestation, or undefined if malformed. */
47
+ export declare function recoverAirnodeSigner(attestation: AirnodeAttestation, body: string): string | undefined;
48
+ /**
49
+ * Checks an attestation against the body that arrived and, when known, the request that
50
+ * was sent. Never throws: a malformed attestation is simply not valid.
51
+ *
52
+ * `body` must be the response text exactly as received. Parsing and re-serializing it
53
+ * changes the bytes and every signature then fails.
54
+ */
55
+ export declare function checkAirnodeAttestation(attestation: AirnodeAttestation, body: string, expectedRequestHash?: string): AirnodeAttestationCheck;
56
+ /**
57
+ * Re-checks an attestation that arrived on an ingested event, without trusting the SDK.
58
+ *
59
+ * `signatureValid` survives only when the kept body still carries the signature *and*
60
+ * holds the very result whose hash was compared — otherwise a valid signature over some
61
+ * other answer could vouch for this one. Without a kept body there is nothing to check, so
62
+ * it is false. `requestBound` is the SDK's: re-deriving it needs the exact request bytes
63
+ * and endpoint URL, which are deliberately never transmitted.
64
+ */
65
+ export declare function reverifyAttestation<T extends AirnodeAttestation & {
66
+ signatureValid: boolean;
67
+ signedBody?: string;
68
+ }>(attestation: T, method: string, resultHash: string | undefined, hashResult: (result: unknown, method: string) => string): T;
69
+ //# sourceMappingURL=airnode.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"airnode.d.ts","sourceRoot":"","sources":["../src/airnode.ts"],"names":[],"mappings":"AAIA;;;;;;;;;;;;;;GAcG;AAEH,MAAM,MAAM,kBAAkB,GAAG;IAC/B,2EAA2E;IAC3E,MAAM,EAAE,MAAM,CAAC;IACf,WAAW,EAAE,MAAM,CAAC;IACpB,qDAAqD;IACrD,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;CACnB,CAAC;AAEF,MAAM,MAAM,uBAAuB,GAAG;IACpC,mFAAmF;IACnF,cAAc,EAAE,OAAO,CAAC;IACxB,qFAAqF;IACrF,YAAY,EAAE,OAAO,CAAC;CACvB,CAAC;AAMF,iGAAiG;AACjG,wBAAgB,sBAAsB,CACpC,OAAO,EAAE,IAAI,CAAC,OAAO,EAAE,KAAK,CAAC,GAC5B,kBAAkB,GAAG,SAAS,CAYhC;AAED;;;;;;GAMG;AACH,wBAAgB,6BAA6B,CAAC,KAAK,EAAE;IACnD,MAAM,EAAE,MAAM,CAAC;IACf,mEAAmE;IACnE,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,MAAM,CAAC;IACb,aAAa,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,EAAE,CAAC;CACpC,GAAG,MAAM,GAAG,SAAS,CA6BrB;AAED,iGAAiG;AACjG,wBAAgB,oBAAoB,CAClC,WAAW,EAAE,kBAAkB,EAC/B,IAAI,EAAE,MAAM,GACX,MAAM,GAAG,SAAS,CAyBpB;AAED;;;;;;GAMG;AACH,wBAAgB,uBAAuB,CACrC,WAAW,EAAE,kBAAkB,EAC/B,IAAI,EAAE,MAAM,EACZ,mBAAmB,CAAC,EAAE,MAAM,GAC3B,uBAAuB,CAUzB;AAED;;;;;;;;GAQG;AACH,wBAAgB,mBAAmB,CACjC,CAAC,SAAS,kBAAkB,GAAG;IAAE,cAAc,EAAE,OAAO,CAAC;IAAC,UAAU,CAAC,EAAE,MAAM,CAAA;CAAE,EAE/E,WAAW,EAAE,CAAC,EACd,MAAM,EAAE,MAAM,EACd,UAAU,EAAE,MAAM,GAAG,SAAS,EAC9B,UAAU,EAAE,CAAC,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,KAAK,MAAM,GACtD,CAAC,CAkBH"}
@@ -0,0 +1,117 @@
1
+ import { secp256k1 } from "@noble/curves/secp256k1";
2
+ import { keccak_256 } from "@noble/hashes/sha3";
3
+ import { bytesToHex, concatBytes, hexToBytes, utf8ToBytes } from "@noble/hashes/utils";
4
+ const HEX_32 = /^0x[0-9a-fA-F]{64}$/;
5
+ const HEX_ADDRESS = /^0x[0-9a-fA-F]{40}$/;
6
+ const HEX_SIGNATURE = /^0x[0-9a-fA-F]{130}$/;
7
+ /** Reads the four attestation headers. Undefined unless all four are present and well formed. */
8
+ export function readAirnodeAttestation(headers) {
9
+ const signer = headers.get("x-airnode-address");
10
+ const requestHash = headers.get("x-airnode-request-hash");
11
+ const timestamp = headers.get("x-airnode-timestamp");
12
+ const signature = headers.get("x-airnode-signature");
13
+ if (!signer || !HEX_ADDRESS.test(signer))
14
+ return undefined;
15
+ if (!requestHash || !HEX_32.test(requestHash))
16
+ return undefined;
17
+ if (!timestamp || !/^\d+$/.test(timestamp))
18
+ return undefined;
19
+ if (!signature || !HEX_SIGNATURE.test(signature))
20
+ return undefined;
21
+ return { signer, requestHash, timestamp, signature };
22
+ }
23
+ /**
24
+ * The request hash an Airnode derives for a passthrough request.
25
+ *
26
+ * keccak256(utf8(JSON.stringify(["passthrough", METHOD, path, queryEntries, headerEntries, body])))
27
+ * where `path` excludes the `/api` prefix and query entries keep their sent order with every
28
+ * `x-airnode-*` parameter removed.
29
+ */
30
+ export function airnodePassthroughRequestHash(input) {
31
+ let parsed;
32
+ try {
33
+ parsed = new URL(input.url);
34
+ }
35
+ catch {
36
+ return undefined;
37
+ }
38
+ const apiIndex = parsed.pathname.indexOf("/api/");
39
+ if (apiIndex === -1)
40
+ return undefined;
41
+ const path = parsed.pathname.slice(apiIndex + "/api".length);
42
+ const queryEntries = [...parsed.searchParams.entries()].filter(([name]) => !name.startsWith("x-airnode-"));
43
+ const headerEntries = [...(input.headerEntries ?? [])].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
44
+ const serialized = JSON.stringify([
45
+ "passthrough",
46
+ input.method.toUpperCase(),
47
+ path,
48
+ queryEntries,
49
+ headerEntries,
50
+ input.body
51
+ ]);
52
+ return toHex(keccak_256(utf8ToBytes(serialized)));
53
+ }
54
+ /** Recovers the address that signed `body` under this attestation, or undefined if malformed. */
55
+ export function recoverAirnodeSigner(attestation, body) {
56
+ try {
57
+ const timestamp = BigInt(attestation.timestamp);
58
+ const digest = keccak_256(concatBytes(hexToBytes(attestation.requestHash.slice(2)), hexToBytes(timestamp.toString(16).padStart(64, "0")), utf8ToBytes(body)));
59
+ const prefixed = keccak_256(concatBytes(utf8ToBytes("\x19Ethereum Signed Message:\n32"), digest));
60
+ const signature = hexToBytes(attestation.signature.slice(2));
61
+ const recovery = signature[64] >= 27 ? signature[64] - 27 : signature[64];
62
+ const publicKey = secp256k1.Signature.fromCompact(signature.slice(0, 64))
63
+ .addRecoveryBit(recovery)
64
+ .recoverPublicKey(prefixed)
65
+ .toRawBytes(false);
66
+ return toHex(keccak_256(publicKey.slice(1)).slice(-20));
67
+ }
68
+ catch {
69
+ return undefined;
70
+ }
71
+ }
72
+ /**
73
+ * Checks an attestation against the body that arrived and, when known, the request that
74
+ * was sent. Never throws: a malformed attestation is simply not valid.
75
+ *
76
+ * `body` must be the response text exactly as received. Parsing and re-serializing it
77
+ * changes the bytes and every signature then fails.
78
+ */
79
+ export function checkAirnodeAttestation(attestation, body, expectedRequestHash) {
80
+ const recovered = recoverAirnodeSigner(attestation, body);
81
+ return {
82
+ signatureValid: recovered !== undefined && recovered.toLowerCase() === attestation.signer.toLowerCase(),
83
+ requestBound: expectedRequestHash !== undefined &&
84
+ expectedRequestHash.toLowerCase() === attestation.requestHash.toLowerCase()
85
+ };
86
+ }
87
+ /**
88
+ * Re-checks an attestation that arrived on an ingested event, without trusting the SDK.
89
+ *
90
+ * `signatureValid` survives only when the kept body still carries the signature *and*
91
+ * holds the very result whose hash was compared — otherwise a valid signature over some
92
+ * other answer could vouch for this one. Without a kept body there is nothing to check, so
93
+ * it is false. `requestBound` is the SDK's: re-deriving it needs the exact request bytes
94
+ * and endpoint URL, which are deliberately never transmitted.
95
+ */
96
+ export function reverifyAttestation(attestation, method, resultHash, hashResult) {
97
+ if (!attestation.signedBody)
98
+ return { ...attestation, signatureValid: false };
99
+ const { signatureValid } = checkAirnodeAttestation(attestation, attestation.signedBody);
100
+ if (!signatureValid)
101
+ return { ...attestation, signatureValid: false };
102
+ let coversResult = resultHash === undefined;
103
+ if (resultHash !== undefined) {
104
+ try {
105
+ const parsed = JSON.parse(attestation.signedBody);
106
+ coversResult =
107
+ parsed.result !== undefined && hashResult(parsed.result, method) === resultHash;
108
+ }
109
+ catch {
110
+ coversResult = false;
111
+ }
112
+ }
113
+ return { ...attestation, signatureValid: coversResult };
114
+ }
115
+ function toHex(bytes) {
116
+ return `0x${bytesToHex(bytes)}`;
117
+ }
@@ -0,0 +1,6 @@
1
+ import { HeightproofEvent } from "./index.js";
2
+ export declare function buildDemoEvents(options: {
3
+ projectId: string;
4
+ serviceName: string;
5
+ }): HeightproofEvent[];
6
+ //# sourceMappingURL=demoEvents.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"demoEvents.d.ts","sourceRoot":"","sources":["../src/demoEvents.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAC;AAE9C,wBAAgB,eAAe,CAAC,OAAO,EAAE;IAAE,SAAS,EAAE,MAAM,CAAC;IAAC,WAAW,EAAE,MAAM,CAAA;CAAE,GAAG,gBAAgB,EAAE,CA0EvG"}
@@ -0,0 +1,126 @@
1
+ import { randomUUID } from "node:crypto";
2
+ export function buildDemoEvents(options) {
3
+ const { projectId, serviceName } = options;
4
+ const now = Date.now();
5
+ return [
6
+ ...range(60).map((index) => httpEvent({
7
+ projectId,
8
+ serviceName,
9
+ now,
10
+ index,
11
+ method: "GET",
12
+ path: `/api/users/${1000 + index}`,
13
+ route: "/api/users/:id",
14
+ statusCode: 200,
15
+ latencyMs: 35 + (index % 40),
16
+ ip: `198.51.100.${10 + (index % 8)}`
17
+ })),
18
+ ...range(35).map((index) => httpEvent({
19
+ projectId,
20
+ serviceName,
21
+ now,
22
+ index: index + 60,
23
+ method: "POST",
24
+ path: "/api/login",
25
+ route: "/api/login",
26
+ statusCode: index % 3 === 0 ? 401 : 403,
27
+ latencyMs: 70 + (index % 20),
28
+ ip: "203.0.113.14",
29
+ authFailed: true
30
+ })),
31
+ ...range(25).map((index) => httpEvent({
32
+ projectId,
33
+ serviceName,
34
+ now,
35
+ index: index + 95,
36
+ method: "POST",
37
+ path: "/graphql",
38
+ route: "/graphql",
39
+ statusCode: index % 10 === 0 ? 500 : 200,
40
+ latencyMs: 110 + (index % 90),
41
+ ip: `192.0.2.${20 + (index % 6)}`,
42
+ kind: "graphql",
43
+ graphQL: { operationName: index % 2 === 0 ? "GetAccount" : "UpdateProfile", operationType: "query" }
44
+ })),
45
+ ...range(18).map((index) => httpEvent({
46
+ projectId,
47
+ serviceName,
48
+ now,
49
+ index: index + 120,
50
+ method: "POST",
51
+ path: "/rpc",
52
+ route: "/rpc",
53
+ statusCode: index % 4 === 0 ? 401 : 200,
54
+ latencyMs: 55 + (index % 50),
55
+ ip: "198.51.100.22",
56
+ authFailed: index % 4 === 0,
57
+ kind: "evm_rpc",
58
+ evmRpc: {
59
+ method: index % 4 === 0 ? "eth_sendRawTransaction" : "eth_call",
60
+ chainId: "1",
61
+ provider: index % 2 === 0 ? "alchemy" : "infura",
62
+ walletAddress: "0x742d35Cc6634C0532925a3b844Bc454e4438f44e",
63
+ contractAddress: "0x0000000000000000000000000000000000000002"
64
+ }
65
+ }))
66
+ ];
67
+ }
68
+ function httpEvent(input) {
69
+ return {
70
+ id: randomUUID(),
71
+ traceId: randomBytesHex(16),
72
+ projectId: input.projectId,
73
+ serviceName: input.serviceName,
74
+ environment: "demo",
75
+ timestamp: new Date(input.now - input.index * 1200).toISOString(),
76
+ kind: input.kind ?? "rest",
77
+ request: {
78
+ method: input.method,
79
+ path: input.path,
80
+ route: input.route,
81
+ ip: input.ip,
82
+ userAgent: input.index % 5 === 0 ? "curl/8.0" : "Mozilla/5.0",
83
+ headers: {
84
+ "content-type": "application/json",
85
+ authorization: "[REDACTED]"
86
+ },
87
+ query: {},
88
+ body: requestBody(input),
89
+ auth: {
90
+ present: true,
91
+ scheme: "Bearer",
92
+ failed: input.authFailed ?? false
93
+ }
94
+ },
95
+ response: {
96
+ statusCode: input.statusCode,
97
+ latencyMs: input.latencyMs,
98
+ bodyBytes: 512 + input.index
99
+ },
100
+ graphQL: input.graphQL,
101
+ evmRpc: input.evmRpc
102
+ };
103
+ }
104
+ function requestBody(input) {
105
+ if (input.kind === "graphql") {
106
+ return {
107
+ operationName: input.graphQL?.operationName,
108
+ query: "query GetAccount { account { id email } }"
109
+ };
110
+ }
111
+ if (input.kind === "evm_rpc") {
112
+ return {
113
+ jsonrpc: "2.0",
114
+ id: 1,
115
+ method: input.evmRpc?.method,
116
+ params: []
117
+ };
118
+ }
119
+ return { sample: true };
120
+ }
121
+ function range(count) {
122
+ return Array.from({ length: count }, (_, index) => index);
123
+ }
124
+ function randomBytesHex(length) {
125
+ return randomUUID().replace(/-/g, "").slice(0, length * 2);
126
+ }
@@ -0,0 +1,154 @@
1
+ import type { HeightproofEvent, ThreatSignal } from "./index.js";
2
+ /**
3
+ * Content-aware RPC detectors D1, D3 and D6 (see docs/detectors.md).
4
+ *
5
+ * These are pure functions over an event plus context the worker gathers from storage, so
6
+ * every firing condition is testable without a database. They deliberately do not fire on
7
+ * absence alone: an empty result, a null receipt and a slow head are all frequently
8
+ * correct. Each rule below fires on a *change* or a *comparison*, never on emptiness.
9
+ */
10
+ export type DetectorThresholds = {
11
+ /** How many block times a head may stand still before it is considered stalled. */
12
+ staleHeadBlockTimeMultiplier: number;
13
+ /** How far behind the fastest endpoint a head may fall before it is considered lagging. */
14
+ maxHeadLagBlocks: number;
15
+ /** Minimum head observations before D1 will judge an endpoint at all. */
16
+ minHeadObservations: number;
17
+ /** How often a result count must recur at the same ceiling to read as a page limit. */
18
+ pageLimitMinHits: number;
19
+ /** Reorg depth at or above which a reorg is worth raising rather than just recording. */
20
+ reorgAlertMinDepth: number;
21
+ /** How long an endpoint may keep serving a superseded block before it counts as lag. */
22
+ reorgConvergenceLagMs: number;
23
+ /** Share of recent requests carrying a throttle signature before it is worth reporting. */
24
+ throttleRateThreshold: number;
25
+ /** Minimum requests behind a rate before D5 will judge an endpoint. */
26
+ minThrottleSample: number;
27
+ /** Latency multiplier over baseline that counts as a cliff. */
28
+ softThrottleLatencyMultiplier: number;
29
+ /** How much worse recent result quality must be than baseline to count as degradation. */
30
+ softThrottleDegradationMultiplier: number;
31
+ };
32
+ export declare const DEFAULT_DETECTOR_THRESHOLDS: DetectorThresholds;
33
+ export type StaleHeadContext = {
34
+ /** Highest head this endpoint reported in the window. */
35
+ endpointHeadBlockNumber?: number;
36
+ /** Milliseconds since this endpoint's head last advanced. */
37
+ endpointHeadStalledMs?: number;
38
+ /** Head observations from this endpoint in the window. */
39
+ endpointHeadObservations?: number;
40
+ /** Highest head reported by any endpoint on this chain in the window. */
41
+ chainHeadBlockNumber?: number;
42
+ /** Median block time measured from observations, for chains not in the static table. */
43
+ measuredBlockTimeMs?: number;
44
+ };
45
+ export type SilentFailureContext = {
46
+ /** This endpoint previously returned a non-null result for the same target (e.g. tx hash). */
47
+ priorNonNullForSameTarget?: boolean;
48
+ /** This endpoint previously returned data for identical params at an earlier block. */
49
+ priorDataForSameCall?: boolean;
50
+ /** Largest result count seen from this endpoint for this method. */
51
+ endpointMaxResultCount?: number;
52
+ /** How many times that maximum recurred — a repeated hard ceiling reads as a page limit. */
53
+ endpointMaxResultCountHits?: number;
54
+ };
55
+ export type CostWasteContext = {
56
+ /** Identical (method, params, block) already recorded for this project in the same block. */
57
+ duplicateCallCount?: number;
58
+ /** Cost units already spent on those duplicates. */
59
+ duplicateCostUnits?: number;
60
+ };
61
+ /**
62
+ * D1 — stale head.
63
+ *
64
+ * Two independent conditions: a head that has stopped advancing while the endpoint is
65
+ * still answering, and a head that trails the fastest endpoint on the same chain.
66
+ */
67
+ export declare function detectStaleHead(event: HeightproofEvent, context?: StaleHeadContext, thresholds?: DetectorThresholds): ThreatSignal[];
68
+ /**
69
+ * D3 — silent failure.
70
+ *
71
+ * Responses that arrive as successes but carry no usable answer. Every rule except the
72
+ * explicit in-body error is comparative, because emptiness on its own is usually correct.
73
+ */
74
+ export declare function detectSilentFailure(event: HeightproofEvent, context?: SilentFailureContext, thresholds?: DetectorThresholds): ThreatSignal[];
75
+ /**
76
+ * D6 — cost and waste.
77
+ *
78
+ * Waste is a billing problem, not a threat, so these signals carry zero weight: they are
79
+ * recorded against the event and aggregated for reporting without inflating a threat score
80
+ * or manufacturing an incident.
81
+ */
82
+ export declare function detectCostWaste(event: HeightproofEvent, context?: CostWasteContext): ThreatSignal[];
83
+ export type PeerResult = {
84
+ endpointHash?: string;
85
+ provider?: string;
86
+ resultHash: string;
87
+ /** Signer of a verified Airnode attestation over this peer's answer, if it carried one. */
88
+ attestedBy?: string;
89
+ };
90
+ export type DisagreementContext = {
91
+ /**
92
+ * Result hashes other endpoints returned for the same call at the same pinned height.
93
+ * Only ever populated from block-pinned verification calls: comparing two `latest`
94
+ * responses measures head skew, not disagreement.
95
+ */
96
+ peerResults?: PeerResult[];
97
+ /** Peers that could not serve the pinned height at all. */
98
+ peersMissingBlock?: number;
99
+ };
100
+ export type ReorgContext = {
101
+ /** A different block hash previously recorded at this height, from any endpoint. */
102
+ priorBlockHashAtHeight?: string;
103
+ /** Distinct heights in the recent window that have shown more than one hash. */
104
+ reorgDepth?: number;
105
+ /** Another endpoint already reports a different hash at this height. */
106
+ supersededByPeer?: boolean;
107
+ /** How long ago a peer first reported the newer hash. */
108
+ peerConvergenceLagMs?: number;
109
+ };
110
+ /**
111
+ * D2 — cross-provider disagreement.
112
+ *
113
+ * At equal block height a mismatch is either a provider bug or an unpropagated reorg.
114
+ * Both are worth raising, and neither is visible to anything that only watches status
115
+ * codes and latency.
116
+ *
117
+ * The caller is responsible for only ever supplying peer results from block-pinned calls.
118
+ * A peer that cannot serve the pinned height is not a disagreement — that is a stale head,
119
+ * and D1 is what reports it.
120
+ */
121
+ export declare function detectDisagreement(event: HeightproofEvent, context?: DisagreementContext): ThreatSignal[];
122
+ /**
123
+ * D4 — reorg lag.
124
+ *
125
+ * A hash changing at a height that was already observed is a reorg. Single-block reorgs
126
+ * are ordinary on mainnet and are recorded rather than alerted; what matters is depth, and
127
+ * whether this endpoint is still serving a chain its peers have already abandoned.
128
+ */
129
+ export declare function detectReorgLag(event: HeightproofEvent, context?: ReorgContext, thresholds?: DetectorThresholds): ThreatSignal[];
130
+ export type ThrottlingContext = {
131
+ /** Share of recent requests to this endpoint that carried a throttle signature. */
132
+ recentThrottleRate?: number;
133
+ /** Requests behind that rate, so a single throttled call cannot read as 100%. */
134
+ recentRequestCount?: number;
135
+ /** Share of recent results that came back empty or errored rather than as values. */
136
+ recentDegradedResultRate?: number;
137
+ /** The same share over the preceding window, for comparison. */
138
+ baselineDegradedResultRate?: number;
139
+ recentP95LatencyMs?: number;
140
+ baselineP95LatencyMs?: number;
141
+ };
142
+ /**
143
+ * D5 — throttling as success.
144
+ *
145
+ * The clean case is a 429. The case that matters is a provider quietly degrading under
146
+ * load: latency steps up and results start coming back empty, with every response still
147
+ * reporting success.
148
+ *
149
+ * The soft rule deliberately requires **both** latency degradation and result degradation.
150
+ * A latency cliff on its own is already reported by `provider_degradation`, and firing here
151
+ * as well would just restate it under a second name.
152
+ */
153
+ export declare function detectThrottling(event: HeightproofEvent, context?: ThrottlingContext, thresholds?: DetectorThresholds): ThreatSignal[];
154
+ //# sourceMappingURL=detectors.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"detectors.d.ts","sourceRoot":"","sources":["../src/detectors.ts"],"names":[],"mappings":"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"}