Skip to content

Commit

Permalink
feat: added RegistryGenerator service class to export ERC token contr…
Browse files Browse the repository at this point in the history
…acts (#1050)

* chore: removed MIRROR_NODE_URL env var

Signed-off-by: Logan Nguyen <[email protected]>

* feat: added RegistryGenerator service class

Signed-off-by: Logan Nguyen <[email protected]>

* chore: addressed feedback

Signed-off-by: Logan Nguyen <[email protected]>

---------

Signed-off-by: Logan Nguyen <[email protected]>
  • Loading branch information
quiet-node authored Dec 5, 2024
1 parent 31f983b commit d4dc991
Show file tree
Hide file tree
Showing 7 changed files with 290 additions and 5 deletions.
Original file line number Diff line number Diff line change
@@ -1,3 +1,2 @@
HEDERA_NETWORK=
MIRROR_NODE_URL=
STARTING_POINT=
Original file line number Diff line number Diff line change
Expand Up @@ -20,5 +20,5 @@

export interface ERCOutputInterface {
address: string;
contractId: string | null;
contractId: string;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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`;
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -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<void>} Promise that resolves when registry files are updated
*/
async generateErcRegistry(
erc20Contracts: ERCOutputInterface[],
erc721Contracts: ERCOutputInterface[]
): Promise<void> {
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<void>} Promise that resolves when registry is updated
* @private
*/
private async updateRegistry(
filePath: string,
newContracts: ERCOutputInterface[]
): Promise<void> {
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<void>} Promise that resolves when file is written
* @private
*/
private async writeContractsToFile(
filePath: string,
contracts: ERCOutputInterface[]
): Promise<void> {
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.');
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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',
};
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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<typeof fs>;

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<any, any>(
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)
);
});
});
});

0 comments on commit d4dc991

Please sign in to comment.