@gibs/common 1.10.1 → 1.13.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,15 @@
1
+ ISC License
2
+
3
+ Copyright (c) 2026 Gibs Finance
4
+
5
+ Permission to use, copy, modify, and/or distribute this software for any
6
+ purpose with or without fee is hereby granted, provided that the above
7
+ copyright notice and this permission notice appear in all copies.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
10
+ REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
11
+ AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
12
+ INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
13
+ LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
14
+ OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
15
+ PERFORMANCE OF THIS SOFTWARE.
@@ -0,0 +1,18 @@
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
+ export declare function ensureResult<T>(value: T | null | undefined | false | 0, context: string): T;
11
+ /**
12
+ * Type guard that asserts a value is defined
13
+ * Similar to ensureResult but uses TypeScript's asserts syntax
14
+ *
15
+ * @param value - The value to check
16
+ * @param message - Error message if assertion fails
17
+ */
18
+ export declare function assertDefined<T>(value: T | null | undefined, message: string): asserts value is T;
package/dist/assert.js ADDED
@@ -0,0 +1,27 @@
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
+ export function ensureResult(value, context) {
11
+ if (!value) {
12
+ throw new Error(`${context}: Expected result but got ${value}`);
13
+ }
14
+ return value;
15
+ }
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
+ export function assertDefined(value, message) {
24
+ if (value === null || value === undefined) {
25
+ throw new Error(message);
26
+ }
27
+ }
@@ -1,4 +1,4 @@
1
- import { jsonAnyStringify } from "./serialize.js";
1
+ import { jsonAnyStringify } from './serialize.js';
2
2
  /**
3
3
  * Memoize a function result and return a single value
4
4
  * @param fn the function to memoize
@@ -0,0 +1,48 @@
1
+ import { type Chain, type HttpTransportConfig, type PublicClient } from 'viem';
2
+ export { MAX_SAME_METHOD_CALLS_PER_SECOND } from './rpcPacer.js';
3
+ /**
4
+ * JSON-RPC request batching config. When enabled, viem coalesces requests made
5
+ * within `wait` milliseconds (up to `batchSize`) into ONE HTTP POST carrying a
6
+ * JSON-RPC array body. This is the default because it sharply cuts request
7
+ * volume on nodes that support it.
8
+ *
9
+ * Note: some public nodes (notably some Binance Smart Chain endpoints) reject the
10
+ * array-wrapped body — even for a single request — which makes every call fail
11
+ * and get retried, BURNING through requests. Such chains must construct their
12
+ * client with `batch: false` (see {@link clientFromChain}'s `batch` option) so
13
+ * requests are sent as plain JSON-RPC objects the node accepts.
14
+ */
15
+ export declare const defaultRpcBatch: {
16
+ readonly wait: 10;
17
+ readonly batchSize: 32;
18
+ };
19
+ /** The default batch config for the client */
20
+ export declare const defaultBatchConfig: {
21
+ batch: {
22
+ readonly wait: 10;
23
+ readonly batchSize: 32;
24
+ };
25
+ };
26
+ /**
27
+ * Returns the chain key for the given chain id and urls
28
+ * @param chainId - the chain id
29
+ * @param urls - the urls
30
+ * @returns the chain key
31
+ */
32
+ export declare const chainKey: (chainId: number, urls: string[]) => string;
33
+ /** The client cache for the given chain id */
34
+ export declare const clientCache: Map<number, {
35
+ key: string;
36
+ client: PublicClient;
37
+ }>;
38
+ export declare const clientFromChain: ({ chain, urls, batch, }: {
39
+ chain: Chain;
40
+ urls: string[];
41
+ /**
42
+ * JSON-RPC request batching for this client's transport. Defaults to
43
+ * {@link defaultRpcBatch}. Pass `false` for chains whose RPC nodes reject the
44
+ * array-wrapped batch body (some Binance Smart Chain endpoints), so requests
45
+ * are sent as plain JSON-RPC objects and are not failed-and-retried.
46
+ */
47
+ batch?: HttpTransportConfig["batch"];
48
+ }) => PublicClient;
package/dist/client.js ADDED
@@ -0,0 +1,164 @@
1
+ import { createPublicClient, createTransport, http, webSocket, } from 'viem';
2
+ import { countMethods, createRpcPacer, MAX_SAME_METHOD_CALLS_PER_SECOND, } from './rpcPacer.js';
3
+ export { MAX_SAME_METHOD_CALLS_PER_SECOND } from './rpcPacer.js';
4
+ /**
5
+ * JSON-RPC request batching config. When enabled, viem coalesces requests made
6
+ * within `wait` milliseconds (up to `batchSize`) into ONE HTTP POST carrying a
7
+ * JSON-RPC array body. This is the default because it sharply cuts request
8
+ * volume on nodes that support it.
9
+ *
10
+ * Note: some public nodes (notably some Binance Smart Chain endpoints) reject the
11
+ * array-wrapped body — even for a single request — which makes every call fail
12
+ * and get retried, BURNING through requests. Such chains must construct their
13
+ * client with `batch: false` (see {@link clientFromChain}'s `batch` option) so
14
+ * requests are sent as plain JSON-RPC objects the node accepts.
15
+ */
16
+ export const defaultRpcBatch = {
17
+ wait: 10,
18
+ batchSize: 32,
19
+ };
20
+ /** The default batch config for the client */
21
+ export const defaultBatchConfig = {
22
+ batch: defaultRpcBatch,
23
+ };
24
+ /**
25
+ * Returns the chain key for the given chain id and urls
26
+ * @param chainId - the chain id
27
+ * @param urls - the urls
28
+ * @returns the chain key
29
+ */
30
+ export const chainKey = (chainId, urls) => {
31
+ return `${chainId},${urls.join(',')}`.toLowerCase();
32
+ };
33
+ /** The client cache for the given chain id */
34
+ export const clientCache = new Map();
35
+ /**
36
+ * Returns the client for the given chain and urls
37
+ * @param chain - the chain
38
+ * @param urls - the urls
39
+ * @returns the client
40
+ */
41
+ /**
42
+ * The one pacer for the whole application.
43
+ *
44
+ * MODULE LEVEL, DELIBERATELY. The endpoint keys its budget on the METHOD, not
45
+ * the chain — six `eth_blockNumber` on PulseChain plus six on Ethereum were
46
+ * answered exactly eight times, the same as twelve on one chain. A pacer per
47
+ * client would let two chains spend one budget twice and neither would know it
48
+ * had.
49
+ *
50
+ * It is built with the same ceiling the batch size uses, so the two can never
51
+ * describe different limits.
52
+ */
53
+ const pacer = createRpcPacer({ ceiling: MAX_SAME_METHOD_CALLS_PER_SECOND });
54
+ /**
55
+ * Holds a request until its methods fit under the ceiling.
56
+ *
57
+ * WIRED HERE RATHER THAN AROUND THE CALLERS because this is the only place that
58
+ * sees the FINAL body: viem coalesces separate reads into one post, so a limiter
59
+ * upstream of the transport would count intentions and this counts what is
60
+ * actually sent.
61
+ *
62
+ * Returning nothing leaves the request untouched — the hook exists to delay, not
63
+ * to rewrite.
64
+ */
65
+ const paceRequest = async (request) => {
66
+ // The HOST, not the full url: one proxy serves every chain from one budget,
67
+ // so `/evm/1` and `/evm/369` must count against the same tally.
68
+ const host = new URL(request.url).host;
69
+ await pacer.reserve(host, countMethods(await request.clone().text().catch(() => null)));
70
+ };
71
+ /**
72
+ * Spreads requests across a chain's endpoints instead of resting on the first.
73
+ *
74
+ * WHAT `fallback` ACTUALLY DOES, and why it is not this. With `rank: false` it
75
+ * sends every request to entry zero and moves on only when that entry ERRORS. So
76
+ * the spares behind a chain carry no load at all: on Ethereum, `valve.city` took
77
+ * one hundred percent of every read while two perfectly good nodes sat idle, and
78
+ * the only way traffic ever reached them was a refusal — a wasted round trip
79
+ * spent discovering a spare we could have used on purpose.
80
+ *
81
+ * That matters because the first entry is a SHARED proxy. Its key is published
82
+ * on chainlist.org, so its per-second budget is not ours alone: measured at a
83
+ * refusal, our own calls in the preceding second were seven against a ceiling of
84
+ * eight. We were not over the limit. Somebody else was using the rest of it, and
85
+ * no amount of pacing on our side fixes that — only using less of that host
86
+ * does.
87
+ *
88
+ * ROUND ROBIN, AND FALLBACK STILL INTACT. Each request starts at the next
89
+ * endpoint in rotation and walks the rest on failure, so a dead host costs one
90
+ * attempt exactly as it did before while a healthy one now carries its share.
91
+ *
92
+ * NOT `rank: true`, which is viem's own answer to this and is the wrong one
93
+ * here: ranking pings EVERY url on EVERY chain every few seconds forever, which
94
+ * on an idle page was once the entire outbound traffic and is what spent the
95
+ * shared budget in the first place. It also reorders a list this project curates
96
+ * deliberately.
97
+ */
98
+ const balanced = (transports) => {
99
+ if (transports.length === 1)
100
+ return transports[0];
101
+ return (config) => {
102
+ const instances = transports.map((transport) => transport(config));
103
+ // Module-level rotation per client, not per call site: every reader on the
104
+ // page shares one cursor, which is what makes the split even rather than
105
+ // each caller starting at zero and piling onto the same host.
106
+ let cursor = 0;
107
+ return createTransport({
108
+ key: 'balanced',
109
+ name: 'Balanced',
110
+ type: 'balanced',
111
+ // CAST AT THE BOUNDARY, deliberately. `EIP1193RequestFn` is generic over
112
+ // the method being called and resolves its own return type; a rotation
113
+ // cannot preserve that relationship because it does not know which
114
+ // transport will answer until it has tried one. The delegates below are
115
+ // fully typed and the shape passing through is unchanged — only the
116
+ // compiler's ability to follow it across the loop is lost.
117
+ request: (async (args) => {
118
+ const start = cursor;
119
+ cursor = (cursor + 1) % instances.length;
120
+ let lastError;
121
+ for (let offset = 0; offset < instances.length; offset += 1) {
122
+ try {
123
+ return await instances[(start + offset) % instances.length].request(args);
124
+ }
125
+ catch (error) {
126
+ // KEPT AND RETHROWN AT THE END, not swallowed. If every endpoint
127
+ // refuses, the caller must see a real error — a rotation that
128
+ // returns undefined when all its hosts are down turns an outage
129
+ // into a silently empty read.
130
+ lastError = error;
131
+ }
132
+ }
133
+ throw lastError;
134
+ }),
135
+ });
136
+ };
137
+ };
138
+ export const clientFromChain = ({ chain, urls, batch = defaultRpcBatch, }) => {
139
+ const key = chainKey(chain.id, urls);
140
+ const existing = clientCache.get(chain.id);
141
+ if (existing && existing.key === key) {
142
+ return existing.client;
143
+ }
144
+ const transport = !urls?.length
145
+ ? http()
146
+ : balanced(urls.map((rpc) => rpc.startsWith('http')
147
+ ? http(rpc, { batch, onFetchRequest: paceRequest })
148
+ : webSocket(rpc, {
149
+ keepAlive: true,
150
+ reconnect: true,
151
+ retryDelay: 250,
152
+ retryCount: 10,
153
+ timeout: 4_000,
154
+ })));
155
+ const client = createPublicClient({
156
+ chain,
157
+ transport,
158
+ });
159
+ clientCache.set(chain.id, {
160
+ key,
161
+ client,
162
+ });
163
+ return client;
164
+ };
@@ -0,0 +1,20 @@
1
+ import { type Chain, type Hex, type PublicClient } from 'viem';
2
+ import type { Erc20Metadata } from './types.js';
3
+ /** The erc20 metadata calls */
4
+ export declare const erc20MetadataCalls: {
5
+ functionName: string;
6
+ }[];
7
+ /**
8
+ * The multicall for the erc20 metadata.
9
+ *
10
+ * The explicit annotation is required rather than stylistic: `@types/lodash-es`
11
+ * re-exports its `MemoizedFunction` type from a nested `@types/lodash` that a
12
+ * generated declaration file cannot name (TS2742). Spelling out the plain call
13
+ * signature also keeps the memoization an implementation detail — no caller
14
+ * touches the `.cache` property it would otherwise publish.
15
+ */
16
+ export declare const multicallErc20: (inputs: {
17
+ client: PublicClient;
18
+ target: Hex;
19
+ chain: Chain;
20
+ }) => Promise<Erc20Metadata>;
@@ -1,5 +1,5 @@
1
+ import * as _ from 'lodash-es';
1
2
  import { erc20Abi, erc20Abi_bytes32, getAddress, } from 'viem';
