Repository: graphprotocol/graph-tooling (ts)

Issue:
Support for Sourcify
Currently we rely on Etherscan and other centralised providers to download the ABIs for contracts. It would be great to use https://sourcify.dev/ first and then fallback to these only if needed. #web3dogfooding

Pull request description:
Add support for Sourcify contract information lookup
Etherscan requires an API key for ABI lookup and other operations.
Sourcify (https://sourcify.dev/) is an open-source decentralized alternative.

## Features

- Contract name, ABI and creation transaction hash (start block) from [Sourcify API](https://docs.sourcify.dev/docs/api/).
- Runs before the registry lookup and replaces default values (not interactive) if not provided by the user.
This means priority for CLI parameters looks like:
user submitted (env/CLI args) > Sourcify API > Default values > Registry fetch

Closes #1001.

## Details

The `getFromSourcify` method returns all the information related to contracts that are normally fetch from registry URLs:
```js
{ abi: ABI; startBlock: string; name: string }
```
It internally makes the RPC call for the `startBlock` using the creation transaction hash.




Patch:
diff --git a/.changeset/flat-spiders-grin.md b/.changeset/flat-spiders-grin.md
new file mode 100644
index 00000000..4459ba58
--- /dev/null
+++ b/.changeset/flat-spiders-grin.md
@@ -0,0 +1,5 @@
+---
+'@graphprotocol/graph-cli': patch
+---
+
+Fix `import.meta.url` not being parsed as path properly
diff --git a/.changeset/spotty-geckos-wonder.md b/.changeset/spotty-geckos-wonder.md
new file mode 100644
index 00000000..ebf9fc6c
--- /dev/null
+++ b/.changeset/spotty-geckos-wonder.md
@@ -0,0 +1,5 @@
+---
+'@graphprotocol/graph-cli': minor
+---
+
+Add support for Sourcify contract information lookup
diff --git a/packages/cli/src/command-helpers/contracts.ts b/packages/cli/src/command-helpers/contracts.ts
index eb6e3564..608e9244 100644
--- a/packages/cli/src/command-helpers/contracts.ts
+++ b/packages/cli/src/command-helpers/contracts.ts
@@ -151,6 +151,61 @@ export class ContractService {
     throw new Error(`Failed to fetch contract name for ${address}`);
   }
 
+  async getFromSourcify(
+    ABICtor: typeof ABI,
+    networkId: string,
+    address: string,
+  ): Promise<{ abi: ABI; startBlock: string; name: string } | null> {
+    try {
+      const network = this.registry.getNetworkById(networkId);
+      if (!network) throw new Error(`Invalid network ${networkId}`);
+
+      if (!network.caip2Id.startsWith('eip155'))
+        throw new Error(`Invalid chainId, Sourcify API only supports EVM chains`);
+
+      const chainId = network.caip2Id.split(':')[1];
+      const url = `https://sourcify.dev/server/files/any/${chainId}/${address}`;
+      const json:
+        | {
+            status: string;
+            files: { name: string; path: string; content: string }[];
+          }
+        | { error: string } = await (
+        await fetch(url).catch(error => {
+          throw new Error(`Sourcify API is unreachable: ${error}`);
+        })
+      ).json();
+
+      if (json) {
+        if ('error' in json) throw new Error(`Sourcify API error: ${json.error}`);
+
+        let metadata: any = json.files.find(e => e.name === 'metadata.json')?.content;
+        if (!metadata) throw new Error('Contract is missing metadata');
+
+        const tx_hash = json.files.find(e => e.name === 'creator-tx-hash.txt')?.content;
+        if (!tx_hash) throw new Error('Contract is missing tx creation hash');
+
+        const tx = await this.fetchTransactionByHash(networkId, tx_hash);
+        if (!tx?.blockNumber)
+          throw new Error(`Can't fetch blockNumber from tx: ${JSON.stringify(tx)}`);
+
+        metadata = JSON.parse(metadata);
+        const contractName = Object.values(metadata.settings.compilationTarget)[0] as string;
+        return {
+          abi: new ABICtor(contractName, undefined, immutable.fromJS(metadata.output.abi)) as ABI,
+          startBlock: Number(tx.blockNumber).toString(),
+          name: contractName,
+        };
+      }
+
+      throw new Error(`No result: ${JSON.stringify(json)}`);
+    } catch (error) {
+      logger(`Failed to fetch from Sourcify: ${error}`);
+    }
+
+    return null;
+  }
+
   private async fetchTransactionByHash(networkId: string, txHash: string) {
     const urls = this.getRpcUrls(networkId);
     if (!urls.length) {
diff --git a/packages/cli/src/commands/add.ts b/packages/cli/src/commands/add.ts
index 776280be..826781ca 100644
--- a/packages/cli/src/commands/add.ts
+++ b/packages/cli/src/commands/add.ts
@@ -80,12 +80,24 @@ export default class AddCommand extends Command {
     if (isLocalHost) this.warn('`localhost` network detected, prompting user for inputs');
     const registry = await loadRegistry();
     const contractService = new ContractService(registry);
+    const sourcifyContractInfo = await contractService.getFromSourcify(
+      EthereumABI,
+      network,
+      address,
+    );
 
     let startBlock = startBlockFlag ? parseInt(startBlockFlag).toString() : startBlockFlag;
     let contractName = contractNameFlag || DEFAULT_CONTRACT_NAME;
-
     let ethabi = null;
-    if (abi) {
+
+    if (sourcifyContractInfo) {
+      startBlock ??= sourcifyContractInfo.startBlock;
+      contractName =
+        contractName == DEFAULT_CONTRACT_NAME ? sourcifyContractInfo.name : contractName;
+      ethabi ??= sourcifyContractInfo.abi;
+    }
+
+    if (!ethabi && abi) {
       ethabi = EthereumABI.load(contractName, abi);
     } else {
       try {
diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts
index f50927eb..731091be 100644
--- a/packages/cli/src/commands/init.ts
+++ b/packages/cli/src/commands/init.ts
@@ -200,6 +200,11 @@ export default class InitCommand extends Command {
     if ((fromContract || spkgPath) && protocol && subgraphName && directory && network && node) {
       const registry = await loadRegistry();
       const contractService = new ContractService(registry);
+      const sourcifyContractInfo = await contractService.getFromSourcify(
+        EthereumABI,
+        network,
+        fromContract!,
+      );
 
       if (!protocolChoices.includes(protocol as ProtocolName)) {
         this.error(
@@ -222,7 +227,9 @@ export default class InitCommand extends Command {
           }
         } else {
           try {
-            abi = await contractService.getABI(ABI, network, fromContract!);
+            abi = sourcifyContractInfo
+              ? sourcifyContractInfo.abi
+              : await contractService.getABI(ABI, network, fromContract!);
           } catch (e) {
             this.exit(1);
           }
@@ -448,7 +455,7 @@ async function processInitForm(
       ];
     };
 
-    let network = networks[0];
+    let network: Network = networks[0];
     let protocolInstance: Protocol = new Protocol('ethereum');
     let isComposedSubgraph = false;
     let isSubstreams = false;
@@ -611,6 +618,22 @@ async function processInitForm(
           return address;
         }
 
+        const sourcifyContractInfo = await contractService.getFromSourcify(
+          EthereumABI,
+          network.id,
+          address,
+        );
+        if (sourcifyContractInfo) {
+          initStartBlock ??= sourcifyContractInfo.startBlock;
+          initContractName ??= sourcifyContractInfo.name;
+          initAbi ??= sourcifyContractInfo.abi;
+          initDebugger.extend('processInitForm')(
+            "infoFromSourcify: '%s'/'%s'",
+            initStartBlock,
+            initContractName,
+          );
+        }
+
         // If ABI is not provided, try to fetch it from Etherscan API
         if (protocolInstance.hasABIs() && !initAbi) {
           abiFromApi = await retryWithPrompt(() =>
@@ -622,6 +645,8 @@ async function processInitForm(
             ),
           );
           initDebugger.extend('processInitForm')("abiFromEtherscan len: '%s'", abiFromApi?.name);
+        } else {
+          abiFromApi = initAbi;
         }
         // If startBlock is not provided, try to fetch it from Etherscan API
         if (!initStartBlock) {
diff --git a/packages/cli/src/commands/local.ts b/packages/cli/src/commands/local.ts
index 6551de54..b9bd3b8d 100644
--- a/packages/cli/src/commands/local.ts
+++ b/packages/cli/src/commands/local.ts
@@ -2,6 +2,7 @@ import { ChildProcess, spawn } from 'node:child_process';
 import http from 'node:http';
 import net from 'node:net';
 import path from 'node:path';
+import { fileURLToPath } from 'node:url';
 import compose from 'docker-compose';
 import { filesystem, patching } from 'gluegun';
 import stripAnsi from 'strip-ansi';
@@ -93,9 +94,8 @@ export default class LocalCommand extends Command {
     const composeFile =
       composeFileFlag ||
       path.join(
-        `${process.platform === 'win32' ? '' : '/'}${
-          /file:\/{2,3}(.+)\/[^/]/.exec(import.meta.url)![1]
-        }`,
+        fileURLToPath(import.meta.url),
+        '..',
         '..',
         '..',
         'resources',
diff --git a/packages/cli/src/subgraph.ts b/packages/cli/src/subgraph.ts
index cdd59cff..93b84fcf 100644
--- a/packages/cli/src/subgraph.ts
+++ b/packages/cli/src/subgraph.ts
@@ -1,4 +1,5 @@
 import path from 'node:path';
+import { fileURLToPath } from 'node:url';
 import fs from 'fs-extra';
 import * as graphql from 'graphql/language/index.js';
 import immutable from 'immutable';
@@ -53,7 +54,8 @@ export default class Subgraph {
     const schema = graphql.parse(
       await fs.readFile(
         path.join(
-          `${process.platform === 'win32' ? '' : '/'}${/file:\/{2,3}(.+)\/[^/]/.exec(import.meta.url)![1]}`,
+          fileURLToPath(import.meta.url),
+          '..',
           'protocols',
           // TODO: substreams/triggers is a special case, should be handled better
           protocol.name === 'substreams/triggers' ? 'substreams' : protocol.name,
diff --git a/packages/cli/src/version.ts b/packages/cli/src/version.ts
index 26971c75..e9fee687 100644
--- a/packages/cli/src/version.ts
+++ b/packages/cli/src/version.ts
@@ -1,15 +1,12 @@
 import fs from 'node:fs';
 import path from 'node:path';
+import { fileURLToPath } from 'node:url';
 
 const packageJson = JSON.parse(
   fs
     .readFileSync(
       // works even when bundled/built because the path to package.json is the same
-      path.join(
-        `${process.platform === 'win32' ? '' : '/'}${/file:\/{2,3}(.+)\/[^/]/.exec(import.meta.url)![1]}`,
-        '..',
-        'package.json',
-      ),
+      path.join(fileURLToPath(import.meta.url), '..', '..', 'package.json'),
     )
     .toString(),
 );


Test patch:
diff --git a/packages/cli/src/command-helpers/contracts.test.ts b/packages/cli/src/command-helpers/contracts.test.ts
index 0036a226..10705ce9 100644
--- a/packages/cli/src/command-helpers/contracts.test.ts
+++ b/packages/cli/src/command-helpers/contracts.test.ts
@@ -1,4 +1,5 @@
 import { describe, expect, test } from 'vitest';
+import EthereumABI from '../protocols/ethereum/abi.js';
 import { ContractService } from './contracts.js';
 import { loadRegistry } from './registry.js';
 
@@ -85,19 +86,89 @@ const TEST_CONTRACT_START_BLOCKS = {
   // },
 };
 
-describe('getStartBlockForContract', { sequential: true }, async () => {
+const TEST_SOURCIFY_CONTRACT_INFO = {
+  mainnet: {
+    '0xc2EdaD668740f1aA35E4D8f227fB8E17dcA888Cd': {
+      name: 'MasterChef',
+      startBlock: 10_736_242,
+    },
+  },
+  optimism: {
+    '0xc35DADB65012eC5796536bD9864eD8773aBc74C4': {
+      name: 'BentoBoxV1',
+      startBlock: 7_019_815,
+    },
+  },
+  wax: {
+    account: {
+      name: null,
+      startBlock: null,
+    },
+  },
+  'non-existing chain': {
+    '0x0000000000000000000000000000000000000000': {
+      name: null,
+      startBlock: null,
+    },
+  },
+};
+
+// Retry helper with configurable number of retries
+async function retry<T>(operation: () => Promise<T>, maxRetries = 3, sleepMs = 5000): Promise<T> {
+  let lastError: Error | undefined;
+  for (let attempt = 0; attempt < maxRetries; attempt++) {
+    try {
+      return await operation();
+    } catch (error) {
+      lastError = error as Error;
+      if (attempt < maxRetries - 1) {
+        await new Promise(resolve => setTimeout(resolve, sleepMs));
+      }
+    }
+  }
+  throw lastError;
+}
+
+describe('getStartBlockForContract', { concurrent: true }, async () => {
   const registry = await loadRegistry();
   const contractService = new ContractService(registry);
   for (const [network, contracts] of Object.entries(TEST_CONTRACT_START_BLOCKS)) {
     for (const [contract, startBlockExp] of Object.entries(contracts)) {
       test(
         `Returns the start block ${network} ${contract} ${startBlockExp}`,
-        async () => {
-          //loop through the TEST_CONTRACT_START_BLOCKS object and test each network
-          const startBlock = await contractService.getStartBlock(network, contract);
+        { timeout: 50_000 },
+        async ({ expect }) => {
+          const startBlock = await retry(
+            () => contractService.getStartBlock(network, contract),
+            10,
+          );
           expect(parseInt(startBlock)).toBe(startBlockExp);
         },
-        { timeout: 10_000 },
+      );
+    }
+  }
+});
+
+describe('getFromSourcifyForContract', { concurrent: true }, async () => {
+  const registry = await loadRegistry();
+  const contractService = new ContractService(registry);
+  for (const [networkId, contractInfo] of Object.entries(TEST_SOURCIFY_CONTRACT_INFO)) {
+    for (const [contract, t] of Object.entries(contractInfo)) {
+      test(
+        `Returns contract information ${networkId} ${contract} ${t.name} ${t.startBlock}`,
+        { timeout: 50_000 },
+        async () => {
+          const result = await retry(() =>
+            contractService.getFromSourcify(EthereumABI, networkId, contract),
+          );
+          if (t.name === null && t.startBlock === null) {
+            expect(result).toBeNull();
+          } else {
+            // Only check name and startBlock, omit API property from Sourcify results
+            const { name, startBlock } = result!;
+            expect(t).toEqual({ name, startBlock: parseInt(startBlock) });
+          }
+        },
       );
     }
   }
diff --git a/packages/cli/src/command-helpers/network.test.ts b/packages/cli/src/command-helpers/network.test.ts
index 8edcc6a8..bc222be9 100644
--- a/packages/cli/src/command-helpers/network.test.ts
+++ b/packages/cli/src/command-helpers/network.test.ts
@@ -1,11 +1,13 @@
 import path from 'node:path';
+import { fileURLToPath } from 'node:url';
 import * as toolbox from 'gluegun';
 import { afterAll, beforeAll, describe, expect, test } from 'vitest';
 import yaml from 'yaml';
 import { initNetworksConfig, updateSubgraphNetwork } from './network.js';
 
 const SUBGRAPH_PATH_BASE = path.join(
-  `${process.platform === 'win32' ? '' : '/'}${/file:\/{2,3}(.+)\/[^/]/.exec(import.meta.url)![1]}`,
+  fileURLToPath(import.meta.url),
+  '..',
   '..',
   '..',
   '..',