@gibs/common 1.18.0 → 1.20.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/assert.js CHANGED
@@ -1,25 +1,9 @@
1
- /**
2
- * Asserts that a value is defined (not null, undefined, or falsy)
3
- * Throws a descriptive error if the value is falsy
4
- *
5
- * @param value - The value to check
6
- * @param context - Context description for the error message
7
- * @returns The value if defined
8
- * @throws Error if value is falsy
9
- */
10
1
  export function ensureResult(value, context) {
11
2
  if (!value) {
12
3
  throw new Error(`${context}: Expected result but got ${value}`);
13
4
  }
14
5
  return value;
15
6
  }
16
- /**
17
- * Type guard that asserts a value is defined
18
- * Similar to ensureResult but uses TypeScript's asserts syntax
19
- *
20
- * @param value - The value to check
21
- * @param message - Error message if assertion fails
22
- */
23
7
  export function assertDefined(value, message) {
24
8
  if (value === null || value === undefined) {
25
9
  throw new Error(message);
package/dist/cache.js CHANGED
@@ -1,10 +1,4 @@
1
1
  import { jsonAnyStringify } from './serialize.js';
2
- /**
3
- * Memoize a function result and return a single value
4
- * @param fn the function to memoize
5
- * @param max the maximum number of values to cache
6
- * @returns the memoized function
7
- */
8
2
  export const maxMemoize = (fn, max = 1024) => {
9
3
  const cache = new Map();
10
4
  return (...a) => {
@@ -24,12 +18,6 @@ export const maxMemoize = (fn, max = 1024) => {
24
18
  };
25
19
  };
26
20
  const hour1 = 1000 * 60 * 60;
27
- /**
28
- * Memoize a function result and return a single value
29
- * @param fn the function to memoize
30
- * @param ttl the time to live for the cache
31
- * @returns the memoized function
32
- */
33
21
  export const ttlMemoizeSingle = (fn, ttl = hour1) => {
34
22
  let cache = null;
35
23
  return (...a) => {
@@ -42,23 +30,9 @@ export const ttlMemoizeSingle = (fn, ttl = hour1) => {
42
30
  return result;
43
31
  };
44
32
  };
45
- /**
46
- * Memoizes a function under a key the caller computes from its arguments.
47
- *
48
- * This is the subset of `lodash.memoize` the packages used: one answer per
49
- * key, never expired, with `cache.delete` and `cache.clear` for eviction. As
50
- * with lodash, the `cache` property may be replaced outright. It
51
- * exists so the published packages carry no lodash-es, whose ECMAScript-module
52
- * only build a React Native jest run cannot load from `node_modules`.
53
- *
54
- * @param fn - the function to memoize
55
- * @param resolver - computes the cache key from the arguments
56
- * @returns the memoized function, with its `cache`
57
- */
58
33
  export const memoizeByKey = (fn, resolver) => {
59
34
  const memoized = Object.assign((...args) => {
60
35
  const key = resolver(...args);
61
- // Read the property on every call: a caller may have replaced the Map.
62
36
  const { cache } = memoized;
63
37
  if (cache.has(key)) {
64
38
  return cache.get(key);
@@ -2,28 +2,12 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.ensureResult = ensureResult;
4
4
  exports.assertDefined = assertDefined;
5
- /**
6
- * Asserts that a value is defined (not null, undefined, or falsy)
7
- * Throws a descriptive error if the value is falsy
8
- *
9
- * @param value - The value to check
10
- * @param context - Context description for the error message
11
- * @returns The value if defined
12
- * @throws Error if value is falsy
13
- */
14
5
  function ensureResult(value, context) {
15
6
  if (!value) {
16
7
  throw new Error(`${context}: Expected result but got ${value}`);
17
8
  }
18
9
  return value;
19
10
  }
20
- /**
21
- * Type guard that asserts a value is defined
22
- * Similar to ensureResult but uses TypeScript's asserts syntax
23
- *
24
- * @param value - The value to check
25
- * @param message - Error message if assertion fails
26
- */
27
11
  function assertDefined(value, message) {
28
12
  if (value === null || value === undefined) {
29
13
  throw new Error(message);
package/dist/cjs/cache.js CHANGED
@@ -2,12 +2,6 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.memoizeByKey = exports.ttlMemoizeSingle = exports.maxMemoize = void 0;
4
4
  const serialize_js_1 = require("./serialize.js");
5
- /**
6
- * Memoize a function result and return a single value
7
- * @param fn the function to memoize
8
- * @param max the maximum number of values to cache
9
- * @returns the memoized function
10
- */
11
5
  const maxMemoize = (fn, max = 1024) => {
12
6
  const cache = new Map();
13
7
  return (...a) => {
@@ -28,12 +22,6 @@ const maxMemoize = (fn, max = 1024) => {
28
22
  };
29
23
  exports.maxMemoize = maxMemoize;
30
24
  const hour1 = 1000 * 60 * 60;
31
- /**
32
- * Memoize a function result and return a single value
33
- * @param fn the function to memoize
34
- * @param ttl the time to live for the cache
35
- * @returns the memoized function
36
- */
37
25
  const ttlMemoizeSingle = (fn, ttl = hour1) => {
38
26
  let cache = null;
39
27
  return (...a) => {
@@ -47,23 +35,9 @@ const ttlMemoizeSingle = (fn, ttl = hour1) => {
47
35
  };
48
36
  };
49
37
  exports.ttlMemoizeSingle = ttlMemoizeSingle;
50
- /**
51
- * Memoizes a function under a key the caller computes from its arguments.
52
- *
53
- * This is the subset of `lodash.memoize` the packages used: one answer per
54
- * key, never expired, with `cache.delete` and `cache.clear` for eviction. As
55
- * with lodash, the `cache` property may be replaced outright. It
56
- * exists so the published packages carry no lodash-es, whose ECMAScript-module
57
- * only build a React Native jest run cannot load from `node_modules`.
58
- *
59
- * @param fn - the function to memoize
60
- * @param resolver - computes the cache key from the arguments
61
- * @returns the memoized function, with its `cache`
62
- */
63
38
  const memoizeByKey = (fn, resolver) => {
64
39
  const memoized = Object.assign((...args) => {
65
40
  const key = resolver(...args);
66
- // Read the property on every call: a caller may have replaced the Map.
67
41
  const { cache } = memoized;
68
42
  if (cache.has(key)) {
69
43
  return cache.get(key);
@@ -6,120 +6,33 @@ const rpcPacer_js_1 = require("./rpcPacer.js");
6
6
  const url_js_1 = require("./url.js");
7
7
  var rpcPacer_js_2 = require("./rpcPacer.js");
8
8
  Object.defineProperty(exports, "MAX_SAME_METHOD_CALLS_PER_SECOND", { enumerable: true, get: function () { return rpcPacer_js_2.MAX_SAME_METHOD_CALLS_PER_SECOND; } });
9
- /**
10
- * JSON-RPC request batching config. When enabled, viem coalesces requests made
11
- * within `wait` milliseconds (up to `batchSize`) into ONE HTTP POST carrying a
12
- * JSON-RPC array body. This is the default because it sharply cuts request
13
- * volume on nodes that support it.
14
- *
15
- * Note: some public nodes (notably some Binance Smart Chain endpoints) reject the
16
- * array-wrapped body — even for a single request — which makes every call fail
17
- * and get retried, BURNING through requests. Such chains must construct their
18
- * client with `batch: false` (see {@link clientFromChain}'s `batch` option) so
19
- * requests are sent as plain JSON-RPC objects the node accepts.
20
- */
21
9
  exports.defaultRpcBatch = {
22
10
  wait: 10,
23
11
  batchSize: 32,
24
12
  };
25
- /** The default batch config for the client */
26
13
  exports.defaultBatchConfig = {
27
14
  batch: exports.defaultRpcBatch,
28
15
  };
29
- /**
30
- * Returns the chain key for the given chain id and urls
31
- * @param chainId - the chain id
32
- * @param urls - the urls
33
- * @returns the chain key
34
- */
35
16
  const chainKey = (chainId, urls) => {
36
17
  return `${chainId},${urls.join(',')}`.toLowerCase();
37
18
  };
38
19
  exports.chainKey = chainKey;
39
- /** The client cache for the given chain id */
40
20
  exports.clientCache = new Map();
41
- /**
42
- * Returns the client for the given chain and urls
43
- * @param chain - the chain
44
- * @param urls - the urls
45
- * @returns the client
46
- */
47
- /**
48
- * The one pacer for the whole application.
49
- *
50
- * MODULE LEVEL, DELIBERATELY. The endpoint keys its budget on the METHOD, not
51
- * the chain — six `eth_blockNumber` on PulseChain plus six on Ethereum were
52
- * answered exactly eight times, the same as twelve on one chain. A pacer per
53
- * client would let two chains spend one budget twice and neither would know it
54
- * had.
55
- *
56
- * It is built with the same ceiling the batch size uses, so the two can never
57
- * describe different limits.
58
- */
59
21
  const pacer = (0, rpcPacer_js_1.createRpcPacer)({ ceiling: rpcPacer_js_1.MAX_SAME_METHOD_CALLS_PER_SECOND });
60
- /**
61
- * Holds a request until its methods fit under the ceiling.
62
- *
63
- * WIRED HERE RATHER THAN AROUND THE CALLERS because this is the only place that
64
- * sees the FINAL body: viem coalesces separate reads into one post, so a limiter
65
- * upstream of the transport would count intentions and this counts what is
66
- * actually sent.
67
- *
68
- * Returning nothing leaves the request untouched — the hook exists to delay, not
69
- * to rewrite.
70
- */
71
22
  const paceRequest = async (request) => {
72
- // The HOST, not the full url: one proxy serves every chain from one budget,
73
- // so `/evm/1` and `/evm/369` must count against the same tally.
74
23
  const { host } = (0, url_js_1.splitUrl)(request.url);
75
24
  await pacer.reserve(host, (0, rpcPacer_js_1.countMethods)(await request.clone().text().catch(() => null)));
76
25
  };
77
- /**
78
- * Spreads requests across a chain's endpoints instead of resting on the first.
79
- *
80
- * WHAT `fallback` ACTUALLY DOES, and why it is not this. With `rank: false` it
81
- * sends every request to entry zero and moves on only when that entry ERRORS. So
82
- * the spares behind a chain carry no load at all: on Ethereum, `valve.city` took
83
- * one hundred percent of every read while two perfectly good nodes sat idle, and
84
- * the only way traffic ever reached them was a refusal — a wasted round trip
85
- * spent discovering a spare we could have used on purpose.
86
- *
87
- * That matters because the first entry is a SHARED proxy. Its key is published
88
- * on chainlist.org, so its per-second budget is not ours alone: measured at a
89
- * refusal, our own calls in the preceding second were seven against a ceiling of
90
- * eight. We were not over the limit. Somebody else was using the rest of it, and
91
- * no amount of pacing on our side fixes that — only using less of that host
92
- * does.
93
- *
94
- * ROUND ROBIN, AND FALLBACK STILL INTACT. Each request starts at the next
95
- * endpoint in rotation and walks the rest on failure, so a dead host costs one
96
- * attempt exactly as it did before while a healthy one now carries its share.
97
- *
98
- * NOT `rank: true`, which is viem's own answer to this and is the wrong one
99
- * here: ranking pings EVERY url on EVERY chain every few seconds forever, which
100
- * on an idle page was once the entire outbound traffic and is what spent the
101
- * shared budget in the first place. It also reorders a list this project curates
102
- * deliberately.
103
- */
104
26
  const balanced = (transports) => {
105
27
  if (transports.length === 1)
106
28
  return transports[0];
107
29
  return (config) => {
108
30
  const instances = transports.map((transport) => transport(config));
109
- // Module-level rotation per client, not per call site: every reader on the
110
- // page shares one cursor, which is what makes the split even rather than
111
- // each caller starting at zero and piling onto the same host.
112
31
  let cursor = 0;
113
32
  return (0, viem_1.createTransport)({
114
33
  key: 'balanced',
115
34
  name: 'Balanced',
116
35
  type: 'balanced',
117
- // CAST AT THE BOUNDARY, deliberately. `EIP1193RequestFn` is generic over
118
- // the method being called and resolves its own return type; a rotation
119
- // cannot preserve that relationship because it does not know which
120
- // transport will answer until it has tried one. The delegates below are
121
- // fully typed and the shape passing through is unchanged — only the
122
- // compiler's ability to follow it across the loop is lost.
123
36
  request: (async (args) => {
124
37
  const start = cursor;
125
38
  cursor = (cursor + 1) % instances.length;
@@ -129,10 +42,6 @@ const balanced = (transports) => {
129
42
  return await instances[(start + offset) % instances.length].request(args);
130
43
  }
131
44
  catch (error) {
132
- // KEPT AND RETHROWN AT THE END, not swallowed. If every endpoint
133
- // refuses, the caller must see a real error — a rotation that
134
- // returns undefined when all its hosts are down turns an outage
135
- // into a silently empty read.
136
45
  lastError = error;
137
46
  }
138
47
  }
package/dist/cjs/erc20.js CHANGED
@@ -4,19 +4,11 @@ exports.multicallErc20 = exports.erc20MetadataCalls = void 0;
4
4
  const viem_1 = require("viem");
5
5
  const cache_js_1 = require("./cache.js");
6
6
  const multicall_js_1 = require("./multicall.js");
7
- /** The erc20 metadata calls */
8
7
  exports.erc20MetadataCalls = [
9
8
  { functionName: 'name' },
10
9
  { functionName: 'symbol' },
11
10
  { functionName: 'decimals' },
12
11
  ];
13
- /**
14
- * The multicall for the erc20 metadata.
15
- *
16
- * The annotation spells out the plain call signature, which keeps the
17
- * memoization an implementation detail: no caller touches the `.cache`
18
- * property it would otherwise publish.
19
- */
20
12
  exports.multicallErc20 = (0, cache_js_1.memoizeByKey)(async ({ client, target, chain }) => {
21
13
  const options = {
22
14
  chain: chain,
@@ -3,91 +3,11 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.multicallRead = exports.chunkBySize = exports.multicallLimits = void 0;
4
4
  const viem_1 = require("viem");
5
5
  const assert_js_1 = require("./assert.js");
6
- /**
7
- * The largest request body the leading endpoint accepts, in bytes. MEASURED,
8
- * not assumed.
9
- *
10
- * `one.valve.city` answers a 102,400 byte body with 200 and a 102,656 byte body
11
- * with 413, bisected on 2026-09-12. That is the 100 kibibyte default of
12
- * `express.json`, and the proxy leads every chain list this application ships,
13
- * so it is the number every other budget here has to clear.
14
- *
15
- * THE PREVIOUS VALUE WAS A GUESS AND IT WAS FIVE TIMES TOO LARGE. It said "the
16
- * one-megabyte body limit that is the default in the reverse proxies these
17
- * endpoints sit behind", which is Nginx's default, not Express's — and the
18
- * endpoint in front is Express. Nobody had asked the endpoint.
19
- */
20
6
  const ENDPOINT_BODY_LIMIT_BYTES = 100 * 1024;
21
- /**
22
- * How large a single HTTP request body to the node may get, in bytes.
23
- *
24
- * Nothing bounded this before, so the whole call list went out at once and the
25
- * size followed whatever the caller happened to be reading. The token picker
26
- * reads a balance per token, and PulseChain's list runs to about eleven and a
27
- * half thousand of them: a single 8.5 MB `eth_call`, repeated on every open,
28
- * which `rpc.pulsechain.com` answered with HTTP 413. The caller catches that and
29
- * falls back to an empty map, so the visible result was not an error — it was
30
- * every balance quietly missing.
31
- *
32
- * Four kibibytes under {@link ENDPOINT_BODY_LIMIT_BYTES}, because the budget
33
- * below counts only the calls: `aggregate3` adds a fixed header of its own and
34
- * the JSON-RPC envelope wraps the lot, and neither is charged to a call.
35
- */
36
7
  const HTTP_BODY_TARGET_BYTES = ENDPOINT_BODY_LIMIT_BYTES - 4 * 1024;
37
- /**
38
- * How many chunks may be in flight at once.
39
- *
40
- * Splitting a request is only an improvement if the pieces do not arrive as a
41
- * burst; an endpoint that rejected one large body will just as happily rate-limit
42
- * two dozen small ones.
43
- *
44
- * ONE, AND RUNNING MORE BUYS NOTHING. The transport coalesces calls made in the
45
- * same tick into one post, so four chunks at a quarter of the budget each arrive
46
- * as ONE post of the same total size as a single full-budget chunk — the same
47
- * bytes, in the same round trip, at four times the call count. The proxy's
48
- * budget counts CALLS, not posts and not bytes, so the wider setting spent four
49
- * times the rate limit for identical throughput.
50
- *
51
- * Measured on one token-picker open, 2026-09-12: at a concurrency of four the
52
- * peak was eleven calls of one method in a second against a ceiling of eight,
53
- * and one post was refused; at one the peak was seven and every post was
54
- * answered. Request bytes fell from 737,057 to 638,890 in the same comparison,
55
- * because nothing had to be resent to a fallback endpoint.
56
- */
57
8
  const CHUNK_CONCURRENCY = 1;
58
- /**
59
- * How much encoded call data one `aggregate3` may carry.
60
- *
61
- * Divided by the concurrency rather than set directly, because the transport
62
- * COALESCES calls made in the same tick into one HTTP body. Chunking to a
63
- * quarter-megabyte budget and running four at a time was measured arriving as a
64
- * single 1.37 MB request — the split was real, and then undone at the layer
65
- * below. What the node sees is this budget multiplied by the concurrency, so
66
- * that is the relationship worth writing down; changing either constant alone
67
- * cannot silently blow the target.
68
- *
69
- * STILL TRUE AT A CONCURRENCY OF ONE, and that is why the division stays rather
70
- * than being folded away: raising the concurrency again without cutting this
71
- * budget would put the target back over the endpoint's limit, and the division
72
- * is what stops that being silent.
73
- */
74
9
  const CALLDATA_BUDGET_BYTES = Math.floor(HTTP_BODY_TARGET_BYTES / CHUNK_CONCURRENCY);
75
- /**
76
- * Bytes of request body a single call costs beyond its own encoded data.
77
- * MEASURED against a real `aggregate3` encoding, not estimated.
78
- *
79
- * `aggregate3` spends five thirty-two byte words per call — the head offset, the
80
- * target, the failure flag, the data offset and the data length — which is 320
81
- * hexadecimal characters, plus up to 63 more padding the call data out to a word
82
- * boundary. A `balanceOf` whose own call data is 74 characters was measured
83
- * costing 448.6 bytes of body, so the real overhead is about 375 and the old
84
- * value of 200 UNDERSTATED IT BY NEARLY A HALF.
85
- *
86
- * That is how a chunk budgeted at 128 kibibytes went out as a 214 kilobyte body.
87
- * Four hundred keeps a small margin over the measurement.
88
- */
89
10
  const PER_CALL_OVERHEAD_BYTES = 400;
90
- /** The size limits, exported so tests assert against the real values. */
91
11
  exports.multicallLimits = {
92
12
  endpointBodyLimitBytes: ENDPOINT_BODY_LIMIT_BYTES,
93
13
  httpBodyTargetBytes: HTTP_BODY_TARGET_BYTES,
@@ -95,22 +15,6 @@ exports.multicallLimits = {
95
15
  chunkConcurrency: CHUNK_CONCURRENCY,
96
16
  perCallOverheadBytes: PER_CALL_OVERHEAD_BYTES,
97
17
  };
98
- /**
99
- * Splits encoded calls into groups whose combined request body stays under
100
- * {@link CALLDATA_BUDGET_BYTES}.
101
- *
102
- * Grouped by measured size rather than by a fixed count on purpose: callers
103
- * differ by more than an order of magnitude in how large one call encodes to — a
104
- * bare `balanceOf` against a router quote carrying a swap path — so any single
105
- * call limit would be far too cautious for one caller and useless for another.
106
- *
107
- * A call that exceeds the budget on its own still gets its own group; splitting
108
- * it further is not possible, and refusing to send it would turn an oversized
109
- * request into a missing result.
110
- *
111
- * @param encoded - the calls to group, in order
112
- * @returns groups preserving the original order
113
- */
114
18
  const chunkBySize = (encoded) => {
115
19
  const chunks = [];
116
20
  let current = [];
@@ -130,14 +34,6 @@ const chunkBySize = (encoded) => {
130
34
  return chunks;
131
35
  };
132
36
  exports.chunkBySize = chunkBySize;
133
- /**
134
- * Runs `task` over every item with at most `concurrency` in flight, preserving
135
- * input order in the result.
136
- *
137
- * @param items - the work items
138
- * @param concurrency - how many may run at once
139
- * @param task - what to do with one item
140
- */
141
37
  const mapWithConcurrency = async (items, concurrency, task) => {
142
38
  const results = new Array(items.length);
143
39
  let next = 0;
@@ -151,33 +47,16 @@ const mapWithConcurrency = async (items, concurrency, task) => {
151
47
  await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, worker));
152
48
  return results;
153
49
  };
154
- /**
155
- * Reads many contract calls through Multicall3, splitting the work across as
156
- * many requests as the size budget needs and reassembling the results in order.
157
- *
158
- * Chunking is invisible to callers: the returned array lines up with `calls`
159
- * one-for-one exactly as it did when every call went out together, and a chunk
160
- * that rejects still rejects the whole read rather than returning a short array
161
- * that a caller would silently misread as "these tokens have no balance".
162
- *
163
- * @param options.chain - the chain, used for its Multicall3 deployment
164
- * @param options.client - the public client to read through
165
- * @param options.abi - the default ABI, when a call does not carry its own
166
- * @param options.calls - the calls to make, in the order results are wanted
167
- * @param options.target - the default contract, when a call does not name one
168
- */
169
50
  const multicallRead = async ({ chain, client, abi, calls, target, }) => {
170
51
  const multicall = (0, viem_1.getContract)({
171
52
  abi: viem_1.multicall3Abi,
172
53
  address: chain.contracts.multicall3.address,
173
- // this seems like a bug in viem, it should be able to infer the type of the client
174
54
  client: client,
175
55
  });
176
56
  const arg = calls.map((call) => ({
177
57
  callData: (0, viem_1.encodeFunctionData)({
178
58
  abi: call.abi || abi,
179
59
  functionName: call.functionName,
180
- // this seems like a bug in viem, it should be able to infer the type of the args
181
60
  args: (call.args || []),
182
61
  }),
183
62
  allowFailure: call.allowFailure || false,
@@ -202,9 +81,6 @@ const multicallRead = async ({ chain, client, abi, calls, target, }) => {
202
81
  }));
203
82
  }
204
83
  catch (err) {
205
- // Deliberately a summary rather than the calls themselves: a failing read can
206
- // hold tens of thousands of them, and dumping the array cost more than the
207
- // failure did.
208
84
  console.error('Multicall aggregate3 failed', {
209
85
  chainId: client.chain?.id,
210
86
  target,
@@ -1,81 +1,19 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.countMethods = exports.createRpcPacer = exports.MAX_SAME_METHOD_CALLS_PER_SECOND = void 0;
4
- /**
5
- * Holds outgoing JSON-RPC requests back so no method crosses the endpoint's
6
- * per-second ceiling.
7
- *
8
- * WHY BATCH SIZE WAS NOT ENOUGH, WHICH IS THE WHOLE POINT OF THIS FILE. The
9
- * transport already caps a single post at the ceiling, and that stops one post
10
- * being refused for being too big. It says nothing about how many posts leave
11
- * per second. Measured on one page load: nine `eth_call` inside a single rolling
12
- * second, spread across four posts of which none was individually too large, and
13
- * the fourth came back `429`.
14
- *
15
- * A ROLLING WINDOW, NOT A BUCKET. The first measurement of this counted calls
16
- * into whole seconds aligned to the page load and reported nothing over the
17
- * ceiling while a refusal sat in the same run — six calls at 1.9 seconds and
18
- * seven at 2.1 fall in different buckets and land 200 milliseconds apart. A rate
19
- * limiter does not know when our page started, so neither does this.
20
- *
21
- * ONE LIMITER FOR THE WHOLE APPLICATION, not one per client — but keyed on the
22
- * HOST as well as the method.
23
- *
24
- * The budget is the host's, and it is shared across chains: six
25
- * `eth_blockNumber` on PulseChain plus six on Ethereum were answered exactly
26
- * eight times, the same as twelve on one chain. So a limiter per client would
27
- * let two chains spend one budget twice and neither would know.
28
- *
29
- * But two DIFFERENT hosts have two different budgets, and counting them
30
- * together would throttle a spare endpoint for traffic it never received —
31
- * which is exactly backwards once requests are spread across hosts on purpose.
32
- */
33
- /**
34
- * The most calls of ONE method the proxy answers in one second. MEASURED.
35
- *
36
- * `one.valve.city` refuses past this with HTTP 429 and the body
37
- * `Rate limit exceeded (per method, per second)`. Probed 2026-09-12 at
38
- * concurrency 1, 2, 3, 4, 6, 8, 12, 16 and 24: every request up to eight was
39
- * answered and everything past eight was refused, with no variance across
40
- * twenty rounds. The budget is shared across CHAINS — six calls on chain 369
41
- * plus six on chain 1 were answered exactly eight times, the same as twelve on
42
- * one chain — so it is keyed on the method, not the chain.
43
- *
44
- * A BATCH THAT CROSSES THE CEILING IS REFUSED WHOLE, which is why this bounds
45
- * the batch size. One post carrying sixteen `eth_blockNumber` calls came back
46
- * with all sixteen refused; the same sixteen sent as separate posts had eight
47
- * answered. Coalescing past the ceiling therefore converts a partial refusal
48
- * into a total one, and viem's default of thirty-two calls per post is four
49
- * times over it.
50
- */
51
4
  exports.MAX_SAME_METHOD_CALLS_PER_SECOND = 8;
52
5
  const systemClock = {
53
6
  now: () => Date.now(),
54
7
  sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
55
8
  };
56
- /** The window the ceiling is counted over. */
57
9
  const WINDOW_MS = 1_000;
58
- /**
59
- * Builds a pacer.
60
- *
61
- * @param options - the ceiling, and optionally a clock to drive it.
62
- * @returns a pacer.
63
- */
64
10
  const createRpcPacer = ({ ceiling, clock = systemClock, }) => {
65
- /** When each still-counting call was sent, per host-and-method, oldest first. */
66
11
  const sent = new Map();
67
- /**
68
- * Reservations run ONE AT A TIME, chained. Two callers checking the window
69
- * concurrently both see room and both take it — the classic check-then-act
70
- * race, and the reason a limiter that looks right can still emit bursts. The
71
- * chain makes "is there room" and "take it" a single step.
72
- */
73
12
  let queue = Promise.resolve();
74
13
  const forget = (method, now) => {
75
14
  const times = sent.get(method);
76
15
  if (!times)
77
16
  return [];
78
- // Times are appended in order, so everything expired is a prefix.
79
17
  let keep = 0;
80
18
  while (keep < times.length && now - times[keep] >= WINDOW_MS)
81
19
  keep += 1;
@@ -84,11 +22,6 @@ const createRpcPacer = ({ ceiling, clock = systemClock, }) => {
84
22
  return live;
85
23
  };
86
24
  const reserveOne = async (key, count) => {
87
- // A request carrying MORE than the ceiling of one method cannot ever fit,
88
- // and waiting for room that will never exist would hang the page forever.
89
- // The transport caps a post at the ceiling, so this is a guard against a
90
- // caller that bypassed it rather than an expected path: spend what there is
91
- // and let the endpoint answer for the rest.
92
25
  const want = Math.min(count, ceiling);
93
26
  for (;;) {
94
27
  const now = clock.now();
@@ -99,9 +32,6 @@ const createRpcPacer = ({ ceiling, clock = systemClock, }) => {
99
32
  sent.set(key, live);
100
33
  return;
101
34
  }
102
- // Wait exactly until the oldest call leaves the window — not a fixed
103
- // retry interval, which would either spin or overshoot. The `+ 1` is so
104
- // the comparison above is past the boundary rather than on it.
105
35
  const oldest = live[0];
106
36
  await clock.sleep(Math.max(1, oldest + WINDOW_MS - now + 1));
107
37
  }
@@ -109,15 +39,10 @@ const createRpcPacer = ({ ceiling, clock = systemClock, }) => {
109
39
  return {
110
40
  reserve: (host, counts) => {
111
41
  const run = queue.then(async () => {
112
- // Ordered so the same body always reserves its methods in the same
113
- // sequence; two bodies reserving the same pair in opposite orders is
114
- // how a queue deadlocks itself.
115
42
  for (const method of [...counts.keys()].sort()) {
116
43
  await reserveOne(`${host}|${method}`, counts.get(method) ?? 0);
117
44
  }
118
45
  });
119
- // The queue must continue even when a reservation throws, or one failure
120
- // stops every later request on the page.
121
46
  queue = run.catch(() => undefined);
122
47
  return run;
123
48
  },
@@ -125,16 +50,6 @@ const createRpcPacer = ({ ceiling, clock = systemClock, }) => {
125
50
  };
126
51
  };
127
52
  exports.createRpcPacer = createRpcPacer;
128
- /**
129
- * Counts the calls per method in a JSON-RPC body.
130
- *
131
- * A batch is an ARRAY and a single call is an object; both arrive here, and a
132
- * body that cannot be read at all counts as nothing rather than throwing —
133
- * pacing is an optimisation and must never be the reason a request fails.
134
- *
135
- * @param body - the request body, as text.
136
- * @returns method to how many calls of it the body carries.
137
- */
138
53
  const countMethods = (body) => {
139
54
  const counts = new Map();
140
55
  if (!body)
@@ -2,7 +2,6 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.jsonAnyParse = exports.isSerializedBigInt = exports.jsonAnyStringify = void 0;
4
4
  const BIGINT_TYPE = 'bigint';
5
- /** The json any stringify for the given key and value */
6
5
  const jsonAnyStringify = (_key, value) => {
7
6
  if (typeof value === BIGINT_TYPE) {
8
7
  return {
@@ -13,29 +12,11 @@ const jsonAnyStringify = (_key, value) => {
13
12
  return value;
14
13
  };
15
14
  exports.jsonAnyStringify = jsonAnyStringify;
16
- /** The is serialized big int for the given value */
17
15
  const isSerializedBigInt = (value) => {
18
16
  return (!!value && typeof value === 'object' && value.__type__ === BIGINT_TYPE);
19
17
  };
20
18
  exports.isSerializedBigInt = isSerializedBigInt;
21
- /**
22
- * The only strings `jsonAnyStringify` ever writes: what `bigint.toString()`
23
- * produces, which is an optional minus sign followed by decimal digits.
24
- *
25
- * Anything else in the `value` field means the record is corrupt, and `BigInt`
26
- * is too permissive to notice. `BigInt('')` is 0n and `BigInt('0x10')` is 16n,
27
- * so a truncated or garbled record parses into a plausible-looking balance
28
- * instead of an error — and a wrong balance is spent, displayed and believed.
29
- */
30
19
  const DECIMAL_BIG_INT = /^-?\d+$/;
31
- /**
32
- * The json any parse for the given key and value.
33
- *
34
- * Throws on a corrupt encoded value rather than returning a number for it. Every
35
- * caller reads from local storage inside a try/catch and falls back to a
36
- * default, so a throw discards one unusable record; a silent zero would survive
37
- * into the arithmetic.
38
- */
39
20
  const jsonAnyParse = (_key, value) => {
40
21
  if ((0, exports.isSerializedBigInt)(value)) {
41
22
  if (typeof value.value !== 'string' || !DECIMAL_BIG_INT.test(value.value)) {