• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

hicommonwealth / commonwealth / 18535365272

15 Oct 2025 04:12PM UTC coverage: 37.26% (-0.1%) from 37.362%
18535365272

Pull #13105

github

web-flow
Merge cad2e1b18 into eea3fa555
Pull Request #13105: 429 retries + reduce parallelization

1984 of 5738 branches covered (34.58%)

Branch coverage included in aggregate %.

0 of 43 new or added lines in 1 file covered. (0.0%)

60 existing lines in 1 file now uncovered.

3479 of 8924 relevant lines covered (38.98%)

44.76 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

0.99
/libs/model/src/services/tokenBalanceCache/util.ts
1
import { HttpBatchClient, Tendermint34Client } from '@cosmjs/tendermint-rpc';
2
import { logger } from '@hicommonwealth/core';
3
import {
4
  decodeParameters,
5
  encodeParameters,
6
} from '@hicommonwealth/evm-protocols';
7
import { ZERO_ADDRESS } from '@hicommonwealth/shared';
8
import { ChainNodeAttributes } from '../../models/chain_node';
9
import { Balances, GetTendermintClientOptions } from './types';
10

11
const log = logger(import.meta);
29✔
12

13
/**
14
 * Helper function to perform fetch with exponential backoff for 429 responses
15
 */
16
async function fetchWithRetry(
17
  url: string,
18
  options: RequestInit,
19
  maxRetries = 5,
×
20
): Promise<Response> {
NEW
21
  let retryCount = 0;
×
22

NEW
23
  while (retryCount <= maxRetries) {
×
NEW
24
    const response = await fetch(url, options);
×
25

26
    // If not a 429, return the response (whether success or other error)
NEW
27
    if (response.status !== 429) {
×
NEW
28
      return response;
×
29
    }
30

31
    // If we've exhausted retries, return the 429 response
NEW
32
    if (retryCount === maxRetries) {
×
NEW
33
      log.warn(
×
34
        `Max retries (${maxRetries}) reached for 429 response on ${url}`,
35
      );
NEW
36
      return response;
×
37
    }
38

39
    // Check for Retry-After header
NEW
40
    const retryAfter = response.headers.get('Retry-After');
×
41
    let waitTime: number;
42

NEW
43
    if (retryAfter && parseInt(retryAfter) > 0) {
×
44
      // Retry-After can be in seconds (integer) or HTTP date
NEW
45
      const retryAfterValue = parseInt(retryAfter);
×
NEW
46
      if (!isNaN(retryAfterValue)) {
×
NEW
47
        waitTime = retryAfterValue * 1000; // Convert to milliseconds
×
NEW
48
        log.info(
×
49
          `Rate limited (429). Waiting ${retryAfterValue}s as specified by` +
50
            ` Retry-After header. Retry ${retryCount + 1}/${maxRetries}`,
51
        );
52
      } else {
53
        // If it's a date, calculate the difference
NEW
54
        const retryDate = new Date(retryAfter);
×
NEW
55
        const now = new Date();
×
NEW
56
        waitTime = Math.max(0, retryDate.getTime() - now.getTime());
×
NEW
57
        log.info(
×
58
          `Rate limited (429). Waiting ${waitTime / 1000}s as specified by` +
59
            ` Retry-After header. Retry ${retryCount + 1}/${maxRetries}`,
60
        );
61
      }
62
    } else {
63
      // Exponential backoff: 1s, 2s, 4s, 8s, 16s
NEW
64
      waitTime = Math.pow(2, retryCount) * 1000;
×
NEW
65
      log.info(
×
66
        `Rate limited (429). Using exponential backoff: ${waitTime / 1000}s.` +
67
          ` Retry ${retryCount + 1}/${maxRetries}`,
68
      );
69
    }
70

NEW
71
    await new Promise((resolve) => setTimeout(resolve, waitTime));
×
NEW
72
    retryCount++;
×
73
  }
74

75
  // This should never be reached due to the return in the loop, but TypeScript needs it
NEW
76
  throw new Error('Unexpected: fetchWithRetry exceeded max retries');
×
77
}
78

79
/**
80
 * This function batches hundreds of RPC requests (1 per address) into a few batched RPC requests.
81
 * This function cannot be used for on-chain batching since several hundred addresses are batched
82
 * for a single RPC request (not 1 RPC request per address).
83
 */
