From c5c90105d147cb5ebb2cf335c087e819e4f5de39 Mon Sep 17 00:00:00 2001 From: Etienne Donneger Date: Fri, 10 Jan 2025 12:31:06 -0500 Subject: [PATCH 1/8] Add support for Sourcify contract information lookup - 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 --- packages/cli/src/command-helpers/contracts.ts | 55 +++++++++++++++++++ packages/cli/src/commands/add.ts | 16 +++++- packages/cli/src/commands/init.ts | 34 +++++++++++- 3 files changed, 101 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/command-helpers/contracts.ts b/packages/cli/src/command-helpers/contracts.ts index eb6e35642..b3712bdb6 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}`); + + const chainId = network.caip2Id.split(':')[1]; + if (!/^\d+$/.test(chainId)) + throw new Error(`Invalid chainId, Sourcify API expects integer value, got '${chainId}'`); + + 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 776280be4..826781ca6 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 f50927eb6..8b37237f9 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -2,6 +2,7 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { filesystem, print, prompt, system } from 'gluegun'; +import immutable from 'immutable'; import { Args, Command, Flags } from '@oclif/core'; import { Network } from '@pinax/graph-networks-registry'; import { appendApiVersionForGraph } from '../command-helpers/compiler.js'; @@ -200,6 +201,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 +228,13 @@ export default class InitCommand extends Command { } } else { try { - abi = await contractService.getABI(ABI, network, fromContract!); + abi = sourcifyContractInfo + ? new EthereumABI( + DEFAULT_CONTRACT_NAME, + undefined, + immutable.fromJS(sourcifyContractInfo.abi), + ) + : await contractService.getABI(ABI, network, fromContract!); } catch (e) { this.exit(1); } @@ -448,7 +460,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 +623,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 +650,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) { From 19cbd8ae6b8e1458c773e309be66de470e99b9f1 Mon Sep 17 00:00:00 2001 From: Etienne Donneger Date: Mon, 13 Jan 2025 16:19:06 -0500 Subject: [PATCH 2/8] Fix incorrect ABI instantiation for `init` with parameters --- packages/cli/src/commands/init.ts | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 8b37237f9..731091be0 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -2,7 +2,6 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { filesystem, print, prompt, system } from 'gluegun'; -import immutable from 'immutable'; import { Args, Command, Flags } from '@oclif/core'; import { Network } from '@pinax/graph-networks-registry'; import { appendApiVersionForGraph } from '../command-helpers/compiler.js'; @@ -229,11 +228,7 @@ export default class InitCommand extends Command { } else { try { abi = sourcifyContractInfo - ? new EthereumABI( - DEFAULT_CONTRACT_NAME, - undefined, - immutable.fromJS(sourcifyContractInfo.abi), - ) + ? sourcifyContractInfo.abi : await contractService.getABI(ABI, network, fromContract!); } catch (e) { this.exit(1); @@ -635,7 +630,7 @@ async function processInitForm( initDebugger.extend('processInitForm')( "infoFromSourcify: '%s'/'%s'", initStartBlock, - initContractName + initContractName, ); } From f1746c5a3b94091e61d77eb44bb0728b21dbca96 Mon Sep 17 00:00:00 2001 From: Etienne Donneger Date: Mon, 13 Jan 2025 16:22:10 -0500 Subject: [PATCH 3/8] Add changeset --- .changeset/spotty-geckos-wonder.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/spotty-geckos-wonder.md diff --git a/.changeset/spotty-geckos-wonder.md b/.changeset/spotty-geckos-wonder.md new file mode 100644 index 000000000..ebf9fc6cf --- /dev/null +++ b/.changeset/spotty-geckos-wonder.md @@ -0,0 +1,5 @@ +--- +'@graphprotocol/graph-cli': minor +--- + +Add support for Sourcify contract information lookup From 84b3d9eecf9d7ee03f6ca09a4cdb527e223abfd3 Mon Sep 17 00:00:00 2001 From: Etienne Donneger Date: Thu, 16 Jan 2025 11:06:57 -0500 Subject: [PATCH 4/8] Throw early for non-EVM chains --- packages/cli/src/command-helpers/contracts.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/command-helpers/contracts.ts b/packages/cli/src/command-helpers/contracts.ts index b3712bdb6..608e92448 100644 --- a/packages/cli/src/command-helpers/contracts.ts +++ b/packages/cli/src/command-helpers/contracts.ts @@ -160,10 +160,10 @@ export class ContractService { const network = this.registry.getNetworkById(networkId); if (!network) throw new Error(`Invalid network ${networkId}`); - const chainId = network.caip2Id.split(':')[1]; - if (!/^\d+$/.test(chainId)) - throw new Error(`Invalid chainId, Sourcify API expects integer value, got '${chainId}'`); + 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: | { From 7aa683f2dcc90519ef412e2e67ffe178f1ac4a71 Mon Sep 17 00:00:00 2001 From: Etienne Donneger Date: Thu, 16 Jan 2025 11:07:14 -0500 Subject: [PATCH 5/8] Add tests for contract name and startBlock info --- .../cli/src/command-helpers/contracts.test.ts | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/packages/cli/src/command-helpers/contracts.test.ts b/packages/cli/src/command-helpers/contracts.test.ts index 0036a2269..8a039fd26 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,6 +86,21 @@ const TEST_CONTRACT_START_BLOCKS = { // }, }; +const TEST_SOURCIFY_CONTRACT_INFO = { + mainnet: { + '0xc2EdaD668740f1aA35E4D8f227fB8E17dcA888Cd': { + name: 'MasterChef', + startBlock: 10_736_242, + }, + }, + optimism: { + '0xc35DADB65012eC5796536bD9864eD8773aBc74C4': { + name: 'BentoBoxV1', + startBlock: 7_019_815, + }, + }, +}; + describe('getStartBlockForContract', { sequential: true }, async () => { const registry = await loadRegistry(); const contractService = new ContractService(registry); @@ -102,3 +118,25 @@ describe('getStartBlockForContract', { sequential: true }, async () => { } } }); + +describe('getFromSourcifyForContract', { sequential: 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, info] of Object.entries(contractInfo)) { + test( + `Returns contract information ${networkId} ${contract} ${info.name} ${info.startBlock}`, + async () => { + // Only check name and startBlock, omit API property from Sourcify results + const { name, startBlock } = (await contractService.getFromSourcify( + EthereumABI, + networkId, + contract, + ))!; + expect(info).toEqual({ name, startBlock: parseInt(startBlock) }); + }, + { timeout: 10_000 }, + ); + } + } +}); From 00a8e2fcd8cef77ec4682d278e05bd31d3c25d47 Mon Sep 17 00:00:00 2001 From: Etienne Donneger Date: Thu, 16 Jan 2025 11:22:09 -0500 Subject: [PATCH 6/8] Add test for non-evm contract lookup --- .../cli/src/command-helpers/contracts.test.ts | 27 ++++++++++++++----- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/command-helpers/contracts.test.ts b/packages/cli/src/command-helpers/contracts.test.ts index 8a039fd26..1304a11aa 100644 --- a/packages/cli/src/command-helpers/contracts.test.ts +++ b/packages/cli/src/command-helpers/contracts.test.ts @@ -99,6 +99,12 @@ const TEST_SOURCIFY_CONTRACT_INFO = { startBlock: 7_019_815, }, }, + wax: { + '': { + name: '', + startBlock: 0, + }, + }, }; describe('getStartBlockForContract', { sequential: true }, async () => { @@ -127,13 +133,20 @@ describe('getFromSourcifyForContract', { sequential: true }, async () => { test( `Returns contract information ${networkId} ${contract} ${info.name} ${info.startBlock}`, async () => { - // Only check name and startBlock, omit API property from Sourcify results - const { name, startBlock } = (await contractService.getFromSourcify( - EthereumABI, - networkId, - contract, - ))!; - expect(info).toEqual({ name, startBlock: parseInt(startBlock) }); + if (networkId == 'wax') { + // Sourcify only supports EVM chains + expect( + await contractService.getFromSourcify(EthereumABI, networkId, contract), + ).toBeNull(); + } else { + // Only check name and startBlock, omit API property from Sourcify results + const { name, startBlock } = (await contractService.getFromSourcify( + EthereumABI, + networkId, + contract, + ))!; + expect(info).toEqual({ name, startBlock: parseInt(startBlock) }); + } }, { timeout: 10_000 }, ); From 141adeab02e1416153a9efe34c9f0cf2981bdae2 Mon Sep 17 00:00:00 2001 From: YaroShkvorets Date: Thu, 16 Jan 2025 17:05:17 -0500 Subject: [PATCH 7/8] removed hardcoded wax check, added test --- .../cli/src/command-helpers/contracts.test.ts | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/packages/cli/src/command-helpers/contracts.test.ts b/packages/cli/src/command-helpers/contracts.test.ts index 1304a11aa..25b4e7576 100644 --- a/packages/cli/src/command-helpers/contracts.test.ts +++ b/packages/cli/src/command-helpers/contracts.test.ts @@ -100,9 +100,15 @@ const TEST_SOURCIFY_CONTRACT_INFO = { }, }, wax: { - '': { - name: '', - startBlock: 0, + account: { + name: null, + startBlock: null, + }, + }, + 'non-existing chain': { + '0x0000000000000000000000000000000000000000': { + name: null, + startBlock: null, }, }, }; @@ -129,23 +135,17 @@ describe('getFromSourcifyForContract', { sequential: 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, info] of Object.entries(contractInfo)) { + for (const [contract, t] of Object.entries(contractInfo)) { test( - `Returns contract information ${networkId} ${contract} ${info.name} ${info.startBlock}`, + `Returns contract information ${networkId} ${contract} ${t.name} ${t.startBlock}`, async () => { - if (networkId == 'wax') { - // Sourcify only supports EVM chains - expect( - await contractService.getFromSourcify(EthereumABI, networkId, contract), - ).toBeNull(); + const result = await 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 } = (await contractService.getFromSourcify( - EthereumABI, - networkId, - contract, - ))!; - expect(info).toEqual({ name, startBlock: parseInt(startBlock) }); + const { name, startBlock } = result!; + expect(t).toEqual({ name, startBlock: parseInt(startBlock) }); } }, { timeout: 10_000 }, From dfd7bde264fb19559a296f6c77f4d7452b8d9f73 Mon Sep 17 00:00:00 2001 From: YaroShkvorets Date: Thu, 16 Jan 2025 17:27:56 -0500 Subject: [PATCH 8/8] add retries to tests --- .../cli/src/command-helpers/contracts.test.ts | 36 ++++++++++++++----- 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/command-helpers/contracts.test.ts b/packages/cli/src/command-helpers/contracts.test.ts index 25b4e7576..10705ce93 100644 --- a/packages/cli/src/command-helpers/contracts.test.ts +++ b/packages/cli/src/command-helpers/contracts.test.ts @@ -113,33 +113,54 @@ const TEST_SOURCIFY_CONTRACT_INFO = { }, }; -describe('getStartBlockForContract', { sequential: true }, async () => { +// Retry helper with configurable number of retries +async function retry(operation: () => Promise, maxRetries = 3, sleepMs = 5000): Promise { + 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', { sequential: true }, async () => { +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 contractService.getFromSourcify(EthereumABI, networkId, contract); + const result = await retry(() => + contractService.getFromSourcify(EthereumABI, networkId, contract), + ); if (t.name === null && t.startBlock === null) { expect(result).toBeNull(); } else { @@ -148,7 +169,6 @@ describe('getFromSourcifyForContract', { sequential: true }, async () => { expect(t).toEqual({ name, startBlock: parseInt(startBlock) }); } }, - { timeout: 10_000 }, ); } }