diff --git a/.changeset/three-rice-lay.md b/.changeset/three-rice-lay.md new file mode 100644 index 00000000..a8e56233 --- /dev/null +++ b/.changeset/three-rice-lay.md @@ -0,0 +1,5 @@ +--- +'@hyperlane-xyz/registry': patch +--- + +Implement methods to adding/removing chains to a LocalRegistry diff --git a/scripts/build.ts b/scripts/build.ts index da3b12aa..fb4be74a 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ import { ChainMetadataSchemaObject } from '@hyperlane-xyz/sdk'; import { pick } from '@hyperlane-xyz/utils'; import fs from 'fs'; @@ -24,8 +25,8 @@ if (fs.existsSync('./tmp')) fs.rmSync(`./tmp`, { recursive: true }); // Start with the contents of src, which we will add to in this script fs.cpSync(`./src`, `./tmp`, { recursive: true }); -let chainMetadata = {}; -let chainAddresses = {}; +const chainMetadata = {}; +const chainAddresses = {}; console.log('Parsing and copying chain data'); for (const file of fs.readdirSync('./chains')) { diff --git a/src/consts.ts b/src/consts.ts new file mode 100644 index 00000000..8e71129b --- /dev/null +++ b/src/consts.ts @@ -0,0 +1 @@ +export const CHAIN_SCHEMA_REF = '# yaml-language-server: $schema=../schema.json'; diff --git a/src/registry/BaseRegistry.ts b/src/registry/BaseRegistry.ts index a1e8bfb9..daf27a31 100644 --- a/src/registry/BaseRegistry.ts +++ b/src/registry/BaseRegistry.ts @@ -1,7 +1,7 @@ import type { Logger } from 'pino'; import type { ChainMap, ChainMetadata, ChainName } from '@hyperlane-xyz/sdk'; -import { ChainAddresses, MaybePromise } from '../types.js'; +import type { ChainAddresses, MaybePromise } from '../types.js'; import type { IRegistry, RegistryContent, RegistryType } from './IRegistry.js'; export const CHAIN_FILE_REGEX = /chains\/([a-z]+)\/([a-z]+)\.yaml/; @@ -36,4 +36,15 @@ export abstract class BaseRegistry implements IRegistry { abstract getChainMetadata(chainName: ChainName): MaybePromise; abstract getAddresses(): MaybePromise>; abstract getChainAddresses(chainName: ChainName): MaybePromise; + abstract addChain(chains: { + chainName: ChainName; + metadata?: ChainMetadata; + addresses?: ChainAddresses; + }): MaybePromise; + abstract updateChain(chains: { + chainName: ChainName; + metadata?: ChainMetadata; + addresses?: ChainAddresses; + }): MaybePromise; + abstract removeChain(chains: ChainName): MaybePromise; } diff --git a/src/registry/GithubRegistry.ts b/src/registry/GithubRegistry.ts index f10e7f00..bea098b3 100644 --- a/src/registry/GithubRegistry.ts +++ b/src/registry/GithubRegistry.ts @@ -123,6 +123,24 @@ export class GithubRegistry extends BaseRegistry implements IRegistry { return ChainAddressesSchema.parse(yamlParse(data)); } + async addChain(_chains: { + chainName: ChainName; + metadata?: ChainMetadata; + addresses?: ChainAddresses; + }): Promise { + throw new Error('TODO: Implement'); + } + async updateChain(_chains: { + chainName: ChainName; + metadata?: ChainMetadata; + addresses?: ChainAddresses; + }): Promise { + throw new Error('TODO: Implement'); + } + async removeChain(_chains: ChainName): Promise { + throw new Error('TODO: Implement'); + } + protected getRawContentUrl(path: string): string { return `https://raw.githubusercontent.com/${this.repoOwner}/${this.repoName}/${this.branch}/${path}`; } diff --git a/src/registry/IRegistry.ts b/src/registry/IRegistry.ts index d8b910c4..9adf4e72 100644 --- a/src/registry/IRegistry.ts +++ b/src/registry/IRegistry.ts @@ -1,5 +1,5 @@ import type { ChainMap, ChainMetadata, ChainName } from '@hyperlane-xyz/sdk'; -import { ChainAddresses, MaybePromise } from '../types.js'; +import type { ChainAddresses, MaybePromise } from '../types.js'; export interface ChainFiles { metadata?: string; @@ -26,5 +26,17 @@ export interface IRegistry { getChainMetadata(chainName: ChainName): MaybePromise; getAddresses(): MaybePromise>; getChainAddresses(chainName: ChainName): MaybePromise; - // TODO: Define write-related methods + addChain(chains: { + chainName: ChainName; + metadata?: ChainMetadata; + addresses?: ChainAddresses; + }): MaybePromise; + updateChain(chains: { + chainName: ChainName; + metadata?: ChainMetadata; + addresses?: ChainAddresses; + }): MaybePromise; + removeChain(chains: ChainName): MaybePromise; + + // TODO define deployment artifact related methods } diff --git a/src/registry/LocalRegistry.ts b/src/registry/LocalRegistry.ts index 8aa16f90..539643e1 100644 --- a/src/registry/LocalRegistry.ts +++ b/src/registry/LocalRegistry.ts @@ -3,9 +3,11 @@ import path from 'path'; import type { Logger } from 'pino'; import { parse as yamlParse } from 'yaml'; -import { type ChainMap, type ChainMetadata, type ChainName } from '@hyperlane-xyz/sdk'; +import type { ChainMap, ChainMetadata, ChainName } from '@hyperlane-xyz/sdk'; +import { CHAIN_SCHEMA_REF } from '../consts.js'; import { ChainAddresses, ChainAddressesSchema } from '../types.js'; +import { toYamlString } from '../utils.js'; import { BaseRegistry, CHAIN_FILE_REGEX } from './BaseRegistry.js'; import { RegistryType, @@ -89,6 +91,40 @@ export class LocalRegistry extends BaseRegistry implements IRegistry { return addresses[chainName]; } + addChain(chain: { + chainName: ChainName; + metadata?: ChainMetadata; + addresses?: ChainAddresses; + }): void { + const currentChains = this.listRegistryContent().chains; + if (currentChains[chain.chainName]) + throw new Error(`Chain ${chain.chainName} already exists in registry`); + + this.createOrUpdateChain(chain); + } + + updateChain(chain: { + chainName: ChainName; + metadata?: ChainMetadata; + addresses?: ChainAddresses; + }): void { + const currentChains = this.listRegistryContent(); + if (!currentChains.chains[chain.chainName]) { + this.logger.debug(`Chain ${chain.chainName} not found in registry, adding it now`); + } + this.createOrUpdateChain(chain); + } + + removeChain(chainName: ChainName): void { + const currentChains = this.listRegistryContent().chains; + if (!currentChains[chainName]) throw new Error(`Chain ${chainName} does not exist in registry`); + + this.removeFiles(Object.values(currentChains[chainName])); + if (this.listContentCache?.chains[chainName]) delete this.listContentCache.chains[chainName]; + if (this.metadataCache?.[chainName]) delete this.metadataCache[chainName]; + if (this.addressCache?.[chainName]) delete this.addressCache[chainName]; + } + protected listFiles(dirPath: string): string[] { const entries = fs.readdirSync(dirPath, { withFileTypes: true }); @@ -99,4 +135,65 @@ export class LocalRegistry extends BaseRegistry implements IRegistry { return filePaths.flat(); } + + protected createOrUpdateChain(chain: { + chainName: ChainName; + metadata?: ChainMetadata; + addresses?: ChainAddresses; + }): void { + if (!chain.metadata && !chain.addresses) + throw new Error(`Chain ${chain.chainName} must have metadata or addresses, preferably both`); + + const currentChains = this.listRegistryContent(); + if (!currentChains.chains[chain.chainName]) { + this.logger.debug(`Chain ${chain.chainName} not found in registry, adding it now`); + } + + if (chain.metadata) { + this.createChainFile( + chain.chainName, + 'metadata', + chain.metadata, + this.getMetadata(), + CHAIN_SCHEMA_REF, + ); + } + if (chain.addresses) { + this.createChainFile(chain.chainName, 'addresses', chain.addresses, this.getAddresses()); + } + } + + protected createChainFile( + chainName: ChainName, + fileName: keyof ChainFiles, + data: any, + cache: ChainMap, + prefix?: string, + ) { + const filePath = path.join(this.uri, this.getChainsPath(), chainName, `${fileName}.yaml`); + const currentChains = this.listRegistryContent().chains; + currentChains[chainName] ||= {}; + currentChains[chainName][fileName] = filePath; + cache[chainName] = data; + this.createFile({ filePath, data: toYamlString(data, prefix) }); + } + + protected createFile(file: { filePath: string; data: string }): void { + const dirPath = path.dirname(file.filePath); + if (!fs.existsSync(dirPath)) + fs.mkdirSync(dirPath, { + recursive: true, + }); + fs.writeFileSync(file.filePath, file.data); + } + + protected removeFiles(filePaths: string[]): void { + for (const filePath of filePaths) { + fs.unlinkSync(filePath); + } + const parentDir = path.dirname(filePaths[0]); + if (fs.readdirSync(parentDir).length === 0) { + fs.rmdirSync(parentDir); + } + } } diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 00000000..084ae629 --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,6 @@ +import { stringify } from 'yaml'; + +export function toYamlString(data: any, prefix?: string): string { + const yamlString = stringify(data); + return prefix ? `${prefix}\n${yamlString}` : yamlString; +} diff --git a/test/unit/registry.test.ts b/test/unit/registry.test.ts index d1f8d4b0..0d82d758 100644 --- a/test/unit/registry.test.ts +++ b/test/unit/registry.test.ts @@ -1,7 +1,11 @@ import { expect } from 'chai'; +import type { ChainMetadata } from '@hyperlane-xyz/sdk'; import { GithubRegistry } from '../../src/registry/GithubRegistry.js'; import { LocalRegistry } from '../../src/registry/LocalRegistry.js'; +import { ChainAddresses } from '../../src/types.js'; + +const MOCK_CHAIN_NAME = 'mockchain'; describe('Registry utilities', () => { const githubRegistry = new GithubRegistry(); @@ -48,5 +52,27 @@ describe('Registry utilities', () => { expect(Object.keys(addresses).length).to.be.greaterThan(0); // Note the short timeout to ensure result is coming from cache }).timeout(250); + + // TODO remove this once GitHubRegistry methods are implemented + if (registry.type === 'github') continue; + + it(`Adds a new chain for ${registry.type} registry`, async () => { + const mockMetadata: ChainMetadata = { + ...(await registry.getChainMetadata('ethereum')), + name: MOCK_CHAIN_NAME, + }; + const mockAddresses: ChainAddresses = await registry.getChainAddresses('ethereum'); + await registry.addChain({ + chainName: MOCK_CHAIN_NAME, + metadata: mockMetadata, + addresses: mockAddresses, + }); + expect((await registry.getChains()).includes(MOCK_CHAIN_NAME)).to.be.true; + }).timeout(5_000); + + it(`Removes a chain for ${registry.type} registry`, async () => { + await registry.removeChain('mockchain'); + expect((await registry.getChains()).includes(MOCK_CHAIN_NAME)).to.be.false; + }).timeout(5_000); } });