voltaire-effect 0.3.0 → 1.0.1

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.
Files changed (88) hide show
  1. package/dist/{X25519Test-D5Q-5fL9.d.ts → X25519Test-avt1DUgp.d.ts} +231 -6
  2. package/dist/crypto/index.d.ts +2 -2
  3. package/dist/crypto/index.js +72 -2
  4. package/dist/{index-3UKSP3cd.d.ts → index-DxwZo3xo.d.ts} +7781 -5513
  5. package/dist/index.d.ts +990 -3096
  6. package/dist/index.js +2374 -1652
  7. package/dist/native/index.d.ts +6 -6
  8. package/dist/native/index.js +2399 -1677
  9. package/dist/primitives/index.d.ts +7 -6
  10. package/dist/primitives/index.js +2966 -2361
  11. package/dist/services/index.d.ts +1631 -1255
  12. package/dist/services/index.js +4493 -3977
  13. package/package.json +7 -3
  14. package/src/crypto/Signers/SignersService.ts +1 -1
  15. package/src/crypto/Signers/errors.ts +29 -0
  16. package/src/crypto/Signers/index.ts +1 -0
  17. package/src/crypto/Signers/operations.ts +26 -8
  18. package/src/crypto/index.ts +10 -11
  19. package/src/index.ts +1 -2
  20. package/src/jsonrpc/Anvil.ts +13 -8
  21. package/src/jsonrpc/Eth.ts +13 -8
  22. package/src/jsonrpc/Hardhat.ts +13 -8
  23. package/src/jsonrpc/IdCounter.ts +21 -5
  24. package/src/jsonrpc/JsonRpc.test.ts +126 -61
  25. package/src/jsonrpc/Net.ts +13 -8
  26. package/src/jsonrpc/Request.ts +16 -8
  27. package/src/jsonrpc/Txpool.ts +13 -8
  28. package/src/jsonrpc/Wallet.ts +13 -8
  29. package/src/jsonrpc/Web3.ts +13 -8
  30. package/src/jsonrpc/index.ts +1 -1
  31. package/src/primitives/Abi/AbiSchema.ts +3 -4
  32. package/src/primitives/Abi/fromBytecode.test.ts +47 -0
  33. package/src/primitives/Abi/fromBytecode.ts +81 -0
  34. package/src/primitives/Abi/index.ts +3 -0
  35. package/src/primitives/AccessList/from.ts +12 -9
  36. package/src/primitives/Address/Checksummed.ts +21 -27
  37. package/src/primitives/Address/from.ts +12 -15
  38. package/src/primitives/Address/toHex.ts +2 -1
  39. package/src/primitives/Base64/from.ts +21 -4
  40. package/src/primitives/Blob/from.ts +12 -4
  41. package/src/primitives/BlockHash/index.ts +2 -2
  42. package/src/primitives/BlockNumber/index.ts +3 -3
  43. package/src/primitives/Bytecode/from.ts +11 -2
  44. package/src/primitives/ContractSignature/verifySignature.ts +3 -5
  45. package/src/primitives/Ens/from.ts +12 -11
  46. package/src/primitives/Hex/from.ts +12 -4
  47. package/src/primitives/Signature/from.ts +11 -2
  48. package/src/primitives/Transaction/EIP2930/index.ts +12 -12
  49. package/src/primitives/Transaction/EIP4844/index.ts +14 -14
  50. package/src/primitives/Transaction/EIP7702/index.ts +13 -13
  51. package/src/primitives/Transaction/Legacy/index.ts +13 -13
  52. package/src/primitives/TransactionHash/index.ts +3 -2
  53. package/src/primitives/TransactionIndex/index.ts +2 -2
  54. package/src/primitives/Trie/Trie.test.ts +70 -0
  55. package/src/primitives/Trie/TrieSchema.ts +26 -0
  56. package/src/primitives/Trie/clear.ts +16 -0
  57. package/src/primitives/Trie/del.ts +18 -0
  58. package/src/primitives/Trie/get.ts +18 -0
  59. package/src/primitives/Trie/index.ts +30 -0
  60. package/src/primitives/Trie/init.ts +13 -0
  61. package/src/primitives/Trie/prove.ts +19 -0
  62. package/src/primitives/Trie/put.ts +20 -0
  63. package/src/primitives/Trie/rootHash.ts +14 -0
  64. package/src/primitives/Trie/verify.ts +18 -0
  65. package/src/primitives/Uint/from.ts +11 -2
  66. package/src/primitives/Uint16/index.ts +5 -4
  67. package/src/primitives/Uint64/index.ts +5 -4
  68. package/src/primitives/Uint8/index.ts +5 -4
  69. package/src/primitives/index.ts +3 -2
  70. package/src/services/BlockExplorerApi/BlockExplorerApi.test.ts +789 -0
  71. package/src/services/BlockExplorerApi/BlockExplorerApi.ts +797 -0
  72. package/src/services/BlockExplorerApi/BlockExplorerApiErrors.ts +176 -0
  73. package/src/services/BlockExplorerApi/BlockExplorerApiService.ts +60 -0
  74. package/src/services/BlockExplorerApi/BlockExplorerApiTypes.ts +225 -0
  75. package/src/services/BlockExplorerApi/index.ts +42 -0
  76. package/src/services/Contract/Contract.test.ts +2 -6
  77. package/src/services/Contract/ContractTypes.ts +26 -8
  78. package/src/services/Contract/estimateGas.test.ts +4 -7
  79. package/src/services/Provider/actions/multicall.ts +28 -9
  80. package/src/services/Provider/actions/readContract.test.ts +8 -11
  81. package/src/services/Provider/actions/readContract.ts +28 -9
  82. package/src/services/Provider/functions/getBlock.ts +2 -1
  83. package/src/services/Provider/functions/getBlockReceipts.ts +2 -1
  84. package/src/services/Provider/functions/getBlockTransactionCount.ts +2 -1
  85. package/src/services/Provider/functions/getUncle.ts +2 -1
  86. package/src/services/Provider/functions/getUncleCount.ts +2 -1
  87. package/src/services/Signer/actions/deployContract.ts +1 -1
  88. package/src/services/index.ts +25 -0
