Repository: shazow/whatsabi (ts)

Issue:
viem: Fix provider abstraction to work with non-public clients
Specifically, we should be operating on viem transports as early as possible, rather than caring about the type of client.

Also add a test suite target for different viem clients.

Related: #104 
viem: Add explicit viem example to the README
I want it to be clear that WhatsABI is usable with viem, too!

Pull request description:
providers: Fix viem non-public clients, wrap autoload ENS error
- Reformatted some tests, ignore whitespace plz
- Added explicit viem example to the README, so it's more clear that whatsabi is not ethers-only (fixes #106)
- Added AutoloadError wrapper for when provider does not support ENS with instructions to specify addressResolver
- Added test for passing in a fake provider without ENS support
- Refactored the provider abstraction to be a bit more EIP-1193-centric.
- Default viem test suite now runs with a `createWalletClient` instead of a `createPublicClient`
- Fixes #105 ?

Obsoletes #104

Patch:
diff --git a/README.md b/README.md
index f70e836..c62feba 100644
--- a/README.md
+++ b/README.md
@@ -32,7 +32,8 @@ Quick start:
 import { ethers } from "ethers";
 import { whatsabi } from "@shazow/whatsabi";
 
-const provider = ethers.getDefaultProvider(); // substitute with your fav provider
+// Works with any provider (or client) library like Ethers.js, Viem, or Web3.js!
+const provider = ethers.getDefaultProvider();
 const address = "0x00000000006c3852cbEf3e08E8dF289169EdE581"; // Or your fav contract address
 
 // Quick-start:
@@ -42,7 +43,19 @@ console.log(result.abi);
 // -> [ ... ]
 ```
 
-Breaking it down:
+Another quick example with Viem:
+
+```typescript
+import { createPublicClient, http } from 'viem'
+import { mainnet } from 'viem/chains'
+import { whatsabi } from "@shazow/whatsabi";
+ 
+const client = createPublicClient({ chain: mainnet, transport: http() })
+const result = await whatsabi.autoload(address, { provider: client });
+```
+
+
+Breaking it down, here's what autoload is doing on the inside:
 
 ```typescript
 const code = await provider.getCode(address); // Load the bytecode
diff --git a/src/auto.ts b/src/auto.ts
index 1d51bd0..6e2085f 100644
--- a/src/auto.ts
+++ b/src/auto.ts
@@ -101,7 +101,14 @@ export async function autoload(address: string, config: AutoloadConfig): Promise
         if (config.addressResolver) {
             address = await config.addressResolver(address);
         } else {
-            address = await provider.getAddress(address);
+            try {
+                address = await provider.getAddress(address);
+            } catch (err) {
+                throw new errors.AutoloadError(`Failed to resolve ENS address using provider.getAddress, try supplying your own resolver in AutoloadConfig by specifying addressResolver`, {
+                    context: { address },
+                    cause: err as Error,
+                });
+            }
         }
     }
 
