UNPKG

@graphprotocol/graph-cli

Version:

CLI for building for and deploying to The Graph

244 lines (243 loc) • 11.9 kB
import { filesystem, prompt, system } from 'gluegun'; import immutable from 'immutable'; import { Args, Command, Errors, Flags } from '@oclif/core'; import { ContractService } from '../command-helpers/contracts.js'; import * as DataSourcesExtractor from '../command-helpers/data-sources.js'; import { updateNetworksFile } from '../command-helpers/network.js'; import { loadRegistry } from '../command-helpers/registry.js'; import { retryWithPrompt } from '../command-helpers/retry.js'; import { generateDataSource, writeABI, writeMapping, writeSchema, writeTestsFiles, } from '../command-helpers/scaffold.js'; import { withSpinner } from '../command-helpers/spinner.js'; import EthereumABI from '../protocols/ethereum/abi.js'; import Protocol from '../protocols/index.js'; import Subgraph from '../subgraph.js'; const DEFAULT_CONTRACT_NAME = 'Contract'; export default class AddCommand extends Command { static description = 'Adds a new datasource to a subgraph.'; static args = { address: Args.string({ description: 'The contract address', required: true, }), 'subgraph-manifest': Args.string({ default: 'subgraph.yaml', }), }; static flags = { help: Flags.help({ char: 'h', }), abi: Flags.string({ summary: 'Path to the contract ABI. If not provided, will be fetched from contract API.', }), 'start-block': Flags.string({ summary: 'The block number to start indexing events from. If not provided, will be fetched from contract API', }), 'contract-name': Flags.string({ summary: 'Name of the contract. If not provided, will be fetched from contract API', }), 'merge-entities': Flags.boolean({ summary: 'Whether to merge entities with the same name.', default: false, }), // TODO: should be networksFile (with an "s"), or? 'network-file': Flags.file({ summary: 'Networks config file path.', default: 'networks.json', }), }; async run() { const { args: { address, 'subgraph-manifest': manifestPath }, flags: { abi, 'contract-name': contractNameFlag, 'merge-entities': mergeEntities, 'network-file': networksFile, 'start-block': startBlockFlag, }, } = await this.parse(AddCommand); const dataSourcesAndTemplates = await DataSourcesExtractor.fromFilePath(manifestPath); const protocol = Protocol.fromDataSources(dataSourcesAndTemplates); const manifest = await Subgraph.load(manifestPath, { protocol }); const network = manifest.result.getIn(['dataSources', 0, 'network']); const result = manifest.result.asMutable(); const isLocalHost = network === 'localhost'; // This flag prevent Etherscan lookups in case the network selected is `localhost` 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 (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 { if (isLocalHost) throw Error; // Triggers user prompting without waiting for Etherscan lookup to fail ethabi = await retryWithPrompt(() => withSpinner('Fetching ABI from contract API...', 'Failed to fetch ABI', 'Warning fetching ABI', () => contractService.getABI(EthereumABI, network, address))); if (!ethabi) throw Error; } catch (error) { // we cannot ask user to do prompt in test environment if (process.env.NODE_ENV !== 'test') { const { abi: abiFile } = await prompt .ask([ { type: 'input', name: 'abi', message: 'ABI file (path)', validate: async (value) => { try { EthereumABI.load(contractName, value); return true; } catch (e) { return `Failed to load ABI from ${value}: ${e.message}`; } }, }, ]) .catch(() => this.exit(1)); // properly handle ESC ethabi = EthereumABI.load(contractName, abiFile); } } } try { if (isLocalHost) throw Error; // Triggers user prompting without waiting for Etherscan lookup to fail startBlock ||= Number(await contractService.getStartBlock(network, address)).toString(); } catch (error) { // we cannot ask user to do prompt in test environment if (process.env.NODE_ENV !== 'test') { // If we can't get the start block, we'll just leave it out of the manifest const { startBlock: userInputStartBlock } = await prompt .ask([ { type: 'input', name: 'startBlock', message: 'Start Block', initial: '0', validate: value => parseInt(value) >= 0, result(value) { return value; }, }, ]) .catch(() => this.exit(1)); startBlock = userInputStartBlock; } } try { if (isLocalHost) throw Error; // Triggers user prompting without waiting for Etherscan lookup to fail if (contractName === DEFAULT_CONTRACT_NAME) { contractName = (await contractService.getContractName(network, address)) ?? DEFAULT_CONTRACT_NAME; } } catch (error) { // not asking user to do prompt in test environment if (process.env.NODE_ENV !== 'test') { const { contractName: userInputContractName } = await prompt .ask([ { type: 'input', name: 'contractName', message: 'Contract Name', initial: 'Contract', validate: value => value && value.length > 0, result(value) { return value; }, }, ]) .catch(() => this.exit(1)); contractName = userInputContractName; } } const entities = getEntities(manifest); const contractNames = getContractNames(manifest); if (contractNames.includes(contractName)) { this.error(`Datasource or template with name ${contractName} already exists, please choose a different name.`, { exit: 1 }); } await writeABI(ethabi, contractName); const { collisionEntities, onlyCollisions, abiData } = updateEventNamesOnCollision(ethabi, entities, contractName, mergeEntities); ethabi.data = abiData; await writeSchema(ethabi, protocol, result.getIn(['schema', 'file']), collisionEntities, contractName); await writeMapping(ethabi, protocol, contractName, collisionEntities); await writeTestsFiles(ethabi, protocol, contractName); const dataSources = result.get('dataSources'); const dataSource = await generateDataSource(protocol, contractName, network, address, ethabi, startBlock); // Handle the collisions edge case by copying another data source yaml data if (mergeEntities && onlyCollisions) { const firstDataSource = dataSources.get(0); const source = dataSource.get('source'); const mapping = firstDataSource.get('mapping').asMutable(); // Save the address of the new data source source.abi = firstDataSource.get('source').get('abi'); dataSource.set('mapping', mapping); dataSource.set('source', source); } result.set('dataSources', dataSources.push(dataSource)); await Subgraph.write(result, manifestPath); // Update networks.json if (filesystem.exists(networksFile)) { await updateNetworksFile(network, contractName, address, networksFile); } // Detect Yarn and/or NPM const yarn = system.which('yarn'); const npm = system.which('npm'); if (!yarn && !npm) { this.error('Neither Yarn nor NPM were found on your system. Please install one of them.', { exit: 1, }); } await withSpinner('Running codegen', 'Failed to run codegen', 'Warning during codegen', async () => await system.run(yarn ? 'yarn codegen' : 'npm run codegen')); this.exit(0); } } const getEntities = (manifest) => { const dataSources = manifest.result.get('dataSources', immutable.List()); const templates = manifest.result.get('templates', immutable.List()); return dataSources .concat(templates) .map((dataSource) => dataSource.getIn(['mapping', 'entities'])) .flatten(); }; const getContractNames = (manifest) => { const dataSources = manifest.result.get('dataSources', immutable.List()); const templates = manifest.result.get('templates', immutable.List()); return dataSources.concat(templates).map((dataSource) => dataSource.get('name')); }; const updateEventNamesOnCollision = (ethabi, entities, contractName, mergeEntities) => { let abiData = ethabi.data; const collisionEntities = []; let onlyCollisions = true; for (let i = 0; i < abiData.size; i++) { const dataRow = abiData.get(i).asMutable(); if (dataRow.get('type') === 'event') { if (entities.includes(dataRow.get('name'))) { if (entities.includes(`${contractName}${dataRow.get('name')}`)) { throw new Errors.CLIError(`Contract name ('${contractName}') + event name ('${dataRow.get('name')}') entity already exists.`, { exit: 1 }); } if (mergeEntities) { collisionEntities.push(dataRow.get('name')); abiData = abiData.asImmutable().delete(i); // needs to be immutable when deleting, yes you read that right - https://github.com/immutable-js/immutable-js/issues/1901 i--; // deletion also shifts values to the left continue; } else { dataRow.set('collision', true); } } else { onlyCollisions = false; } } abiData = abiData.asMutable().set(i, dataRow); } return { abiData, collisionEntities, onlyCollisions }; };