@warptoad/skinny-fat-imt-js 0.0.2

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/src/config.ts ADDED
@@ -0,0 +1,11 @@
1
+ export const DEPLOY_BLOCK:{[chainId:number]:number} = {
2
+ 31337: 0, // hardhat
3
+ 11155111: 11400000, // sepolia (not deployed yet)
4
+ 1: 25700000, // mainnet (not deployed yet)
5
+ 324: 71400000, // zk-sync era (not deployed yet)
6
+ }
7
+
8
+ export function getDeploymentBlock(chainId:number) {
9
+ const block = DEPLOY_BLOCK[chainId]
10
+ return block ? block : 0n
11
+ }
@@ -0,0 +1,414 @@
1
+ import { encodeEventTopics, formatLog, numberToHex, parseEventLogs } from 'viem'
2
+ import type {
3
+ Abi,
4
+ AbiEvent,
5
+ AbiParameter,
6
+ AbiParameterToPrimitiveType,
7
+ Address,
8
+ GetLogsParameters,
9
+ Log,
10
+ LogTopic,
11
+ PublicClient,
12
+ } from 'viem'
13
+
14
+ /**
15
+ * Returns the smallest bigint.
16
+ */
17
+ export function minBigInt(a: bigint, b: bigint) {
18
+ return a < b ? a : b;
19
+ }
20
+
21
+ /**
22
+ * A log of any of the events in TAbiEvent. Distributes over the union, so `.eventName`
23
+ * and `.args` stay narrowable per event.
24
+ */
25
+ export type EventLog<TAbiEvent extends AbiEvent> =
26
+ TAbiEvent extends AbiEvent ? Log<bigint, number, false, TAbiEvent, true> : never;
27
+
28
+
29
+ export type PostQueryEventFilter<TAbiEvent extends AbiEvent> = (allEvents: EventLog<TAbiEvent>[], newChunkEvents: EventLog<TAbiEvent>[],firstBlock:bigint, lastBlock:bigint) => [result:EventLog<TAbiEvent>[],quitEarly:boolean]
30
+
31
+
32
+ type UnionToIntersection<TUnion> =
33
+ (TUnion extends unknown ? (arg: TUnion) => void : never) extends (arg: infer TIntersection) => void
34
+ ? TIntersection
35
+ : never;
36
+
37
+ type IndexedInput<TAbiEvent extends AbiEvent> = Extract<TAbiEvent['inputs'][number], { indexed: true }>;
38
+
39
+ /**
40
+ * Names of the indexed params of a single event. Falls back to `string` when the abi wasn't
41
+ * declared `as const` (`indexed` widened to boolean), so this stays usable instead of collapsing to never.
42
+ */
43
+ type IndexedNames<TAbiEvent extends AbiEvent> =
44
+ [IndexedInput<TAbiEvent>] extends [never] ? string : NonNullable<IndexedInput<TAbiEvent>['name']>;
45
+
46
+ /**
47
+ * Indexed param names present in *every* event of the union. Wrapping each event's names in an
48
+ * object and intersecting turns the union of name-sets into their intersection.
49
+ */
50
+ type SharedIndexedNames<TAbiEvent extends AbiEvent> =
51
+ UnionToIntersection<TAbiEvent extends AbiEvent ? { names: IndexedNames<TAbiEvent> } : never> extends { names: infer TNames }
52
+ ? TNames & string
53
+ : never;
54
+
55
+ type IndexedInputByName<TAbiEvent extends AbiEvent, TName extends string> =
56
+ Extract<IndexedInput<TAbiEvent>, { name: TName }>;
57
+
58
+ type SharedArgValue<TAbiEvent extends AbiEvent, TName extends string> =
59
+ [IndexedInputByName<TAbiEvent, TName>] extends [never]
60
+ ? unknown
61
+ : IndexedInputByName<TAbiEvent, TName> extends infer TInput
62
+ ? TInput extends AbiParameter ? AbiParameterToPrimitiveType<TInput> : never
63
+ : never;
64
+
65
+ /**
66
+ * Filter on indexed params that all queried events share. A value may be a single value or an
67
+ * array of values (matches any of them), same as viem's `args`.
68
+ */
69
+ export type SharedEventFilterArgs<TAbiEvent extends AbiEvent> = {
70
+ [TName in SharedIndexedNames<TAbiEvent>]?:
71
+ | SharedArgValue<TAbiEvent, TName>
72
+ | readonly SharedArgValue<TAbiEvent, TName>[]
73
+ | null
74
+ | undefined
75
+ };
76
+
77
+ /**
78
+ * Finds the abi entries of an event by name. Overloaded events (same name, different params)
79
+ * yield multiple entries, all are returned.
80
+ */
81
+ function getEventAbis<TAbiEvent extends AbiEvent>(abi: Abi, eventName: string): TAbiEvent[] {
82
+ const matches = abi.filter((item) => item.type === 'event' && item.name === eventName) as TAbiEvent[];
83
+ if (matches.length === 0) {
84
+ throw new Error(`Event "${eventName}" not found in ABI`);
85
+ }
86
+ return matches;
87
+ }
88
+
89
+ /**
90
+ * Fetches contract events in chunks to avoid RPC limits on big block ranges.
91
+ * Can filter on indexed args and scan backwards (latest first) but returns in normal order (earliest first).
92
+ *
93
+ * Reverse order helps when you want recent events first, so you can stop early with maxEvents without scanning everything.
94
+ * maxEvents lets you quit once you hit enough events, saving RPC calls.
95
+ *
96
+ * Note: No concurrency implemented. This usually messes with rate limits on most RPCs. For local setups, just bump up chunkSize.
97
+ *
98
+ * @param {PublicClient} args.publicClient
99
+ * @param {{ address: Address; abi: TAbi }} args.contract - the viem contract object (returned from getContract). Or just pass {address: 0xUrContract; abi: ["ur", "abi"] }
100
+ * @param {TEventName} args.eventName
101
+ * @param {GetLogsParameters<TAbiEvent>['args']} [args.eventFilterArgs] - Filters for indexed parameters (this is passed to publicClient.getLogs aka eth_getLog)
102
+ * @param {bigint} [args.firstBlock] - Start block (inclusive). Defaults to 0n.
103
+ * @param {bigint} [args.lastBlock] - End block (inclusive). Defaults to current block.
104
+ * @param {boolean} [args.reverseOrder] - Scan latest to earliest, but returns the normal order.
105
+ * @param {number} [args.maxEvents] - Max events to fetch; stops early if hit. (events are counted after postQueryFilter is applied)
106
+ * @param {bigint} [args.chunkSize] - amount of block will be requested with eth_getLogs
107
+ * @param {(events: EventLog<TAbiEvent>[]) => EventLog<TAbiEvent>[]} [args.postQueryFilter] - Filter applied after events are queried.
108
+ *
109
+ * @returns {Promise<EventLog<TAbiEvent>[]>} Array of event logs, earliest to latest.
110
+ */
111
+ export async function queryEventInChunks<
112
+ const TAbi extends Abi,
113
+ const TEventName extends string,
114
+ TAbiEvent extends AbiEvent = Extract<TAbi[number], AbiEvent & { name: TEventName }>
115
+ >({
116
+ publicClient,
117
+ contract,
118
+ eventName,
119
+ eventFilterArgs,
120
+ firstBlock = 0n,
121
+ lastBlock,
122
+ reverseOrder = false,
123
+ maxEvents = Infinity,
124
+ chunkSize = 20000n,
125
+ postQueryFilter,
126
+ }: {
127
+ publicClient: PublicClient;
128
+ contract: { address: Address; abi: TAbi };
129
+ eventName: TEventName;
130
+ eventFilterArgs?: GetLogsParameters<TAbiEvent>['args'];
131
+ firstBlock?:bigint;
132
+ lastBlock?: bigint;
133
+ reverseOrder?: boolean;
134
+ maxEvents?: number;
135
+ chunkSize?: bigint;
136
+ postQueryFilter?:PostQueryEventFilter<TAbiEvent>;
137
+ }): Promise<EventLog<TAbiEvent>[]> {
138
+ const address = contract.address;
139
+
140
+ // Find the event abi based on eventName (now fully typed)
141
+ const eventAbi = getEventAbis<TAbiEvent>(contract.abi, eventName)[0];
142
+
143
+ return await scanInChunks<TAbiEvent>({
144
+ publicClient,
145
+ firstBlock,
146
+ lastBlock,
147
+ reverseOrder,
148
+ maxEvents,
149
+ chunkSize,
150
+ postQueryFilter,
151
+ queryChunk: async (fromBlock, toBlock) => await publicClient.getLogs({
152
+ address,
153
+ event: eventAbi,
154
+ args: eventFilterArgs,
155
+ fromBlock,
156
+ toBlock,
157
+ }) as EventLog<TAbiEvent>[],
158
+ });
159
+ }
160
+
161
+ /**
162
+ * Same as {@link queryEventInChunks} but for several events at once: one eth_getLogs per chunk with
163
+ * every event signature OR'd into topic0, so N event names cost the same amount of RPC calls as 1.
164
+ *
165
+ * Filtering (sharedEventFilterArgs) is possible, but only on indexed params that *every* queried event has
166
+ * at the same indexed position and with the same type. eth_getLogs topics are positional, not named:
167
+ * topic1 is the 1st indexed param of whatever event matched, so a filter on it only means one thing
168
+ * if all the events agree on what their 1st indexed param is. Filtering on a param that isn't shared
169
+ * like that throws, instead of silently matching the wrong param on some of the events.
170
+ *
171
+ * @param {PublicClient} args.publicClient
172
+ * @param {{ address: Address; abi: TAbi }} args.contract - the viem contract object (returned from getContract). Or just pass {address: 0xUrContract; abi: ["ur", "abi"] }
173
+ * @param {TEventName[]} args.eventNames - the events to query, all in one filter
174
+ * @param {SharedEventFilterArgs<TAbiEvent>} [args.sharedEventFilterArgs] - Filters for indexed params shared by all queried events, e.g. { treeId: [1n, 2n] }. Throws if a param isn't shared.
175
+ * @param {bigint} [args.firstBlock] - Start block (inclusive). Defaults to 0n.
176
+ * @param {bigint} [args.lastBlock] - End block (inclusive). Defaults to current block.
177
+ * @param {boolean} [args.reverseOrder] - Scan latest to earliest, but returns the normal order.
178
+ * @param {number} [args.maxEvents] - Max events to fetch; stops early if hit. (events are counted after postQueryFilter is applied)
179
+ * @param {bigint} [args.chunkSize] - amount of block will be requested with eth_getLogs
180
+ * @param {PostQueryEventFilter<TAbiEvent>} [args.postQueryFilter] - Filter applied after events are queried.
181
+ *
182
+ * @returns {Promise<EventLog<TAbiEvent>[]>} Array of logs of all requested events mixed together, earliest to latest.
183
+ */
184
+ export async function queryMultiEventsInChunks<
185
+ const TAbi extends Abi,
186
+ const TEventName extends string,
187
+ TAbiEvent extends AbiEvent = Extract<TAbi[number], AbiEvent & { name: TEventName }>
188
+ >({
189
+ publicClient,
190
+ contract,
191
+ eventNames,
192
+ sharedEventFilterArgs,
193
+ firstBlock = 0n,
194
+ lastBlock,
195
+ reverseOrder = false,
196
+ maxEvents = Infinity,
197
+ chunkSize = 20000n,
198
+ postQueryFilter,
199
+ }: {
200
+ publicClient: PublicClient;
201
+ contract: { address: Address; abi: TAbi };
202
+ eventNames: readonly TEventName[];
203
+ sharedEventFilterArgs?: SharedEventFilterArgs<TAbiEvent>;
204
+ firstBlock?:bigint;
205
+ lastBlock?: bigint;
206
+ reverseOrder?: boolean;
207
+ maxEvents?: number;
208
+ chunkSize?: bigint;
209
+ postQueryFilter?:PostQueryEventFilter<TAbiEvent>;
210
+ }): Promise<EventLog<TAbiEvent>[]> {
211
+ const address = contract.address;
212
+ const abi = contract.abi;
213
+
214
+ if (eventNames.length === 0) {
215
+ throw new Error(`No event names given`);
216
+ }
217
+
218
+ const eventAbis = eventNames.flatMap((eventName) => getEventAbis<AbiEvent>(abi, eventName));
219
+ const topics = buildSharedTopics(eventAbis, sharedEventFilterArgs);
220
+
221
+ return await scanInChunks<TAbiEvent>({
222
+ publicClient,
223
+ firstBlock,
224
+ lastBlock,
225
+ reverseOrder,
226
+ maxEvents,
227
+ chunkSize,
228
+ postQueryFilter,
229
+ // viem's getLogs refuses args together with multiple events (it can't map named args onto
230
+ // positional topics for a union of events), so the filter is handed to the node as raw topics.
231
+ queryChunk: async (fromBlock, toBlock) => {
232
+ const rpcLogs = await publicClient.request({
233
+ method: 'eth_getLogs',
234
+ params: [{
235
+ address,
236
+ topics,
237
+ fromBlock: numberToHex(fromBlock),
238
+ toBlock: numberToHex(toBlock),
239
+ }],
240
+ });
241
+ return parseEventLogs({
242
+ abi: abi as Abi,
243
+ eventName: [...eventNames],
244
+ logs: rpcLogs.map((log) => formatLog(log)),
245
+ strict: true,
246
+ }) as unknown as EventLog<TAbiEvent>[];
247
+ },
248
+ });
249
+ }
250
+
251
+ /**
252
+ * Builds the eth_getLogs topics for a multi event query: topic0 is every event signature (OR'd),
253
+ * the rest are the shared indexed args. Throws if an arg isn't shared by all events.
254
+ */
255
+ function buildSharedTopics(eventAbis: AbiEvent[], sharedEventFilterArgs?: Record<string, unknown>): LogTopic[] {
256
+ const signatures = eventAbis.map((eventAbi) => encodeEventTopics({ abi: [eventAbi] })[0]);
257
+ const topic0 = [...new Set(signatures)];
258
+
259
+ const filterNames = Object.entries(sharedEventFilterArgs ?? {})
260
+ .filter(([, value]) => value !== undefined && value !== null)
261
+ .map(([name]) => name);
262
+ if (filterNames.length === 0) {
263
+ return [topic0];
264
+ }
265
+
266
+ const indexedInputsPerEvent = eventAbis.map((eventAbi) => ({
267
+ eventAbi,
268
+ inputs: eventAbi.inputs.filter((input) => input.indexed),
269
+ }));
270
+
271
+ for (const name of filterNames) {
272
+ // Every event must have this param indexed, at the same position and with the same type,
273
+ // otherwise the same topic slot would mean different things per event.
274
+ const found = indexedInputsPerEvent.map(({ eventAbi, inputs }) => {
275
+ const position = inputs.findIndex((input) => input.name === name);
276
+ return {
277
+ eventName: eventAbi.name,
278
+ position,
279
+ type: position === -1 ? undefined : inputs[position].type,
280
+ isNonIndexedParam: position === -1 && eventAbi.inputs.some((input) => input.name === name),
281
+ };
282
+ });
283
+
284
+ const missing = found.filter(({ position }) => position === -1);
285
+ if (missing.length !== 0) {
286
+ const reasons = missing
287
+ .map(({ eventName, isNonIndexedParam }) =>
288
+ `${eventName} (${isNonIndexedParam ? 'not indexed' : 'no such param'})`)
289
+ .join(', ');
290
+ throw new Error(
291
+ `Cannot filter on "${name}": it is not an indexed param of every queried event. Missing in: ${reasons}. ` +
292
+ `Only params shared by all events can be filtered, since eth_getLogs topics are positional.`
293
+ );
294
+ }
295
+
296
+ const positions = [...new Set(found.map(({ position }) => position))];
297
+ if (positions.length !== 1) {
298
+ const perEvent = found.map(({ eventName, position }) => `${eventName}: #${position + 1}`).join(', ');
299
+ throw new Error(
300
+ `Cannot filter on "${name}": it is not the same indexed param position in every queried event (${perEvent}). ` +
301
+ `A topic slot is positional, so it must line up across all events.`
302
+ );
303
+ }
304
+
305
+ const types = [...new Set(found.map(({ type }) => type))];
306
+ if (types.length !== 1) {
307
+ const perEvent = found.map(({ eventName, type }) => `${eventName}: ${type}`).join(', ');
308
+ throw new Error(
309
+ `Cannot filter on "${name}": its type differs between the queried events (${perEvent}).`
310
+ );
311
+ }
312
+ }
313
+
314
+ // Positions were just verified to be identical across all events, so encoding against the first
315
+ // event gives the topics for all of them.
316
+ const [, ...argTopics] = encodeEventTopics({
317
+ abi: [eventAbis[0]] as Abi,
318
+ eventName: eventAbis[0].name,
319
+ args: sharedEventFilterArgs,
320
+ });
321
+
322
+ // Trailing nulls are just "match anything", no need to send them.
323
+ while (argTopics.length !== 0 && argTopics[argTopics.length - 1] === null) {
324
+ argTopics.pop();
325
+ }
326
+
327
+ return [topic0, ...argTopics] as LogTopic[];
328
+ }
329
+
330
+ /**
331
+ * Walks the block range in chunks, calling queryChunk per chunk, and applies
332
+ * postQueryFilter / maxEvents. Shared by the single and multi event queries.
333
+ *
334
+ * TODO: optional concurrency (bounded worker pool over the chunk indices, default 1 so nothing
335
+ * changes for whoever doesn't opt in). Chunks are independent, so this is close to a linear speedup
336
+ * up to whatever ceiling applies:
337
+ *
338
+ * - self hosters (biggest win by far): no rate limit at all, the ceiling is NVMe queue depth and
339
+ * cores. Erigon/reth resolve a chunk from their inverted log index (bitmap of blocks per topic),
340
+ * so the per chunk cost is mostly materializing receipts for matched blocks, which parallelizes
341
+ * fine. Bump chunkSize way up too, the provider result caps below don't exist locally.
342
+ * - paid RPCs (still worth it, just capped): providers meter *throughput*, not concurrency, so
343
+ * sequential scanning leaves most of the budget unused. Infura bills eth_getLogs at 255 credits
344
+ * against a per second credit ceiling (2k/s free, up to 40k/s on higher plans), Alchemy meters
345
+ * CUPS (330 free, 10k on PAYG, and it lets you burst above your tier on elastic capacity).
346
+ * Little's law gives the in flight budget: creditsPerSecond * avgLatency / costPerRequest, so
347
+ * Infura free at ~500ms per call is only ~4 in flight, a Team plan is ~78. Past that it's 429s.
348
+ * - free/public RPCs: don't. That's what the default of 1 is for.
349
+ *
350
+ * Two things to get right when implementing:
351
+ * - maxEvents early stop: in flight chunks overshoot before the stop is noticed. Harmless forwards,
352
+ * but in reverseOrder the results have to be stitched back in block order before slicing.
353
+ * - retries: concurrency turns a rate limit into 429s on several chunks at once, so it needs
354
+ * per chunk backoff to not just fail the whole scan.
355
+ */
356
+ async function scanInChunks<TAbiEvent extends AbiEvent>({
357
+ publicClient,
358
+ queryChunk,
359
+ firstBlock,
360
+ lastBlock,
361
+ reverseOrder,
362
+ maxEvents,
363
+ chunkSize,
364
+ postQueryFilter,
365
+ }: {
366
+ publicClient: PublicClient;
367
+ queryChunk: (fromBlock: bigint, toBlock: bigint) => Promise<EventLog<TAbiEvent>[]>;
368
+ firstBlock: bigint;
369
+ lastBlock?: bigint;
370
+ reverseOrder: boolean;
371
+ maxEvents: number;
372
+ chunkSize: bigint;
373
+ postQueryFilter?: PostQueryEventFilter<TAbiEvent>
374
+ }): Promise<EventLog<TAbiEvent>[]> {
375
+ lastBlock ??= await publicClient.getBlockNumber();
376
+ let allEvents: EventLog<TAbiEvent>[] = [];
377
+ let done = false;
378
+
379
+ const scanLogic = async (index: bigint):Promise<[EventLog<TAbiEvent>[], bigint, bigint]> => {
380
+ const start = firstBlock + index * chunkSize;
381
+ const stop = minBigInt(start + chunkSize - 1n, lastBlock);
382
+ return [(await queryChunk(start, stop)), start, stop];
383
+ };
384
+
385
+ const range = lastBlock - firstBlock + 1n;
386
+ const numIters = Math.ceil(Number(range) / Number(chunkSize));
387
+ if (reverseOrder) {
388
+ for (let index = BigInt(numIters - 1); index >= 0n; index--) {
389
+ const [events, firstBlock, lastBlock] = await scanLogic(index);
390
+ allEvents = [...events as EventLog<TAbiEvent>[], ...allEvents];
391
+ if (postQueryFilter) {
392
+ [allEvents, done] = postQueryFilter(allEvents, events, firstBlock, lastBlock)
393
+ }
394
+ allEvents = allEvents.slice(-maxEvents);
395
+ if (done || allEvents.length >= maxEvents) {
396
+ console.log(`stopped scanning at chunk ${index}/${numIters-1}`);
397
+ break
398
+ };
399
+ }
400
+
401
+ } else {
402
+ for (let index = 0n; index < BigInt(numIters); index++) {
403
+ const [events, firstBlock, lastBlock] = await scanLogic(index);
404
+ allEvents = [...allEvents, ...events as EventLog<TAbiEvent>[]];
405
+ if (postQueryFilter) {
406
+ [allEvents, done] = postQueryFilter(allEvents, events, firstBlock, lastBlock)
407
+ }
408
+ allEvents = allEvents.slice(0, maxEvents);
409
+ if (done || allEvents.length >= maxEvents) {console.log(`stopped scanning at chunk ${index}/${numIters-1}`);break};
410
+ }
411
+ }
412
+
413
+ return allEvents;
414
+ }
package/src/index.ts ADDED
@@ -0,0 +1,4 @@
1
+ export * from "./Trees.js";
2
+ export * from "./config.js";
3
+ export * from "./interfaceId.js";
4
+ export * from "./abis.js";
@@ -0,0 +1,77 @@
1
+ import { toFunctionSelector, toHex, type Abi, type AbiFunction, type Address, type Hex, type PublicClient } from "viem";
2
+
3
+ /** Function selectors of an ABI, excluding ERC-165's own `supportsInterface`. */
4
+ function interfaceSelectors(abi: Abi): Hex[] {
5
+ return abi
6
+ .filter((item): item is AbiFunction => item.type === "function" && item.name !== "supportsInterface")
7
+ .map((fn) => toFunctionSelector(fn));
8
+ }
9
+
10
+ /**
11
+ * ERC-165 interface id (`type(I).interfaceId`) for a Solidity interface, computed from its
12
+ * ABI: the XOR of the interface's function selectors. Pass an artifact's `.abi` straight in.
13
+ *
14
+ * A compiled ABI flattens every inherited function in, but Solidity excludes inherited
15
+ * functions from `type(I).interfaceId`. So `excludeFunctionsOf` lets you subtract functions
16
+ * that belong to a base — pass the base interface's ABI(s) and their functions are removed
17
+ * before the XOR. The common case is an interface that extends another (e.g.
18
+ * `IFatIMTReadableStorage is IFatIMTReadableEvent` → pass the event ABI); a plain interface
19
+ * extending only `IERC165` needs nothing, since `supportsInterface` is always excluded.
20
+ *
21
+ * @param abi the interface's ABI (e.g. `artifact.abi`)
22
+ * @param excludeFunctionsOf ABIs whose functions to subtract first — typically the base
23
+ * interface(s) `abi` inherits; `IERC165` is already handled
24
+ */
25
+ export function getInterfaceId(abi: Abi, excludeFunctionsOf: readonly Abi[] = []): Hex {
26
+ const excluded = new Set(excludeFunctionsOf.flatMap(interfaceSelectors));
27
+ let id = 0n;
28
+ for (const selector of interfaceSelectors(abi)) {
29
+ if (excluded.has(selector)) continue;
30
+ id ^= BigInt(selector);
31
+ }
32
+ return toHex(id, { size: 4 });
33
+ }
34
+
35
+ /**
36
+ * The ERC-165 `supportsInterface(bytes4)` function. Its selector (`0x01ffc9a7`) and signature
37
+ * are fixed by the standard, so this ABI is identical on every ERC-165 contract.
38
+ */
39
+ export const SUPPORTS_INTERFACE_ABI = [
40
+ {
41
+ type: "function",
42
+ name: "supportsInterface",
43
+ stateMutability: "view",
44
+ inputs: [{ name: "interfaceId", type: "bytes4" }],
45
+ outputs: [{ name: "", type: "bool" }],
46
+ },
47
+ ] as const satisfies Abi;
48
+
49
+ /** Whether `address` reports support for `interfaceId` via ERC-165 `supportsInterface`. */
50
+ export function supportsInterface(client: PublicClient, address: Address, interfaceId: Hex): Promise<boolean> {
51
+ return client.readContract({
52
+ address,
53
+ abi: SUPPORTS_INTERFACE_ABI,
54
+ functionName: "supportsInterface",
55
+ args: [interfaceId],
56
+ });
57
+ }
58
+
59
+ /**
60
+ * Probe a contract for a batch of ERC-165 interfaces at once. Give it a map of your own
61
+ * labels to interface ids (e.g. `{ erc721: "0x80ac58cd", metadata: "0x5b5e139f" }`) and it
62
+ * returns the same labels mapped to whether the contract reports support. All queries run
63
+ * concurrently.
64
+ *
65
+ * @param client viem public client to read with
66
+ * @param address address of the (deployed) contract to probe
67
+ * @param interfaceIds map of caller-chosen labels to the interface ids to check
68
+ */
69
+ export async function detectSupportedInterfaces<K extends string>(
70
+ client: PublicClient,
71
+ address: Address,
72
+ interfaceIds: Record<K, Hex>,
73
+ ): Promise<Record<K, boolean>> {
74
+ const labels = Object.keys(interfaceIds) as K[];
75
+ const supported = await Promise.all(labels.map((label) => supportsInterface(client, address, interfaceIds[label])));
76
+ return Object.fromEntries(labels.map((label, i) => [label, supported[i]])) as Record<K, boolean>;
77
+ }