diff --git a/tools/erc-repository-indexer/erc-contract-indexer/example.env b/tools/erc-repository-indexer/erc-contract-indexer/example.env index 92aaaf0d4..b3f6229c0 100644 --- a/tools/erc-repository-indexer/erc-contract-indexer/example.env +++ b/tools/erc-repository-indexer/erc-contract-indexer/example.env @@ -1,3 +1,2 @@ HEDERA_NETWORK= -MIRROR_NODE_URL= STARTING_POINT= diff --git a/tools/erc-repository-indexer/erc-contract-indexer/src/schemas/ERCRegistrySchemas.ts b/tools/erc-repository-indexer/erc-contract-indexer/src/schemas/ERCRegistrySchemas.ts index 6712986c5..f6df96167 100644 --- a/tools/erc-repository-indexer/erc-contract-indexer/src/schemas/ERCRegistrySchemas.ts +++ b/tools/erc-repository-indexer/erc-contract-indexer/src/schemas/ERCRegistrySchemas.ts @@ -20,5 +20,5 @@ export interface ERCOutputInterface { address: string; - contractId: string | null; + contractId: string; } diff --git a/tools/erc-repository-indexer/erc-contract-indexer/src/services/contractScanner.ts b/tools/erc-repository-indexer/erc-contract-indexer/src/services/contractScanner.ts index c074d57f3..c1d84e8c8 100644 --- a/tools/erc-repository-indexer/erc-contract-indexer/src/services/contractScanner.ts +++ b/tools/erc-repository-indexer/erc-contract-indexer/src/services/contractScanner.ts @@ -36,8 +36,8 @@ export class ContractScannerService { private readonly mirrorNodeBaseUrl: string; constructor() { - this.mirrorNodeBaseUrl = - process.env.MIRROR_NODE_URL || constants.MIRROR_NODE_FALL_BACK_BASE_URL; + const network = process.env.HEDERA_NETWORK || 'previewnet'; + this.mirrorNodeBaseUrl = `https:${network}.mirrornode.hedera.com`; } /** diff --git a/tools/erc-repository-indexer/erc-contract-indexer/src/services/registryGenerator.ts b/tools/erc-repository-indexer/erc-contract-indexer/src/services/registryGenerator.ts new file mode 100644 index 000000000..c92431641 --- /dev/null +++ b/tools/erc-repository-indexer/erc-contract-indexer/src/services/registryGenerator.ts @@ -0,0 +1,139 @@ +/*- + * + * Hedera Smart Contracts + * + * Copyright (C) 2024 Hedera Hashgraph, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +import fs from 'fs'; +import path from 'path'; +import constants from '../utils/constants'; +import { ERCOutputInterface } from '../schemas/ERCRegistrySchemas'; +import { Helper } from '../utils/helper'; + +export class RegistryGenerator { + /** + * @private + * @readonly + * @property {string} erc20JsonFilePath - The file path where ERC20 contract registry data will be stored + */ + private readonly erc20JsonFilePath: string; + + /** + * @private + * @readonly + * @property {string} erc721JsonFilePath - The file path where ERC721 contract registry data will be stored + */ + private readonly erc721JsonFilePath: string; + + constructor() { + this.erc20JsonFilePath = Helper.buildFilePath( + constants.ERC_20_JSON_FILE_NAME + ); + this.erc721JsonFilePath = Helper.buildFilePath( + constants.ERC_721_JSON_FILE_NAME + ); + } + + /** + * Generates registry files for ERC20 and ERC721 contracts by updating existing registries with new contracts. + * @param {ERCOutputInterface[]} erc20Contracts - Array of ERC20 contract interfaces to add to registry + * @param {ERCOutputInterface[]} erc721Contracts - Array of ERC721 contract interfaces to add to registry + * @returns {Promise} Promise that resolves when registry files are updated + */ + async generateErcRegistry( + erc20Contracts: ERCOutputInterface[], + erc721Contracts: ERCOutputInterface[] + ): Promise { + const updatePromises = []; + + if (erc20Contracts.length) { + updatePromises.push( + this.updateRegistry(this.erc20JsonFilePath, erc20Contracts) + ); + } + + if (erc721Contracts.length) { + updatePromises.push( + this.updateRegistry(this.erc721JsonFilePath, erc721Contracts) + ); + } + + // Wait for all updates to complete in parallel + await Promise.all(updatePromises); + } + + /** + * Updates a registry file with new contracts, removing duplicates if any. + * @param {string} filePath - Path to the registry file + * @param {ERCOutputInterface[]} newContracts - New contracts to add to registry + * @returns {Promise} Promise that resolves when registry is updated + * @private + */ + private async updateRegistry( + filePath: string, + newContracts: ERCOutputInterface[] + ): Promise { + console.log('Pushing new ERC token contracts to registry...'); + + const existingContracts = this.readExistingContracts(filePath); + + // Create a Map to deduplicate contracts by contractId + const contractMap = new Map( + [...existingContracts, ...newContracts].map((contract) => [ + contract.contractId, + contract, + ]) + ); + + // Convert Map values back to array for file writing + const uniqueContracts = Array.from(contractMap.values()); + + await this.writeContractsToFile(filePath, uniqueContracts); + } + + /** + * Reads existing contracts from a registry file. + * @param {string} filePath - Path to the registry file + * @returns {ERCOutputInterface[]} Array of existing contracts, or empty array if file doesn't exist + * @private + */ + private readExistingContracts(filePath: string): ERCOutputInterface[] { + // Cache file read result to avoid multiple disk reads + if (!fs.existsSync(filePath)) { + return []; + } + const fileContent = fs.readFileSync(filePath, 'utf8'); + return fileContent ? JSON.parse(fileContent) : []; + } + + /** + * Writes contracts to a registry file. + * @param {string} filePath - Path to the registry file + * @param {ERCOutputInterface[]} contracts - Contracts to write to file + * @returns {Promise} Promise that resolves when file is written + * @private + */ + private async writeContractsToFile( + filePath: string, + contracts: ERCOutputInterface[] + ): Promise { + const dir = path.dirname(filePath); + await fs.promises.mkdir(dir, { recursive: true }); + await fs.promises.writeFile(filePath, JSON.stringify(contracts, null, 2)); + console.log('Finish pushing new ERC token contracts to registry.'); + } +} diff --git a/tools/erc-repository-indexer/erc-contract-indexer/src/utils/constants.ts b/tools/erc-repository-indexer/erc-contract-indexer/src/utils/constants.ts index 100f5ea2a..18a8e7732 100644 --- a/tools/erc-repository-indexer/erc-contract-indexer/src/utils/constants.ts +++ b/tools/erc-repository-indexer/erc-contract-indexer/src/utils/constants.ts @@ -21,5 +21,6 @@ export default { RETRY_DELAY_MS: 15000, GET_CONTRACT_ENDPOINT: '/api/v1/contracts', - MIRROR_NODE_FALL_BACK_BASE_URL: 'https://previewnet.mirrornode.hedera.com', + ERC_20_JSON_FILE_NAME: 'erc-20.json', + ERC_721_JSON_FILE_NAME: 'erc-721.json', }; diff --git a/tools/erc-repository-indexer/erc-contract-indexer/src/utils/helper.ts b/tools/erc-repository-indexer/erc-contract-indexer/src/utils/helper.ts index f5ac19a60..606c49662 100644 --- a/tools/erc-repository-indexer/erc-contract-indexer/src/utils/helper.ts +++ b/tools/erc-repository-indexer/erc-contract-indexer/src/utils/helper.ts @@ -18,9 +18,22 @@ * */ +import path from 'path'; import constants from './constants'; export class Helper { + /** + * Constructs the file path for the specified file name based on the current Hedera network. + * The network is determined by the HEDERA_NETWORK environment variable, defaulting to 'previewnet'. + * + * @param {string} fileName - The name of the file for which to build the path. + * @returns {string} The constructed file path. + */ + static buildFilePath(fileName: string): string { + const network = process.env.HEDERA_NETWORK || 'previewnet'; + return path.join(__dirname, '../../erc-registry', network, fileName); + } + /** * Builds a URL for the mirror node API by combining the base URL with either a pagination token or the default endpoint * @param {string} mirrorNodeBaseUrl - The base URL of the mirror node API diff --git a/tools/erc-repository-indexer/erc-contract-indexer/tests/services/registryGenerator.test.ts b/tools/erc-repository-indexer/erc-contract-indexer/tests/services/registryGenerator.test.ts new file mode 100644 index 000000000..8b52b9e0a --- /dev/null +++ b/tools/erc-repository-indexer/erc-contract-indexer/tests/services/registryGenerator.test.ts @@ -0,0 +1,133 @@ +import fs from 'fs'; +import path from 'path'; +import { RegistryGenerator } from '../../src/services/registryGenerator'; +import { ERCOutputInterface } from '../../src/schemas/ERCRegistrySchemas'; +import constants from '../../src/utils/constants'; + +jest.mock('fs', () => ({ + ...jest.requireActual('fs'), + existsSync: jest.fn(), + readFileSync: jest.fn(), + promises: { + mkdir: jest.fn(), + writeFile: jest.fn(), + }, +})); + +const mockedFs = fs as jest.Mocked; + +describe('RegistryGenerator', () => { + let registry: RegistryGenerator; + const mockERC20Path = constants.ERC_20_JSON_FILE_NAME; + const mockContractA = [{ contractId: '123', address: '0x123' }]; + const mockContractB = [{ contractId: '456', address: '0x456' }]; + + beforeEach(() => { + registry = new RegistryGenerator(); + + jest.clearAllMocks(); + (fs.existsSync as jest.Mock).mockReturnValue(true); + (fs.readFileSync as jest.Mock).mockReturnValue( + JSON.stringify(mockContractA) + ); + (fs.promises.mkdir as jest.Mock).mockResolvedValue(undefined); + (fs.promises.writeFile as jest.Mock).mockResolvedValue(undefined); + }); + + describe('generateErcRegistry', () => { + it('should call updateRegistry for both ERC20 and ERC721 paths', async () => { + const updateRegistrySpy = jest.spyOn( + registry, + 'updateRegistry' + ); + + await registry.generateErcRegistry(mockContractA, mockContractB); + + expect(updateRegistrySpy).toHaveBeenCalledTimes(2); + expect(updateRegistrySpy).toHaveBeenCalledWith( + registry['erc20JsonFilePath'], + mockContractA + ); + expect(updateRegistrySpy).toHaveBeenCalledWith( + registry['erc721JsonFilePath'], + mockContractB + ); + }); + }); + + describe('readExistingContracts', () => { + it('should return an empty array if file does not exist', () => { + mockedFs.existsSync.mockReturnValue(false); + + const result = registry['readExistingContracts'](mockERC20Path); + + expect(result).toEqual([]); + }); + + it('should parse JSON from file successfully', () => { + const mockData = mockContractA; + mockedFs.readFileSync.mockReturnValue(JSON.stringify(mockData)); + + const result = registry['readExistingContracts'](mockERC20Path); + + expect(result).toEqual(mockData); + }); + + it('should throw error when file read fails', () => { + mockedFs.readFileSync.mockImplementation(() => { + throw new Error('Read error'); + }); + + expect(() => registry['readExistingContracts'](mockERC20Path)).toThrow( + 'Read error' + ); + }); + }); + + describe('writeContractsToFile', () => { + it('should create directories and write contracts to file', async () => { + const mockContracts: ERCOutputInterface[] = mockContractA; + + await registry['writeContractsToFile'](mockERC20Path, mockContracts); + + expect(mockedFs.promises.mkdir).toHaveBeenCalledWith( + path.dirname(mockERC20Path), + { recursive: true } + ); + expect(mockedFs.promises.writeFile).toHaveBeenCalledWith( + mockERC20Path, + JSON.stringify(mockContracts, null, 2) + ); + }); + + it('should throw error when write fails', async () => { + jest + .spyOn(mockedFs.promises, 'writeFile') + .mockRejectedValue(new Error('Write error')); + + await expect( + registry['writeContractsToFile'](mockERC20Path, mockContractA) + ).rejects.toThrow('Write error'); + }); + }); + + describe('updateRegistry', () => { + it('should remove duplicates and write unique contracts to file', async () => { + const existingContracts: ERCOutputInterface[] = mockContractA; + const newContracts: ERCOutputInterface[] = [ + mockContractA[0], + mockContractB[0], + ]; + + mockedFs.readFileSync.mockReturnValue(JSON.stringify(existingContracts)); + + await registry['updateRegistry'](mockERC20Path, newContracts); + + const expectedContracts = [mockContractA[0], mockContractB[0]]; + expect(mockedFs.promises.writeFile).toHaveBeenCalledWith( + mockERC20Path, + JSON.stringify(expectedContracts, null, 2) + ); + }); + }); +});