diff --git a/src/providers.ts b/src/providers.ts
index 37e7d82..95dac40 100644
--- a/src/providers.ts
+++ b/src/providers.ts
@@ -20,14 +20,25 @@ export interface ENSProvider {
 
 export interface Provider extends StorageProvider, CallProvider, CodeProvider, ENSProvider { };
 
+
 export interface AnyProvider { }; // TODO: Can we narrow this more?
 
 
+interface EIP1193RequestArguments {
+  readonly method: string;
+  readonly params?: readonly unknown[] | object;
+}
+
+interface EIP1193 {
+    request(args: EIP1193RequestArguments): Promise<unknown>;
+}
+
+
 // Abstract away web3 provider inconsistencies
 
 export function CompatibleProvider(provider: any): Provider {
     if (typeof provider.getAddress === "function") {
-        return new GenericProvider(provider);
+        return new HighLevelProvider(provider);
     }
     if (typeof provider.resolveName === "function") {
         // Ethers-like
@@ -36,14 +47,18 @@ export function CompatibleProvider(provider: any): Provider {
         }
         // Probably FallbackProvider or a different custom wrapper?
         // Need to use higher-level functions.
-        return new GenericProvider(provider);
+        return new HighLevelProvider(provider);
     }
     if (typeof provider.getEnsAddress === "function") {
         return new ViemProvider(provider);
     }
-    if (typeof provider.eth !== undefined) {
+    if (typeof provider?.eth?.ens?.getAddress === "function") {
         return new Web3Provider(provider);
     }
+    if (typeof provider.request === "function") {
+        // Might be a viem transport, or something else
+        return new RPCProvider(provider);
+    }
 
     throw new errors.ProviderError("Unsupported provider, please open an issue: https://github.com/shazow/whatsabi/issues", {
         context: { provider },
@@ -53,42 +68,51 @@ export function CompatibleProvider(provider: any): Provider {
 // RPCPRovider thesis is: let's stop trying to adapt to every RPC wrapper library's high-level functions
 // and instead have a discovery for the lowest-level RPC call function that we can use directly.
 // At least whenever possible. Higher-level functionality like getAddress is still tricky.
-abstract class RPCProvider implements Provider {
+class RPCProvider implements Provider, EIP1193 {
     provider: any;
 
     constructor(provider: any) {
         this.provider = provider;
     }
 
-    abstract send(method: string, params: Array<any>): Promise<any>;
+    // Based on EIP-1193
+    request(req: {method: string, params?: object|Array<unknown>}): Promise<any> {
+        return this.provider.request(req);
+    }
 
     getStorageAt(address: string, slot: number | string): Promise<string> {
         if (typeof slot === "number") {
             slot = bytesToHex(slot);
         }
-        return this.send("eth_getStorageAt", [address, slot, "latest"]);
+        return this.request({method: "eth_getStorageAt", params: [address, slot, "latest"]});
     }
 
     call(transaction: { to: string, data: string }): Promise<string> {
-        return this.send("eth_call", [
+        return this.request({ method: "eth_call", params: [
             {
                 from: "0x0000000000000000000000000000000000000001",
                 to: transaction.to,
                 data: transaction.data,
             },
             "latest"
-        ]);
+        ]});
     }
 
     getCode(address: string): Promise<string> {
-        return this.send("eth_getCode", [address, "latest"]);
+        return this.request({ method: "eth_getCode", params: [address, "latest"]});
     }
 
-    abstract getAddress(name: string): Promise<string>;
-
+    getAddress(name: string): Promise<string> {
+        throw new MissingENSProviderError("Provider does not implement getAddress, required to resolve ENS", {
+            context: {name, provider: this.provider},
+        });
+    }
 }
 
-class GenericProvider implements Provider {
+export class MissingENSProviderError extends errors.ProviderError { };
+
+// HighLevelProvider is used for high-level providers like ethers' FallbackProvider
+class HighLevelProvider implements Provider {
     provider: any;
 
     constructor(provider: any) {
@@ -124,7 +148,7 @@ type JSONRPCResponse = {
 };
 
 class Web3Provider extends RPCProvider {
-    send(method: string, params: Array<any>): Promise<any> {
+    request({method, params}: EIP1193RequestArguments): Promise<any> {
         // this.provider is the web3 instance, we need web3.provider
         const r = this.provider.currentProvider.request({ method, params, "jsonrpc": "2.0", id: "1" });
         return r.then((resp: JSONRPCResponse) => {
@@ -145,8 +169,9 @@ export class Web3ProviderError extends errors.ProviderError { };
 
 
 class EthersProvider extends RPCProvider {
-    send(method: string, params: Array<any>): Promise<any> {
-        return this.provider.send(method, params);
+    request(args: EIP1193RequestArguments): Promise<any> {
+        // Fun fact: Before 2020, EIP1193 draft had used .send(method, params) instead of .request({method, params})
+        return this.provider.send(args.method, args.params);
     }
 
     getAddress(name: string): Promise<string> {
@@ -155,8 +180,8 @@ class EthersProvider extends RPCProvider {
 }
 
 class ViemProvider extends RPCProvider {
-    send(method: string, params: Array<any>): Promise<any> {
-        return this.provider.transport.request({ method, params });
+    request(args: EIP1193RequestArguments): Promise<any> {
+        return this.provider.transport.request(args);
     }
 
     getAddress(name: string): Promise<string> {


Test patch:
diff --git a/src/__tests__/auto.test.ts b/src/__tests__/auto.test.ts
index e863a99..157e93b 100644
--- a/src/__tests__/auto.test.ts
+++ b/src/__tests__/auto.test.ts
@@ -8,57 +8,62 @@ import { test, online_test } from "./env";
 const TIMEOUT = 15000;
 
 test('autoload throws typed error', async () => {
-  // @ts-expect-error: Expected 2 arguments, but got 1
-  await expect(autoload("0xf00")).rejects.toThrow(/config is undefined/);
+    // @ts-expect-error: Expected 2 arguments, but got 1
+    await expect(autoload("0xf00")).rejects.toThrow(/config is undefined/);
+
+    const fakeProvider = {
+        request: () => {},
+    }
+    await expect(autoload("abc.eth", { provider: fakeProvider })).rejects.toThrow(/Failed to resolve ENS/);
 });
 
 online_test('autoload selectors', async ({ provider }) => {
-  const address = "0x4A137FD5e7a256eF08A7De531A17D0BE0cc7B6b6"; // Random unverified contract
-  const { abi } = await autoload(address, {
-    provider: provider,
-    abiLoader: false,
-    signatureLookup: false,
-  });
-  expect(abi).toContainEqual({"selector": "0x6dbf2fa0", "type": "function"});
-  expect(abi).toContainEqual({"selector": "0xec0ab6a7", "type": "function"});
+    const address = "0x4A137FD5e7a256eF08A7De531A17D0BE0cc7B6b6"; // Random unverified contract
+    const { abi } = await autoload(address, {
+        provider: provider,
+        abiLoader: false,
+        signatureLookup: false,
+    });
+    expect(abi).toContainEqual({"selector": "0x6dbf2fa0", "type": "function"});
+    expect(abi).toContainEqual({"selector": "0xec0ab6a7", "type": "function"});
 }, TIMEOUT);
 
 online_test('autoload selectors with experimental metadata', async ({ provider }) => {
-  const address = "0x4A137FD5e7a256eF08A7De531A17D0BE0cc7B6b6"; // Random unverified contract
-  const { abi } = await autoload(address, {
-    provider: provider,
-    abiLoader: false,
-    signatureLookup: false,
-    enableExperimentalMetadata: true,
-  });
-  expect(abi).toContainEqual({"inputs": [{"type": "bytes", "name": ""}], "payable": true, "selector": "0x6dbf2fa0", "stateMutability": "payable", "type": "function"});
-  expect(abi).toContainEqual({"inputs": [{"type": "bytes", "name": ""}], "payable": true, "selector": "0xec0ab6a7", "stateMutability": "payable", "type": "function"});
+    const address = "0x4A137FD5e7a256eF08A7De531A17D0BE0cc7B6b6"; // Random unverified contract
+    const { abi } = await autoload(address, {
+        provider: provider,
+        abiLoader: false,
+        signatureLookup: false,
+        enableExperimentalMetadata: true,
+    });
+    expect(abi).toContainEqual({"inputs": [{"type": "bytes", "name": ""}], "payable": true, "selector": "0x6dbf2fa0", "stateMutability": "payable", "type": "function"});
+    expect(abi).toContainEqual({"inputs": [{"type": "bytes", "name": ""}], "payable": true, "selector": "0xec0ab6a7", "stateMutability": "payable", "type": "function"});
 }, TIMEOUT);
 
 
 online_test('autoload full', async ({ provider, env }) => {
-  const address = "0x4A137FD5e7a256eF08A7De531A17D0BE0cc7B6b6"; // Random unverified contract
-  const { abi } = await autoload(address, {
-    provider: provider,
-    abiLoader: new whatsabi.loaders.MultiABILoader([
-      new whatsabi.loaders.SourcifyABILoader(),
-      new whatsabi.loaders.EtherscanABILoader({ apiKey: env.ETHERSCAN_API_KEY }),
-    ]),
-    signatureLookup: new whatsabi.loaders.MultiSignatureLookup([
-      new whatsabi.loaders.OpenChainSignatureLookup(),
-      new whatsabi.loaders.FourByteSignatureLookup(),
-    ]),
-    //onProgress: (phase: string, ...args: any[]) => { console.debug("PROGRESS", phase, args); },
-  });
-  expect(abi).toContainEqual({"constant": false, "inputs": [{"type": "address", "name": ""}, {"type": "uint256", "name": ""}, {"type": "bytes", "name": ""}], "name": "call", "payable": false, "selector": "0x6dbf2fa0", "sig": "call(address,uint256,bytes)", "type": "function"})
+    const address = "0x4A137FD5e7a256eF08A7De531A17D0BE0cc7B6b6"; // Random unverified contract
+    const { abi } = await autoload(address, {
+        provider: provider,
+        abiLoader: new whatsabi.loaders.MultiABILoader([
+            new whatsabi.loaders.SourcifyABILoader(),
+            new whatsabi.loaders.EtherscanABILoader({ apiKey: env.ETHERSCAN_API_KEY }),
+        ]),
+        signatureLookup: new whatsabi.loaders.MultiSignatureLookup([
+            new whatsabi.loaders.OpenChainSignatureLookup(),
+            new whatsabi.loaders.FourByteSignatureLookup(),
+        ]),
+        //onProgress: (phase: string, ...args: any[]) => { console.debug("PROGRESS", phase, args); },
+    });
+    expect(abi).toContainEqual({"constant": false, "inputs": [{"type": "address", "name": ""}, {"type": "uint256", "name": ""}, {"type": "bytes", "name": ""}], "name": "call", "payable": false, "selector": "0x6dbf2fa0", "sig": "call(address,uint256,bytes)", "type": "function"})
 
-  expect(abi).toContainEqual({"selector": "0xec0ab6a7", "type": "function"});
+    expect(abi).toContainEqual({"selector": "0xec0ab6a7", "type": "function"});
 }, TIMEOUT);
 
 online_test('autoload non-contract', async ({ provider }) => {
-  const address = "0x0000000000000000000000000000000000000000"; // Random unverified contract
-  const { abi } = await autoload(address, {
-    provider: provider,
-  });
-  expect(abi).toStrictEqual([]);
+    const address = "0x0000000000000000000000000000000000000000"; // Random unverified contract
+    const { abi } = await autoload(address, {
+        provider: provider,
+    });
+    expect(abi).toStrictEqual([]);
 });
diff --git a/src/__tests__/env.ts b/src/__tests__/env.ts
index 7d7bd3a..2e462b7 100644
--- a/src/__tests__/env.ts
+++ b/src/__tests__/env.ts
@@ -1,7 +1,7 @@
 import { test, describe } from 'vitest';
 
 import { ethers } from "ethers";
-import { createPublicClient, http } from 'viem';
+import { createPublicClient, createWalletClient, http } from 'viem';
 import { Web3 } from "web3";
 
 import { withCache } from "../internal/filecache";
@@ -23,10 +23,10 @@ const provider = CompatibleProvider(function() {
         rpc_url = "https://mainnet.infura.io/v3/" + env.INFURA_API_KEY;
     }
 
-    if (env.PROVIDER === "viem") {
-        return createPublicClient({
-            transport: http(rpc_url ?? DEFAULT_PUBLIC_RPC),
-        });
+    if (env.PROVIDER?.startsWith("viem")) {
+        const transport = http(rpc_url ?? DEFAULT_PUBLIC_RPC);
+        if (env.PROVIDER.endsWith("publicClient")) return createPublicClient({ transport });
+        return createWalletClient({ transport });
     }
 
     if (env.PROVIDER === "web3") {
diff --git a/src/__tests__/examples.test.ts b/src/__tests__/examples.test.ts
index 56467f9..1f40934 100644
--- a/src/__tests__/examples.test.ts
+++ b/src/__tests__/examples.test.ts
@@ -8,95 +8,95 @@ const TIMEOUT = 25000;
 
 cached_test('README usage', async ({ provider, withCache }) => {
 
-  const address = "0x00000000006c3852cbEf3e08E8dF289169EdE581"; // Or your fav contract address
-
-  const code = await withCache(
-    `${address}_code`,
-    async () => {
-      return await provider.getCode(address)
-    },
-  )
-
-  const selectors = whatsabi.selectorsFromBytecode(code); // Get the callable selectors
-  
-  // console.log(selectors); // ["0x06fdde03", "0x46423aa7", "0x55944a42", ...]
-  expect(selectors).toEqual(expect.arrayContaining(["0x06fdde03", "0x46423aa7", "0x55944a42"]));
-
-  {
-    const abi = whatsabi.abiFromBytecode(code);
-    // console.log(abi);
-    // [
-    //    {"type": "event", "hash": "0x721c20121297512b72821b97f5326877ea8ecf4bb9948fea5bfcb6453074d37f"},
-    //    {"type": "function", "payable": true, "selector": "0x06fdde03"},
-    //    {"type": "function", "payable": true, "selector": "0x46423aa7"},
-    //    ...
-    // ]
-
-    expect(abi).toContainEqual({"hash": "0x721c20121297512b72821b97f5326877ea8ecf4bb9948fea5bfcb6453074d37f", "type": "event"});
-    expect(abi).toContainEqual(
-      {"payable": true, "selector": "0xb3a34c4c", "type": "function", "stateMutability": "payable", "inputs": [{"type": "bytes", "name": ""}], "outputs": [{"type": "bytes", "name": ""}]},
-    );
-  }
-
-  const signatureLookup = new whatsabi.loaders.OpenChainSignatureLookup();
-  {
-    const sig = await signatureLookup.loadFunctions("0x06fdde03");
-    expect(sig).toStrictEqual(["name()"]);
-  }
-  {
-    const sig = await signatureLookup.loadFunctions("0x46423aa7");
-    expect(sig).toStrictEqual(["getOrderStatus(bytes32)"]);
-  }
-
-  const event = await signatureLookup.loadEvents("0x721c20121297512b72821b97f5326877ea8ecf4bb9948fea5bfcb6453074d37f");
-  expect(event).toContainEqual("CounterIncremented(uint256,address)")
+    const address = "0x00000000006c3852cbEf3e08E8dF289169EdE581"; // Or your fav contract address
+
+    const code = await withCache(
+        `${address}_code`,
+        async () => {
+            return await provider.getCode(address)
+        },
+    )
+
+    const selectors = whatsabi.selectorsFromBytecode(code); // Get the callable selectors
+    
+    // console.log(selectors); // ["0x06fdde03", "0x46423aa7", "0x55944a42", ...]
+    expect(selectors).toEqual(expect.arrayContaining(["0x06fdde03", "0x46423aa7", "0x55944a42"]));
+
+    {
+        const abi = whatsabi.abiFromBytecode(code);
+        // console.log(abi);
+        // [
+        //        {"type": "event", "hash": "0x721c20121297512b72821b97f5326877ea8ecf4bb9948fea5bfcb6453074d37f"},
+        //        {"type": "function", "payable": true, "selector": "0x06fdde03"},
+        //        {"type": "function", "payable": true, "selector": "0x46423aa7"},
+        //        ...
+        // ]
+
+        expect(abi).toContainEqual({"hash": "0x721c20121297512b72821b97f5326877ea8ecf4bb9948fea5bfcb6453074d37f", "type": "event"});
+        expect(abi).toContainEqual(
+            {"payable": true, "selector": "0xb3a34c4c", "type": "function", "stateMutability": "payable", "inputs": [{"type": "bytes", "name": ""}], "outputs": [{"type": "bytes", "name": ""}]},
+        );
+    }
+
+    const signatureLookup = new whatsabi.loaders.OpenChainSignatureLookup();
+    {
+        const sig = await signatureLookup.loadFunctions("0x06fdde03");
+        expect(sig).toStrictEqual(["name()"]);
+    }
+    {
+        const sig = await signatureLookup.loadFunctions("0x46423aa7");
+        expect(sig).toStrictEqual(["getOrderStatus(bytes32)"]);
+    }
+
+    const event = await signatureLookup.loadEvents("0x721c20121297512b72821b97f5326877ea8ecf4bb9948fea5bfcb6453074d37f");
+    expect(event).toContainEqual("CounterIncremented(uint256,address)")
 }, TIMEOUT)
 
 online_test('README autoload', async ({ provider }) => {
-  const address = "0x00000000006c3852cbEf3e08E8dF289169EdE581"; // Or your fav contract address
-
-  {
-    let result = await whatsabi.autoload(address, {
-      provider: provider,
-
-      // * Optional loaders:
-      // abiLoader: whatsabi.loaders.defaultABILoader,
-      // signatureLoader: whatsabi.loaders.defaultSignatureLookup,
-
-      // There is a handy helper for adding the default loaders but with your own settings
-      ... whatsabi.loaders.defaultsWithEnv({
-        SOURCIFY_CHAIN_ID: 42161,
-        ETHERSCAN_BASE_URL: "https://api.arbiscan.io/api",
-        //ETHERSCAN_API_KEY: "MYSECRETAPIKEY",
-      }),
-
-      // * Optional hooks:
-      // onProgress: (phase: string) => { ... }
-      // onError: (phase: string, context: any) => { ... }
-
-      onProgress: (phase) => console.log("autoload progress", phase),
-      onError: (phase, context) => console.log("autoload error", phase, context),
-
-      // * Optional settings:
-      // followProxies: false,
-      // enableExperimentalMetadata: false,
-    });
-    expect(result.abi).toContainEqual(
-      // 'function name() pure returns (string contractName)'
-      {"inputs":[],"name":"name","outputs":[{"internalType":"string","name":"contractName","type":"string"}],"stateMutability":"pure","type":"function"}
-    );
-
-    if (result.followProxies) {
-        result = await result.followProxies();
+    const address = "0x00000000006c3852cbEf3e08E8dF289169EdE581"; // Or your fav contract address
+
+    {
+        let result = await whatsabi.autoload(address, {
+            provider: provider,
+
+            // * Optional loaders:
+            // abiLoader: whatsabi.loaders.defaultABILoader,
+            // signatureLoader: whatsabi.loaders.defaultSignatureLookup,
+
+            // There is a handy helper for adding the default loaders but with your own settings
+            ... whatsabi.loaders.defaultsWithEnv({
+                SOURCIFY_CHAIN_ID: 42161,
+                ETHERSCAN_BASE_URL: "https://api.arbiscan.io/api",
+                //ETHERSCAN_API_KEY: "MYSECRETAPIKEY",
+            }),
+
+            // * Optional hooks:
+            // onProgress: (phase: string) => { ... }
+            // onError: (phase: string, context: any) => { ... }
+
+            onProgress: (phase) => console.log("autoload progress", phase),
+            onError: (phase, context) => console.log("autoload error", phase, context),
+
+            // * Optional settings:
+            // followProxies: false,
+            // enableExperimentalMetadata: false,
+        });
+        expect(result.abi).toContainEqual(
+            // 'function name() pure returns (string contractName)'
+            {"inputs":[],"name":"name","outputs":[{"internalType":"string","name":"contractName","type":"string"}],"stateMutability":"pure","type":"function"}
+        );
+
+        if (result.followProxies) {
+                result = await result.followProxies();
+        }
+    }
+
+    {
+            const { abi, address } = await whatsabi.autoload("0x4f8AD938eBA0CD19155a835f617317a6E788c868", {
+                    provider,
+                    followProxies: true,
+            });
+            expect(abi.length).toBeGreaterThan(0);
+            expect(address).toBe("0x964f84048f0d9bb24b82413413299c0a1d61ea9f");
     }
-  }
-
-  {
-      const { abi, address } = await whatsabi.autoload("0x4f8AD938eBA0CD19155a835f617317a6E788c868", {
-          provider,
-          followProxies: true,
-      });
-      expect(abi.length).toBeGreaterThan(0);
-      expect(address).toBe("0x964f84048f0d9bb24b82413413299c0a1d61ea9f");
-  }
 }, TIMEOUT);