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..b3712bdb 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 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) {