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 diff --git a/packages/cli/src/command-helpers/contracts.test.ts b/packages/cli/src/command-helpers/contracts.test.ts index 0036a2269..10705ce93 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(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', { 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/contracts.ts b/packages/cli/src/command-helpers/contracts.ts index eb6e35642..608e92448 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 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..731091be0 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) {