@@ -0,0 +1,797 @@
1
+ /**
2
+ * @fileoverview Block Explorer API layer factory.
3
+ *
4
+ * @module BlockExplorerApi
5
+ * @since 0.0.1
6
+ *
7
+ * @description
8
+ * Factory functions for creating BlockExplorerApiService layers.
9
+ * Supports explicit configuration and environment-based configuration.
10
+ */
11
+
12
+ import {
13
+ Address,
14
+ BrandedAbi,
15
+ type BrandedHex,
16
+ Hex,
17
+ } from "@tevm/voltaire";
18
+ import * as Effect from "effect/Effect";
19
+ import * as Layer from "effect/Layer";
20
+ import * as Runtime from "effect/Runtime";
21
+ import { Redacted } from "effect";
22
+ import { ContractCallError, ContractWriteError } from "../Contract/ContractTypes.js";
23
+ import { ProviderService } from "../Provider/ProviderService.js";
24
+ import { SignerService } from "../Signer/SignerService.js";
25
+ import { getCode, call, getStorageAt } from "../Provider/functions/index.js";
26
+
27
+ type HexType = BrandedHex.HexType;
28
+ import {
29
+ BlockExplorerConfigError,
30
+ BlockExplorerNotFoundError,
31
+ BlockExplorerRateLimitError,
32
+ BlockExplorerResponseError,
33
+ BlockExplorerDecodeError,
34
+ BlockExplorerUnexpectedError,
35
+ BlockExplorerProxyResolutionError,
36
+ } from "./BlockExplorerApiErrors.js";
37
+ import { BlockExplorerApiService, type BlockExplorerApiShape } from "./BlockExplorerApiService.js";
38
+ import type {
39
+ AbiItem,
40
+ BlockExplorerApiConfig,
41
+ ContractSourceFile,
42
+ ExplorerContractInstance,
43
+ ExplorerSourceId,
44
+ GetAbiOptions,
45
+ GetContractOptions,
46
+ GetSourcesOptions,
47
+ } from "./BlockExplorerApiTypes.js";
48
+ import type { BlockExplorerApiError } from "./BlockExplorerApiErrors.js";
49
+ import { ChainService } from "../Chain/ChainService.js";
50
+
51
+ // WhatsAbi imports
52
+ import { loaders, autoload, abiFromBytecode } from "@shazow/whatsabi";
53
+
54
+ // Type for WhatsAbi ABI (ABIFunction | ABIEvent)[]
55
+ type WhatsAbiItem = {
56
+ type: "function" | "event";
57
+ selector?: string;
58
+ hash?: string;
59
+ name?: string;
60
+ outputs?: Array<{ type: string; name: string; components?: Array<{ type: string; name: string }> }>;
61
+ inputs?: Array<{ type: string; name: string; components?: Array<{ type: string; name: string }> }>;
62
+ sig?: string;
63
+ sigAlts?: string[];
64
+ payable?: boolean;
65
+ stateMutability?: "nonpayable" | "payable" | "view" | "pure";
66
+ };
67
+ type WhatsABI = WhatsAbiItem[];
68
+
69
+ /**
70
+ * Default source order for resolution.
71
+ */
72
+ const DEFAULT_SOURCE_ORDER: ReadonlyArray<ExplorerSourceId> = [
73
+ "sourcify",
74
+ "etherscanV2",
75
+ "blockscout",
76
+ ];
77
+
78
+ /**
79
+ * Normalize ABI from WhatsAbi format to our standard format.
80
+ */
81
+ function normalizeAbi(rawAbi: WhatsABI): ReadonlyArray<AbiItem> {
82
+ return rawAbi.map((item: WhatsAbiItem): AbiItem | null => {
83
+ if (item.type === "function") {
84
+ return {
85
+ type: "function",
86
+ name: item.name,
87
+ inputs: item.inputs?.map((i: { type: string; name: string; components?: Array<{ type: string; name: string }> }) => ({
88
+ name: i.name,
89
+ type: i.type,
90
+ components: i.components as AbiItem["inputs"],
91
+ })),
92
+ outputs: item.outputs?.map((o: { type: string; name: string; components?: Array<{ type: string; name: string }> }) => ({
93
+ name: o.name,
94
+ type: o.type,
95
+ components: o.components as AbiItem["outputs"],
96
+ })),
97
+ stateMutability: item.stateMutability,
98
+ };
99
+ }
100
+ if (item.type === "event") {
101
+ return {
102
+ type: "event",
103
+ name: item.name,
104
+ anonymous: false,
105
+ };
106
+ }
107
+ return null;
108
+ }).filter((item: AbiItem | null): item is AbiItem => item !== null);
109
+ }
110
+
111
+ /**
112
+ * Detect rate limit from response.
113
+ */
114
+ function isRateLimitResponse(body: string): boolean {
115
+ const lower = body.toLowerCase();
116
+ return (
117
+ lower.includes("rate limit") ||
118
+ lower.includes("too many requests") ||
119
+ lower.includes("max rate limit reached")
120
+ );
121
+ }
122
+
123
+ /**
124
+ * Parse retry-after from rate limit response.
125
+ */
126
+ function parseRetryAfter(body: string): number | undefined {
127
+ const match = body.match(/(\d+)\s*seconds?/i);
128
+ return match ? parseInt(match[1], 10) : undefined;
129
+ }
130
+
131
+ /**
132
+ * Get API key as string from config (handles Redacted).
133
+ */
134
+ function getApiKey(
135
+ key: Redacted.Redacted<string> | string | undefined,
136
+ ): string | undefined {
137
+ if (key === undefined) return undefined;
138
+ if (typeof key === "string") return key;
139
+ return Redacted.value(key);
140
+ }
141
+
142
+ /**
143
+ * Create loaders based on configuration.
144
+ */
145
+ function createLoaders(
146
+ config: BlockExplorerApiConfig,
147
+ chainId: number,
148
+ ): loaders.ABILoader[] {
149
+ const result: loaders.ABILoader[] = [];
150
+ const sourceOrder = config.sourceOrder ?? DEFAULT_SOURCE_ORDER;
151
+
152
+ for (const source of sourceOrder) {
153
+ const sourceConfig = config.sources[source];
154
+ if (!sourceConfig?.enabled) continue;
155
+
156
+ switch (source) {
157
+ case "sourcify": {
158
+ result.push(
159
+ new loaders.SourcifyABILoader({
160
+ chainId,
161
+ ...(sourceConfig.baseUrl && { baseURL: sourceConfig.baseUrl }),
162
+ }),
163
+ );
164
+ break;
165
+ }
166
+ case "etherscanV2": {
167
+ const apiKey = getApiKey(
168
+ (sourceConfig as typeof config.sources.etherscanV2)?.apiKey,
169
+ );
170
+ if (apiKey) {
171
+ result.push(
172
+ new loaders.EtherscanABILoader({
173
+ apiKey,
174
+ ...(sourceConfig.baseUrl && { baseURL: sourceConfig.baseUrl }),
175
+ }),
176
+ );
177
+ }
178
+ break;
179
+ }
180
+ case "blockscout": {
181
+ const apiKey = getApiKey(
182
+ (sourceConfig as typeof config.sources.blockscout)?.apiKey,
183
+ );
184
+ result.push(
185
+ new loaders.BlockscoutABILoader({
186
+ ...(apiKey && { apiKey }),
187
+ ...(sourceConfig.baseUrl && { baseURL: sourceConfig.baseUrl }),
188
+ }),
189
+ );
190
+ break;
191
+ }
192
+ }
193
+ }
194
+
195
+ return result;
196
+ }
197
+
198
+ /**
199
+ * Map WhatsAbi loader error to typed error.
200
+ */
201
+ function mapLoaderError(
202
+ error: unknown,
203
+ source: ExplorerSourceId,
204
+ address: `0x${string}`,
205
+ ): BlockExplorerApiError {
206
+ if (error instanceof Error) {
207
+ const message = error.message;
208
+
209
+ if (isRateLimitResponse(message)) {
210
+ return new BlockExplorerRateLimitError(
211
+ source,
212
+ address,
213
+ message,
214
+ parseRetryAfter(message),
215
+ );
216
+ }
217
+
218
+ if (
219
+ message.includes("not found") ||
220
+ message.includes("not verified") ||
221
+ message.includes("404")
222
+ ) {
223
+ return new BlockExplorerNotFoundError(address, [source]);
224
+ }
225
+
226
+ if (
227
+ message.includes("JSON") ||
228
+ message.includes("parse") ||
229
+ message.includes("decode")
230
+ ) {
231
+ return new BlockExplorerDecodeError(
232
+ source,
233
+ address,
234
+ message,
235
+ message.slice(0, 200),
236
+ );
237
+ }
238
+
239
+ return new BlockExplorerResponseError(source, address, message);
240
+ }
241
+
242
+ return new BlockExplorerUnexpectedError("getContract", String(error), error);
243
+ }
244
+
245
+ /**
246
+ * Determine which source returned the ABI based on the loader instance.
247
+ */
248
+ function getSourceFromLoader(
249
+ loader: loaders.ABILoader,
250
+ ): ExplorerSourceId {
251
+ if (loader instanceof loaders.SourcifyABILoader) return "sourcify";
252
+ if (loader instanceof loaders.EtherscanABILoader) return "etherscanV2";
253
+ if (loader instanceof loaders.BlockscoutABILoader) return "blockscout";
254
+ return "sourcify"; // Default fallback
255
+ }
256
+
257
+ /**
258
+ * WhatsAbi provider interface for autoload.
259
+ */
260
+ interface WhatsAbiProvider {
261
+ getCode(address: string): Promise<string>;
262
+ getStorageAt(address: string, slot: number | string): Promise<string>;
263
+ call(tx: { to: string; data: string }): Promise<string>;
264
+ }
265
+
266
+ // ============================================================================
267
+ // Contract method building helpers
268
+ // ============================================================================
269
+
270
+ function getFunctionSignature(item: AbiItem): string {
271
+ const inputs = item.inputs ?? [];
272
+ const types = inputs.map((i) => i.type).join(",");
273
+ return `${item.name ?? ""}(${types})`;
274
+ }
275
+
276
+ function isViewFunction(item: AbiItem): boolean {
277
+ return (
278
+ item.type === "function" &&
279
+ (item.stateMutability === "view" || item.stateMutability === "pure")
280
+ );
281
+ }
282
+
283
+ function isWriteFunction(item: AbiItem): boolean {
284
+ return (
285
+ item.type === "function" &&
286
+ (item.stateMutability === "nonpayable" || item.stateMutability === "payable")
287
+ );
288
+ }
289
+
290
+ function encodeArgs(
291
+ abi: ReadonlyArray<AbiItem>,
292
+ functionName: string,
293
+ args: ReadonlyArray<unknown>,
294
+ ): HexType {
295
+ return BrandedAbi.encodeFunction(
296
+ abi as unknown as BrandedAbi.Abi,
297
+ functionName,
298
+ args as unknown[],
299
+ );
300
+ }
301
+
302
+ function decodeResult(
303
+ abi: ReadonlyArray<AbiItem>,
304
+ functionName: string,
305
+ data: HexType,
306
+ ): Effect.Effect<unknown, ContractCallError> {
307
+ return Effect.try({
308
+ try: () => {
309
+ const fn = abi.find(
310
+ (item): item is BrandedAbi.Function.FunctionType =>
311
+ item.type === "function" && (item as any).name === functionName,
312
+ ) as BrandedAbi.Function.FunctionType | undefined;
313
+ if (!fn) throw new Error(`Function ${functionName} not found in ABI`);
314
+
315
+ const bytes = Hex.toBytes(data);
316
+ const decoded = BrandedAbi.Function.decodeResult(fn, bytes);
317
+ if (fn.outputs.length === 1) {
318
+ return decoded[0];
319
+ }
320
+ return decoded;
321
+ },
322
+ catch: (e) =>
323
+ new ContractCallError(
324
+ { address: "", method: functionName, args: [] },
325
+ e instanceof Error ? e.message : "Decode error",
326
+ { cause: e instanceof Error ? e : undefined },
327
+ ),
328
+ });
329
+ }
330
+
331
+ function buildMethodMaps(
332
+ address: `0x${string}`,
333
+ abi: ReadonlyArray<AbiItem>,
334
+ ): Pick<ExplorerContractInstance, "read" | "simulate" | "write" | "call"> {
335
+ const functions = abi.filter((item) => item.type === "function");
336
+
337
+ const byName = new Map<string, AbiItem[]>();
338
+ for (const fn of functions) {
339
+ if (!fn.name) continue;
340
+ const existing = byName.get(fn.name) ?? [];
341
+ existing.push(fn);
342
+ byName.set(fn.name, existing);
343
+ }
344
+
345
+ const bySignature = new Map<string, AbiItem>();
346
+ for (const fn of functions) {
347
+ if (!fn.name) continue;
348
+ const sig = getFunctionSignature(fn);
349
+ bySignature.set(sig, fn);
350
+ }
351
+
352
+ const createReadMethod = (
353
+ fn: AbiItem,
354
+ ): ((...args: ReadonlyArray<unknown>) => Effect.Effect<unknown, ContractCallError, ProviderService>) => {
355
+ return (...args: ReadonlyArray<unknown>) =>
356
+ Effect.gen(function* () {
357
+ const data = encodeArgs(abi, fn.name!, args);
358
+ const result = yield* call({ to: address, data }).pipe(
359
+ Effect.mapError(
360
+ (e) =>
361
+ new ContractCallError(
362
+ { address, method: fn.name!, args: args as unknown[] },
363
+ e.message,
364
+ { cause: e },
365
+ ),
366
+ ),
367
+ );
368
+ return yield* decodeResult(abi, fn.name!, result as HexType);
369
+ });
370
+ };
371
+
372
+ const createSimulateMethod = (
373
+ fn: AbiItem,
374
+ ): ((...args: ReadonlyArray<unknown>) => Effect.Effect<unknown, ContractCallError, ProviderService>) => {
375
+ return (...args: ReadonlyArray<unknown>) =>
376
+ Effect.gen(function* () {
377
+ const data = encodeArgs(abi, fn.name!, args);
378
+ const result = yield* call({ to: address, data }).pipe(
379
+ Effect.mapError(
380
+ (e) =>
381
+ new ContractCallError(
382
+ { address, method: fn.name!, args: args as unknown[], simulate: true },
383
+ e.message,
384
+ { cause: e },
385
+ ),
386
+ ),
387
+ );
388
+ return yield* decodeResult(abi, fn.name!, result as HexType);
389
+ });
390
+ };
391
+
392
+ const createWriteMethod = (
393
+ fn: AbiItem,
394
+ ): ((...args: ReadonlyArray<unknown>) => Effect.Effect<`0x${string}`, ContractWriteError, SignerService>) => {
395
+ return (...args: ReadonlyArray<unknown>) =>
396
+ Effect.gen(function* () {
397
+ const signer = yield* SignerService;
398
+ const data = encodeArgs(abi, fn.name!, args);
399
+ const brandedAddress = Address.fromHex(address);
400
+ const txHash = yield* signer
401
+ .sendTransaction({
402
+ to: brandedAddress as unknown as undefined,
403
+ data: data as unknown as undefined,
404
+ })
405
+ .pipe(
406
+ Effect.mapError(
407
+ (e) =>
408
+ new ContractWriteError(
409
+ { address, method: fn.name!, args: args as unknown[] },
410
+ e.message,
411
+ { cause: e instanceof Error ? e : undefined },
412
+ ),
413
+ ),
414
+ );
415
+ return txHash as unknown as `0x${string}`;
416
+ });
417
+ };
418
+
419
+ const read: Record<
420
+ string,
421
+ (...args: ReadonlyArray<unknown>) => Effect.Effect<unknown, ContractCallError, ProviderService>
422
+ > = {};
423
+
424
+ for (const fn of functions) {
425
+ if (!fn.name || !isViewFunction(fn)) continue;
426
+
427
+ const sig = getFunctionSignature(fn);
428
+ const overloads = byName.get(fn.name) ?? [];
429
+
430
+ read[sig] = createReadMethod(fn);
431
+
432
+ if (overloads.length === 1) {
433
+ read[fn.name] = createReadMethod(fn);
434
+ }
435
+ }
436
+
437
+ const simulate: Record<
438
+ string,
439
+ (...args: ReadonlyArray<unknown>) => Effect.Effect<unknown, ContractCallError, ProviderService>
440
+ > = {};
441
+
442
+ for (const fn of functions) {
443
+ if (!fn.name || !isWriteFunction(fn)) continue;
444
+
445
+ const sig = getFunctionSignature(fn);
446
+ const overloads = byName.get(fn.name) ?? [];
447
+
448
+ simulate[sig] = createSimulateMethod(fn);
449
+ if (overloads.length === 1) {
450
+ simulate[fn.name] = createSimulateMethod(fn);
451
+ }
452
+ }
453
+
454
+ const write: Record<
455
+ string,
456
+ (...args: ReadonlyArray<unknown>) => Effect.Effect<`0x${string}`, ContractWriteError, SignerService>
457
+ > = {};
458
+
459
+ for (const fn of functions) {
460
+ if (!fn.name || !isWriteFunction(fn)) continue;
461
+
462
+ const sig = getFunctionSignature(fn);
463
+ const overloads = byName.get(fn.name) ?? [];
464
+
465
+ write[sig] = createWriteMethod(fn);
466
+ if (overloads.length === 1) {
467
+ write[fn.name] = createWriteMethod(fn);
468
+ }
469
+ }
470
+
471
+ const callFn = (
472
+ signature: string,
473
+ args: ReadonlyArray<unknown>,
474
+ ): Effect.Effect<unknown, ContractCallError, ProviderService> => {
475
+ const fn = bySignature.get(signature);
476
+ if (!fn) {
477
+ return Effect.fail(
478
+ new ContractCallError(
479
+ { address, method: signature, args: args as unknown[] },
480
+ `Function not found: ${signature}`,
481
+ ),
482
+ );
483
+ }
484
+ return createReadMethod(fn)(...args);
485
+ };
486
+
487
+ return { read, simulate, write, call: callFn };
488
+ }
489
+
490
+ /**
491
+ * Create the BlockExplorerApiService implementation.
492
+ */
493
+ function makeBlockExplorerApi(
494
+ config: BlockExplorerApiConfig,
495
+ ): Effect.Effect<
496
+ BlockExplorerApiShape,
497
+ BlockExplorerConfigError,
498
+ ChainService | ProviderService
499
+ > {
500
+ return Effect.gen(function* () {
501
+ const chain = yield* ChainService;
502
+ const chainId = chain.id;
503
+
504
+ // Capture runtime for bridging Effect to Promise in proxy resolution
505
+ const runtime = yield* Effect.runtime<ProviderService>();
506
+ const runPromise = Runtime.runPromise(runtime);
507
+
508
+ const enabledSources = (
509
+ Object.entries(config.sources) as [ExplorerSourceId, { enabled: boolean } | undefined][]
510
+ ).filter(([_, v]) => v?.enabled);
511
+
512
+ if (enabledSources.length === 0) {
513
+ return yield* Effect.fail(
514
+ new BlockExplorerConfigError(
515
+ "At least one explorer source must be enabled",
516
+ ),
517
+ );
518
+ }
519
+
520
+ const abiLoaders = createLoaders(config, chainId);
521
+
522
+ const cache = new Map<
523
+ string,
524
+ { value: ExplorerContractInstance; expiry: number }
525
+ >();
526
+ const cacheEnabled = config.cache?.enabled ?? false;
527
+ const cacheTtl = config.cache?.ttlMillis ?? 300_000;
528
+ const cacheCapacity = config.cache?.capacity ?? 100;
529
+
530
+ function getCacheKey(
531
+ address: `0x${string}`,
532
+ options?: GetContractOptions,
533
+ ): string {
534
+ return `${chainId}:${address.toLowerCase()}:${options?.followProxies ?? config.followProxiesByDefault ?? false}`;
535
+ }
536
+
537
+ function getFromCache(
538
+ key: string,
539
+ ): ExplorerContractInstance | undefined {
540
+ if (!cacheEnabled) return undefined;
541
+ const entry = cache.get(key);
542
+ if (!entry) return undefined;
543
+ if (Date.now() > entry.expiry) {
544
+ cache.delete(key);
545
+ return undefined;
546
+ }
547
+ return entry.value;
548
+ }
549
+
550
+ function setCache(key: string, value: ExplorerContractInstance): void {
551
+ if (!cacheEnabled) return;
552
+ if (cache.size >= cacheCapacity) {
553
+ const oldest = cache.keys().next().value;
554
+ if (oldest) cache.delete(oldest);
555
+ }
556
+ cache.set(key, { value, expiry: Date.now() + cacheTtl });
557
+ }
558
+
559
+ const getContract = (
560
+ address: `0x${string}`,
561
+ options?: GetContractOptions,
562
+ ): Effect.Effect<ExplorerContractInstance, BlockExplorerApiError> =>
563
+ Effect.gen(function* () {
564
+ const cacheKey = getCacheKey(address, options);
565
+ const cached = getFromCache(cacheKey);
566
+ if (cached) return cached;
567
+
568
+ const resolution = options?.resolution ?? "verified-first";
569
+ const includeSources = options?.includeSources ?? false;
570
+ const shouldFollowProxies = options?.followProxies ?? config.followProxiesByDefault ?? false;
571
+
572
+ // Create WhatsAbi-compatible provider using our Effect-based provider
573
+ const whatsabiProvider: WhatsAbiProvider = {
574
+ getCode: (addr: string) =>
575
+ runPromise(getCode(addr as `0x${string}`)),
576
+ getStorageAt: (addr: string, slot: number | string) => {
577
+ const slotHex = typeof slot === "number"
578
+ ? `0x${slot.toString(16)}` as `0x${string}`
579
+ : slot.startsWith("0x") ? slot as `0x${string}` : `0x${slot}` as `0x${string}`;
580
+ return runPromise(getStorageAt(addr as `0x${string}`, slotHex));
581
+ },
582
+ call: (tx: { to: string; data: string }) =>
583
+ runPromise(call({ to: tx.to as `0x${string}`, data: tx.data as `0x${string}` })),
584
+ };
585
+
586
+ // Create combined loader from our configured sources
587
+ const combinedLoader = abiLoaders.length > 0
588
+ ? new loaders.MultiABILoader(abiLoaders)
589
+ : false;
590
+
591
+ // Use autoload for unified ABI loading + proxy resolution
592
+ const result = yield* Effect.tryPromise({
593
+ try: () => autoload(address, {
594
+ provider: whatsabiProvider,
595
+ abiLoader: combinedLoader,
596
+ followProxies: shouldFollowProxies,
597
+ signatureLookup: false, // Don't do 4byte lookups
598
+ }),
599
+ catch: (e) => {
600
+ const message = e instanceof Error ? e.message : String(e);
601
+ if (isRateLimitResponse(message)) {
602
+ return new BlockExplorerRateLimitError(
603
+ "etherscanV2",
604
+ address,
605
+ message,
606
+ parseRetryAfter(message),
607
+ );
608
+ }
609
+ if (message.includes("not found") || message.includes("not verified")) {
610
+ return new BlockExplorerNotFoundError(address, ["autoload"]);
611
+ }
612
+ return new BlockExplorerUnexpectedError("getContract", message, e);
613
+ },
614
+ });
615
+
616
+ // Handle case where no ABI was found from verified sources
617
+ if (!result.abi || result.abi.length === 0) {
618
+ if (resolution === "best-effort" && config.enableBestEffortAbiRecovery) {
619
+ // Try to recover ABI from bytecode using static analysis
620
+ const bytecode = yield* Effect.tryPromise({
621
+ try: () => whatsabiProvider.getCode(result.address),
622
+ catch: () => new BlockExplorerUnexpectedError(
623
+ "getContract",
624
+ "Failed to fetch bytecode for best-effort recovery",
625
+ undefined,
626
+ ),
627
+ });
628
+
629
+ if (bytecode && bytecode !== "0x" && bytecode !== "0x0") {
630
+ const recoveredAbi = yield* Effect.try({
631
+ try: () => abiFromBytecode(bytecode),
632
+ catch: (e) => new BlockExplorerUnexpectedError(
633
+ "getContract",
634
+ `Failed to recover ABI from bytecode: ${e instanceof Error ? e.message : String(e)}`,
635
+ e,
636
+ ),
637
+ });
638
+
639
+ if (recoveredAbi && recoveredAbi.length > 0) {
640
+ const normalizedAbi = normalizeAbi(recoveredAbi as WhatsABI);
641
+ const resolvedAddr = result.address as `0x${string}`;
642
+ const methods = buildMethodMaps(resolvedAddr, normalizedAbi);
643
+
644
+ const contract: ExplorerContractInstance = {
645
+ address: resolvedAddr,
646
+ requestedAddress: address,
647
+ abi: normalizedAbi,
648
+ resolution: { mode: "best-effort", source: "whatsabi" },
649
+ ...(result.proxies.length > 0 && {
650
+ proxies: result.proxies.map(p => ({
651
+ kind: p.name,
652
+ address: address,
653
+ })),
654
+ }),
655
+ ...methods,
656
+ };
657
+
658
+ setCache(cacheKey, contract);
659
+ return contract;
660
+ }
661
+ }
662
+ }
663
+ return yield* Effect.fail(
664
+ new BlockExplorerNotFoundError(address, ["autoload"]),
665
+ );
666
+ }
667
+
668
+ // Extract proxy chain from result
669
+ const proxyChain: Array<{ kind: string; address: `0x${string}` }> = result.proxies.map(p => ({
670
+ kind: p.name,
671
+ address: address, // Original address was the proxy
672
+ }));
673
+
674
+ // Determine which source returned the ABI
675
+ const source: ExplorerSourceId = result.abiLoadedFrom
676
+ ? getSourceFromLoader(result.abiLoadedFrom)
677
+ : "sourcify";
678
+
679
+ const normalizedAbi = normalizeAbi(result.abi as WhatsABI);
680
+ const resolvedAddr = result.address as `0x${string}`;
681
+ const methods = buildMethodMaps(resolvedAddr, normalizedAbi);
682
+
683
+ const contract: ExplorerContractInstance = {
684
+ address: resolvedAddr,
685
+ requestedAddress: address,
686
+ abi: normalizedAbi,
687
+ resolution: { mode: "verified", source },
688
+ ...(proxyChain.length > 0 && { proxies: proxyChain }),
689
+ ...(includeSources && { sources: [] as ContractSourceFile[] }),
690
+ ...methods,
691
+ };
692
+
693
+ setCache(cacheKey, contract);
694
+ return contract;
695
+ });
696
+
697
+ const getAbi = (
698
+ address: `0x${string}`,
699
+ options?: GetAbiOptions,
700
+ ): Effect.Effect<ReadonlyArray<AbiItem>, BlockExplorerApiError> =>
701
+ Effect.map(
702
+ getContract(address, {
703
+ resolution: options?.resolution,
704
+ followProxies: options?.followProxies,
705
+ includeSources: false,
706
+ }),
707
+ (contract) => contract.abi,
708
+ );
709
+
710
+ const getSources = (
711
+ address: `0x${string}`,
712
+ options?: GetSourcesOptions,
713
+ ): Effect.Effect<ReadonlyArray<ContractSourceFile>, BlockExplorerApiError> =>
714
+ Effect.flatMap(
715
+ getContract(address, {
716
+ includeSources: true,
717
+ followProxies: options?.followProxies,
718
+ }),
719
+ (contract) => {
720
+ if (!contract.sources || contract.sources.length === 0) {
721
+ return Effect.fail(
722
+ new BlockExplorerNotFoundError(address, ["sources"]),
723
+ );
724
+ }
725
+ return Effect.succeed(contract.sources);
726
+ },
727
+ );
728
+
729
+ return { getContract, getAbi, getSources };
730
+ });
731
+ }
732
+
733
+ /**
734
+ * Create a BlockExplorerApiService layer from explicit configuration.
735
+ * Requires ChainService. ProviderService is required when followProxies is used.
736
+ * @since 0.0.1
737
+ */
738
+ export function BlockExplorerApi(
739
+ config: BlockExplorerApiConfig,
740
+ ): Layer.Layer<BlockExplorerApiService, BlockExplorerConfigError, ChainService | ProviderService> {
741
+ return Layer.effect(BlockExplorerApiService, makeBlockExplorerApi(config));
742
+ }
743
+
744
+ /**
745
+ * Create a BlockExplorerApiService layer from environment variables.
746
+ * Requires ChainService. ProviderService is required when followProxies is used.
747
+ * @since 0.0.1
748
+ */
749
+ BlockExplorerApi.fromEnv = function fromEnv(): Layer.Layer<
750
+ BlockExplorerApiService,
751
+ BlockExplorerConfigError,
752
+ ChainService | ProviderService
753
+ > {
754
+ return Layer.effect(
755
+ BlockExplorerApiService,
756
+ Effect.gen(function* () {
757
+ const etherscanKey =
758
+ typeof process !== "undefined"
759
+ ? process.env.ETHERSCAN_API_KEY
760
+ : undefined;
761
+ const blockscoutKey =
762
+ typeof process !== "undefined"
763
+ ? process.env.BLOCKSCOUT_API_KEY
764
+ : undefined;
765
+ const sourcesEnv =
766
+ typeof process !== "undefined"
767
+ ? process.env.VOLTAIRE_EXPLORER_SOURCES
768
+ : undefined;
769
+
770
+ const sourceOrder: ExplorerSourceId[] = sourcesEnv
771
+ ? (sourcesEnv.split(",").map((s) => s.trim()) as ExplorerSourceId[])
772
+ : (["sourcify", "etherscanV2", "blockscout"] as const).slice();
773
+
774
+ const config: BlockExplorerApiConfig = {
775
+ sources: {
776
+ sourcify: { enabled: true },
777
+ etherscanV2: {
778
+ enabled: true,
779
+ apiKey: etherscanKey ? Redacted.make(etherscanKey) : undefined,
780
+ },
781
+ blockscout: {
782
+ enabled: true,
783
+ apiKey: blockscoutKey ? Redacted.make(blockscoutKey) : undefined,
784
+ },
785
+ },
786
+ sourceOrder,
787
+ cache: {
788
+ enabled: true,
789
+ ttlMillis: 300_000,
790
+ capacity: 100,
791
+ },
792
+ };
793
+
794
+ return yield* makeBlockExplorerApi(config);
795
+ }),
796
+ );
797
+ };