84
export async function evmOffChainRpcBatching(
85
  source: {
86
    evmChainId: number;
87
    url: string;
88
    contractAddress?: string;
89
  },
90
  rpc: {
91
    method: 'eth_call' | 'eth_getBalance';
92
    getParams: (
93
      address: string,
94
      contractAddress?: string,
95
    ) => string | Record<string, string>;
96
    batchSize?: number;
97
  },
98
  addresses: string[],
99
): Promise<{ balances: Balances; failedAddresses: string[] }> {
UNCOV
100
  if (!rpc.batchSize) rpc.batchSize = 500;
×
101

NEW
UNCOV
102
  const MAX_PARALLEL_REQUESTS = 5;
×
103
  // maps an RPC request id to an address
UNCOV
104
  const idAddressMap: Balances = {};
×
NEW
UNCOV
105
  let failedAddresses: string[] = [];
×
NEW
UNCOV
106
  const jsonPromises: Promise<any>[] = [];
×
107

108
  const chainNodeErrorMsg =
NEW
UNCOV
109
    `${failingChainNodeError} RPC batch request failed for method '${rpc.method}' ` +
×
110
    `with batch size ${rpc.batchSize} on evm chain id ${source.evmChainId}${
111
      source.contractAddress ? ` for token ${source.contractAddress}` : ''
×
112
    }.`;
113

114
  // First, prepare all batch request payloads
115
  const batchPayloads: Array<{
116
    rpcRequests: any[];
117
    startIndex: number;
NEW
118
  }> = [];
×
UNCOV
119
  let id = 1;
×
120

UNCOV
121
  for (
×
122
    let startIndex = 0;
×
123
    startIndex < addresses.length;
124
    startIndex += rpc.batchSize
125
  ) {
126
    const endIndex = Math.min(startIndex + rpc.batchSize, addresses.length);
×
127
    const batchAddresses = addresses.slice(startIndex, endIndex);
×
128
    const rpcRequests = [];
×
129
    for (const address of batchAddresses) {
×
130
      rpcRequests.push({
×
131
        method: rpc.method,
132
        params: [rpc.getParams(address, source.contractAddress), 'latest'],
133
        id,
134
        jsonrpc: '2.0',
135
      });
136
      idAddressMap[id] = address;
×
137
      ++id;
×
138
    }
NEW
139
    batchPayloads.push({ rpcRequests, startIndex });
×
140
  }
141

142
  // Process batches in groups of MAX_PARALLEL_REQUESTS
NEW
UNCOV
143
  for (let i = 0; i < batchPayloads.length; i += MAX_PARALLEL_REQUESTS) {
×
NEW
144
    const endIndex = Math.min(i + MAX_PARALLEL_REQUESTS, batchPayloads.length);
×
NEW
145
    const currentBatchGroup = batchPayloads.slice(i, endIndex);
×
146

147
    // Create and execute fetch promises for this group only
NEW
148
    const batchRequestPromises = currentBatchGroup.map((payload) =>
×
NEW
UNCOV
149
      fetchWithRetry(source.url, {
×
150
        method: 'POST',
151
        body: JSON.stringify(payload.rpcRequests),
152
        headers: { 'Content-Type': 'application/json' },
153
      }),
154
    );
155

NEW
UNCOV
156
    const responses = await Promise.allSettled(batchRequestPromises);
×
NEW
157
    responses.forEach((res, index) => {
×
158
      // handle a failed batch request
NEW
159
      if (res.status === 'rejected') {
×
NEW
160
        const payload = currentBatchGroup[index];
×
NEW
161
        const relevantAddresses = addresses.slice(
×
162
          payload.startIndex,
163
          Math.min(payload.startIndex + rpc.batchSize!, addresses.length),
164
        );
NEW
165
        failedAddresses = [...failedAddresses, ...relevantAddresses];
×
NEW
166
        log.fatal(chainNodeErrorMsg, res.reason);
×
167
      } else {
NEW
UNCOV
168
        jsonPromises.push(res.value.json());
×
169
      }
170
    });
171
  }
172

173
  let datas;
174
  try {
×
175
    datas = (await Promise.all(jsonPromises)).flat();
×
176
  } catch (e) {
177
    log.fatal(chainNodeErrorMsg, e instanceof Error ? e : undefined);
×
178
    return {
×
179
      balances: {},
180
      failedAddresses: addresses,
181
    };
182
  }
183

184
  const balances: Balances = {};
×
185
  for (const data of datas) {
×
186
    if (data.error) {
×
187
      failedAddresses.push(idAddressMap[data.id]);
×
188
      const msg = `RPC request failed on EVM chain id ${source.evmChainId}${
×
189
        source.contractAddress ? ` for token ${source.contractAddress}` : ''
×
190
      }.`;
UNCOV
191
      log.error(msg, data.error);
×
192
      continue;
×
193
    }
194

195
    const address = idAddressMap[data.id];
×
196
    if (source.contractAddress) {
×
UNCOV
197
      const { 0: balance } = decodeParameters({
×
198
        abiInput: ['uint256'],
199
        data: data.result,
200
      });
UNCOV
201
      balances[address] = String(balance);
×
202
    } else {
203
      balances[address] = BigInt(data.result).toString();
×
204
    }
205
  }
206

207
  return { balances, failedAddresses };
×
208
}
209