2
- import _ from 'lodash';
3
3
  import { multicallRead } from './multicall.js';
4
4
  /** The erc20 metadata calls */
5
5
  export const erc20MetadataCalls = [
@@ -7,7 +7,15 @@ export const erc20MetadataCalls = [
7
7
  { functionName: 'symbol' },
8
8
  { functionName: 'decimals' },
9
9
  ];
10
- /** The multicall for the erc20 metadata */
10
+ /**
11
+ * The multicall for the erc20 metadata.
12
+ *
13
+ * The explicit annotation is required rather than stylistic: `@types/lodash-es`
14
+ * re-exports its `MemoizedFunction` type from a nested `@types/lodash` that a
15
+ * generated declaration file cannot name (TS2742). Spelling out the plain call
16
+ * signature also keeps the memoization an implementation detail — no caller
17
+ * touches the `.cache` property it would otherwise publish.
18
+ */
11
19
  export const multicallErc20 = _.memoize(async ({ client, target, chain }) => {
12
20
  const options = {
13
21
  chain: chain,
@@ -1,6 +1,7 @@
1
- export * from './types.js';
2
- export * from './serialize.js';
1
+ export * from './assert.js';
3
2
  export * from './cache.js';
4
- export * from './multicall.js';
5
- export * from './erc20.js';
6
3
  export * from './client.js';
4
+ export * from './erc20.js';
5
+ export * from './multicall.js';
6
+ export * from './serialize.js';
7
+ export * from './types.js';
@@ -1,6 +1,7 @@
1
- export * from './types.js';
2
- export * from './serialize.js';
1
+ export * from './assert.js';
3
2
  export * from './cache.js';
4
- export * from './multicall.js';
5
- export * from './erc20.js';
6
3
  export * from './client.js';
4
+ export * from './erc20.js';
5
+ export * from './multicall.js';
6
+ export * from './serialize.js';
7
+ export * from './types.js';
@@ -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,213 @@
1
+ import * as _ from 'lodash-es';
2
+ import { decodeFunctionResult, encodeFunctionData, getContract, multicall3Abi, } from 'viem';
3
+ import { ensureResult } from './assert.js';
4
+ /**
5
+ * The largest request body the leading endpoint accepts, in bytes. MEASURED,
6
+ * not assumed.
7
+ *
8
+ * `one.valve.city` answers a 102,400 byte body with 200 and a 102,656 byte body
9
+ * with 413, bisected on 2026-09-12. That is the 100 kibibyte default of
10
+ * `express.json`, and the proxy leads every chain list this application ships,
11
+ * so it is the number every other budget here has to clear.
12
+ *
13
+ * THE PREVIOUS VALUE WAS A GUESS AND IT WAS FIVE TIMES TOO LARGE. It said "the
14
+ * one-megabyte body limit that is the default in the reverse proxies these
15
+ * endpoints sit behind", which is Nginx's default, not Express's — and the
16
+ * endpoint in front is Express. Nobody had asked the endpoint.
17
+ */
18
+ const ENDPOINT_BODY_LIMIT_BYTES = 100 * 1024;
19
+ /**
20
+ * How large a single HTTP request body to the node may get, in bytes.
21
+ *
22
+ * Nothing bounded this before, so the whole call list went out at once and the
23
+ * size followed whatever the caller happened to be reading. The token picker
24
+ * reads a balance per token, and PulseChain's list runs to about eleven and a
25
+ * half thousand of them: a single 8.5 MB `eth_call`, repeated on every open,
26
+ * which `rpc.pulsechain.com` answered with HTTP 413. The caller catches that and
27
+ * falls back to an empty map, so the visible result was not an error — it was
28
+ * every balance quietly missing.
29
+ *
30
+ * Four kibibytes under {@link ENDPOINT_BODY_LIMIT_BYTES}, because the budget
31
+ * below counts only the calls: `aggregate3` adds a fixed header of its own and
32
+ * the JSON-RPC envelope wraps the lot, and neither is charged to a call.
33
+ */
34
+ const HTTP_BODY_TARGET_BYTES = ENDPOINT_BODY_LIMIT_BYTES - 4 * 1024;
35
+ /**
36
+ * How many chunks may be in flight at once.
37
+ *
38
+ * Splitting a request is only an improvement if the pieces do not arrive as a
39
+ * burst; an endpoint that rejected one large body will just as happily rate-limit
40
+ * two dozen small ones.
41
+ *
42
+ * ONE, AND RUNNING MORE BUYS NOTHING. The transport coalesces calls made in the
43
+ * same tick into one post, so four chunks at a quarter of the budget each arrive
44
+ * as ONE post of the same total size as a single full-budget chunk — the same
45
+ * bytes, in the same round trip, at four times the call count. The proxy's
46
+ * budget counts CALLS, not posts and not bytes, so the wider setting spent four
47
+ * times the rate limit for identical throughput.
48
+ *
49
+ * Measured on one token-picker open, 2026-09-12: at a concurrency of four the
50
+ * peak was eleven calls of one method in a second against a ceiling of eight,
51
+ * and one post was refused; at one the peak was seven and every post was
52
+ * answered. Request bytes fell from 737,057 to 638,890 in the same comparison,
53
+ * because nothing had to be resent to a fallback endpoint.
54
+ */
55
+ const CHUNK_CONCURRENCY = 1;
56
+ /**
57
+ * How much encoded call data one `aggregate3` may carry.
58
+ *
59
+ * Divided by the concurrency rather than set directly, because the transport
60
+ * COALESCES calls made in the same tick into one HTTP body. Chunking to a
61
+ * quarter-megabyte budget and running four at a time was measured arriving as a
62
+ * single 1.37 MB request — the split was real, and then undone at the layer
63
+ * below. What the node sees is this budget multiplied by the concurrency, so
64
+ * that is the relationship worth writing down; changing either constant alone
65
+ * cannot silently blow the target.
66
+ *
67
+ * STILL TRUE AT A CONCURRENCY OF ONE, and that is why the division stays rather
68
+ * than being folded away: raising the concurrency again without cutting this
69
+ * budget would put the target back over the endpoint's limit, and the division
70
+ * is what stops that being silent.
71
+ */
72
+ const CALLDATA_BUDGET_BYTES = Math.floor(HTTP_BODY_TARGET_BYTES / CHUNK_CONCURRENCY);
73
+ /**
74
+ * Bytes of request body a single call costs beyond its own encoded data.
75
+ * MEASURED against a real `aggregate3` encoding, not estimated.
76
+ *
77
+ * `aggregate3` spends five thirty-two byte words per call — the head offset, the
78
+ * target, the failure flag, the data offset and the data length — which is 320
79
+ * hexadecimal characters, plus up to 63 more padding the call data out to a word
80
+ * boundary. A `balanceOf` whose own call data is 74 characters was measured
81
+ * costing 448.6 bytes of body, so the real overhead is about 375 and the old
82
+ * value of 200 UNDERSTATED IT BY NEARLY A HALF.
83
+ *
84
+ * That is how a chunk budgeted at 128 kibibytes went out as a 214 kilobyte body.
85
+ * Four hundred keeps a small margin over the measurement.
86
+ */
87
+ const PER_CALL_OVERHEAD_BYTES = 400;
88
+ /** The size limits, exported so tests assert against the real values. */
89
+ export const multicallLimits = {
90
+ endpointBodyLimitBytes: ENDPOINT_BODY_LIMIT_BYTES,
91
+ httpBodyTargetBytes: HTTP_BODY_TARGET_BYTES,
92
+ callDataBudgetBytes: CALLDATA_BUDGET_BYTES,
93
+ chunkConcurrency: CHUNK_CONCURRENCY,
94
+ perCallOverheadBytes: PER_CALL_OVERHEAD_BYTES,
95
+ };
96
+ /**
97
+ * Splits encoded calls into groups whose combined request body stays under
98
+ * {@link CALLDATA_BUDGET_BYTES}.
99
+ *
100
+ * Grouped by measured size rather than by a fixed count on purpose: callers
101
+ * differ by more than an order of magnitude in how large one call encodes to — a
102
+ * bare `balanceOf` against a router quote carrying a swap path — so any single
103
+ * call limit would be far too cautious for one caller and useless for another.
104
+ *
105
+ * A call that exceeds the budget on its own still gets its own group; splitting
106
+ * it further is not possible, and refusing to send it would turn an oversized
107
+ * request into a missing result.
108
+ *
109
+ * @param encoded - the calls to group, in order
110
+ * @returns groups preserving the original order
111
+ */
112
+ export const chunkBySize = (encoded) => {
113
+ const chunks = [];
114
+ let current = [];
115
+ let currentBytes = 0;
116
+ for (const call of encoded) {
117
+ const bytes = call.callData.length + PER_CALL_OVERHEAD_BYTES;
118
+ if (current.length > 0 && currentBytes + bytes > CALLDATA_BUDGET_BYTES) {
119
+ chunks.push(current);
120
+ current = [];
121
+ currentBytes = 0;
122
+ }
123
+ current.push(call);
124
+ currentBytes += bytes;
125
+ }
126
+ if (current.length > 0)
127
+ chunks.push(current);
128
+ return chunks;
129
+ };
130
+ /**
131
+ * Runs `task` over every item with at most `concurrency` in flight, preserving
132
+ * input order in the result.
133
+ *
134
+ * @param items - the work items
135
+ * @param concurrency - how many may run at once
136
+ * @param task - what to do with one item
137
+ */
138
+ const mapWithConcurrency = async (items, concurrency, task) => {
139
+ const results = new Array(items.length);
140
+ let next = 0;
141
+ const worker = async () => {
142
+ while (next < items.length) {
143
+ const index = next;
144
+ next += 1;
145
+ results[index] = await task(items[index]);
146
+ }
147
+ };
148
+ await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, worker));
149
+ return results;
150
+ };
151
+ /**
152
+ * Reads many contract calls through Multicall3, splitting the work across as
153
+ * many requests as the size budget needs and reassembling the results in order.
154
+ *
155
+ * Chunking is invisible to callers: the returned array lines up with `calls`
156
+ * one-for-one exactly as it did when every call went out together, and a chunk
157
+ * that rejects still rejects the whole read rather than returning a short array
158
+ * that a caller would silently misread as "these tokens have no balance".
159
+ *
160
+ * @param options.chain - the chain, used for its Multicall3 deployment
161
+ * @param options.client - the public client to read through
162
+ * @param options.abi - the default ABI, when a call does not carry its own
163
+ * @param options.calls - the calls to make, in the order results are wanted
164
+ * @param options.target - the default contract, when a call does not name one
165
+ */
166
+ export const multicallRead = async ({ chain, client, abi, calls, target, }) => {
167
+ const multicall = getContract({
168
+ abi: multicall3Abi,
169
+ address: chain.contracts.multicall3.address,
170
+ // this seems like a bug in viem, it should be able to infer the type of the client
171
+ client: client,
172
+ });
173
+ const arg = calls.map((call) => ({
174
+ callData: encodeFunctionData({
175
+ abi: call.abi || abi,
176
+ functionName: call.functionName,
177
+ // this seems like a bug in viem, it should be able to infer the type of the args
178
+ args: (call.args || []),
179
+ }),
180
+ allowFailure: call.allowFailure || false,
181
+ target: (call.target || target),
182
+ }));
183
+ try {
184
+ const chunks = chunkBySize(arg);
185
+ const chunkResults = await mapWithConcurrency(chunks, CHUNK_CONCURRENCY, async (chunk) => ensureResult(await multicall.read.aggregate3([chunk]), 'Multicall aggregate3'));
186
+ const r = chunkResults.flat();
187
+ return calls.map((call, i) => call.allowFailure
188
+ ? r[i].success
189
+ ? decodeFunctionResult({
190
+ abi: call.abi || abi,
191
+ functionName: call.functionName,
192
+ data: r[i].returnData,
193
+ })
194
+ : r[i].returnData
195
+ : decodeFunctionResult({
196
+ abi: call.abi || abi,
197
+ functionName: call.functionName,
198
+ data: r[i].returnData,
199
+ }));
200
+ }
201
+ catch (err) {
202
+ // Deliberately a summary rather than the calls themselves: a failing read can
203
+ // hold tens of thousands of them, and dumping the array cost more than the
204
+ // failure did.
205
+ console.error('Multicall aggregate3 failed', {
206
+ chainId: client.chain?.id,
207
+ target,
208
+ callCount: calls.length,
209
+ functionNames: _.uniq(calls.map((call) => call.functionName)),
210
+ });
211
+ throw err;
212
+ }
213
+ };