@gibs/common 1.13.0 → 1.17.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/README.md +34 -4
- package/cache/package.json +6 -0
- package/client/package.json +6 -0
- package/dist/cache.d.ts +25 -0
- package/dist/cache.js +27 -0
- package/dist/cjs/assert.d.ts +18 -0
- package/dist/cjs/assert.js +31 -0
- package/dist/cjs/cache.d.ts +39 -0
- package/dist/cjs/cache.js +77 -0
- package/dist/cjs/client.d.ts +48 -0
- package/dist/cjs/client.js +171 -0
- package/dist/cjs/erc20.d.ts +18 -0
- package/dist/cjs/erc20.js +39 -0
- package/dist/cjs/index.d.ts +8 -0
- package/dist/cjs/index.js +24 -0
- package/dist/cjs/multicall.d.ts +56 -0
- package/dist/cjs/multicall.js +217 -0
- package/dist/cjs/package.json +3 -0
- package/dist/cjs/rpcPacer.d.ts +88 -0
- package/dist/cjs/rpcPacer.js +158 -0
- package/dist/cjs/serialize.d.ts +19 -0
- package/dist/cjs/serialize.js +48 -0
- package/dist/cjs/types.d.ts +11 -0
- package/dist/cjs/types.js +2 -0
- package/dist/cjs/url.d.ts +110 -0
- package/dist/cjs/url.js +292 -0
- package/dist/client.js +2 -1
- package/dist/erc20.d.ts +3 -5
- package/dist/erc20.js +5 -7
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/multicall.js +1 -2
- package/dist/url.d.ts +110 -0
- package/dist/url.js +281 -0
- package/erc20/package.json +6 -0
- package/llms.txt +89 -0
- package/multicall/package.json +6 -0
- package/package.json +205 -8
- package/serialize/package.json +6 -0
- package/types/package.json +6 -0
- package/url/package.json +6 -0
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { type Abi, type Chain, type Hex, type PublicClient } from 'viem';
|
|
2
|
+
import type { Call } from './types.js';
|
|
3
|
+
/** The size limits, exported so tests assert against the real values. */
|
|
4
|
+
export declare const multicallLimits: {
|
|
5
|
+
readonly endpointBodyLimitBytes: number;
|
|
6
|
+
readonly httpBodyTargetBytes: number;
|
|
7
|
+
readonly callDataBudgetBytes: number;
|
|
8
|
+
readonly chunkConcurrency: 1;
|
|
9
|
+
readonly perCallOverheadBytes: 400;
|
|
10
|
+
};
|
|
11
|
+
/** An `aggregate3` tuple, already encoded and ready to send. */
|
|
12
|
+
type EncodedCall = {
|
|
13
|
+
callData: Hex;
|
|
14
|
+
allowFailure: boolean;
|
|
15
|
+
target: Hex;
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* Splits encoded calls into groups whose combined request body stays under
|
|
19
|
+
* {@link CALLDATA_BUDGET_BYTES}.
|
|
20
|
+
*
|
|
21
|
+
* Grouped by measured size rather than by a fixed count on purpose: callers
|
|
22
|
+
* differ by more than an order of magnitude in how large one call encodes to — a
|
|
23
|
+
* bare `balanceOf` against a router quote carrying a swap path — so any single
|
|
24
|
+
* call limit would be far too cautious for one caller and useless for another.
|
|
25
|
+
*
|
|
26
|
+
* A call that exceeds the budget on its own still gets its own group; splitting
|
|
27
|
+
* it further is not possible, and refusing to send it would turn an oversized
|
|
28
|
+
* request into a missing result.
|
|
29
|
+
*
|
|
30
|
+
* @param encoded - the calls to group, in order
|
|
31
|
+
* @returns groups preserving the original order
|
|
32
|
+
*/
|
|
33
|
+
export declare const chunkBySize: (encoded: EncodedCall[]) => EncodedCall[][];
|
|
34
|
+
/**
|
|
35
|
+
* Reads many contract calls through Multicall3, splitting the work across as
|
|
36
|
+
* many requests as the size budget needs and reassembling the results in order.
|
|
37
|
+
*
|
|
38
|
+
* Chunking is invisible to callers: the returned array lines up with `calls`
|
|
39
|
+
* one-for-one exactly as it did when every call went out together, and a chunk
|
|
40
|
+
* that rejects still rejects the whole read rather than returning a short array
|
|
41
|
+
* that a caller would silently misread as "these tokens have no balance".
|
|
42
|
+
*
|
|
43
|
+
* @param options.chain - the chain, used for its Multicall3 deployment
|
|
44
|
+
* @param options.client - the public client to read through
|
|
45
|
+
* @param options.abi - the default ABI, when a call does not carry its own
|
|
46
|
+
* @param options.calls - the calls to make, in the order results are wanted
|
|
47
|
+
* @param options.target - the default contract, when a call does not name one
|
|
48
|
+
*/
|
|
49
|
+
export declare const multicallRead: <T>({ chain, client, abi, calls, target, }: {
|
|
50
|
+
chain: Chain;
|
|
51
|
+
client: PublicClient;
|
|
52
|
+
abi: Abi;
|
|
53
|
+
calls: Call[];
|
|
54
|
+
target?: Hex;
|
|
55
|
+
}) => Promise<T>;
|
|
56
|
+
export {};
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.multicallRead = exports.chunkBySize = exports.multicallLimits = void 0;
|
|
4
|
+
const viem_1 = require("viem");
|
|
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
|
+
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
|
+
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
|
+
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
|
+
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
|
+
const PER_CALL_OVERHEAD_BYTES = 400;
|
|
90
|
+
/** The size limits, exported so tests assert against the real values. */
|
|
91
|
+
exports.multicallLimits = {
|
|
92
|
+
endpointBodyLimitBytes: ENDPOINT_BODY_LIMIT_BYTES,
|
|
93
|
+
httpBodyTargetBytes: HTTP_BODY_TARGET_BYTES,
|
|
94
|
+
callDataBudgetBytes: CALLDATA_BUDGET_BYTES,
|
|
95
|
+
chunkConcurrency: CHUNK_CONCURRENCY,
|
|
96
|
+
perCallOverheadBytes: PER_CALL_OVERHEAD_BYTES,
|
|
97
|
+
};
|
|
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
|
+
const chunkBySize = (encoded) => {
|
|
115
|
+
const chunks = [];
|
|
116
|
+
let current = [];
|
|
117
|
+
let currentBytes = 0;
|
|
118
|
+
for (const call of encoded) {
|
|
119
|
+
const bytes = call.callData.length + PER_CALL_OVERHEAD_BYTES;
|
|
120
|
+
if (current.length > 0 && currentBytes + bytes > CALLDATA_BUDGET_BYTES) {
|
|
121
|
+
chunks.push(current);
|
|
122
|
+
current = [];
|
|
123
|
+
currentBytes = 0;
|
|
124
|
+
}
|
|
125
|
+
current.push(call);
|
|
126
|
+
currentBytes += bytes;
|
|
127
|
+
}
|
|
128
|
+
if (current.length > 0)
|
|
129
|
+
chunks.push(current);
|
|
130
|
+
return chunks;
|
|
131
|
+
};
|
|
132
|
+
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
|
+
const mapWithConcurrency = async (items, concurrency, task) => {
|
|
142
|
+
const results = new Array(items.length);
|
|
143
|
+
let next = 0;
|
|
144
|
+
const worker = async () => {
|
|
145
|
+
while (next < items.length) {
|
|
146
|
+
const index = next;
|
|
147
|
+
next += 1;
|
|
148
|
+
results[index] = await task(items[index]);
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, worker));
|
|
152
|
+
return results;
|
|
153
|
+
};
|
|
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
|
+
const multicallRead = async ({ chain, client, abi, calls, target, }) => {
|
|
170
|
+
const multicall = (0, viem_1.getContract)({
|
|
171
|
+
abi: viem_1.multicall3Abi,
|
|
172
|
+
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
|
+
client: client,
|
|
175
|
+
});
|
|
176
|
+
const arg = calls.map((call) => ({
|
|
177
|
+
callData: (0, viem_1.encodeFunctionData)({
|
|
178
|
+
abi: call.abi || abi,
|
|
179
|
+
functionName: call.functionName,
|
|
180
|
+
// this seems like a bug in viem, it should be able to infer the type of the args
|
|
181
|
+
args: (call.args || []),
|
|
182
|
+
}),
|
|
183
|
+
allowFailure: call.allowFailure || false,
|
|
184
|
+
target: (call.target || target),
|
|
185
|
+
}));
|
|
186
|
+
try {
|
|
187
|
+
const chunks = (0, exports.chunkBySize)(arg);
|
|
188
|
+
const chunkResults = await mapWithConcurrency(chunks, CHUNK_CONCURRENCY, async (chunk) => (0, assert_js_1.ensureResult)(await multicall.read.aggregate3([chunk]), 'Multicall aggregate3'));
|
|
189
|
+
const r = chunkResults.flat();
|
|
190
|
+
return calls.map((call, i) => call.allowFailure
|
|
191
|
+
? r[i].success
|
|
192
|
+
? (0, viem_1.decodeFunctionResult)({
|
|
193
|
+
abi: call.abi || abi,
|
|
194
|
+
functionName: call.functionName,
|
|
195
|
+
data: r[i].returnData,
|
|
196
|
+
})
|
|
197
|
+
: r[i].returnData
|
|
198
|
+
: (0, viem_1.decodeFunctionResult)({
|
|
199
|
+
abi: call.abi || abi,
|
|
200
|
+
functionName: call.functionName,
|
|
201
|
+
data: r[i].returnData,
|
|
202
|
+
}));
|
|
203
|
+
}
|
|
204
|
+
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
|
+
console.error('Multicall aggregate3 failed', {
|
|
209
|
+
chainId: client.chain?.id,
|
|
210
|
+
target,
|
|
211
|
+
callCount: calls.length,
|
|
212
|
+
functionNames: [...new Set(calls.map((call) => call.functionName))],
|
|
213
|
+
});
|
|
214
|
+
throw err;
|
|
215
|
+
}
|
|
216
|
+
};
|
|
217
|
+
exports.multicallRead = multicallRead;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Holds outgoing JSON-RPC requests back so no method crosses the endpoint's
|
|
3
|
+
* per-second ceiling.
|
|
4
|
+
*
|
|
5
|
+
* WHY BATCH SIZE WAS NOT ENOUGH, WHICH IS THE WHOLE POINT OF THIS FILE. The
|
|
6
|
+
* transport already caps a single post at the ceiling, and that stops one post
|
|
7
|
+
* being refused for being too big. It says nothing about how many posts leave
|
|
8
|
+
* per second. Measured on one page load: nine `eth_call` inside a single rolling
|
|
9
|
+
* second, spread across four posts of which none was individually too large, and
|
|
10
|
+
* the fourth came back `429`.
|
|
11
|
+
*
|
|
12
|
+
* A ROLLING WINDOW, NOT A BUCKET. The first measurement of this counted calls
|
|
13
|
+
* into whole seconds aligned to the page load and reported nothing over the
|
|
14
|
+
* ceiling while a refusal sat in the same run — six calls at 1.9 seconds and
|
|
15
|
+
* seven at 2.1 fall in different buckets and land 200 milliseconds apart. A rate
|
|
16
|
+
* limiter does not know when our page started, so neither does this.
|
|
17
|
+
*
|
|
18
|
+
* ONE LIMITER FOR THE WHOLE APPLICATION, not one per client — but keyed on the
|
|
19
|
+
* HOST as well as the method.
|
|
20
|
+
*
|
|
21
|
+
* The budget is the host's, and it is shared across chains: six
|
|
22
|
+
* `eth_blockNumber` on PulseChain plus six on Ethereum were answered exactly
|
|
23
|
+
* eight times, the same as twelve on one chain. So a limiter per client would
|
|
24
|
+
* let two chains spend one budget twice and neither would know.
|
|
25
|
+
*
|
|
26
|
+
* But two DIFFERENT hosts have two different budgets, and counting them
|
|
27
|
+
* together would throttle a spare endpoint for traffic it never received —
|
|
28
|
+
* which is exactly backwards once requests are spread across hosts on purpose.
|
|
29
|
+
*/
|
|
30
|
+
/**
|
|
31
|
+
* The most calls of ONE method the proxy answers in one second. MEASURED.
|
|
32
|
+
*
|
|
33
|
+
* `one.valve.city` refuses past this with HTTP 429 and the body
|
|
34
|
+
* `Rate limit exceeded (per method, per second)`. Probed 2026-09-12 at
|
|
35
|
+
* concurrency 1, 2, 3, 4, 6, 8, 12, 16 and 24: every request up to eight was
|
|
36
|
+
* answered and everything past eight was refused, with no variance across
|
|
37
|
+
* twenty rounds. The budget is shared across CHAINS — six calls on chain 369
|
|
38
|
+
* plus six on chain 1 were answered exactly eight times, the same as twelve on
|
|
39
|
+
* one chain — so it is keyed on the method, not the chain.
|
|
40
|
+
*
|
|
41
|
+
* A BATCH THAT CROSSES THE CEILING IS REFUSED WHOLE, which is why this bounds
|
|
42
|
+
* the batch size. One post carrying sixteen `eth_blockNumber` calls came back
|
|
43
|
+
* with all sixteen refused; the same sixteen sent as separate posts had eight
|
|
44
|
+
* answered. Coalescing past the ceiling therefore converts a partial refusal
|
|
45
|
+
* into a total one, and viem's default of thirty-two calls per post is four
|
|
46
|
+
* times over it.
|
|
47
|
+
*/
|
|
48
|
+
export declare const MAX_SAME_METHOD_CALLS_PER_SECOND = 8;
|
|
49
|
+
/** How the pacer reads the clock and waits, so a test can supply its own. */
|
|
50
|
+
export interface PacerClock {
|
|
51
|
+
/** Milliseconds, monotonic enough for a one-second window. */
|
|
52
|
+
now: () => number;
|
|
53
|
+
/** Resolves after roughly `ms`. */
|
|
54
|
+
sleep: (ms: number) => Promise<void>;
|
|
55
|
+
}
|
|
56
|
+
/** A paced sender. */
|
|
57
|
+
export interface RpcPacer {
|
|
58
|
+
/**
|
|
59
|
+
* Waits until `counts` can be spent without crossing the ceiling, then
|
|
60
|
+
* records them as spent.
|
|
61
|
+
*
|
|
62
|
+
* @param counts - how many calls of each method this request carries.
|
|
63
|
+
*/
|
|
64
|
+
reserve: (host: string, counts: ReadonlyMap<string, number>) => Promise<void>;
|
|
65
|
+
/** How many calls of a method are inside the window for a host. Testing aid. */
|
|
66
|
+
spent: (host: string, method: string) => number;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Builds a pacer.
|
|
70
|
+
*
|
|
71
|
+
* @param options - the ceiling, and optionally a clock to drive it.
|
|
72
|
+
* @returns a pacer.
|
|
73
|
+
*/
|
|
74
|
+
export declare const createRpcPacer: ({ ceiling, clock, }: {
|
|
75
|
+
ceiling: number;
|
|
76
|
+
clock?: PacerClock;
|
|
77
|
+
}) => RpcPacer;
|
|
78
|
+
/**
|
|
79
|
+
* Counts the calls per method in a JSON-RPC body.
|
|
80
|
+
*
|
|
81
|
+
* A batch is an ARRAY and a single call is an object; both arrive here, and a
|
|
82
|
+
* body that cannot be read at all counts as nothing rather than throwing —
|
|
83
|
+
* pacing is an optimisation and must never be the reason a request fails.
|
|
84
|
+
*
|
|
85
|
+
* @param body - the request body, as text.
|
|
86
|
+
* @returns method to how many calls of it the body carries.
|
|
87
|
+
*/
|
|
88
|
+
export declare const countMethods: (body: string | null | undefined) => Map<string, number>;
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
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
|
+
exports.MAX_SAME_METHOD_CALLS_PER_SECOND = 8;
|
|
52
|
+
const systemClock = {
|
|
53
|
+
now: () => Date.now(),
|
|
54
|
+
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
55
|
+
};
|
|
56
|
+
/** The window the ceiling is counted over. */
|
|
57
|
+
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
|
+
const createRpcPacer = ({ ceiling, clock = systemClock, }) => {
|
|
65
|
+
/** When each still-counting call was sent, per host-and-method, oldest first. */
|
|
66
|
+
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
|
+
let queue = Promise.resolve();
|
|
74
|
+
const forget = (method, now) => {
|
|
75
|
+
const times = sent.get(method);
|
|
76
|
+
if (!times)
|
|
77
|
+
return [];
|
|
78
|
+
// Times are appended in order, so everything expired is a prefix.
|
|
79
|
+
let keep = 0;
|
|
80
|
+
while (keep < times.length && now - times[keep] >= WINDOW_MS)
|
|
81
|
+
keep += 1;
|
|
82
|
+
const live = keep === 0 ? times : times.slice(keep);
|
|
83
|
+
sent.set(method, live);
|
|
84
|
+
return live;
|
|
85
|
+
};
|
|
86
|
+
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
|
+
const want = Math.min(count, ceiling);
|
|
93
|
+
for (;;) {
|
|
94
|
+
const now = clock.now();
|
|
95
|
+
const live = forget(key, now);
|
|
96
|
+
if (live.length + want <= ceiling) {
|
|
97
|
+
for (let i = 0; i < count; i += 1)
|
|
98
|
+
live.push(now);
|
|
99
|
+
sent.set(key, live);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
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
|
+
const oldest = live[0];
|
|
106
|
+
await clock.sleep(Math.max(1, oldest + WINDOW_MS - now + 1));
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
return {
|
|
110
|
+
reserve: (host, counts) => {
|
|
111
|
+
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
|
+
for (const method of [...counts.keys()].sort()) {
|
|
116
|
+
await reserveOne(`${host}|${method}`, counts.get(method) ?? 0);
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
// The queue must continue even when a reservation throws, or one failure
|
|
120
|
+
// stops every later request on the page.
|
|
121
|
+
queue = run.catch(() => undefined);
|
|
122
|
+
return run;
|
|
123
|
+
},
|
|
124
|
+
spent: (host, method) => forget(`${host}|${method}`, clock.now()).length,
|
|
125
|
+
};
|
|
126
|
+
};
|
|
127
|
+
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
|
+
const countMethods = (body) => {
|
|
139
|
+
const counts = new Map();
|
|
140
|
+
if (!body)
|
|
141
|
+
return counts;
|
|
142
|
+
let parsed;
|
|
143
|
+
try {
|
|
144
|
+
parsed = JSON.parse(body);
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
return counts;
|
|
148
|
+
}
|
|
149
|
+
const entries = Array.isArray(parsed) ? parsed : [parsed];
|
|
150
|
+
for (const entry of entries) {
|
|
151
|
+
const method = entry?.method;
|
|
152
|
+
if (typeof method !== 'string')
|
|
153
|
+
continue;
|
|
154
|
+
counts.set(method, (counts.get(method) ?? 0) + 1);
|
|
155
|
+
}
|
|
156
|
+
return counts;
|
|
157
|
+
};
|
|
158
|
+
exports.countMethods = countMethods;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
declare const BIGINT_TYPE: "bigint";
|
|
2
|
+
type SerializedBigInt = {
|
|
3
|
+
__type__: typeof BIGINT_TYPE;
|
|
4
|
+
value: string;
|
|
5
|
+
};
|
|
6
|
+
/** The json any stringify for the given key and value */
|
|
7
|
+
export declare const jsonAnyStringify: (_key: string, value: unknown) => unknown;
|
|
8
|
+
/** The is serialized big int for the given value */
|
|
9
|
+
export declare const isSerializedBigInt: (value: unknown) => value is SerializedBigInt;
|
|
10
|
+
/**
|
|
11
|
+
* The json any parse for the given key and value.
|
|
12
|
+
*
|
|
13
|
+
* Throws on a corrupt encoded value rather than returning a number for it. Every
|
|
14
|
+
* caller reads from local storage inside a try/catch and falls back to a
|
|
15
|
+
* default, so a throw discards one unusable record; a silent zero would survive
|
|
16
|
+
* into the arithmetic.
|
|
17
|
+
*/
|
|
18
|
+
export declare const jsonAnyParse: (_key: string, value: unknown) => unknown;
|
|
19
|
+
export {};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.jsonAnyParse = exports.isSerializedBigInt = exports.jsonAnyStringify = void 0;
|
|
4
|
+
const BIGINT_TYPE = 'bigint';
|
|
5
|
+
/** The json any stringify for the given key and value */
|
|
6
|
+
const jsonAnyStringify = (_key, value) => {
|
|
7
|
+
if (typeof value === BIGINT_TYPE) {
|
|
8
|
+
return {
|
|
9
|
+
__type__: BIGINT_TYPE,
|
|
10
|
+
value: value.toString(),
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
return value;
|
|
14
|
+
};
|
|
15
|
+
exports.jsonAnyStringify = jsonAnyStringify;
|
|
16
|
+
/** The is serialized big int for the given value */
|
|
17
|
+
const isSerializedBigInt = (value) => {
|
|
18
|
+
return (!!value && typeof value === 'object' && value.__type__ === BIGINT_TYPE);
|
|
19
|
+
};
|
|
20
|
+
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
|
+
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
|
+
const jsonAnyParse = (_key, value) => {
|
|
40
|
+
if ((0, exports.isSerializedBigInt)(value)) {
|
|
41
|
+
if (typeof value.value !== 'string' || !DECIMAL_BIG_INT.test(value.value)) {
|
|
42
|
+
throw new TypeError(`corrupt stored big integer: ${JSON.stringify(value.value)} is not a decimal integer`);
|
|
43
|
+
}
|
|
44
|
+
return BigInt(value.value);
|
|
45
|
+
}
|
|
46
|
+
return value;
|
|
47
|
+
};
|
|
48
|
+
exports.jsonAnyParse = jsonAnyParse;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Abi, Hex } from 'viem';
|
|
2
|
+
/** The inputs for a multicall read */
|
|
3
|
+
export type Call = {
|
|
4
|
+
allowFailure?: boolean;
|
|
5
|
+
functionName: string;
|
|
6
|
+
target?: Hex;
|
|
7
|
+
abi?: Abi;
|
|
8
|
+
args?: (bigint | Hex | Hex[] | bigint[] | string | string[])[];
|
|
9
|
+
};
|
|
10
|
+
/** The erc20 metadata */
|
|
11
|
+
export type Erc20Metadata = [string, string, number];
|