210
/**
211
 * This function uses the on-chain Balance Fetcher contract to batch fetch balances
212
 * for many different addresses using a single RPC request. This is extremely scalable
213
 * because we can batch multiple requests together e.g. each on-chain call can batch 1k
214
 * paired with 100 batched RPC requests that means we can fetch 100k address balances
215
 * from a single HTTP request. ONLY WORKS FOR ERC20 and ETH!
216
 */
217
export async function evmBalanceFetcherBatching(
218
  source: {
219
    evmChainId: number;
220
    url: string;
221
    contractAddress?: string;
222
  },
223
  rpc: {
224
    batchSize?: number;
225
  },
226
  addresses: string[],
227
): Promise<{ balances: Balances; failedAddresses: string[] }> {
228
  if (!rpc.batchSize) rpc.batchSize = 500;
×
229
  // 0x0 tells the on-chain contract to only return ETH balances
230
  if (!source.contractAddress) source.contractAddress = ZERO_ADDRESS;
×
231

UNCOV
232
  const rpcRequests = [];
×
233

234
  for (
×
UNCOV
235
    let startIndex = 0;
×
236
    startIndex < addresses.length;
237
    startIndex += rpc.batchSize
238
  ) {
UNCOV
239
    const endIndex = Math.min(startIndex + rpc.batchSize, addresses.length);
×
UNCOV
240
    const batchAddresses = addresses.slice(startIndex, endIndex);
×
241

242
    const calldata =
UNCOV
243
      '0xf0002ea9' +
×
244
      encodeParameters({
245
        abiInput: ['address[]', 'address[]'],
246
        data: [batchAddresses, [source.contractAddress]],
247
      }).substring(2);
248

UNCOV
249
    rpcRequests.push({
×
250
      method: 'eth_call',
251
      params: [
252
        {
253
          to: mapNodeToBalanceFetcherContract(source.evmChainId),
254
          data: calldata,
255
        },
256
        'latest',
257
      ],
258
      id: startIndex,
259
      jsonrpc: '2.0',
260
    });
261
  }
262

263
  const errorMsg =
UNCOV
264
    `On-chain batch request failed ` +
×
265
    `with batch size ${rpc.batchSize} on evm chain id ${source.evmChainId}${
266
      source.contractAddress ? `for token ${source.contractAddress}` : ''
×
267
    }.`;
268

UNCOV
269
  const datas = await evmRpcRequest(source.url, rpcRequests, errorMsg);
×
270
  if (!datas)
×
UNCOV
271
    return {
×
272
      balances: {},
273
      failedAddresses: addresses,
274
    };
275

276
  const addressBalanceMap: Balances = {};
×
UNCOV
277
  let failedAddresses: string[] = [];
×
278

UNCOV
279
  if (datas.error) {
×
UNCOV
280
    log.error(errorMsg, datas.error);
×
UNCOV
281
    return { balances: {}, failedAddresses: addresses };
×
282
  } else {
UNCOV
283
    for (const data of datas) {
×
284
      // this replicates the batches used when creating the requests
285
      // note -> data.id is the startIndex defined in the loop above
UNCOV
286
      const endIndex = Math.min(data.id + rpc.batchSize, addresses.length);
×
UNCOV
287
      const relevantAddresses = addresses.slice(data.id, endIndex);
×
288

UNCOV
289
      if (data.error) {
×
UNCOV
290
        failedAddresses = [...failedAddresses, ...relevantAddresses];
×
291
        const msg =
UNCOV
292
          'Balance Fetcher Contract request failed on EVM ' +
×
293
          `chain id: ${source.evmChainId}${
294
            source.contractAddress ? `for token ${source.contractAddress}` : ''
×
295
          }.`;
296
        log.error(msg, data.error);
×
297
        continue;
×
298
      }
299

UNCOV
300
      const { 0: balances } = decodeParameters({
×
301
        abiInput: ['uint256[]'],
302
        data: data.result,
303
      });
304
      relevantAddresses.forEach(
×
UNCOV
305
        (key, i) => (addressBalanceMap[key] = String((<number[]>balances)[i])),
×
306
      );
307
    }
308
  }
309

310
  return { balances: addressBalanceMap, failedAddresses };
×
311
}
312

313
/**
314
 * Maps an EVM chain id to the contract address of that chains Balance Checker contract.
315
 * All supported contract addresses can be found here: https://github.com/wbobeirne/eth-balance-checker
316
 * Some contract addresses are available in the open PRs or issues.
317
 */
