diff --git a/.changeset/brown-taxis-invite.md b/.changeset/brown-taxis-invite.md new file mode 100644 index 000000000..23d173ac2 --- /dev/null +++ b/.changeset/brown-taxis-invite.md @@ -0,0 +1,6 @@ +--- +'@hyperlane-xyz/registry': minor +--- + +Add PartialRegistry class +Add merge() method to IRegistry diff --git a/src/index.ts b/src/index.ts index 3ca47f495..76677f28b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,4 +5,5 @@ export { BaseRegistry, CHAIN_FILE_REGEX } from './registry/BaseRegistry.js'; export { GithubRegistry, GithubRegistryOptions } from './registry/GithubRegistry.js'; export { ChainFiles, IRegistry, RegistryContent, RegistryType } from './registry/IRegistry.js'; export { MergedRegistry, MergedRegistryOptions } from './registry/MergedRegistry.js'; +export { PartialRegistry, PartialRegistryOptions } from './registry/PartialRegistry.js'; export { ChainAddresses, ChainAddressesSchema } from './types.js'; diff --git a/src/registry/BaseRegistry.ts b/src/registry/BaseRegistry.ts index fd820f6d9..07396206a 100644 --- a/src/registry/BaseRegistry.ts +++ b/src/registry/BaseRegistry.ts @@ -3,6 +3,7 @@ import type { Logger } from 'pino'; import type { ChainMap, ChainMetadata, ChainName, WarpCoreConfig } from '@hyperlane-xyz/sdk'; import type { ChainAddresses, MaybePromise } from '../types.js'; import type { IRegistry, RegistryContent, RegistryType } from './IRegistry.js'; +import { MergedRegistry } from './MergedRegistry.js'; export const CHAIN_FILE_REGEX = /chains\/([a-z0-9]+)\/([a-z]+)\.(yaml|svg)/; @@ -72,4 +73,8 @@ export abstract class BaseRegistry implements IRegistry { }): MaybePromise; abstract removeChain(chain: ChainName): MaybePromise; abstract addWarpRoute(config: WarpCoreConfig): MaybePromise; + + merge(otherRegistry: IRegistry): IRegistry { + return new MergedRegistry({ registries: [this, otherRegistry], logger: this.logger }); + } } diff --git a/src/registry/GithubRegistry.ts b/src/registry/GithubRegistry.ts index 8cce11fa3..15196da56 100644 --- a/src/registry/GithubRegistry.ts +++ b/src/registry/GithubRegistry.ts @@ -29,6 +29,11 @@ interface TreeNode { url: string; } +/** + * A registry that uses a github repository as its data source. + * Reads are performed via the github API and github's raw content URLs. + * Writes are not yet supported (TODO) + */ export class GithubRegistry extends BaseRegistry implements IRegistry { public readonly type = RegistryType.Github; public readonly url: URL; diff --git a/src/registry/IRegistry.ts b/src/registry/IRegistry.ts index 841cd2ee3..02cb665a7 100644 --- a/src/registry/IRegistry.ts +++ b/src/registry/IRegistry.ts @@ -18,6 +18,7 @@ export enum RegistryType { Github = 'github', Local = 'local', Merged = 'merged', + Partial = 'partial', } export interface IRegistry { @@ -50,4 +51,6 @@ export interface IRegistry { addWarpRoute(config: WarpCoreConfig): MaybePromise; // TODO define more deployment artifact related methods + + merge(otherRegistry: IRegistry): IRegistry; } diff --git a/src/registry/LocalRegistry.ts b/src/registry/LocalRegistry.ts index c70bdc402..faafd575e 100644 --- a/src/registry/LocalRegistry.ts +++ b/src/registry/LocalRegistry.ts @@ -8,13 +8,14 @@ import type { ChainMap, ChainMetadata, ChainName, WarpCoreConfig } from '@hyperl import { 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 { CHAIN_FILE_REGEX } from './BaseRegistry.js'; import { RegistryType, type ChainFiles, type IRegistry, type RegistryContent, } from './IRegistry.js'; +import { SynchronousRegistry } from './SynchronousRegistry.js'; import { warpConfigToWarpAddresses } from './warp-utils.js'; export interface LocalRegistryOptions { @@ -22,7 +23,11 @@ export interface LocalRegistryOptions { logger?: Logger; } -export class LocalRegistry extends BaseRegistry implements IRegistry { +/** + * A registry that uses a local file system path as its data source. + * Requires file system access so it cannot be used in the browser. + */ +export class LocalRegistry extends SynchronousRegistry implements IRegistry { public readonly type = RegistryType.Local; constructor(options: LocalRegistryOptions) { @@ -48,10 +53,6 @@ export class LocalRegistry extends BaseRegistry implements IRegistry { return (this.listContentCache = { chains, deployments: {} }); } - getChains(): Array { - return Object.keys(this.listRegistryContent().chains); - } - getMetadata(): ChainMap { if (this.metadataCache) return this.metadataCache; const chainMetadata: ChainMap = {}; @@ -64,11 +65,6 @@ export class LocalRegistry extends BaseRegistry implements IRegistry { return (this.metadataCache = chainMetadata); } - getChainMetadata(chainName: ChainName): ChainMetadata | null { - const metadata = this.getMetadata(); - return metadata[chainName] ?? null; - } - getAddresses(): ChainMap { if (this.addressCache) return this.addressCache; const chainAddresses: ChainMap = {}; @@ -81,43 +77,10 @@ export class LocalRegistry extends BaseRegistry implements IRegistry { return (this.addressCache = chainAddresses); } - getChainAddresses(chainName: ChainName): ChainAddresses | null { - const addresses = this.getAddresses(); - return addresses[chainName] ?? null; - } - - 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]; + const chainFiles = this.listRegistryContent().chains[chainName]; + super.removeChain(chainName); + this.removeFiles(Object.values(chainFiles)); } protected listFiles(dirPath: string): string[] { diff --git a/src/registry/MergedRegistry.ts b/src/registry/MergedRegistry.ts index ca2b33472..4833dd640 100644 --- a/src/registry/MergedRegistry.ts +++ b/src/registry/MergedRegistry.ts @@ -3,7 +3,6 @@ import type { Logger } from 'pino'; import type { ChainMap, ChainMetadata, ChainName, WarpCoreConfig } from '@hyperlane-xyz/sdk'; import { ChainAddresses } from '../types.js'; import { objMerge } from '../utils.js'; -import { BaseRegistry } from './BaseRegistry.js'; import { IRegistry, RegistryContent, RegistryType } from './IRegistry.js'; export interface MergedRegistryOptions { @@ -11,14 +10,23 @@ export interface MergedRegistryOptions { logger?: Logger; } -export class MergedRegistry extends BaseRegistry implements IRegistry { +/** + * A registry that accepts multiple sub-registries. + * Read methods are performed on all sub-registries and the results are merged. + * Write methods are performed on all sub-registries. + * Can be created manually or by calling `.merge()` on an existing registry. + */ +export class MergedRegistry implements IRegistry { public readonly type = RegistryType.Merged; + public readonly uri = '__merged_registry__'; public readonly registries: Array; + protected readonly logger: Logger; constructor({ registries, logger }: MergedRegistryOptions) { - super({ uri: '__merged_registry__', logger }); if (!registries.length) throw new Error('At least one registry URI is required'); this.registries = registries; + // @ts-ignore + this.logger = logger || console; } async listRegistryContent(): Promise { @@ -51,6 +59,11 @@ export class MergedRegistry extends BaseRegistry implements IRegistry { return (await this.getAddresses())[chainName] || null; } + async getChainLogoUri(chainName: ChainName): Promise { + const results = await this.multiRegistryRead((r) => r.getChainLogoUri(chainName)); + return results.find((uri) => !!uri) || null; + } + async addChain(chain: { chainName: ChainName; metadata?: ChainMetadata; @@ -111,4 +124,11 @@ export class MergedRegistry extends BaseRegistry implements IRegistry { } } } + + merge(otherRegistry: IRegistry): IRegistry { + return new MergedRegistry({ + registries: [...this.registries, otherRegistry], + logger: this.logger, + }); + } } diff --git a/src/registry/PartialRegistry.ts b/src/registry/PartialRegistry.ts new file mode 100644 index 000000000..5addc04ce --- /dev/null +++ b/src/registry/PartialRegistry.ts @@ -0,0 +1,74 @@ +import type { Logger } from 'pino'; + +import type { ChainMap, ChainMetadata, ChainName, WarpCoreConfig } from '@hyperlane-xyz/sdk'; +import { ChainAddresses } from '../types.js'; +import { ChainFiles, IRegistry, RegistryContent, RegistryType } from './IRegistry.js'; +import { SynchronousRegistry } from './SynchronousRegistry.js'; + +const PARTIAL_URI_PLACEHOLDER = '__partial_registry__'; + +/** + * A registry that accepts partial data, such as incomplete chain metadata or addresses. + * Useful for merging with other registries force overrides of subsets of data. + */ +export interface PartialRegistryOptions { + chainMetadata?: ChainMap>; + chainAddresses?: ChainMap>; + // TODO add more fields here as needed + logger?: Logger; +} + +export class PartialRegistry extends SynchronousRegistry implements IRegistry { + public readonly type = RegistryType.Partial; + public chainMetadata: ChainMap>; + public chainAddresses: ChainMap>; + + constructor({ chainMetadata, chainAddresses, logger }: PartialRegistryOptions) { + super({ uri: PARTIAL_URI_PLACEHOLDER, logger }); + this.chainMetadata = chainMetadata || {}; + this.chainAddresses = chainAddresses || {}; + } + + listRegistryContent(): RegistryContent { + const chains: ChainMap = {}; + Object.keys(this.chainMetadata).forEach((c) => { + chains[c] ||= {}; + chains[c].metadata = PARTIAL_URI_PLACEHOLDER; + }); + Object.keys(this.chainAddresses).forEach((c) => { + chains[c] ||= {}; + chains[c].addresses = PARTIAL_URI_PLACEHOLDER; + }); + return { + chains, + deployments: {}, + }; + } + + getMetadata(): ChainMap { + return this.chainMetadata as ChainMap; + } + + getAddresses(): ChainMap { + return this.chainAddresses as ChainMap; + } + + removeChain(chainName: ChainName): void { + super.removeChain(chainName); + if (this.chainMetadata?.[chainName]) delete this.chainMetadata[chainName]; + if (this.chainAddresses?.[chainName]) delete this.chainAddresses[chainName]; + } + + addWarpRoute(_config: WarpCoreConfig): void { + throw new Error('Method not implemented.'); + } + + protected createOrUpdateChain(chain: { + chainName: ChainName; + metadata?: ChainMetadata; + addresses?: ChainAddresses; + }): void { + if (chain.metadata) this.chainMetadata[chain.chainName] = chain.metadata; + if (chain.addresses) this.chainAddresses[chain.chainName] = chain.addresses; + } +} diff --git a/src/registry/SynchronousRegistry.ts b/src/registry/SynchronousRegistry.ts new file mode 100644 index 000000000..3d81bc330 --- /dev/null +++ b/src/registry/SynchronousRegistry.ts @@ -0,0 +1,71 @@ +import type { ChainMap, ChainMetadata, ChainName, WarpCoreConfig } from '@hyperlane-xyz/sdk'; + +import { ChainAddresses } from '../types.js'; +import { BaseRegistry } from './BaseRegistry.js'; +import { IRegistry, RegistryContent } from './IRegistry.js'; + +/** + * Shared code for sync registries like the FileSystem and Partial registries. + * This is required because of the inconsistent sync/async methods across registries. + * If the Infra package can be updated to work with async-only methods, this code can be moved to the BaseRegistry class. + */ +export abstract class SynchronousRegistry extends BaseRegistry implements IRegistry { + abstract listRegistryContent(): RegistryContent; + + getChains(): Array { + return Object.keys(this.listRegistryContent().chains); + } + + abstract getMetadata(): ChainMap; + + getChainMetadata(chainName: ChainName): ChainMetadata | null { + return this.getMetadata()[chainName] || null; + } + + abstract getAddresses(): ChainMap; + + getChainAddresses(chainName: ChainName): ChainAddresses | null { + return this.getAddresses()[chainName] || null; + } + + 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`); + + 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]; + } + + abstract addWarpRoute(config: WarpCoreConfig): void; + + protected abstract createOrUpdateChain(chain: { + chainName: ChainName; + metadata?: ChainMetadata; + addresses?: ChainAddresses; + }): void; +} diff --git a/test/unit/registry.test.ts b/test/unit/registry.test.ts index 9720ae588..8b4e6d590 100644 --- a/test/unit/registry.test.ts +++ b/test/unit/registry.test.ts @@ -8,11 +8,14 @@ import { GithubRegistry } from '../../src/registry/GithubRegistry.js'; import { RegistryType } from '../../src/registry/IRegistry.js'; import { LocalRegistry } from '../../src/registry/LocalRegistry.js'; import { MergedRegistry } from '../../src/registry/MergedRegistry.js'; +import { PartialRegistry } from '../../src/registry/PartialRegistry.js'; import { ChainAddresses } from '../../src/types.js'; const MOCK_CHAIN_NAME = 'mockchain'; const MOCK_CHAIN_NAME2 = 'mockchain2'; +const MOCK_DISPLAY_NAME = 'faketherum'; const MOCK_SYMBOL = 'MOCK'; +const MOCK_ADDRESS = '0x0000000000000000000000000000000000000001'; describe('Registry utilities', () => { const githubRegistry = new GithubRegistry(); @@ -23,9 +26,16 @@ describe('Registry utilities', () => { const localRegistry = new LocalRegistry({ uri: './' }); expect(localRegistry.uri).to.be.a('string'); - const mergedRegistry = new MergedRegistry({ registries: [githubRegistry, localRegistry] }); + const partialRegistry = new PartialRegistry({ + chainMetadata: { ethereum: { chainId: 1, displayName: MOCK_DISPLAY_NAME } }, + chainAddresses: { ethereum: { mailbox: MOCK_ADDRESS } }, + }); + + const mergedRegistry = new MergedRegistry({ + registries: [githubRegistry, localRegistry, partialRegistry], + }); - for (const registry of [githubRegistry, localRegistry, mergedRegistry]) { + for (const registry of [githubRegistry, localRegistry, partialRegistry, mergedRegistry]) { it(`Lists all chains for ${registry.type} registry`, async () => { const chains = await registry.getChains(); expect(chains.length).to.be.greaterThan(0); @@ -36,6 +46,11 @@ describe('Registry utilities', () => { const metadata = await registry.getMetadata(); expect(Object.keys(metadata).length).to.be.greaterThan(0); expect(metadata['ethereum'].chainId).to.eql(1); + if (registry.type === RegistryType.Partial || registry.type === RegistryType.Merged) { + expect(metadata['ethereum'].displayName).to.eql(MOCK_DISPLAY_NAME); + } else { + expect(metadata['ethereum'].displayName).to.eql('Ethereum'); + } }).timeout(10_000); it(`Fetches single chain metadata for ${registry.type} registry`, async () => {