@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/dist/rpc.js ADDED
@@ -0,0 +1,391 @@
1
+ import { createHash } from "node:crypto";
2
+ /**
3
+ * Methods whose top-level result is an EIP-1474 QUANTITY.
4
+ *
5
+ * This distinction is load-bearing. QUANTITY is minimally encoded (`0x1b4`), DATA is
6
+ * byte-aligned and keeps its leading zeros (`0x000...01`). Stripping zeros from DATA
7
+ * would make `eth_call` returning uint256(1) collide with a bare `0x1`, so zero-stripping
8
+ * is only ever applied where the spec says the value is a QUANTITY.
9
+ */
10
+ const QUANTITY_RESULT_METHODS = new Set([
11
+ "eth_blockNumber",
12
+ "eth_getBalance",
13
+ "eth_getTransactionCount",
14
+ "eth_gasPrice",
15
+ "eth_maxPriorityFeePerGas",
16
+ "eth_estimateGas",
17
+ "eth_chainId",
18
+ "eth_protocolVersion",
19
+ "eth_getBlockTransactionCountByNumber",
20
+ "eth_getBlockTransactionCountByHash"
21
+ ]);
22
+ /** Object keys that carry a QUANTITY wherever they appear in a result. */
23
+ const QUANTITY_FIELDS = new Set([
24
+ "blocknumber",
25
+ "blocktimestamp",
26
+ "timestamp",
27
+ "gas",
28
+ "gasused",
29
+ "cumulativegasused",
30
+ "gasprice",
31
+ "effectivegasprice",
32
+ "maxfeepergas",
33
+ "maxpriorityfeepergas",
34
+ "basefeepergas",
35
+ "blobgasused",
36
+ "excessblobgas",
37
+ "blobgasprice",
38
+ "value",
39
+ "nonce",
40
+ "transactionindex",
41
+ "logindex",
42
+ "status",
43
+ "type",
44
+ "chainid",
45
+ "size",
46
+ "difficulty",
47
+ "totaldifficulty",
48
+ "v",
49
+ "yparity"
50
+ ]);
51
+ /**
52
+ * Methods whose result is an array whose order is not guaranteed across providers.
53
+ * Canonical ordering is (blockNumber, logIndex) — the chain's own ordering.
54
+ */
55
+ const UNORDERED_ARRAY_METHODS = new Set(["eth_getLogs", "eth_getFilterLogs", "eth_getFilterChanges"]);
56
+ /**
57
+ * Where in the params list each method carries its block tag.
58
+ * `-1` means the method does not take one.
59
+ */
60
+ const BLOCK_TAG_PARAM_INDEX = {
61
+ eth_call: 1,
62
+ eth_getBalance: 1,
63
+ eth_getCode: 1,
64
+ eth_getTransactionCount: 1,
65
+ eth_getStorageAt: 2,
66
+ eth_estimateGas: 1,
67
+ eth_getBlockByNumber: 0,
68
+ eth_getBlockTransactionCountByNumber: 0,
69
+ eth_getProof: 2
70
+ };
71
+ /**
72
+ * Default compute-unit weights, used for cost accounting (detector D6).
73
+ *
74
+ * These are order-of-magnitude defaults modelled on published provider pricing. Providers
75
+ * change their weights, and they differ between providers, so treat these as a starting
76
+ * point to be overridden per deployment rather than as authoritative billing figures.
77
+ */
78
+ export const DEFAULT_METHOD_COST_UNITS = {
79
+ eth_chainId: 0,
80
+ eth_blockNumber: 10,
81
+ eth_getTransactionReceipt: 15,
82
+ eth_getBlockByNumber: 16,
83
+ eth_getStorageAt: 17,
84
+ eth_getTransactionByHash: 17,
85
+ eth_getBalance: 19,
86
+ eth_getCode: 19,
87
+ eth_gasPrice: 19,
88
+ eth_call: 26,
89
+ eth_getTransactionCount: 26,
90
+ eth_getLogs: 75,
91
+ eth_estimateGas: 87,
92
+ eth_sendRawTransaction: 250
93
+ };
94
+ /** Cost weight for a method, or `undefined` when the method is not in the table. */
95
+ export function methodCostUnits(method, table = DEFAULT_METHOD_COST_UNITS) {
96
+ return table[method];
97
+ }
98
+ /**
99
+ * Nominal block time in milliseconds, used by the stale-head detector (D1) to decide how
100
+ * long a head may stand still before it is suspicious. Unknown chains fall back to a
101
+ * measured median at the call site rather than to a global constant.
102
+ */
103
+ export const CHAIN_BLOCK_TIME_MS = {
104
+ "1": 12_000, // Ethereum mainnet
105
+ "10": 2_000, // Optimism
106
+ "137": 2_000, // Polygon PoS
107
+ "8453": 2_000, // Base
108
+ "42161": 250, // Arbitrum One
109
+ "11155111": 12_000 // Sepolia
110
+ };
111
+ export function chainBlockTimeMs(chainId) {
112
+ if (!chainId)
113
+ return undefined;
114
+ return CHAIN_BLOCK_TIME_MS[chainId];
115
+ }
116
+ /**
117
+ * Hash of an RPC endpoint URL.
118
+ *
119
+ * Provider URLs embed API keys (`https://eth-mainnet.g.alchemy.com/v2/<KEY>`), so the URL
120
+ * itself must never be persisted or transmitted. This lets two endpoints be told apart
121
+ * without Heightproof ever holding the credential.
122
+ */
123
+ export function hashEndpoint(url) {
124
+ return createHash("sha256").update(url).digest("hex").slice(0, 32);
125
+ }
126
+ /** Canonical hash of a JSON-RPC result, for same-height comparison across providers. */
127
+ export function hashRpcResult(result, method) {
128
+ const normalized = normalizeRpcResult(result, method);
129
+ return createHash("sha256").update(JSON.stringify(normalized) ?? "undefined").digest("hex").slice(0, 32);
130
+ }
131
+ /**
132
+ * Reduce a JSON-RPC result to canonical form:
133
+ *
134
+ * - object keys sorted, so key order never affects the hash
135
+ * - `undefined`-valued keys dropped; explicit `null` preserved (they mean different things)
136
+ * - hex lowercased everywhere (hex is case-insensitive, so case is never a real difference)
137
+ * - leading zeros stripped only from values the spec defines as QUANTITY
138
+ * - provider-order-dependent arrays (logs) sorted into chain order
139
+ */
140
+ export function normalizeRpcResult(result, method) {
141
+ const rootIsQuantity = method ? QUANTITY_RESULT_METHODS.has(method) : false;
142
+ const normalized = normalizeValue(result, rootIsQuantity);
143
+ if (method && UNORDERED_ARRAY_METHODS.has(method) && Array.isArray(normalized)) {
144
+ return sortLogs(normalized);
145
+ }
146
+ return normalized;
147
+ }
148
+ function normalizeValue(value, isQuantity) {
149
+ if (value === null || value === undefined)
150
+ return value ?? null;
151
+ if (typeof value === "string") {
152
+ return normalizeString(value, isQuantity);
153
+ }
154
+ if (typeof value === "number" || typeof value === "boolean")
155
+ return value;
156
+ if (Array.isArray(value)) {
157
+ return value.map((entry) => normalizeValue(entry, false));
158
+ }
159
+ if (typeof value === "object") {
160
+ const source = value;
161
+ const out = {};
162
+ for (const key of Object.keys(source).sort()) {
163
+ const child = source[key];
164
+ if (child === undefined)
165
+ continue;
166
+ out[key] = normalizeValue(child, QUANTITY_FIELDS.has(key.toLowerCase()));
167
+ }
168
+ return out;
169
+ }
170
+ return value;
171
+ }
172
+ function normalizeString(value, isQuantity) {
173
+ if (!isHexString(value))
174
+ return value;
175
+ const lowered = value.toLowerCase();
176
+ if (!isQuantity)
177
+ return lowered;
178
+ const digits = lowered.slice(2).replace(/^0+/, "");
179
+ return digits.length === 0 ? "0x0" : `0x${digits}`;
180
+ }
181
+ function isHexString(value) {
182
+ return /^0x[0-9a-f]*$/i.test(value);
183
+ }
184
+ /**
185
+ * Order logs the way the chain does: by block, then by position within the block.
186
+ * Entries missing either field keep their relative order so the sort stays stable.
187
+ */
188
+ function sortLogs(logs) {
189
+ return [...logs].sort((left, right) => {
190
+ const leftBlock = hexToNumber(readField(left, "blockNumber"));
191
+ const rightBlock = hexToNumber(readField(right, "blockNumber"));
192
+ if (leftBlock !== rightBlock)
193
+ return leftBlock - rightBlock;
194
+ const leftIndex = hexToNumber(readField(left, "logIndex"));
195
+ const rightIndex = hexToNumber(readField(right, "logIndex"));
196
+ return leftIndex - rightIndex;
197
+ });
198
+ }
199
+ function readField(value, key) {
200
+ if (!value || typeof value !== "object" || Array.isArray(value))
201
+ return undefined;
202
+ return value[key];
203
+ }
204
+ function hexToNumber(value) {
205
+ if (typeof value !== "string" || !isHexString(value))
206
+ return Number.MAX_SAFE_INTEGER;
207
+ const parsed = Number.parseInt(value, 16);
208
+ return Number.isNaN(parsed) ? Number.MAX_SAFE_INTEGER : parsed;
209
+ }
210
+ /**
211
+ * Classify a response so silent failures (D3) are visible without storing the result.
212
+ *
213
+ * `empty_array` and `null` are recorded as facts, not as failures — emptiness is often
214
+ * correct. D3 only ever fires on a *change* or a disagreement, never on emptiness alone.
215
+ */
216
+ export function classifyResultShape(result, errorInBody = false) {
217
+ if (errorInBody)
218
+ return "error_in_body";
219
+ if (result === null || result === undefined)
220
+ return "null";
221
+ if (Array.isArray(result) && result.length === 0)
222
+ return "empty_array";
223
+ // A bare `0x` is an empty DATA return. For eth_call it means the call reverted or hit a
224
+ // contract that isn't there — a different failure signature from a null result, and one
225
+ // that reads as an ordinary success to anything looking only at status codes.
226
+ if (result === "0x")
227
+ return "empty_data";
228
+ return "value";
229
+ }
230
+ /**
231
+ * Number of entries in an array result, used to spot silently truncated pages (D3).
232
+ * Non-array results have no count rather than a count of zero.
233
+ */
234
+ export function resultCount(result) {
235
+ return Array.isArray(result) ? result.length : undefined;
236
+ }
237
+ /** The block tag a call was made against, as written by the caller. */
238
+ export function extractBlockTag(method, params) {
239
+ const index = BLOCK_TAG_PARAM_INDEX[method];
240
+ if (index === undefined || !Array.isArray(params))
241
+ return undefined;
242
+ const tag = params[index];
243
+ return typeof tag === "string" ? tag : undefined;
244
+ }
245
+ /**
246
+ * The concrete height a call resolved against.
247
+ *
248
+ * Prefers a height carried by the result itself, since that is what the provider actually
249
+ * answered from; falls back to a numeric block tag in the request.
250
+ */
251
+ export function extractBlockNumber(method, params, result) {
252
+ const fromResult = readField(result, "blockNumber");
253
+ if (typeof fromResult === "string" && isHexString(fromResult)) {
254
+ return String(Number.parseInt(fromResult, 16));
255
+ }
256
+ if (QUANTITY_RESULT_METHODS.has(method) && method === "eth_blockNumber" && typeof result === "string") {
257
+ return String(Number.parseInt(result, 16));
258
+ }
259
+ const tag = extractBlockTag(method, params);
260
+ if (tag && isHexString(tag))
261
+ return String(Number.parseInt(tag, 16));
262
+ return undefined;
263
+ }
264
+ /** The block hash a result was served from, when the response carries one (reorg tracking, D4). */
265
+ export function extractBlockHash(result) {
266
+ const direct = readField(result, "blockHash");
267
+ if (typeof direct === "string" && /^0x[0-9a-f]{64}$/i.test(direct))
268
+ return direct.toLowerCase();
269
+ const asBlock = readField(result, "hash");
270
+ const hasParent = readField(result, "parentHash");
271
+ if (hasParent && typeof asBlock === "string" && /^0x[0-9a-f]{64}$/i.test(asBlock)) {
272
+ return asBlock.toLowerCase();
273
+ }
274
+ return undefined;
275
+ }
276
+ /** A JSON-RPC error object returned inside an HTTP 200 body. */
277
+ export function extractRpcError(body) {
278
+ const error = readField(body, "error");
279
+ if (!error || typeof error !== "object")
280
+ return undefined;
281
+ const code = readField(error, "code");
282
+ const message = readField(error, "message");
283
+ return {
284
+ code: typeof code === "number" ? code : undefined,
285
+ message: typeof message === "string" ? message : undefined
286
+ };
287
+ }
288
+ /**
289
+ * Methods whose answer at a fixed block height is deterministic, and which are therefore
290
+ * safe to replay against another endpoint for comparison (detector D2).
291
+ *
292
+ * This is an allowlist, never a denylist: shadowing `eth_gasPrice` or `eth_estimateGas`
293
+ * would compare two legitimately different answers and report every sample as a
294
+ * disagreement.
295
+ */
296
+ export const VERIFIABLE_METHODS = new Set([
297
+ "eth_call",
298
+ "eth_getBalance",
299
+ "eth_getCode",
300
+ "eth_getStorageAt",
301
+ "eth_getTransactionCount",
302
+ "eth_getLogs",
303
+ "eth_getTransactionReceipt",
304
+ "eth_getBlockByNumber"
305
+ ]);
306
+ /** Block tags whose result is not fixed, and so can never be compared across endpoints. */
307
+ const UNPINNABLE_TAGS = new Set(["pending"]);
308
+ export function isVerifiableMethod(method) {
309
+ return VERIFIABLE_METHODS.has(method);
310
+ }
311
+ export function blockTagParamIndex(method) {
312
+ return BLOCK_TAG_PARAM_INDEX[method];
313
+ }
314
+ /**
315
+ * Rewrite a call's block tag to a concrete height so two endpoints can be asked exactly
316
+ * the same question.
317
+ *
318
+ * This is the single most important step in cross-provider verification. Comparing a
319
+ * `latest` response from one endpoint against a `latest` response from another measures
320
+ * how far apart their heads are, not whether they disagree — and at any real head skew
321
+ * that produces a false positive on essentially every sample.
322
+ *
323
+ * Returns `undefined` when the call cannot be pinned, in which case it must not be
324
+ * shadowed at all.
325
+ */
326
+ export function pinBlockTag(method, params, blockNumber) {
327
+ if (!Array.isArray(params))
328
+ return undefined;
329
+ const height = Number(blockNumber);
330
+ if (!Number.isFinite(height) || height < 0)
331
+ return undefined;
332
+ const hexHeight = `0x${height.toString(16)}`;
333
+ // Log queries carry their range inside a filter object rather than as a positional tag.
334
+ if (method === "eth_getLogs" || method === "eth_getFilterLogs") {
335
+ const filter = params[0];
336
+ if (!filter || typeof filter !== "object" || Array.isArray(filter))
337
+ return undefined;
338
+ const source = filter;
339
+ if (typeof source.blockHash === "string")
340
+ return [...params];
341
+ const pinnedFilter = { ...source };
342
+ for (const key of ["fromBlock", "toBlock"]) {
343
+ const value = source[key];
344
+ if (value === undefined)
345
+ continue;
346
+ if (typeof value !== "string")
347
+ return undefined;
348
+ if (UNPINNABLE_TAGS.has(value))
349
+ return undefined;
350
+ if (value === "latest" || value === "safe" || value === "finalized")
351
+ pinnedFilter[key] = hexHeight;
352
+ }
353
+ return [pinnedFilter, ...params.slice(1)];
354
+ }
355
+ const index = blockTagParamIndex(method);
356
+ // A method with no block tag is already pinned by its own argument — a receipt is
357
+ // identified by transaction hash, not by height.
358
+ if (index === undefined)
359
+ return [...params];
360
+ const tag = params[index];
361
+ if (typeof tag === "string" && UNPINNABLE_TAGS.has(tag))
362
+ return undefined;
363
+ const pinned = [...params];
364
+ pinned[index] = hexHeight;
365
+ return pinned;
366
+ }
367
+ /**
368
+ * JSON-RPC error codes providers use to mean "you are being rate limited".
369
+ * Codes vary between providers; the message check below covers the rest.
370
+ */
371
+ const THROTTLE_ERROR_CODES = new Set([429, -32005, -32007, -32029]);
372
+ /**
373
+ * Provider phrasings for rate limiting. Deliberately narrow: a loose pattern here would
374
+ * swallow ordinary execution errors like "gas limit exceeded" and turn every reverted
375
+ * call into a throttling finding.
376
+ */
377
+ const THROTTLE_MESSAGE_PATTERN = /rate.?limit|too many requests|throttl|quota|capacity exceeded|request limit|compute unit/i;
378
+ /**
379
+ * Whether a response carries a rate-limiting signature (detector D5).
380
+ *
381
+ * Covers the clean case (HTTP 429) and the case that actually matters: throttling
382
+ * delivered as a JSON-RPC error inside an HTTP 200, where nothing watching status codes
383
+ * will ever see it.
384
+ */
385
+ export function isThrottleSignature(statusCode, rpcErrorCode, rpcErrorMessage) {
386
+ if (statusCode === 429)
387
+ return true;
388
+ if (rpcErrorCode !== undefined && THROTTLE_ERROR_CODES.has(rpcErrorCode))
389
+ return true;
390
+ return Boolean(rpcErrorMessage && THROTTLE_MESSAGE_PATTERN.test(rpcErrorMessage));
391
+ }
package/package.json CHANGED
@@ -1,6 +1,50 @@
1
1
  {
2
2
  "name": "@heightproof/shared",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Shared event schema, JSON-RPC normalization and reliability detectors for Heightproof.",
5
+ "keywords": [
6
+ "heightproof",
7
+ "ethereum",
8
+ "json-rpc",
9
+ "observability"
10
+ ],
11
+ "license": "MIT",
12
+ "repository": {
13
+ "type": "git",
14
+ "url": "git+https://github.com/macjayz/heightproof.git",
15
+ "directory": "packages/shared"
16
+ },
17
+ "homepage": "https://github.com/macjayz/heightproof#readme",
18
+ "bugs": "https://github.com/macjayz/heightproof/issues",
19
+ "type": "module",
20
+ "main": "dist/index.js",
21
+ "types": "dist/index.d.ts",
22
+ "exports": {
23
+ ".": {
24
+ "types": "./dist/index.d.ts",
25
+ "import": "./dist/index.js"
26
+ }
27
+ },
28
+ "files": [
29
+ "dist",
30
+ "README.md",
31
+ "LICENSE"
32
+ ],
33
+ "engines": {
34
+ "node": ">=20"
35
+ },
36
+ "publishConfig": {
37
+ "access": "public"
38
+ },
39
+ "scripts": {
40
+ "build": "tsc -p tsconfig.json",
41
+ "test": "vitest run",
42
+ "prepublishOnly": "npm run build"
43
+ },
44
+ "dependencies": {
45
+ "@noble/curves": "1.9.7",
46
+ "@noble/hashes": "1.8.0",
47
+ "@opentelemetry/api": "^1.9.1",
48
+ "zod": "^3.23.8"
49
+ }
50
+ }