318
export function mapNodeToBalanceFetcherContract(
319
  ethChainId: ChainNodeAttributes['eth_chain_id'],
320
) {
UNCOV
321
  switch (ethChainId) {
×
322
    case 1: // Ethereum Mainnet
323
    case 1337: // Local Ganache - assuming fork of mainnet
324
      return '0xb1f8e55c7f64d203c1400b9d8555d050f94adf39';
×
325
    case 5: // Goerli
UNCOV
326
      return '0x9788C4E93f9002a7ad8e72633b11E8d1ecd51f9b';
×
327
    case 56: // BSC
328
    case 97: // BSC Testnet
UNCOV
329
      return '0x2352c63A83f9Fd126af8676146721Fa00924d7e4';
×
330
    case 137: // Polygon
331
    case 80001: // Polygon Mumbai
332
      return '0x2352c63A83f9Fd126af8676146721Fa00924d7e4';
×
333
    case 10: // Optimism Mainnet
UNCOV
334
      return '0xB1c568e9C3E6bdaf755A60c7418C269eb11524FC';
×
335
    case 42161: // Arbitrum One
UNCOV
336
      return '0x151E24A486D7258dd7C33Fb67E4bB01919B7B32c';
×
337
    case 43114: // Avalanche
UNCOV
338
      return '0xD023D153a0DFa485130ECFdE2FAA7e612EF94818';
×
339
    case 43113: // Avalanche Testnet
UNCOV
340
      return '0x100665685d533F65bdD0BD1d65ca6387FC4F4FDB';
×
341
    case 250: // Fantom
UNCOV
342
      return '0x07f697424ABe762bB808c109860c04eA488ff92B';
×
343
    case 4002: // Fantom Testnet
UNCOV
344
      return '0x8B14C79f24986B127EC7208cE4e93E8e45125F8f';
×
345
    case 1284: // Moonbeam -- unverified
UNCOV
346
      return '0xf614056a46e293DD701B9eCeBa5df56B354b75f9';
×
347
    case 1285: // Moonriver -- unverified
348
      return '0xDEAa846cca7FEc9e76C8e4D56A55A75bb0973888';
×
349
    case 1313161554: // Aurora Mainnet -- unverified
UNCOV
350
      return '0x100665685d533F65bdD0BD1d65ca6387FC4F4FDB';
×
351
    case 1313161555: // Aurora Testnet -- unverified
UNCOV
352
      return '0x60d2714e1a9Fd5e9580A66f6aF6b259C77A87b09';
×
353
    case 25: // Cronos Mainnet -- unverified
UNCOV
354
      return '0x8b14c79f24986b127ec7208ce4e93e8e45125f8f';
×
355
    case 338: // Cronos Testnet -- unverified
356
      return '0x8b14c79f24986b127ec7208ce4e93e8e45125f8f';
×
357
    case 66: // OKXChain Mainnet:
UNCOV
358
      return '0x42CD9068d471c861796D56A37f8BFEae19DAC12F';
×
359
    case 9001: // Evmos -- unverified
UNCOV
360
      return '0x42CD9068d471c861796D56A37f8BFEae19DAC12F';
×
361
    case 59144: // Linea Mainnet -- unverified
UNCOV
362
      return '0xF62e6a41561b3650a69Bb03199C735e3E3328c0D';
×
363
    case 59140: // Linea Testnet -- unverified
UNCOV
364
      return '0x10dAd7Ca3921471f616db788D9300DC97Db01783';
×
365
    case 11155111: // Sepolia
UNCOV
366
      return '0xBfbCed302deD369855fc5f7668356e123ca4B329';
×
367
  }
368
}
369

370
export const failingChainNodeError = 'FAILING OR RATE LIMITED CHAIN NODE:';
29✔
371

372
export async function evmRpcRequest(
373
  rpcEndpoint: string,
374
  rawRequestBody: Record<string, unknown> | Array<Record<string, unknown>>,
375
  errorMsg: string,
376
) {
377
  let data;
UNCOV
378
  try {
×
NEW
379
    const response = await fetchWithRetry(rpcEndpoint, {
×
380
      method: 'POST',
381
      body: JSON.stringify(rawRequestBody),
382
      headers: { 'Content-Type': 'application/json' },
383
    });
UNCOV
384
    data = await response.json();
×
385
  } catch (e) {
UNCOV
386
    const augmentedMsg = `${failingChainNodeError} ${errorMsg}`;
×
387
    log.fatal(augmentedMsg, e instanceof Error ? e : undefined);
×
388
  }
389

UNCOV
390
  return data;
×
391
}
392

393
export async function getTendermintClient(
394
  options: GetTendermintClientOptions,
395
): Promise<Tendermint34Client> {
UNCOV
396
  const batchClient = new HttpBatchClient(
×
397
    options.chainNode?.private_url || options.chainNode.url,
×
398
    {
399
      batchSizeLimit: options.batchSize || 100,
×
400
      dispatchInterval: 10,
401
    },
402
  );
UNCOV
403
  return await Tendermint34Client.create(batchClient);
×
404
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc