diff --git a/packages/backend/.env.example b/packages/backend/.env.example index 47fe03b0..85e95fc6 100644 --- a/packages/backend/.env.example +++ b/packages/backend/.env.example @@ -48,6 +48,15 @@ CELO_DISCOVERY_ENABLED=false CELO_RPC_URL= CELOSCAN_API_KEY= +#-----TRACKING----- + +# Disables/enables oApps tracking on given chain +ETHEREUM_TRACKING_ENABLED=false +# RPC provider used to fetch default oApp configuration +ETHEREUM_TRACKING_RPC_URL= +# HTTP endpoint used to fetch data about availalbe oApps +ETHEREUM_TRACKING_LIST_API_URL= + #-----OPTIONAL----- # Define the data indexing root tick interval in milliseconds @@ -146,4 +155,6 @@ CELO_START_BLOCK= CELO_RPC_LOGS_MAX_RANGE= CELO_EVENT_INDEXER_AMT_BATCHES= CELOSCAN_MIN_TIMESTAMP= -CELO_LOGGER_ENABLED= \ No newline at end of file +CELO_LOGGER_ENABLED= + + diff --git a/packages/backend/package.json b/packages/backend/package.json index 530629b4..557eff50 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -31,15 +31,16 @@ "@l2beat/discovery-types": "0.8.0", "@l2beat/uif": "^0.2.3", "@lz/libs": "*", + "@lz/testnet": "*", "@sentry/node": "^7.73.0", "@types/deep-diff": "^1.0.4", "deep-diff": "^1.0.2", - "@lz/testnet": "*", "ethers": "^5.7.2", "knex": "^2.5.1", "koa": "^2.14.2", "koa-conditional-get": "^3.0.0", "koa-etag": "^4.0.0", + "node-fetch": "2", "pg": "^8.11.3", "source-map-support": "^0.5.21", "zod": "^3.22.2" diff --git a/packages/backend/src/Application.ts b/packages/backend/src/Application.ts index 8687b881..75b541f7 100644 --- a/packages/backend/src/Application.ts +++ b/packages/backend/src/Application.ts @@ -9,6 +9,7 @@ import { createHealthModule } from './modules/HealthModule' import { createStatusModule } from './modules/StatusModule' import { Database } from './peripherals/database/shared/Database' import { handleServerError, reportError } from './tools/ErrorReporter' +import { createTrackingModule } from './tracking/TrackingModule' export class Application { start: () => Promise @@ -25,6 +26,7 @@ export class Application { createDiscoveryModule({ database, logger, config }), createStatusModule({ database, logger, config }), createConfigModule({ config }), + createTrackingModule({ database, logger, config }), ] const apiServer = new ApiServer( diff --git a/packages/backend/src/config/Config.ts b/packages/backend/src/config/Config.ts index 78ef046b..fe7f708c 100644 --- a/packages/backend/src/config/Config.ts +++ b/packages/backend/src/config/Config.ts @@ -39,6 +39,9 @@ export interface Config { readonly linea: DiscoverySubmoduleConfig } } + readonly tracking: { + readonly ethereum: TrackingSubmoduleConfig + } } export interface ApiConfig { @@ -84,3 +87,21 @@ export interface EthereumLikeDiscoveryConfig { loggerEnabled?: boolean unsupportedEtherscanMethods?: EtherscanUnsupportedMethods } + +export type TrackingSubmoduleConfig = + | { + enabled: true + config: TrackingConfig + } + | { + enabled: false + config: null + } + +export interface TrackingConfig { + listApiUrl: string + rpcUrl: string + tickIntervalMs: number + multicall: MulticallConfig + ulnV2Address: EthereumAddress +} diff --git a/packages/backend/src/config/config.common.ts b/packages/backend/src/config/config.discovery.ts similarity index 100% rename from packages/backend/src/config/config.common.ts rename to packages/backend/src/config/config.discovery.ts diff --git a/packages/backend/src/config/config.local.ts b/packages/backend/src/config/config.local.ts index b136fea0..46550a0c 100644 --- a/packages/backend/src/config/config.local.ts +++ b/packages/backend/src/config/config.local.ts @@ -1,7 +1,8 @@ import { Env, LoggerOptions } from '@l2beat/backend-tools' import { Config } from './Config' -import { getCommonDiscoveryConfig } from './config.common' +import { getCommonDiscoveryConfig } from './config.discovery' +import { getCommonTrackingConfig } from './config.tracking' import { getGitCommitSha } from './getGitCommitSha' export function getLocalConfig(env: Env): Config { @@ -24,5 +25,6 @@ export function getLocalConfig(env: Env): Config { commitSha: getGitCommitSha(), }, discovery: getCommonDiscoveryConfig(env), + tracking: getCommonTrackingConfig(env), } } diff --git a/packages/backend/src/config/config.production.ts b/packages/backend/src/config/config.production.ts index d6dd95dc..11048825 100644 --- a/packages/backend/src/config/config.production.ts +++ b/packages/backend/src/config/config.production.ts @@ -1,7 +1,8 @@ import { Env } from '@l2beat/backend-tools' import { Config } from './Config' -import { getCommonDiscoveryConfig } from './config.common' +import { getCommonDiscoveryConfig } from './config.discovery' +import { getCommonTrackingConfig } from './config.tracking' import { getGitCommitSha } from './getGitCommitSha' export function getProductionConfig(env: Env): Config { @@ -27,5 +28,6 @@ export function getProductionConfig(env: Env): Config { commitSha: getGitCommitSha(), }, discovery: getCommonDiscoveryConfig(env), + tracking: getCommonTrackingConfig(env), } } diff --git a/packages/backend/src/config/config.tracking.ts b/packages/backend/src/config/config.tracking.ts new file mode 100644 index 00000000..b46c3f5a --- /dev/null +++ b/packages/backend/src/config/config.tracking.ts @@ -0,0 +1,64 @@ +import { Env } from '@l2beat/backend-tools' +import { MulticallConfig } from '@l2beat/discovery' +import { EthereumAddress } from '@lz/libs' + +import { Config, TrackingSubmoduleConfig } from './Config' +import { coreAddressesV1, ethereumMulticallConfig } from './discovery/ethereum' + +export function getCommonTrackingConfig(env: Env): Config['tracking'] { + const createTrackingConfig = configFromTemplate(env) + + return { + ethereum: createTrackingConfig({ + chainNamePrefix: 'ETHEREUM', + multicallConfig: ethereumMulticallConfig, + ulnV2Address: EthereumAddress(coreAddressesV1.ultraLightNodeV2), + }), + } +} + +function configFromTemplate(env: Env) { + return function ({ + chainNamePrefix, + multicallConfig, + ulnV2Address, + }: { + /** + * The prefix of the environment variables that configure the chain. + */ + chainNamePrefix: string + + /** + * Multicall configuration for given chain + */ + multicallConfig: MulticallConfig + + /** + * Address of UlnV2 on given chain + */ + ulnV2Address: EthereumAddress + }): TrackingSubmoduleConfig { + const isEnabled = env.boolean(`${chainNamePrefix}_TRACKING_ENABLED`, false) + + if (!isEnabled) { + return { + enabled: false, + config: null, + } + } + + return { + enabled: true, + config: { + listApiUrl: env.string( + `${chainNamePrefix}_TRACKING_LIST_API_URL`, + 'https://l2beat-production.herokuapp.com/api/integrations/layerzero-oapps', + ), + tickIntervalMs: env.integer('TRACKING_TICK_INTERVAL_MS', 60_000_000), + rpcUrl: env.string(`${chainNamePrefix}_TRACKING_RPC_URL`), + ulnV2Address: ulnV2Address, + multicall: multicallConfig, + }, + } + } +} diff --git a/packages/backend/src/config/discovery/ethereum.ts b/packages/backend/src/config/discovery/ethereum.ts index eb8c17eb..d2b811c8 100644 --- a/packages/backend/src/config/discovery/ethereum.ts +++ b/packages/backend/src/config/discovery/ethereum.ts @@ -14,6 +14,7 @@ import { } from '../types' export { + coreAddressesV1, ethereumChangelogWhitelist, ethereumDiscoveryConfig, ethereumEventsToWatch, diff --git a/packages/backend/src/modules/DiscoveryModule.ts b/packages/backend/src/modules/DiscoveryModule.ts index 7f02d42e..8378d9cf 100644 --- a/packages/backend/src/modules/DiscoveryModule.ts +++ b/packages/backend/src/modules/DiscoveryModule.ts @@ -137,9 +137,7 @@ export function createDiscoveryModule({ start: async () => { statusLogger.info('Starting discovery module') - for (const submodule of submodules) { - await submodule.start?.() - } + await Promise.all(submodules.map((submodule) => submodule.start?.())) statusLogger.info('Main discovery module started') }, diff --git a/packages/backend/src/peripherals/database/OAppConfigurationRepository.test.ts b/packages/backend/src/peripherals/database/OAppConfigurationRepository.test.ts new file mode 100644 index 00000000..0b976828 --- /dev/null +++ b/packages/backend/src/peripherals/database/OAppConfigurationRepository.test.ts @@ -0,0 +1,73 @@ +import { Logger } from '@l2beat/backend-tools' +import { ChainId, EthereumAddress } from '@lz/libs' +import { expect } from 'earl' + +import { setupDatabaseTestSuite } from '../../test/database' +import { + OAppConfigurationRecord, + OAppConfigurationRepository, +} from './OAppConfigurationRepository' + +describe(OAppConfigurationRepository.name, () => { + const { database } = setupDatabaseTestSuite() + const repository = new OAppConfigurationRepository(database, Logger.SILENT) + + before(async () => await repository.deleteAll()) + afterEach(async () => await repository.deleteAll()) + + describe(OAppConfigurationRepository.prototype.addMany.name, () => { + it('merges rows on insert', async () => { + const record1 = mockRecord({ oAppId: 1, targetChainId: ChainId.ETHEREUM }) + const record2 = mockRecord({ oAppId: 2, targetChainId: ChainId.OPTIMISM }) + + await repository.addMany([record1, record2]) + + const recordsBeforeMerge = await repository.findAll() + + await repository.addMany([record1, record2]) + + const recordsAfterMerge = await repository.findAll() + + expect(recordsBeforeMerge.length).toEqual(2) + expect(recordsAfterMerge.length).toEqual(2) + }) + }) + + describe(OAppConfigurationRepository.prototype.findByOAppIds.name, () => { + it('returns only records with matching oAppId', async () => { + const record1 = mockRecord({ + oAppId: 1, + }) + const record2 = mockRecord({ + oAppId: 2, + }) + const record3 = mockRecord({ + oAppId: 3, + }) + + await repository.addMany([record1, record2, record3]) + + const result = await repository.findByOAppIds([1, 2]) + + expect(result.length).toEqual(2) + }) + }) +}) + +function mockRecord( + overrides?: Partial, +): OAppConfigurationRecord { + return { + oAppId: 1, + targetChainId: ChainId.ETHEREUM, + configuration: { + inboundBlockConfirmations: 2, + outboundBlockConfirmations: 2, + relayer: EthereumAddress.random(), + oracle: EthereumAddress.random(), + outboundProofType: 2, + inboundProofLibraryVersion: 2, + }, + ...overrides, + } +} diff --git a/packages/backend/src/peripherals/database/OAppConfigurationRepository.ts b/packages/backend/src/peripherals/database/OAppConfigurationRepository.ts new file mode 100644 index 00000000..7a36c02d --- /dev/null +++ b/packages/backend/src/peripherals/database/OAppConfigurationRepository.ts @@ -0,0 +1,72 @@ +import { Logger } from '@l2beat/backend-tools' +import { ChainId } from '@lz/libs' +import type { OAppConfigurationRow } from 'knex/types/tables' + +import { OAppConfiguration } from '../../tracking/domain/configuration' +import { BaseRepository, CheckConvention } from './shared/BaseRepository' +import { Database } from './shared/Database' + +export interface OAppConfigurationRecord { + oAppId: number + targetChainId: ChainId + configuration: OAppConfiguration +} + +export class OAppConfigurationRepository extends BaseRepository { + constructor(database: Database, logger: Logger) { + super(database, logger) + this.autoWrap>(this) + } + + public async addMany(records: OAppConfigurationRecord[]): Promise { + const rows = records.map(toRow) + const knex = await this.knex() + + await knex('oapp_configuration') + .insert(rows) + .onConflict(['oapp_id', 'target_chain_id']) + .merge() + + return rows.length + } + + public async findAll(): Promise { + const knex = await this.knex() + + const rows = await knex('oapp_configuration').select('*') + + return rows.map(toRecord) + } + public async findByOAppIds( + oAppIds: number[], + ): Promise { + const knex = await this.knex() + + const rows = await knex('oapp_configuration') + .select('*') + .whereIn('oapp_id', oAppIds) + + return rows.map(toRecord) + } + + async deleteAll(): Promise { + const knex = await this.knex() + return knex('oapp_configuration').delete() + } +} + +function toRow(record: OAppConfigurationRecord): OAppConfigurationRow { + return { + oapp_id: record.oAppId, + target_chain_id: Number(record.targetChainId), + configuration: JSON.stringify(record.configuration), + } +} + +function toRecord(row: OAppConfigurationRow): OAppConfigurationRecord { + return { + oAppId: row.oapp_id, + targetChainId: ChainId(row.target_chain_id), + configuration: OAppConfiguration.parse(row.configuration), + } +} diff --git a/packages/backend/src/peripherals/database/OAppDefaultConfigurationRepository.test.ts b/packages/backend/src/peripherals/database/OAppDefaultConfigurationRepository.test.ts new file mode 100644 index 00000000..7c1c6c18 --- /dev/null +++ b/packages/backend/src/peripherals/database/OAppDefaultConfigurationRepository.test.ts @@ -0,0 +1,89 @@ +import { Logger } from '@l2beat/backend-tools' +import { ChainId, EthereumAddress } from '@lz/libs' +import { expect } from 'earl' + +import { setupDatabaseTestSuite } from '../../test/database' +import { ProtocolVersion } from '../../tracking/domain/const' +import { + OAppDefaultConfigurationRecord, + OAppDefaultConfigurationRepository, +} from './OAppDefaultConfigurationRepository' + +describe(OAppDefaultConfigurationRepository.name, () => { + const { database } = setupDatabaseTestSuite() + const repository = new OAppDefaultConfigurationRepository( + database, + Logger.SILENT, + ) + + before(async () => await repository.deleteAll()) + afterEach(async () => await repository.deleteAll()) + + describe(OAppDefaultConfigurationRepository.prototype.addMany.name, () => { + it('merges rows on insert', async () => { + const record1 = mockRecord({ + sourceChainId: ChainId.ETHEREUM, + targetChainId: ChainId.OPTIMISM, + protocolVersion: ProtocolVersion.V1, + }) + const record2 = mockRecord({ + sourceChainId: ChainId.ETHEREUM, + targetChainId: ChainId.ARBITRUM, + protocolVersion: ProtocolVersion.V1, + }) + + await repository.addMany([record1, record2]) + + const recordsBeforeMerge = await repository.findAll() + + await repository.addMany([record1, record2]) + + const recordsAfterMerge = await repository.findAll() + + expect(recordsBeforeMerge.length).toEqual(2) + expect(recordsAfterMerge.length).toEqual(2) + }) + }) + + describe( + OAppDefaultConfigurationRepository.prototype.getBySourceChain.name, + () => { + it('returns only records with matching source chain', async () => { + const record1 = mockRecord({ + sourceChainId: ChainId.ETHEREUM, + }) + const record2 = mockRecord({ + sourceChainId: ChainId.OPTIMISM, + }) + const record3 = mockRecord({ + sourceChainId: ChainId.BSC, + }) + + await repository.addMany([record1, record2, record3]) + + const result = await repository.getBySourceChain(ChainId.ETHEREUM) + + expect(result.length).toEqual(1) + }) + }, + ) +}) + +function mockRecord( + overrides?: Partial, +): OAppDefaultConfigurationRecord { + return { + sourceChainId: ChainId.ETHEREUM, + targetChainId: ChainId.ETHEREUM, + protocolVersion: ProtocolVersion.V1, + configuration: { + inboundBlockConfirmations: 2, + outboundBlockConfirmations: 2, + relayer: EthereumAddress.random(), + oracle: EthereumAddress.random(), + outboundProofType: 2, + inboundProofLibraryVersion: 2, + }, + ...overrides, + } +} diff --git a/packages/backend/src/peripherals/database/OAppDefaultConfigurationRepository.ts b/packages/backend/src/peripherals/database/OAppDefaultConfigurationRepository.ts new file mode 100644 index 00000000..7e132012 --- /dev/null +++ b/packages/backend/src/peripherals/database/OAppDefaultConfigurationRepository.ts @@ -0,0 +1,82 @@ +import { Logger } from '@l2beat/backend-tools' +import { ChainId } from '@lz/libs' +import type { OAppDefaultConfigurationRow } from 'knex/types/tables' + +import { OAppConfiguration } from '../../tracking/domain/configuration' +import { BaseRepository, CheckConvention } from './shared/BaseRepository' +import { Database } from './shared/Database' + +export interface OAppDefaultConfigurationRecord { + protocolVersion: string + sourceChainId: ChainId + targetChainId: ChainId + configuration: OAppConfiguration +} + +export class OAppDefaultConfigurationRepository extends BaseRepository { + constructor(database: Database, logger: Logger) { + super(database, logger) + this.autoWrap>(this) + } + + public async addMany( + records: OAppDefaultConfigurationRecord[], + ): Promise { + const rows = records.map(toRow) + const knex = await this.knex() + + await knex('oapp_default_configuration') + .insert(rows) + .onConflict(['protocol_version', 'source_chain_id', 'target_chain_id']) + .merge() + + return rows.length + } + + public async findAll(): Promise { + const knex = await this.knex() + + const rows = await knex('oapp_default_configuration').select('*') + + return rows.map(toRecord) + } + + public async getBySourceChain( + sourceChainId: ChainId, + ): Promise { + const knex = await this.knex() + + const rows = await knex('oapp_default_configuration') + .select('*') + .where('source_chain_id', sourceChainId) + + return rows.map(toRecord) + } + + async deleteAll(): Promise { + const knex = await this.knex() + return knex('oapp_default_configuration').delete() + } +} + +function toRow( + record: OAppDefaultConfigurationRecord, +): OAppDefaultConfigurationRow { + return { + protocol_version: record.protocolVersion, + source_chain_id: Number(record.sourceChainId), + target_chain_id: Number(record.targetChainId), + configuration: JSON.stringify(record.configuration), + } +} + +function toRecord( + row: OAppDefaultConfigurationRow, +): OAppDefaultConfigurationRecord { + return { + protocolVersion: row.protocol_version, + sourceChainId: ChainId(row.source_chain_id), + targetChainId: ChainId(row.target_chain_id), + configuration: OAppConfiguration.parse(row.configuration), + } +} diff --git a/packages/backend/src/peripherals/database/OAppRepository.test.ts b/packages/backend/src/peripherals/database/OAppRepository.test.ts new file mode 100644 index 00000000..cecc65e1 --- /dev/null +++ b/packages/backend/src/peripherals/database/OAppRepository.test.ts @@ -0,0 +1,71 @@ +import { Logger } from '@l2beat/backend-tools' +import { ChainId, EthereumAddress } from '@lz/libs' +import { expect } from 'earl' + +import { setupDatabaseTestSuite } from '../../test/database' +import { ProtocolVersion } from '../../tracking/domain/const' +import { OAppRecord, OAppRepository } from './OAppRepository' + +describe(OAppRepository.name, () => { + const { database } = setupDatabaseTestSuite() + const repository = new OAppRepository(database, Logger.SILENT) + + before(async () => await repository.deleteAll()) + afterEach(async () => await repository.deleteAll()) + + describe(OAppRepository.prototype.addMany.name, () => { + it('merges rows on insert', async () => { + const record1 = mockRecord({ id: 1, name: 'OApp1' }) + const record2 = mockRecord({ id: 2, name: 'OApp2' }) + + await repository.addMany([record1, record2]) + + const recordsBeforeMerge = await repository.getAll() + + await repository.addMany([record1, record2]) + + const recordsAfterMerge = await repository.getAll() + + expect(recordsBeforeMerge.length).toEqual(2) + expect(recordsAfterMerge.length).toEqual(2) + }) + }) + + describe(OAppRepository.prototype.getBySourceChain.name, () => { + it('returns only records with matching source chain', async () => { + const record1 = mockRecord({ + id: 1, + name: 'OApp1', + sourceChainId: ChainId.ETHEREUM, + }) + const record2 = mockRecord({ + id: 2, + name: 'OApp2', + sourceChainId: ChainId.OPTIMISM, + }) + const record3 = mockRecord({ + id: 3, + name: 'OApp3', + sourceChainId: ChainId.BSC, + }) + + await repository.addMany([record1, record2, record3]) + + const result = await repository.getBySourceChain(ChainId.ETHEREUM) + + expect(result.length).toEqual(1) + }) + }) +}) + +function mockRecord(overrides?: Partial): Omit { + return { + name: 'name', + symbol: 'symbol', + address: EthereumAddress.random(), + sourceChainId: ChainId.ETHEREUM, + protocolVersion: ProtocolVersion.V1, + iconUrl: 'https://example.com/icon.png', + ...overrides, + } +} diff --git a/packages/backend/src/peripherals/database/OAppRepository.ts b/packages/backend/src/peripherals/database/OAppRepository.ts new file mode 100644 index 00000000..71cdc9f5 --- /dev/null +++ b/packages/backend/src/peripherals/database/OAppRepository.ts @@ -0,0 +1,91 @@ +import { Logger } from '@l2beat/backend-tools' +import { ChainId, EthereumAddress } from '@lz/libs' +import type { OAppRow } from 'knex/types/tables' + +import { ProtocolVersion, toProtocolVersion } from '../../tracking/domain/const' +import { BaseRepository, CheckConvention } from './shared/BaseRepository' +import { Database } from './shared/Database' + +export interface OAppRecord { + id: number + name: string + symbol: string + iconUrl?: string + protocolVersion: ProtocolVersion + address: EthereumAddress + sourceChainId: ChainId +} + +export class OAppRepository extends BaseRepository { + constructor(database: Database, logger: Logger) { + super(database, logger) + this.autoWrap>(this) + } + + public async addMany(records: Omit[]): Promise { + const rows = records.map(toRow) + + const knex = await this.knex() + + const ids = await knex('oapp') + .insert(rows) + .onConflict([ + 'name', + 'symbol', + 'protocol_version', + 'address', + 'source_chain_id', + ]) + .merge() + .returning('id') + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return ids.map((id) => id.id) + } + + public async getAll(): Promise { + const knex = await this.knex() + + const rows = await knex('oapp').select('*') + + return rows.map(toRecord) + } + + public async getBySourceChain(sourceChainId: ChainId): Promise { + const knex = await this.knex() + + const rows = await knex('oapp') + .select('*') + .where('source_chain_id', sourceChainId) + + return rows.map(toRecord) + } + + async deleteAll(): Promise { + const knex = await this.knex() + return knex('oapp').delete() + } +} + +function toRecord(row: OAppRow): OAppRecord { + return { + id: row.id, + name: row.name, + symbol: row.symbol, + protocolVersion: toProtocolVersion(row.protocol_version), + address: EthereumAddress(row.address), + sourceChainId: ChainId(row.source_chain_id), + iconUrl: row.icon_url, + } +} + +function toRow(record: Omit): Omit { + return { + name: record.name, + symbol: record.symbol, + protocol_version: record.protocolVersion, + address: record.address.toString(), + source_chain_id: Number(record.sourceChainId), + icon_url: record.iconUrl, + } +} diff --git a/packages/backend/src/peripherals/database/migrations/016_oapps_tracking.ts b/packages/backend/src/peripherals/database/migrations/016_oapps_tracking.ts new file mode 100644 index 00000000..7cc6a0e8 --- /dev/null +++ b/packages/backend/src/peripherals/database/migrations/016_oapps_tracking.ts @@ -0,0 +1,57 @@ +/* + ====== IMPORTANT NOTICE ====== + +DO NOT EDIT OR RENAME THIS FILE + +This is a migration file. Once created the file should not be renamed or edited, +because migrations are only run once on the production server. + +If you find that something was incorrectly set up in the `up` function you +should create a new migration file that fixes the issue. + +*/ + +import { Knex } from 'knex' + +export async function up(knex: Knex): Promise { + await knex.schema.createTable('oapp', (table) => { + table.increments('id').primary() + table.string('protocol_version').notNullable() + table.string('name').notNullable() + table.string('symbol').notNullable() + table.string('address').notNullable() + table.integer('source_chain_id').notNullable() + table.string('icon_url') + + table.unique([ + 'name', + 'symbol', + 'protocol_version', + 'address', + 'source_chain_id', + ]) + }) + + await knex.schema.createTable('oapp_configuration', (table) => { + table.integer('oapp_id').notNullable() + table.integer('target_chain_id').notNullable() + table.jsonb('configuration').notNullable() + + table.unique(['oapp_id', 'target_chain_id']) + }) + + await knex.schema.createTable('oapp_default_configuration', (table) => { + table.string('protocol_version').notNullable() + table.integer('source_chain_id').notNullable() + table.integer('target_chain_id').notNullable() + table.jsonb('configuration').notNullable() + + table.unique(['protocol_version', 'source_chain_id', 'target_chain_id']) + }) +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTable('oapp') + await knex.schema.dropTable('oapp_configuration') + await knex.schema.dropTable('oapp_default_configuration') +} diff --git a/packages/backend/src/peripherals/database/shared/types.ts b/packages/backend/src/peripherals/database/shared/types.ts index 311c3c57..b1fd16cf 100644 --- a/packages/backend/src/peripherals/database/shared/types.ts +++ b/packages/backend/src/peripherals/database/shared/types.ts @@ -59,6 +59,29 @@ declare module 'knex/types/tables' { operation: string } + interface OAppRow { + id: number + protocol_version: string + name: string + symbol: string + address: string + source_chain_id: number + icon_url?: string + } + + interface OAppConfigurationRow { + oapp_id: number + target_chain_id: number + configuration: string + } + + interface OAppDefaultConfigurationRow { + protocol_version: string + source_chain_id: number + target_chain_id: number + configuration: string + } + interface Tables { block_numbers: BlockNumberRow indexer_states: IndexerStateRow @@ -66,5 +89,10 @@ declare module 'knex/types/tables' { current_discovery: CurrentDiscoveryRow provider_cache: ProviderCacheRow events: EventRow + changelog: ChangelogRow + milestones: MilestoneRow + oapp: OAppRow + oapp_configuration: OAppConfigurationRow + oapp_default_configuration: OAppDefaultConfigurationRow } } diff --git a/packages/backend/src/tracking/TrackingModule.ts b/packages/backend/src/tracking/TrackingModule.ts new file mode 100644 index 00000000..4fdb9ea0 --- /dev/null +++ b/packages/backend/src/tracking/TrackingModule.ts @@ -0,0 +1,209 @@ +import { Logger } from '@l2beat/backend-tools' +import { + DiscoveryLogger, + DiscoveryProvider, + EtherscanLikeClient, + HttpClient, + MulticallClient, + MulticallConfig, +} from '@l2beat/discovery' +import { ChainId } from '@lz/libs' +import { providers } from 'ethers' + +import { Config } from '../config' +import { TrackingConfig } from '../config/Config' +import { ApplicationModule } from '../modules/ApplicationModule' +import { CurrentDiscoveryRepository } from '../peripherals/database/CurrentDiscoveryRepository' +import { OAppConfigurationRepository } from '../peripherals/database/OAppConfigurationRepository' +import { OAppDefaultConfigurationRepository } from '../peripherals/database/OAppDefaultConfigurationRepository' +import { OAppRepository } from '../peripherals/database/OAppRepository' +import { Database } from '../peripherals/database/shared/Database' +import { ProtocolVersion } from './domain/const' +import { ClockIndexer } from './domain/indexers/ClockIndexer' +import { DefaultConfigurationIndexer } from './domain/indexers/DefaultConfigurationIndexer' +import { OAppConfigurationIndexer } from './domain/indexers/OAppConfigurationIndexer' +import { OAppListIndexer } from './domain/indexers/OAppListIndexer' +import { DiscoveryDefaultConfigurationsProvider } from './domain/providers/DefaultConfigurationsProvider' +import { BlockchainOAppConfigurationProvider } from './domain/providers/OAppConfigurationProvider' +import { HttpOAppListProvider } from './domain/providers/OAppsListProvider' +import { TrackingController } from './http/TrackingController' +import { createTrackingRouter } from './http/TrackingRouter' + +export { createTrackingModule } + +interface Dependencies { + config: Config + database: Database + logger: Logger +} + +interface SubmoduleDependencies { + logger: Logger + config: TrackingConfig + repositories: { + currentDiscovery: CurrentDiscoveryRepository + oApp: OAppRepository + oAppConfiguration: OAppConfigurationRepository + oAppDefaultConfiguration: OAppDefaultConfigurationRepository + } +} + +function createTrackingModule(dependencies: Dependencies): ApplicationModule { + const availableChainConfigs = Object.keys( + dependencies.config.tracking, + ) as (keyof Config['tracking'])[] + const statusLogger = dependencies.logger.for('TrackingModule') + + const enabledChainsToTrack = availableChainConfigs.filter( + (chainName) => dependencies.config.tracking[chainName].enabled, + ) + + const currDiscoveryRepo = new CurrentDiscoveryRepository( + dependencies.database, + dependencies.logger, + ) + const oAppRepo = new OAppRepository( + dependencies.database, + dependencies.logger, + ) + const oAppConfigurationRepo = new OAppConfigurationRepository( + dependencies.database, + dependencies.logger, + ) + const oAppDefaultConfigurationRepo = new OAppDefaultConfigurationRepository( + dependencies.database, + dependencies.logger, + ) + + const controller = new TrackingController( + oAppRepo, + oAppConfigurationRepo, + oAppDefaultConfigurationRepo, + ) + + const router = createTrackingRouter(controller) + + const submodules = enabledChainsToTrack.flatMap((chainName) => { + const submoduleConfig = dependencies.config.tracking[chainName] + + if (!submoduleConfig.enabled) { + statusLogger.warn('Tracking submodule disabled', { chainName }) + return [] + } + + return createTrackingSubmodule( + { + logger: dependencies.logger, + config: submoduleConfig.config, + repositories: { + currentDiscovery: currDiscoveryRepo, + oApp: oAppRepo, + oAppConfiguration: oAppConfigurationRepo, + oAppDefaultConfiguration: oAppDefaultConfigurationRepo, + }, + }, + chainName, + ) + }) + + return { + start: async () => { + statusLogger.info('Starting tracking module') + + for (const submodule of submodules) { + await submodule.start?.() + } + + statusLogger.info('Tracking module started') + }, + + routers: [router], + } +} + +function createTrackingSubmodule( + { logger, config, repositories }: SubmoduleDependencies, + chain: keyof Config['tracking'], +): ApplicationModule { + const statusLogger = logger.for('TrackingSubmodule').tag(chain) + const chainId = ChainId.fromName(chain) + + const provider = new providers.StaticJsonRpcProvider(config.rpcUrl) + + const multicall = getMulticall(provider, config.multicall) + + const httpClient = new HttpClient() + + const oAppListProvider = new HttpOAppListProvider( + logger, + httpClient, + config.listApiUrl, + ) + + const defaultConfigurationsProvider = + new DiscoveryDefaultConfigurationsProvider( + repositories.currentDiscovery, + chainId, + logger, + ) + + const oAppConfigProvider = new BlockchainOAppConfigurationProvider( + provider, + multicall, + config.ulnV2Address, + chainId, + logger, + ) + + const clockIndexer = new ClockIndexer(logger, config.tickIntervalMs, chainId) + const oAppListIndexer = new OAppListIndexer( + logger, + chainId, + oAppListProvider, + repositories.oApp, + [clockIndexer], + ) + + const oAppConfigurationIndexer = new OAppConfigurationIndexer( + logger, + chainId, + oAppConfigProvider, + repositories.oApp, + repositories.oAppConfiguration, + [oAppListIndexer], + ) + + const defaultConfigurationIndexer = new DefaultConfigurationIndexer( + logger, + chainId, + ProtocolVersion.V1, + defaultConfigurationsProvider, + repositories.oAppDefaultConfiguration, + [oAppConfigurationIndexer], + ) + + return { + start: async () => { + statusLogger.info('Starting tracking submodule') + await clockIndexer.start() + await oAppListIndexer.start() + await oAppConfigurationIndexer.start() + await defaultConfigurationIndexer.start() + statusLogger.info('Tracking submodule started') + }, + } +} + +function getMulticall( + provider: providers.StaticJsonRpcProvider, + config: MulticallConfig, +): MulticallClient { + const dummyExplorer = {} as EtherscanLikeClient + const discoveryProvider = new DiscoveryProvider( + provider, + dummyExplorer, + DiscoveryLogger.SILENT, + ) + + return new MulticallClient(discoveryProvider, config) +} diff --git a/packages/backend/src/tracking/domain/configuration.ts b/packages/backend/src/tracking/domain/configuration.ts new file mode 100644 index 00000000..2ff28337 --- /dev/null +++ b/packages/backend/src/tracking/domain/configuration.ts @@ -0,0 +1,18 @@ +import { EthereumAddress, stringAs, SupportedChainId } from '@lz/libs' +import { z } from 'zod' + +export { OAppConfiguration } +export type { OAppConfigurations } + +const OAppConfiguration = z.object({ + oracle: stringAs(EthereumAddress), + relayer: stringAs(EthereumAddress), + inboundProofLibraryVersion: z.number(), + outboundProofType: z.number(), + outboundBlockConfirmations: z.number(), + inboundBlockConfirmations: z.number(), +}) + +type OAppConfiguration = z.infer + +type OAppConfigurations = Record diff --git a/packages/backend/src/tracking/domain/const.ts b/packages/backend/src/tracking/domain/const.ts new file mode 100644 index 00000000..c1d0da99 --- /dev/null +++ b/packages/backend/src/tracking/domain/const.ts @@ -0,0 +1,19 @@ +export { ProtocolVersion, toProtocolVersion } + +const ProtocolVersion = { + V1: 'V1', + V2: 'V2', +} as const + +type ProtocolVersion = (typeof ProtocolVersion)[keyof typeof ProtocolVersion] + +function toProtocolVersion(version: string): ProtocolVersion { + switch (version) { + case 'V1': + return ProtocolVersion.V1 + case 'V2': + return ProtocolVersion.V2 + default: + throw new Error(`Unknown protocol version ${version}`) + } +} diff --git a/packages/backend/src/tracking/domain/indexers/ClockIndexer.ts b/packages/backend/src/tracking/domain/indexers/ClockIndexer.ts new file mode 100644 index 00000000..50ee887d --- /dev/null +++ b/packages/backend/src/tracking/domain/indexers/ClockIndexer.ts @@ -0,0 +1,22 @@ +import { Logger } from '@l2beat/backend-tools' +import { RootIndexer } from '@l2beat/uif' +import { ChainId } from '@lz/libs' + +export class ClockIndexer extends RootIndexer { + constructor( + logger: Logger, + private readonly tickInterval: number, + chainId: ChainId, + ) { + super(logger.tag(ChainId.getName(chainId))) + } + override async start(): Promise { + await super.start() + this.requestTick() + setInterval(() => this.requestTick(), this.tickInterval) + } + + async tick(): Promise { + return Promise.resolve(Math.floor(Date.now() / 1000)) + } +} diff --git a/packages/backend/src/tracking/domain/indexers/DefaultConfigurationIndexer.ts b/packages/backend/src/tracking/domain/indexers/DefaultConfigurationIndexer.ts new file mode 100644 index 00000000..4a277c68 --- /dev/null +++ b/packages/backend/src/tracking/domain/indexers/DefaultConfigurationIndexer.ts @@ -0,0 +1,69 @@ +import { Logger } from '@l2beat/backend-tools' +import { ChildIndexer, Indexer } from '@l2beat/uif' +import { ChainId } from '@lz/libs' + +import { + OAppDefaultConfigurationRecord, + OAppDefaultConfigurationRepository, +} from '../../../peripherals/database/OAppDefaultConfigurationRepository' +import { OAppConfigurations } from '../configuration' +import { ProtocolVersion } from '../const' +import { DefaultConfigurationsProvider } from '../providers/DefaultConfigurationsProvider' + +export class DefaultConfigurationIndexer extends ChildIndexer { + protected height = 0 + constructor( + logger: Logger, + private readonly chainId: ChainId, + private readonly protocolVersion: ProtocolVersion, + private readonly defaultConfigProvider: DefaultConfigurationsProvider, + private readonly defaultConfigurationsRepo: OAppDefaultConfigurationRepository, + parents: Indexer[], + ) { + super(logger.tag(ChainId.getName(chainId)), parents) + } + + protected override async update(_from: number, to: number): Promise { + const defaultConfig = await this.defaultConfigProvider.getConfigurations() + + if (!defaultConfig) { + return to + } + + const records = configToRecords( + defaultConfig, + this.protocolVersion, + this.chainId, + ) + + await this.defaultConfigurationsRepo.addMany(records) + + return to + } + + public override getSafeHeight(): Promise { + return Promise.resolve(this.height) + } + + protected override setSafeHeight(height: number): Promise { + this.height = height + return Promise.resolve() + } + + protected override invalidate(targetHeight: number): Promise { + return Promise.resolve(targetHeight) + } +} + +function configToRecords( + configs: OAppConfigurations, + protocolVersion: ProtocolVersion, + sourceChainId: ChainId, +): OAppDefaultConfigurationRecord[] { + return Object.entries(configs).map(([chain, configuration]) => ({ + protocolVersion, + sourceChainId, + targetChainId: ChainId(+chain), + configuration, + })) +} diff --git a/packages/backend/src/tracking/domain/indexers/OAppConfigurationIndexer.ts b/packages/backend/src/tracking/domain/indexers/OAppConfigurationIndexer.ts new file mode 100644 index 00000000..331bbbfb --- /dev/null +++ b/packages/backend/src/tracking/domain/indexers/OAppConfigurationIndexer.ts @@ -0,0 +1,67 @@ +import { Logger } from '@l2beat/backend-tools' +import { ChildIndexer, Indexer } from '@l2beat/uif' +import { ChainId } from '@lz/libs' + +import { + OAppConfigurationRecord, + OAppConfigurationRepository, +} from '../../../peripherals/database/OAppConfigurationRepository' +import { OAppRepository } from '../../../peripherals/database/OAppRepository' +import { OAppConfigurations } from '../configuration' +import { OAppConfigurationProvider } from '../providers/OAppConfigurationProvider' + +export class OAppConfigurationIndexer extends ChildIndexer { + protected height = 0 + constructor( + logger: Logger, + private readonly chainId: ChainId, + private readonly oAppConfigProvider: OAppConfigurationProvider, + private readonly oAppRepo: OAppRepository, + private readonly oAppConfigurationRepo: OAppConfigurationRepository, + parents: Indexer[], + ) { + super(logger.tag(ChainId.getName(chainId)), parents) + } + + protected override async update(_from: number, to: number): Promise { + const oApps = await this.oAppRepo.getBySourceChain(this.chainId) + + const configurationRecords = await Promise.all( + oApps.map(async (oApp) => { + const oAppConfigs = await this.oAppConfigProvider.getConfiguration( + oApp.address, + ) + + return configToRecord(oAppConfigs, oApp.id) + }), + ) + + await this.oAppConfigurationRepo.addMany(configurationRecords.flat()) + + return to + } + + public override getSafeHeight(): Promise { + return Promise.resolve(this.height) + } + + protected override setSafeHeight(height: number): Promise { + this.height = height + return Promise.resolve() + } + + protected override invalidate(targetHeight: number): Promise { + return Promise.resolve(targetHeight) + } +} + +function configToRecord( + configs: OAppConfigurations, + oAppId: number, +): OAppConfigurationRecord[] { + return Object.entries(configs).map(([chain, configuration]) => ({ + targetChainId: ChainId(+chain), + configuration, + oAppId, + })) +} diff --git a/packages/backend/src/tracking/domain/indexers/OAppListIndexer.ts b/packages/backend/src/tracking/domain/indexers/OAppListIndexer.ts new file mode 100644 index 00000000..d2a96030 --- /dev/null +++ b/packages/backend/src/tracking/domain/indexers/OAppListIndexer.ts @@ -0,0 +1,52 @@ +import { Logger } from '@l2beat/backend-tools' +import { ChildIndexer, Indexer } from '@l2beat/uif' +import { ChainId } from '@lz/libs' + +import { OAppRepository } from '../../../peripherals/database/OAppRepository' +import { ProtocolVersion } from '../const' +import { OAppListProvider } from '../providers/OAppsListProvider' + +export class OAppListIndexer extends ChildIndexer { + protected height = 0 + constructor( + logger: Logger, + private readonly chainId: ChainId, + private readonly oAppListProvider: OAppListProvider, + private readonly oAppRepo: OAppRepository, + parents: Indexer[], + ) { + super(logger.tag(ChainId.getName(chainId)), parents) + } + + protected override async update(_from: number, to: number): Promise { + const oApps = await this.oAppListProvider.getOApps() + + this.logger.info(`Loaded V1 oApps to be checked`, { + amount: oApps.length, + }) + + await this.oAppRepo.addMany( + oApps.map((oApp) => ({ + ...oApp, + iconUrl: oApp.iconUrl ?? undefined, + protocolVersion: ProtocolVersion.V1, + sourceChainId: this.chainId, + })), + ) + + return to + } + + public override getSafeHeight(): Promise { + return Promise.resolve(this.height) + } + + protected override setSafeHeight(height: number): Promise { + this.height = height + return Promise.resolve() + } + + protected override invalidate(targetHeight: number): Promise { + return Promise.resolve(targetHeight) + } +} diff --git a/packages/backend/src/tracking/domain/providers/DefaultConfigurationsProvider.test.ts b/packages/backend/src/tracking/domain/providers/DefaultConfigurationsProvider.test.ts new file mode 100644 index 00000000..0263cedf --- /dev/null +++ b/packages/backend/src/tracking/domain/providers/DefaultConfigurationsProvider.test.ts @@ -0,0 +1,163 @@ +import { assert, Logger } from '@l2beat/backend-tools' +import { DiscoveryOutput } from '@l2beat/discovery-types' +import { ChainId } from '@lz/libs' +import { expect, mockFn, mockObject } from 'earl' + +import { CurrentDiscoveryRepository } from '../../../peripherals/database/CurrentDiscoveryRepository' +import { DiscoveryDefaultConfigurationsProvider } from './DefaultConfigurationsProvider' + +describe(DiscoveryDefaultConfigurationsProvider.name, () => { + it('returns null if no latest discovery was found', async () => { + const currDiscoveryRepo = mockObject({ + find: mockFn().resolvesTo(null), + }) + + const provider = new DiscoveryDefaultConfigurationsProvider( + currDiscoveryRepo, + ChainId.ETHEREUM, + Logger.SILENT, + ) + + const result = await provider.getConfigurations() + + expect(result).toEqual(null) + }) + + it('returns default configurations based on discovery output', async () => { + const currDiscoveryRepo = mockObject({ + find: mockFn().resolvesTo({ discoveryOutput: mockOutput }), + }) + + const provider = new DiscoveryDefaultConfigurationsProvider( + currDiscoveryRepo, + ChainId.ETHEREUM, + Logger.SILENT, + ) + + const result = await provider.getConfigurations() + + assert(result) + const keys = Object.keys(result) + + // Remapped chain ids + expect(keys).toEqual([ + '1', + '10', + '56', + '137', + '1101', + '8453', + '42161', + '42220', + '43114', + '59144', + ]) + + const configurationsPerChain = Object.values(result) + + for (const config of configurationsPerChain) { + expect(config.oracle).toBeTruthy() + expect(config.relayer).toBeTruthy() + expect(config.inboundProofLibraryVersion).toBeTruthy() + expect(config.outboundProofType).toBeTruthy() + expect(config.outboundBlockConfirmations).toBeTruthy() + expect(config.inboundBlockConfirmations).toBeTruthy() + } + }) +}) + +const mockOutput = { + contracts: [ + { + name: 'UltraLightNodeV2', + address: '0x4D73AdB72bC3DD368966edD0f0b2148401A178E2', + values: { + defaultAppConfig: { + '101': { + inboundProofLib: 2, + inboundBlockConfirm: 15, + outboundProofType: 2, + outboundBlockConfirm: 15, + relayer: '0x902F09715B6303d4173037652FA7377e5b98089E', + oracle: '0xD56e4eAb23cb81f43168F9F45211Eb027b9aC7cc', + }, + '102': { + inboundProofLib: 2, + inboundBlockConfirm: 20, + outboundProofType: 2, + outboundBlockConfirm: 15, + relayer: '0x902F09715B6303d4173037652FA7377e5b98089E', + oracle: '0xD56e4eAb23cb81f43168F9F45211Eb027b9aC7cc', + }, + '106': { + inboundProofLib: 2, + inboundBlockConfirm: 12, + outboundProofType: 2, + outboundBlockConfirm: 15, + relayer: '0x902F09715B6303d4173037652FA7377e5b98089E', + oracle: '0xD56e4eAb23cb81f43168F9F45211Eb027b9aC7cc', + }, + '109': { + inboundProofLib: 2, + inboundBlockConfirm: 512, + outboundProofType: 2, + outboundBlockConfirm: 15, + relayer: '0x902F09715B6303d4173037652FA7377e5b98089E', + oracle: '0xD56e4eAb23cb81f43168F9F45211Eb027b9aC7cc', + }, + '110': { + inboundProofLib: 2, + inboundBlockConfirm: 20, + outboundProofType: 2, + outboundBlockConfirm: 15, + relayer: '0x902F09715B6303d4173037652FA7377e5b98089E', + oracle: '0xD56e4eAb23cb81f43168F9F45211Eb027b9aC7cc', + }, + '111': { + inboundProofLib: 2, + inboundBlockConfirm: 20, + outboundProofType: 2, + outboundBlockConfirm: 15, + relayer: '0x902F09715B6303d4173037652FA7377e5b98089E', + oracle: '0xD56e4eAb23cb81f43168F9F45211Eb027b9aC7cc', + }, + '125': { + inboundProofLib: 2, + inboundBlockConfirm: 5, + outboundProofType: 2, + outboundBlockConfirm: 15, + relayer: '0x902F09715B6303d4173037652FA7377e5b98089E', + oracle: '0xD56e4eAb23cb81f43168F9F45211Eb027b9aC7cc', + }, + + '158': { + inboundProofLib: 2, + inboundBlockConfirm: 20, + outboundProofType: 2, + outboundBlockConfirm: 15, + relayer: '0x902F09715B6303d4173037652FA7377e5b98089E', + oracle: '0x5a54fe5234E811466D5366846283323c954310B2', + }, + + '183': { + inboundProofLib: 2, + inboundBlockConfirm: 10, + outboundProofType: 2, + outboundBlockConfirm: 15, + relayer: '0x902F09715B6303d4173037652FA7377e5b98089E', + oracle: '0xD56e4eAb23cb81f43168F9F45211Eb027b9aC7cc', + }, + '184': { + inboundProofLib: 2, + inboundBlockConfirm: 10, + outboundProofType: 2, + outboundBlockConfirm: 15, + relayer: '0x902F09715B6303d4173037652FA7377e5b98089E', + oracle: '0xD56e4eAb23cb81f43168F9F45211Eb027b9aC7cc', + }, + }, + }, + derivedName: 'UltraLightNodeV2', + }, + ], +} as unknown as DiscoveryOutput diff --git a/packages/backend/src/tracking/domain/providers/DefaultConfigurationsProvider.ts b/packages/backend/src/tracking/domain/providers/DefaultConfigurationsProvider.ts new file mode 100644 index 00000000..504460bb --- /dev/null +++ b/packages/backend/src/tracking/domain/providers/DefaultConfigurationsProvider.ts @@ -0,0 +1,98 @@ +import { Logger } from '@l2beat/backend-tools' +import { ChainId, EndpointID } from '@lz/libs' + +import { + getContractByName, + getContractValue, +} from '../../../api/controllers/discovery/utils' +import { LZ_CONTRACTS_NAMES } from '../../../config/constants' +import { CurrentDiscoveryRepository } from '../../../peripherals/database/CurrentDiscoveryRepository' +import { OAppConfigurations } from '../configuration' + +export type { DefaultConfigurationsProvider } +export { DiscoveryDefaultConfigurationsProvider } + +type RawDefaultConfigurations = Record< + number, + Partial> +> + +interface DefaultConfigurationsProvider { + getConfigurations(): Promise +} + +class DiscoveryDefaultConfigurationsProvider + implements DefaultConfigurationsProvider +{ + constructor( + private readonly currDiscoveryRepo: CurrentDiscoveryRepository, + private readonly chainId: ChainId, + private readonly logger: Logger, + ) { + this.logger = this.logger.for(this).tag(ChainId.getName(chainId)) + } + + async getConfigurations(): Promise { + const latestDiscovery = await this.currDiscoveryRepo.find(this.chainId) + + if (!latestDiscovery) { + this.logger.error( + `No discovery found for chain ${ChainId.getName(this.chainId)}`, + ) + return null + } + + const ulnV2 = getContractByName( + LZ_CONTRACTS_NAMES.V1.ULTRA_LIGHT_NODE_V2, + latestDiscovery.discoveryOutput, + ) + + const defaultAppConfig = getContractValue( + ulnV2, + 'defaultAppConfig', + ) + + return this.remapEndpointIds(defaultAppConfig) + } + + /** + * Remap endpoint ids to chain ids and strip unsupported chains + */ + private remapEndpointIds( + raw: RawDefaultConfigurations, + ): OAppConfigurations | null { + const supportedChainIds = ChainId.getAll() + + const mapped = Object.entries(raw).flatMap(([endpointId, config]) => { + const chainId = EndpointID.decodeV1(endpointId) + + // Flat map + if (!chainId || !supportedChainIds.includes(chainId)) { + return [] + } + + return [ + [ + chainId.valueOf(), + { + oracle: config.oracle, + relayer: config.relayer, + inboundProofLibraryVersion: config.inboundProofLib, + outboundProofType: config.outboundProofType, + outboundBlockConfirmations: config.outboundBlockConfirm, + inboundBlockConfirmations: config.inboundBlockConfirm, + }, + ], + ] as const + }) + + if (mapped.length !== supportedChainIds.length) { + this.logger.error( + `Not all chains have been remapped from EID. Expected ${supportedChainIds.length}, got ${mapped.length}`, + ) + return null + } + + return Object.fromEntries(mapped) as unknown as OAppConfigurations + } +} diff --git a/packages/backend/src/tracking/domain/providers/OAppConfigurationProvider.test.ts b/packages/backend/src/tracking/domain/providers/OAppConfigurationProvider.test.ts new file mode 100644 index 00000000..f83f4d14 --- /dev/null +++ b/packages/backend/src/tracking/domain/providers/OAppConfigurationProvider.test.ts @@ -0,0 +1,72 @@ +import { Logger } from '@l2beat/backend-tools' +import { MulticallClient } from '@l2beat/discovery' +// eslint-disable-next-line import/no-internal-modules +import { MulticallResponse } from '@l2beat/discovery/dist/discovery/provider/multicall/types' +// eslint-disable-next-line import/no-internal-modules +import { Bytes } from '@l2beat/discovery/dist/utils/Bytes' +import { ChainId, EthereumAddress } from '@lz/libs' +import { expect, mockFn, mockObject } from 'earl' +import { providers } from 'ethers' + +import { BlockchainOAppConfigurationProvider } from './OAppConfigurationProvider' + +// getAppConfig() +const mockBytesResponse = Bytes.fromHex( + '0000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000902f09715b6303d4173037652fa7377e5b98089e0000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000f000000000000000000000000d56e4eab23cb81f43168f9f45211eb027b9ac7cc', +) + +describe(BlockchainOAppConfigurationProvider.name, () => { + it('decodes latest configuration for given OApp', async () => { + const blockNumber = 123 + const oAppAddress = EthereumAddress.random() + const rpcProvider = mockObject({ + getBlockNumber: mockFn().resolvesTo(blockNumber), + }) + + const mcResponse: MulticallResponse[] = ChainId.getAll().map(() => ({ + success: true, + data: mockBytesResponse, + })) + + const multicall = mockObject({ + multicall: mockFn().resolvesTo(mcResponse), + }) + + const provider = new BlockchainOAppConfigurationProvider( + rpcProvider, + multicall, + EthereumAddress.random(), + ChainId.ETHEREUM, + Logger.SILENT, + ) + + const result = await provider.getConfiguration(oAppAddress) + + const keys = Object.keys(result) + + // Remapped chain ids + expect(keys).toEqual([ + '1', + '10', + '56', + '137', + '1101', + '8453', + '42161', + '42220', + '43114', + '59144', + ]) + + const configurationsPerChain = Object.values(result) + + for (const config of configurationsPerChain) { + expect(config.oracle).toBeTruthy() + expect(config.relayer).toBeTruthy() + expect(config.inboundProofLibraryVersion).toBeTruthy() + expect(config.outboundProofType).toBeTruthy() + expect(config.outboundBlockConfirmations).toBeTruthy() + expect(config.inboundBlockConfirmations).toBeTruthy() + } + }) +}) diff --git a/packages/backend/src/tracking/domain/providers/OAppConfigurationProvider.ts b/packages/backend/src/tracking/domain/providers/OAppConfigurationProvider.ts new file mode 100644 index 00000000..a4a88f97 --- /dev/null +++ b/packages/backend/src/tracking/domain/providers/OAppConfigurationProvider.ts @@ -0,0 +1,111 @@ +import { assert, Logger } from '@l2beat/backend-tools' +import { MulticallClient } from '@l2beat/discovery' +import { + MulticallRequest, + MulticallResponse, + // eslint-disable-next-line import/no-internal-modules +} from '@l2beat/discovery/dist/discovery/provider/multicall/types' +// eslint-disable-next-line import/no-internal-modules +import { Bytes } from '@l2beat/discovery/dist/utils/Bytes' +import { ChainId, EndpointID, EthereumAddress } from '@lz/libs' +import { BigNumber, providers, utils } from 'ethers' + +import { OAppConfiguration, OAppConfigurations } from '../configuration' + +export { BlockchainOAppConfigurationProvider } +export type { OAppConfigurationProvider } + +interface OAppConfigurationProvider { + getConfiguration(address: EthereumAddress): Promise +} + +const iface = new utils.Interface([ + 'function getAppConfig(uint16 _remoteChainId, address _ua) view returns (tuple(uint16 inboundProofLibraryVersion, uint64 inboundBlockConfirmations, address relayer, uint16 outboundProofType, uint64 outboundBlockConfirmations, address oracle))', +]) + +class BlockchainOAppConfigurationProvider implements OAppConfigurationProvider { + constructor( + private readonly provider: providers.StaticJsonRpcProvider, + private readonly multicall: MulticallClient, + private readonly ulnV2Address: EthereumAddress, + chainId: ChainId, + private readonly logger: Logger, + ) { + this.logger = this.logger.for(this).tag(ChainId.getName(chainId)) + } + public async getConfiguration( + address: EthereumAddress, + ): Promise { + const blockNumber = await this.provider.getBlockNumber() + + const supportedChains = ChainId.getAll() + + const supportedEids = supportedChains.flatMap( + (chainId) => EndpointID.encodeV1(chainId) ?? [], + ) + + assert( + supportedEids.length === supportedChains.length, + 'Cannot translate some chains to EID', + ) + + const requests = supportedEids.map((eid) => + this.encodeForMulticall(address, eid), + ) + + const result = await this.multicall.multicall(requests, blockNumber) + + const decoded = result.map((res, i) => { + const eid = supportedEids[i] + assert(eid !== undefined) + + const chainId = EndpointID.decodeV1(eid) + + assert(chainId !== undefined, 'Cannot translate EID to chain id') + + const config = this.decodeFromMulticall(res) + + return [chainId.valueOf(), config] as const + }) + + return Object.fromEntries(decoded) as OAppConfigurations + } + + private encodeForMulticall( + oAppAddress: EthereumAddress, + eid: number, + ): MulticallRequest { + { + const data = iface.encodeFunctionData('getAppConfig', [eid, oAppAddress]) + + return { + address: this.ulnV2Address, + data: Bytes.fromHex(data), + } + } + } + + private decodeFromMulticall(response: MulticallResponse): OAppConfiguration { + const [decoded] = iface.decodeFunctionResult( + 'getAppConfig', + response.data.toString(), + ) + + const toParse: unknown = { + /* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access */ + oracle: decoded.oracle, + relayer: decoded.relayer, + inboundProofLibraryVersion: decoded.inboundProofLibraryVersion, + outboundProofType: decoded.outboundProofType, + inboundBlockConfirmations: BigNumber.from( + decoded.inboundBlockConfirmations, + ).toNumber(), + outboundBlockConfirmations: BigNumber.from( + decoded.outboundBlockConfirmations, + ).toNumber(), + /* eslint-enable */ + } + + return OAppConfiguration.parse(toParse) + } +} diff --git a/packages/backend/src/tracking/domain/providers/OAppsListProvider.test.ts b/packages/backend/src/tracking/domain/providers/OAppsListProvider.test.ts new file mode 100644 index 00000000..2fb1f585 --- /dev/null +++ b/packages/backend/src/tracking/domain/providers/OAppsListProvider.test.ts @@ -0,0 +1,97 @@ +import { Logger } from '@l2beat/backend-tools' +import { HttpClient } from '@l2beat/discovery' +import { EthereumAddress } from '@lz/libs' +import { expect, mockFn, mockObject } from 'earl' + +import { HttpOAppListProvider } from './OAppsListProvider' + +describe(HttpOAppListProvider.name, () => { + describe(HttpOAppListProvider.prototype.getOApps.name, () => { + it('rejects when response is not ok', async () => { + const logger = mockObject({ + error: mockFn(() => {}), + }) + const client = mockObject({ + fetch: mockFn().resolvesTo({ + ok: false, + }), + }) + + const provider = new HttpOAppListProvider( + logger, + client, + 'http://example.com', + ) + + await expect(async () => provider.getOApps()).toBeRejectedWith( + 'Failed to fetch OApps from given url', + ) + }) + + it('rejects if response shape is incorrect', async () => { + const mockResponse = [ + { + namea: 'name', + address: '0x00000000000000000000000000000000000dead', + iconUrl: 'iconUrl', + incorrectField: 'incorrectField', + }, + ] + + const logger = mockObject({ + error: mockFn(() => {}), + }) + const client = mockObject({ + fetch: mockFn().resolvesTo({ + ok: true, + json: mockFn().resolvesTo(mockResponse), + }), + }) + + const provider = new HttpOAppListProvider( + logger, + client, + 'http://example.com', + ) + + await expect(async () => provider.getOApps()).toBeRejected() + }) + + it('fetches and parses response', async () => { + const mockResponse = [ + { + name: 'name1', + symbol: 'symbol1', + address: EthereumAddress.random(), + iconUrl: 'iconUrl1', + }, + { + name: 'name2', + symbol: 'symbol2', + address: EthereumAddress.random(), + iconUrl: null, + }, + ] + + const logger = mockObject({ + error: mockFn(() => {}), + }) + const client = mockObject({ + fetch: mockFn().resolvesTo({ + ok: true, + json: mockFn().resolvesTo(mockResponse), + }), + }) + + const provider = new HttpOAppListProvider( + logger, + client, + 'http://example.com', + ) + + const result = await provider.getOApps() + + expect(result).toEqual(mockResponse) + }) + }) +}) diff --git a/packages/backend/src/tracking/domain/providers/OAppsListProvider.ts b/packages/backend/src/tracking/domain/providers/OAppsListProvider.ts new file mode 100644 index 00000000..0128bbc0 --- /dev/null +++ b/packages/backend/src/tracking/domain/providers/OAppsListProvider.ts @@ -0,0 +1,43 @@ +import { assert, Logger } from '@l2beat/backend-tools' +import { HttpClient } from '@l2beat/discovery' +import { EthereumAddress, stringAs } from '@lz/libs' +import { z } from 'zod' + +export { HttpOAppListProvider, OAppDto, OAppListDto } +export type { OAppListProvider } + +const OAppDto = z.object({ + name: z.string(), + symbol: z.string(), + address: stringAs(EthereumAddress), + iconUrl: z.string().nullable(), +}) + +type OAppDto = z.infer + +const OAppListDto = z.array(OAppDto) +type OAppListDto = z.infer + +interface OAppListProvider { + getOApps(): Promise +} + +class HttpOAppListProvider implements OAppListProvider { + constructor( + private readonly logger: Logger, + private readonly client: HttpClient, + private readonly url: string, + ) {} + async getOApps(): Promise { + try { + const result = await this.client.fetch(this.url) + + assert(result.ok, 'Failed to fetch OApps from given url') + + return OAppListDto.parse(await result.json()) + } catch (e) { + this.logger.error('Failed to fetch and parse OApps', e) + throw e + } + } +} diff --git a/packages/backend/src/tracking/http/TrackingController.test.ts b/packages/backend/src/tracking/http/TrackingController.test.ts new file mode 100644 index 00000000..7062df2f --- /dev/null +++ b/packages/backend/src/tracking/http/TrackingController.test.ts @@ -0,0 +1,205 @@ +import { assert } from '@l2beat/backend-tools' +import { ChainId, EthereumAddress } from '@lz/libs' +import { expect, mockObject } from 'earl' + +import { + OAppConfigurationRecord, + OAppConfigurationRepository, +} from '../../peripherals/database/OAppConfigurationRepository' +import { + OAppDefaultConfigurationRecord, + OAppDefaultConfigurationRepository, +} from '../../peripherals/database/OAppDefaultConfigurationRepository' +import { + OAppRecord, + OAppRepository, +} from '../../peripherals/database/OAppRepository' +import { OAppConfiguration } from '../domain/configuration' +import { ProtocolVersion } from '../domain/const' +import { TrackingController } from './TrackingController' + +describe(TrackingController.name, () => { + describe('getOApps', () => { + it('returns null when there are no configurations for the chain ID', async () => { + const chainId = ChainId.ETHEREUM + const oAppRepo = mockObject({}) + const oAppConfigRepo = mockObject({}) + const oAppDefaultConfigRepo = + mockObject({ + getBySourceChain: () => Promise.resolve([]), + }) + + const controller = new TrackingController( + oAppRepo, + oAppConfigRepo, + oAppDefaultConfigRepo, + ) + const result = await controller.getOApps(chainId) + + expect(result).toEqual(null) + }) + + describe('with oApps and default configurations', () => { + it('returns oApps with configurations marking if given configuration is a default one', async () => { + const chainId = ChainId.ETHEREUM + + const defaultConfiguration: OAppConfiguration = { + inboundBlockConfirmations: 1, + outboundBlockConfirmations: 1, + outboundProofType: 2, + inboundProofLibraryVersion: 2, + relayer: EthereumAddress( + '0x0000000000000000000000000000000000000001', + ), + oracle: EthereumAddress('0x0000000000000000000000000000000000000002'), + } + + const customConfiguration = { + ...defaultConfiguration, + relayer: EthereumAddress( + '0x0000000000000000000000000000000000000003', + ), + oracle: EthereumAddress('0x0000000000000000000000000000000000000004'), + } + const oAppA: OAppRecord = { + id: 1, + name: 'App 1', + symbol: 'APP1', + protocolVersion: ProtocolVersion.V1, + address: EthereumAddress( + '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + ), + sourceChainId: chainId, + } + + const oAppB: OAppRecord = { + id: 2, + name: 'App 2', + symbol: 'APP2', + protocolVersion: ProtocolVersion.V1, + address: EthereumAddress( + '0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB', + ), + sourceChainId: chainId, + } + + const mockConfigurations: OAppConfigurationRecord[] = [ + { + oAppId: oAppA.id, + targetChainId: ChainId.ETHEREUM, + configuration: defaultConfiguration, + }, + { + oAppId: oAppA.id, + targetChainId: ChainId.BSC, + configuration: customConfiguration, + }, + { + oAppId: oAppB.id, + targetChainId: ChainId.ETHEREUM, + configuration: customConfiguration, + }, + { + oAppId: oAppB.id, + targetChainId: ChainId.OPTIMISM, + configuration: defaultConfiguration, + }, + ] + + const mockDefaultConfigurations: OAppDefaultConfigurationRecord[] = [ + { + sourceChainId: chainId, + targetChainId: ChainId.ETHEREUM, + protocolVersion: ProtocolVersion.V1, + configuration: defaultConfiguration, + }, + { + sourceChainId: chainId, + targetChainId: ChainId.OPTIMISM, + protocolVersion: ProtocolVersion.V1, + configuration: defaultConfiguration, + }, + { + sourceChainId: chainId, + targetChainId: ChainId.BSC, + protocolVersion: ProtocolVersion.V1, + configuration: defaultConfiguration, + }, + ] + + const oAppRepo = mockObject({ + getBySourceChain: () => Promise.resolve([oAppA, oAppB]), + }) + + const oAppConfigRepo = mockObject({ + findByOAppIds: () => Promise.resolve(mockConfigurations), + }) + const oAppDefaultConfigRepo = + mockObject({ + getBySourceChain: () => Promise.resolve(mockDefaultConfigurations), + }) + + const controller = new TrackingController( + oAppRepo, + oAppConfigRepo, + oAppDefaultConfigRepo, + ) + + const result = await controller.getOApps(chainId) + + assert(result) + expect(result.sourceChainId).toEqual(chainId) + expect(result.oApps).toEqual([ + { + name: oAppA.name, + symbol: oAppA.symbol, + address: oAppA.address, + iconUrl: null, + // reflects mockConfigurations + configurations: [ + { + targetChainId: ChainId.ETHEREUM, + isDefault: true, + }, + { + targetChainId: ChainId.BSC, + changedConfiguration: { + relayer: customConfiguration.relayer, + oracle: customConfiguration.oracle, + }, + isDefault: false, + }, + ], + }, + { + name: oAppB.name, + symbol: oAppB.symbol, + address: oAppB.address, + iconUrl: null, + // reflects mockConfigurations + configurations: [ + { + targetChainId: ChainId.ETHEREUM, + changedConfiguration: { + oracle: customConfiguration.oracle, + relayer: customConfiguration.relayer, + }, + isDefault: false, + }, + { + targetChainId: ChainId.OPTIMISM, + isDefault: true, + }, + ], + }, + ]) + expect(result.defaultConfigurations).toEqual( + mockDefaultConfigurations.map((c) => ({ + targetChainId: c.targetChainId, + configuration: c.configuration, + })), + ) + }) + }) + }) +}) diff --git a/packages/backend/src/tracking/http/TrackingController.ts b/packages/backend/src/tracking/http/TrackingController.ts new file mode 100644 index 00000000..e7cf63d8 --- /dev/null +++ b/packages/backend/src/tracking/http/TrackingController.ts @@ -0,0 +1,134 @@ +import { assert } from '@l2beat/backend-tools' +import { + ChainId, + OAppsResponse, + OAppWithConfigs, + ResolvedConfigurationWithAppId, +} from '@lz/libs' + +import { + OAppConfigurationRecord, + OAppConfigurationRepository, +} from '../../peripherals/database/OAppConfigurationRepository' +import { + OAppDefaultConfigurationRecord, + OAppDefaultConfigurationRepository, +} from '../../peripherals/database/OAppDefaultConfigurationRepository' +import { + OAppRecord, + OAppRepository, +} from '../../peripherals/database/OAppRepository' +import { OAppConfiguration } from '../domain/configuration' + +export { TrackingController } + +class TrackingController { + constructor( + private readonly oAppRepo: OAppRepository, + private readonly oAppConfigurationRepo: OAppConfigurationRepository, + private readonly oAppDefaultConfigRepo: OAppDefaultConfigurationRepository, + ) {} + + async getOApps(chainId: ChainId): Promise { + const defaultConfigurations = + await this.oAppDefaultConfigRepo.getBySourceChain(chainId) + + if (defaultConfigurations.length === 0) { + return null + } + + const oApps = await this.oAppRepo.getBySourceChain(chainId) + + if (oApps.length === 0) { + return null + } + + const configurations = await this.oAppConfigurationRepo.findByOAppIds( + oApps.map((o) => o.id), + ) + + const resolvesConfigurations = resolveConfigurationChanges( + configurations, + defaultConfigurations, + ) + + const oAppsWithConfigs = attachConfigurations(oApps, resolvesConfigurations) + + return { + sourceChainId: chainId, + oApps: oAppsWithConfigs, + + defaultConfigurations: defaultConfigurations.map((record) => ({ + targetChainId: record.targetChainId, + configuration: record.configuration, + })), + } + } +} + +function attachConfigurations( + oApps: OAppRecord[], + configurations: ResolvedConfigurationWithAppId[], +): OAppWithConfigs[] { + return oApps.map((oApp) => { + const configs = configurations.filter((config) => config.oAppId === oApp.id) + + const configsWithoutId = configs.map(({ oAppId: _, ...rest }) => rest) + + return { + name: oApp.name, + symbol: oApp.symbol, + address: oApp.address, + iconUrl: oApp.iconUrl ? oApp.iconUrl : null, + configurations: configsWithoutId, + } + }) +} + +function resolveConfigurationChanges( + configurations: OAppConfigurationRecord[], + defaults: OAppDefaultConfigurationRecord[], +): ResolvedConfigurationWithAppId[] { + return configurations.map((configuration) => { + const dCfg = defaults.find( + (d) => d.targetChainId === configuration.targetChainId, + ) + + assert( + dCfg, + `no default configuration found for target chain: ${configuration.targetChainId.valueOf()}`, + ) + + const keys = Object.keys(dCfg.configuration) as (keyof OAppConfiguration)[] + + const diffKeys = keys.flatMap((key) => { + if (dCfg.configuration[key] !== configuration.configuration[key]) { + return key + } + return [] + }) + + if (diffKeys.length === 0) { + const result = { + oAppId: configuration.oAppId, + targetChainId: configuration.targetChainId, + isDefault: true, + } as const + + return result + } + + return { + oAppId: configuration.oAppId, + targetChainId: configuration.targetChainId, + isDefault: false, + changedConfiguration: diffKeys.reduce>( + (acc, key) => ({ + ...acc, + [key]: configuration.configuration[key], + }), + {}, + ), + } as const + }) +} diff --git a/packages/backend/src/tracking/http/TrackingRouter.ts b/packages/backend/src/tracking/http/TrackingRouter.ts new file mode 100644 index 00000000..b66ee2d0 --- /dev/null +++ b/packages/backend/src/tracking/http/TrackingRouter.ts @@ -0,0 +1,33 @@ +import Router from '@koa/router' +import { ChainId, stringAs } from '@lz/libs' +import { z } from 'zod' + +import { withTypedContext } from '../../api/routes/typedContext' +import { TrackingController } from './TrackingController' + +export { createTrackingRouter } + +function createTrackingRouter(trackingController: TrackingController): Router { + const router = new Router() + + router.get( + '/tracking/:chainId', + withTypedContext( + z.object({ + params: z.object({ chainId: stringAs((s) => ChainId.fromName(s)) }), + }), + async (ctx): Promise => { + const data = await trackingController.getOApps(ctx.params.chainId) + + if (!data) { + ctx.status = 404 + return + } + + ctx.body = data + }, + ), + ) + + return router +} diff --git a/packages/libs/src/apis/TrackingApi.ts b/packages/libs/src/apis/TrackingApi.ts new file mode 100644 index 00000000..f9a9529f --- /dev/null +++ b/packages/libs/src/apis/TrackingApi.ts @@ -0,0 +1,79 @@ +import { z } from 'zod' + +import { ChainId } from '../chainId' +import { branded, EthereumAddress } from '../utils' + +export { + OAppsResponse, + OAppWithConfigs, + ResolvedConfiguration, + ResolvedConfigurationWithAppId, +} + +const OAppConfiguration = z.object({ + relayer: branded(z.string(), EthereumAddress), + oracle: branded(z.string(), EthereumAddress), + inboundProofLibraryVersion: z.number(), + outboundProofType: z.number(), + outboundBlockConfirmations: z.number(), + inboundBlockConfirmations: z.number(), +}) +type OAppConfiguration = z.infer + +const BaseConfiguration = z.object({ + targetChainId: branded(z.number(), ChainId), + isDefault: z.boolean(), +}) +type BaseConfiguration = z.infer + +const DefaultConfiguration = BaseConfiguration.extend({ + isDefault: z.literal(true), +}) +type DefaultConfiguration = z.infer + +const ChangedConfiguration = BaseConfiguration.extend({ + isDefault: z.literal(false), + changedConfiguration: OAppConfiguration.partial(), +}) +type ChangedConfiguration = z.infer + +const ResolvedConfiguration = z.discriminatedUnion('isDefault', [ + DefaultConfiguration, + ChangedConfiguration, +]) +type ResolvedConfiguration = z.infer + +const ResolvedConfigurationWithAppId = z.discriminatedUnion('isDefault', [ + DefaultConfiguration.extend({ + oAppId: z.number(), + }), + ChangedConfiguration.extend({ + oAppId: z.number(), + }), +]) +type ResolvedConfigurationWithAppId = z.infer< + typeof ResolvedConfigurationWithAppId +> + +const OAppWithConfigs = z.object({ + name: z.string(), + symbol: z.string(), + address: branded(z.string(), EthereumAddress), + iconUrl: z.string().nullable(), + configurations: z.array(ResolvedConfiguration), +}) + +type OAppWithConfigs = z.infer + +const OAppsResponse = z.object({ + sourceChainId: branded(z.number(), ChainId), + oApps: z.array(OAppWithConfigs), + defaultConfigurations: z.array( + z.object({ + targetChainId: branded(z.number(), ChainId), + configuration: OAppConfiguration, + }), + ), +}) + +type OAppsResponse = z.infer diff --git a/packages/libs/src/apis/index.ts b/packages/libs/src/apis/index.ts index 0973c5ef..65500cca 100644 --- a/packages/libs/src/apis/index.ts +++ b/packages/libs/src/apis/index.ts @@ -2,3 +2,4 @@ export * from './ChangelogApi' export * from './ConfigApi' export * from './DiscoveryApi' export * from './StatusApi' +export * from './TrackingApi' diff --git a/packages/libs/src/chainId/ChainId.ts b/packages/libs/src/chainId/ChainId.ts index 580e97bd..959845eb 100644 --- a/packages/libs/src/chainId/ChainId.ts +++ b/packages/libs/src/chainId/ChainId.ts @@ -61,7 +61,6 @@ const CHAIN_IDS = { 59144: 'linea', 8453: 'base', 1101: 'polygon-zkevm', - 100: 'gnosis', } as const export type SupportedChainId = keyof typeof CHAIN_IDS @@ -77,4 +76,3 @@ ChainId.CELO = chainIdFromName('celo') ChainId.LINEA = chainIdFromName('linea') ChainId.BASE = chainIdFromName('base') ChainId.POLYGON_ZKEVM = chainIdFromName('polygon-zkevm') -ChainId.GNOSIS = chainIdFromName('gnosis') diff --git a/packages/libs/src/chainId/blockExplorerUrls.ts b/packages/libs/src/chainId/blockExplorerUrls.ts index f541e6e2..f32dae73 100644 --- a/packages/libs/src/chainId/blockExplorerUrls.ts +++ b/packages/libs/src/chainId/blockExplorerUrls.ts @@ -41,7 +41,6 @@ export const BLOCK_EXPLORER_URLS: Record = { celo: 'https://celoscan.io/', linea: 'https://lineascan.build/', base: 'https://basescan.org/', - gnosis: 'https://gnosisscan.io/', } export function getBlockExplorerName(chainId: ChainId): string { @@ -59,5 +58,4 @@ const BLOCK_EXPLORER_NAMES: Record = { celo: 'CeloScan', linea: 'LineaScan', base: 'BaseScan', - gnosis: 'GnosisScan', } diff --git a/yarn.lock b/yarn.lock index 29197518..a931f694 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4434,7 +4434,7 @@ node-addon-api@^4.2.0: resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-4.3.0.tgz#52a1a0b475193e0928e98e0426a0d1254782b77f" integrity sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ== -node-fetch@^2.6.12, node-fetch@^2.6.6, node-fetch@^2.6.7: +node-fetch@2, node-fetch@^2.6.12, node-fetch@^2.6.6, node-fetch@^2.6.7: version "2.7.0" resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.7.0.tgz#d0f0fa6e3e2dc1d27efcd8ad99d550bda94d187d" integrity sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==