diff --git a/apps/contract-verification/.env.example b/apps/contract-verification/.env.example index 2bccfd436..f0bd18199 100644 --- a/apps/contract-verification/.env.example +++ b/apps/contract-verification/.env.example @@ -9,3 +9,7 @@ CLOUDFLARE_D1_TOKEN="" VITE_LOG_LEVEL="debug" VITE_BASE_URL="http://localhost:6969" + +# Dynamic chain registry (optional) +# CHAINS_CONFIG_URL="https://example.com/chains" +# CHAINS_CONFIG_AUTH_TOKEN is a Cloudflare Secrets Store binding (see wrangler.json secrets_store_secrets) diff --git a/apps/contract-verification/env.d.ts b/apps/contract-verification/env.d.ts index 9ca5c6eb1..cf70cdd0a 100644 --- a/apps/contract-verification/env.d.ts +++ b/apps/contract-verification/env.d.ts @@ -19,6 +19,11 @@ interface EnvironmentVariables { readonly CLOUDFLARE_DATABASE_ID: string readonly CLOUDFLARE_D1_TOKEN: string readonly CLOUDFLARE_D1_ENVIRONMENT: 'local' | (string & {}) + + /** URL to fetch dynamic chain configs from (optional). */ + readonly CHAINS_CONFIG_URL?: string + /** Bearer token for authenticating with the chain config endpoint (optional). */ + readonly CHAINS_CONFIG_AUTH_TOKEN?: string } // Node.js `process.env` auto-completion diff --git a/apps/contract-verification/src/index.tsx b/apps/contract-verification/src/index.tsx index 8794f6456..ca2095e1c 100644 --- a/apps/contract-verification/src/index.tsx +++ b/apps/contract-verification/src/index.tsx @@ -13,9 +13,14 @@ import { contextStorage } from 'hono/context-storage' import { docsRoute } from '#route.docs.tsx' import { verifyRoute } from '#route.verify.ts' -import { sourcifyChains } from '#wagmi.config.ts' +import { staticChains } from '#wagmi.config.ts' import { VerificationContainer } from '#container.ts' import { legacyVerifyRoute } from '#route.verify-legacy.ts' +import { + type ChainRegistry, + chainRegistry, + resolveAuthToken, +} from '#lib/chain-registry.ts' import { configureLogger, getLogger, withContext } from '#lib/logger.ts' import { lookupAllChainContractsRoute, lookupRoute } from '#route.lookup.ts' import { handleError, originMatches, sourcifyError } from '#lib/utilities.ts' @@ -41,7 +46,11 @@ function isWhitelistedOrigin(origin: string | undefined) { ) } -type AppEnv = { Bindings: Cloudflare.Env } +export type AppEnv = { + Bindings: Cloudflare.Env + Variables: { chainRegistry: ChainRegistry } +} + const factory = createFactory() export const app = factory.createApp() @@ -65,6 +74,18 @@ app.use(async (context, next) => { next, ) }) + +// Chain registry middleware -- fetches dynamic chains if CHAINS_CONFIG_URL is set +app.use( + chainRegistry({ + staticChains, + url: env.CHAINS_CONFIG_URL || undefined, + authToken: await resolveAuthToken( + env.CHAINS_CONFIG_AUTH_TOKEN_SECRET || env.CHAINS_CONFIG_AUTH_TOKEN, + ), + }), +) + app.use( cors({ allowMethods: ['GET', 'POST', 'OPTIONS', 'HEAD'], @@ -128,8 +149,9 @@ app.get('/favicon.ico', (context) => app .get('/health', (context) => context.text('ok')) .get('/', (context) => context.redirect('/docs')) - // TODO: match sourcify `https://sourcify.dev/server/chains` response schema - .get('/chains', (context) => context.json(sourcifyChains)) + .get('/chains', (context) => + context.json(context.get('chainRegistry').getSourcifyChains()), + ) .get('/version', async (context) => context.json({ version: packageJSON.version, diff --git a/apps/contract-verification/src/lib/chain-registry.ts b/apps/contract-verification/src/lib/chain-registry.ts new file mode 100644 index 000000000..790176fb2 --- /dev/null +++ b/apps/contract-verification/src/lib/chain-registry.ts @@ -0,0 +1,267 @@ +import * as z from 'zod/mini' +import type { Chain } from 'viem' +import { defineChain } from 'viem' +import { createMiddleware } from 'hono/factory' + +import { getLogger } from '#lib/logger.ts' + +const logger = getLogger(['tempo', 'chain-registry']) + +// --------------------------------------------------------------------------- +// Zod schema -- chainlist.org-compatible, most fields optional +// --------------------------------------------------------------------------- + +const zExplorer = z.object({ + name: z.string(), + url: z.string(), + standard: z.optional(z.string()), +}) + +const zNativeCurrency = z.object({ + name: z.string(), + symbol: z.string(), + decimals: z.number(), +}) + +const zChainEntry = z.object({ + chainId: z.number(), + rpc: z.array(z.string()).check(z.minLength(1)), + hidden: z.optional(z.boolean()), + name: z.optional(z.string()), + chain: z.optional(z.string()), + shortName: z.optional(z.string()), + infoURL: z.optional(z.string()), + nativeCurrency: z.optional(zNativeCurrency), + explorers: z.optional(z.array(zExplorer)), +}) + +const zChainsResponse = z.record(z.string(), zChainEntry) + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +type ChainEntry = { + chain: Chain + hidden: boolean +} + +export const zSourcifyChain = z.object({ + name: z.string(), + title: z.optional(z.string()), + chainId: z.number(), + rpc: z.array(z.string()), + traceSupportedRPCs: z.array( + z.object({ + type: z.optional(z.string()), + index: z.optional(z.number()), + }), + ), + supported: z.boolean(), + etherscanAPI: z.boolean(), +}) + +export type SourcifyChain = z.infer + +// --------------------------------------------------------------------------- +// ChainRegistry +// --------------------------------------------------------------------------- + +export class ChainRegistry { + private readonly entries: Map + + constructor(entries: Map) { + this.entries = entries + } + + /** Build a registry from static chains only (no external fetch). */ + static fromStatic(staticChains: readonly Chain[]): ChainRegistry { + const entries = new Map() + for (const chain of staticChains) { + entries.set(chain.id, { chain, hidden: false }) + } + return new ChainRegistry(entries) + } + + /** Fetch chain configs from an external URL and merge with static chains. */ + static async fromUrl(options: { + url: string + authToken?: string | undefined + staticChains: readonly Chain[] + }): Promise { + const registry = ChainRegistry.fromStatic(options.staticChains) + + try { + const headers = new Headers({ Accept: 'application/json' }) + if (options.authToken) { + headers.set('Authorization', `Bearer ${options.authToken}`) + } + + const response = await fetch(options.url, { headers }) + + if (!response.ok) { + logger.warn('chain_registry_fetch_failed', { + status: response.status, + url: options.url, + }) + return registry + } + + const json = await response.json() + const parsed = zChainsResponse.safeParse(json) + + if (!parsed.success) { + logger.warn('chain_registry_parse_failed', { + error: parsed.error, + url: options.url, + }) + return registry + } + + for (const [key, entry] of Object.entries(parsed.data)) { + const chainId = entry.chainId + + // static chains always take precedence + if (registry.entries.has(chainId)) { + logger.debug('chain_registry_skip_static', { + chainId, + key, + }) + continue + } + + const httpUrls = entry.rpc.filter( + (url) => url.startsWith('http://') || url.startsWith('https://'), + ) + if (httpUrls.length === 0) { + logger.warn('chain_registry_no_http_rpc', { chainId, key }) + continue + } + + const defaultExplorer = entry.explorers?.[0] + + const chain = defineChain({ + id: chainId, + name: entry.name ?? `Chain ${chainId}`, + nativeCurrency: entry.nativeCurrency ?? { + name: 'Ether', + symbol: 'ETH', + decimals: 18, + }, + rpcUrls: { + default: { + http: httpUrls as [string, ...string[]], + }, + }, + ...(defaultExplorer + ? { + blockExplorers: { + default: { + name: defaultExplorer.name, + url: defaultExplorer.url, + }, + }, + } + : {}), + }) + + registry.entries.set(chainId, { + chain, + hidden: entry.hidden ?? false, + }) + } + + logger.info('chain_registry_loaded', { + total: registry.entries.size, + dynamic: registry.entries.size - options.staticChains.length, + url: options.url, + }) + } catch (error) { + logger.warn('chain_registry_error', { + error: error instanceof Error ? error.message : String(error), + url: options.url, + }) + } + + return registry + } + + /** Returns the viem Chain for a given ID, regardless of hidden flag. */ + getChain(chainId: number): Chain | undefined { + return this.entries.get(chainId)?.chain + } + + /** Whether a chain ID exists in the registry, regardless of hidden flag. */ + isSupported(chainId: number): boolean { + return this.entries.has(chainId) + } + + /** Whether a chain ID is marked as hidden. Returns false for unknown chains. */ + isHidden(chainId: number): boolean { + return this.entries.get(chainId)?.hidden ?? false + } + + /** Returns all non-hidden chains in Sourcify-compatible format. */ + getSourcifyChains(): SourcifyChain[] { + const result: SourcifyChain[] = [] + for (const { chain, hidden } of this.entries.values()) { + if (hidden) continue + result.push({ + name: chain.name, + title: chain.name, + chainId: chain.id, + rpc: [...chain.rpcUrls.default.http], + traceSupportedRPCs: [], + supported: true, + etherscanAPI: false, + }) + } + return result + } +} + +// --------------------------------------------------------------------------- +// Hono middleware factory +// --------------------------------------------------------------------------- + +/** + * Resolves an auth token from a SecretsStoreSecret binding, a plain string, + * or undefined. Secrets Store is tried first, falling back to string. + */ +export async function resolveAuthToken( + token: { get(): Promise } | string | undefined, +): Promise { + if (typeof token === 'string') return token || undefined + if (token && typeof token.get === 'function') { + try { + return (await token.get()) || undefined + } catch { + // Secrets Store binding may not be configured (e.g. in tests) + } + } + return undefined +} + +/** + * Creates a Hono middleware that initializes a `ChainRegistry` and sets it on + * the context. + */ +export function chainRegistry(options: { + staticChains: readonly Chain[] + url?: string | undefined + authToken?: string | undefined +}) { + return createMiddleware<{ + Variables: { chainRegistry: ChainRegistry } + }>(async (context, next) => { + const registry = options.url + ? await ChainRegistry.fromUrl({ + url: options.url, + authToken: options.authToken, + staticChains: options.staticChains, + }) + : ChainRegistry.fromStatic(options.staticChains) + context.set('chainRegistry', registry) + await next() + }) +} diff --git a/apps/contract-verification/src/route.lookup.ts b/apps/contract-verification/src/route.lookup.ts index f719e3aad..70a160dde 100644 --- a/apps/contract-verification/src/route.lookup.ts +++ b/apps/contract-verification/src/route.lookup.ts @@ -12,7 +12,7 @@ import { compiledContractsSourcesTable, compiledContractsSignaturesTable, } from '#database/schema.ts' -import { chainIds } from '#wagmi.config.ts' +import type { AppEnv } from '#index.tsx' import { getLogger } from '#lib/logger.ts' import { formatError, getDb, sourcifyError } from '#lib/utilities.ts' @@ -24,8 +24,8 @@ const logger = getLogger(['tempo']) * GET /v2/contracts/{chainId} */ -const lookupRoute = new Hono<{ Bindings: Cloudflare.Env }>() -const lookupAllChainContractsRoute = new Hono<{ Bindings: Cloudflare.Env }>() +const lookupRoute = new Hono() +const lookupAllChainContractsRoute = new Hono() // GET /v2/contract/all-chains/:address - Get verified contract at an address on all chains // Note: This route must be defined before /:chainId/:address to avoid matching conflicts @@ -68,26 +68,33 @@ lookupRoute ) .where(eq(contractDeploymentsTable.address, addressBytes)) + const registry = context.get('chainRegistry') + // Transform results to minimal format per OpenAPI spec - const contracts = results.map((row) => { - const runtimeMatchStatus = row.runtimeMatch ? 'exact_match' : 'match' - const creationMatchStatus = row.creationMatch ? 'exact_match' : 'match' - const matchStatus = - runtimeMatchStatus === 'exact_match' || - creationMatchStatus === 'exact_match' + // Filter out results for hidden chains to prevent leaking chain IDs + const contracts = results + .filter((row) => !registry.isHidden(row.chainId)) + .map((row) => { + const runtimeMatchStatus = row.runtimeMatch ? 'exact_match' : 'match' + const creationMatchStatus = row.creationMatch ? 'exact_match' - : runtimeMatchStatus || creationMatchStatus - - return { - matchId: String(row.matchId), - match: matchStatus, - creationMatch: creationMatchStatus, - runtimeMatch: runtimeMatchStatus, - chainId: String(row.chainId), - address: Hex.fromBytes(new Uint8Array(row.address as ArrayBuffer)), - verifiedAt: row.verifiedAt, - } - }) + : 'match' + const matchStatus = + runtimeMatchStatus === 'exact_match' || + creationMatchStatus === 'exact_match' + ? 'exact_match' + : runtimeMatchStatus || creationMatchStatus + + return { + matchId: String(row.matchId), + match: matchStatus, + creationMatch: creationMatchStatus, + runtimeMatch: runtimeMatchStatus, + chainId: String(row.chainId), + address: Hex.fromBytes(new Uint8Array(row.address as ArrayBuffer)), + verifiedAt: row.verifiedAt, + } + }) return context.json({ results: contracts }) } catch (error) { @@ -119,7 +126,7 @@ lookupRoute 'invalid_chain_id', `Invalid chainId format: ${chainId}`, ) - if (!chainIds.includes(chainIdNumber)) + if (!context.get('chainRegistry').isSupported(chainIdNumber)) return sourcifyError( context, 400, @@ -552,7 +559,7 @@ lookupAllChainContractsRoute.get('/:chainId', async (context) => { 'invalid_chain_id', `Invalid chainId format: ${chainId}`, ) - if (!chainIds.includes(chainIdNumber)) + if (!context.get('chainRegistry').isSupported(chainIdNumber)) return sourcifyError( context, 400, diff --git a/apps/contract-verification/src/route.verify-legacy.ts b/apps/contract-verification/src/route.verify-legacy.ts index fdc11b843..adc13c26f 100644 --- a/apps/contract-verification/src/route.verify-legacy.ts +++ b/apps/contract-verification/src/route.verify-legacy.ts @@ -31,7 +31,7 @@ import { type ImmutableReferences, getVyperImmutableReferences, } from '#lib/bytecode-matching.ts' -import { chains, chainIds } from '#wagmi.config.ts' +import type { AppEnv } from '#index.tsx' import { getLogger } from '#lib/logger.ts' const logger = getLogger(['tempo']) @@ -54,7 +54,7 @@ const LegacyVyperRequestSchema = z.object({ creatorTxHash: z.optional(z.string()), }) -const legacyVerifyRoute = new Hono<{ Bindings: Cloudflare.Env }>() +const legacyVerifyRoute = new Hono() // POST /verify/vyper - Legacy Sourcify Vyper verification (used by Foundry) legacyVerifyRoute.post('/vyper', async (context) => { @@ -104,7 +104,8 @@ legacyVerifyRoute.post('/vyper', async (context) => { } = body const chainId = Number(chain) - if (!chainIds.includes(chainId)) { + const registry = context.get('chainRegistry') + if (!registry.isSupported(chainId)) { return sourcifyError( context, 400, @@ -167,7 +168,7 @@ legacyVerifyRoute.post('/vyper', async (context) => { }) } - const chainConfig = chains.find((chain) => chain.id === chainId) + const chainConfig = registry.getChain(chainId) if (!chainConfig) { return sourcifyError( context, diff --git a/apps/contract-verification/src/route.verify.ts b/apps/contract-verification/src/route.verify.ts index c880ce829..e56399efe 100644 --- a/apps/contract-verification/src/route.verify.ts +++ b/apps/contract-verification/src/route.verify.ts @@ -35,7 +35,8 @@ import { type ImmutableReferences, getVyperImmutableReferences, } from '#lib/bytecode-matching.ts' -import { chains, chainIds } from '#wagmi.config.ts' +import type { AppEnv } from '#index.tsx' +import type { ChainRegistry } from '#lib/chain-registry.ts' import { getLogger } from '#lib/logger.ts' const logger = getLogger(['tempo']) @@ -77,7 +78,7 @@ function timestampToMs(value: string): number { * POST /verify/solc-json */ -const verifyRoute = new Hono<{ Bindings: Cloudflare.Env }>() +const verifyRoute = new Hono() // POST /v2/verify/metadata/:chainId/:address - Verify Contract (using Solidity metadata.json) verifyRoute @@ -149,7 +150,8 @@ verifyRoute } const chainId = Number(_chainId) - if (!chainIds.includes(chainId)) { + const registry = context.get('chainRegistry') + if (!registry.isSupported(chainId)) { return sourcifyError( context, 400, @@ -308,6 +310,7 @@ verifyRoute chainId, address, parsedBody.data as VerificationInput, + registry, ), ) @@ -600,7 +603,7 @@ type VerificationDeps = { name: string, ) => ContainerLike createPublicClient?: (params: { - chain: (typeof chains)[keyof typeof chains] + chain: import('viem').Chain transport: ReturnType }) => PublicClientLike } @@ -649,6 +652,7 @@ async function runVerificationJob( chainId: number, address: string, body: VerificationInput, + chainRegistry: ChainRegistry, deps?: VerificationDeps, ): Promise { const db = getDb(env.CONTRACTS_DB) @@ -665,7 +669,7 @@ async function runVerificationJob( const contractName = contractIdentifier.slice(lastColonIndex + 1) try { - const chain = chains.find((chain) => chain.id === chainId) + const chain = chainRegistry.getChain(chainId) if (!chain) { throw new Error(`Chain ${chainId} is not supported`) } diff --git a/apps/contract-verification/src/wagmi.config.ts b/apps/contract-verification/src/wagmi.config.ts index af9f9fc06..68eecd78c 100644 --- a/apps/contract-verification/src/wagmi.config.ts +++ b/apps/contract-verification/src/wagmi.config.ts @@ -1,5 +1,6 @@ import { Address } from 'ox' import * as z from 'zod/mini' + import { tempoDevnet, tempo as tempoMainnet, @@ -24,38 +25,19 @@ export const tempoTestnetExtended = tempoTestnet.extend({ feeToken: '0x20c0000000000000000000000000000000000001', }) -export const chainIds = [ - tempoDevnet.id, - tempoTestnet.id, - tempoMainnet.id, -] as const -export type ChainId = (typeof chainIds)[number] -export const chains = [ +/** Static Tempo chains -- always available, cannot be overridden by dynamic config. */ +export const staticChains = [ tempoDevnetExtended, tempoTestnetExtended, tempoMainnetExtended, ] as const + export const chainFeeTokens = { [tempoDevnet.id]: tempoDevnetExtended.feeToken, [tempoTestnet.id]: tempoTestnetExtended.feeToken, [tempoMainnet.id]: tempoMainnetExtended.feeToken, } as const -export const sourcifyChains = chains.map((chain) => { - const returnValue = { - name: chain.name, - title: chain.name, - chainId: chain.id, - rpc: [chain.rpcUrls.default.http, chain.rpcUrls.default.webSocket].flat(), - supported: true, - etherscanAPI: false, - _extra: {}, - } - if (chain?.blockExplorers) - returnValue._extra = { blockExplorer: chain?.blockExplorers.default } - return returnValue -}) - export const zAddress = (opts?: { lowercase?: boolean }) => z.pipe( z.string(), diff --git a/apps/contract-verification/test/e2e/verification.test.ts b/apps/contract-verification/test/e2e/verification.test.ts index d4b94b4ea..21540024d 100644 --- a/apps/contract-verification/test/e2e/verification.test.ts +++ b/apps/contract-verification/test/e2e/verification.test.ts @@ -6,6 +6,8 @@ import { describe, expect, it } from 'vitest' import * as DB from '#database/schema.ts' import { runVerificationJob } from '#route.verify.ts' +import { staticChains } from '#wagmi.config.ts' +import { ChainRegistry } from '#lib/chain-registry.ts' import { counterFixture } from '../fixtures/counter.fixture.ts' const VerificationIdSchema = z.object({ verificationId: z.string() }) @@ -62,12 +64,13 @@ describe('full verification flow', () => { counterFixture.chainId, counterFixture.address, verifyRequestBody, + ChainRegistry.fromStatic(staticChains), { createPublicClient: () => ({ getCode: async () => counterFixture.onchainRuntimeBytecode, }), getContainer: () => ({ - fetch: async (request) => { + fetch: async (request: Request) => { const url = new URL(request.url) if (request.method === 'POST' && url.pathname === '/compile') { return Response.json(counterFixture.solcOutput, { status: 200 }) diff --git a/apps/contract-verification/test/integration/rate-limit-whitelist.test.ts b/apps/contract-verification/test/integration/rate-limit-whitelist.test.ts index e2bc153b7..9eb8c4a25 100644 --- a/apps/contract-verification/test/integration/rate-limit-whitelist.test.ts +++ b/apps/contract-verification/test/integration/rate-limit-whitelist.test.ts @@ -3,7 +3,8 @@ import { describe, expect, it, vi } from 'vitest' import { app } from '#index.tsx' -const whitelistedOrigin = env.WHITELISTED_ORIGINS.split(',')[0] ?? 'http://localhost' +const whitelistedOrigin = + env.WHITELISTED_ORIGINS.split(',')[0] ?? 'http://localhost' describe('rate limit whitelist', () => { it('skips rate limiting for whitelisted origins', async () => { diff --git a/apps/contract-verification/test/integration/route.lookup.test.ts b/apps/contract-verification/test/integration/route.lookup.test.ts index ca9fe5452..18594dbf8 100644 --- a/apps/contract-verification/test/integration/route.lookup.test.ts +++ b/apps/contract-verification/test/integration/route.lookup.test.ts @@ -6,7 +6,7 @@ import { drizzle } from 'drizzle-orm/d1' import * as DB from '#database/schema.ts' import { app } from '#index.tsx' -import { chainIds } from '#wagmi.config.ts' +import { staticChains } from '#wagmi.config.ts' describe('gET /v2/contract/all-chains/:address', () => { it('returns 400 for invalid address', async () => { @@ -21,7 +21,7 @@ describe('gET /v2/contract/all-chains/:address', () => { it('returns verified contracts for a valid address', async () => { const db = drizzle(env.CONTRACTS_DB) - const chainId = chainIds[0] + const chainId = staticChains[0].id const address = '0x1111111111111111111111111111111111111111' const addressBytes = Hex.toBytes(address) const runtimeHash = new Uint8Array(32).fill(1) diff --git a/apps/contract-verification/test/integration/route.verify-legacy.test.ts b/apps/contract-verification/test/integration/route.verify-legacy.test.ts index 8c6f5e0b6..dcfe59f12 100644 --- a/apps/contract-verification/test/integration/route.verify-legacy.test.ts +++ b/apps/contract-verification/test/integration/route.verify-legacy.test.ts @@ -6,7 +6,9 @@ import { Hash, Hex } from 'ox' import { beforeEach, describe, expect, it, vi } from 'vitest' import * as DB from '#database/schema.ts' -import { chainIds } from '#wagmi.config.ts' +import { staticChains } from '#wagmi.config.ts' +import { ChainRegistry } from '#lib/chain-registry.ts' +import type { AppEnv } from '#index.tsx' const { mockCreatePublicClient, mockGetRandom } = vi.hoisted(() => ({ mockCreatePublicClient: vi.fn(), @@ -44,7 +46,7 @@ describe('POST /verify/vyper', () => { }) it('stores deployment metadata when creatorTxHash is provided', async () => { - const chainId = chainIds[0] + const chainId = staticChains[0].id const address = '0x1111111111111111111111111111111111111111' as const const creatorTxHash = '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as const @@ -94,7 +96,12 @@ describe('POST /verify/vyper', () => { }) const { legacyVerifyRoute } = await import('#route.verify-legacy.ts') - const app = new Hono<{ Bindings: Cloudflare.Env }>() + const app = new Hono() + const registry = ChainRegistry.fromStatic(staticChains) + app.use(async (c, next) => { + c.set('chainRegistry', registry) + await next() + }) app.route('/verify', legacyVerifyRoute) const response = await app.request( @@ -135,7 +142,7 @@ describe('POST /verify/vyper', () => { }) it('leaves deployment metadata null when creatorTxHash is not provided', async () => { - const chainId = chainIds[0] + const chainId = staticChains[0].id const address = '0x1111111111111111111111111111111111111111' as const const runtimeBytecode = createVyperBytecode('6000') const creationBytecode = createVyperBytecode('60016000') @@ -175,7 +182,12 @@ describe('POST /verify/vyper', () => { }) const { legacyVerifyRoute } = await import('#route.verify-legacy.ts') - const app = new Hono<{ Bindings: Cloudflare.Env }>() + const app = new Hono() + const registry2 = ChainRegistry.fromStatic(staticChains) + app.use(async (c, next) => { + c.set('chainRegistry', registry2) + await next() + }) app.route('/verify', legacyVerifyRoute) const response = await app.request( diff --git a/apps/contract-verification/test/unit/chain-registry.test.ts b/apps/contract-verification/test/unit/chain-registry.test.ts new file mode 100644 index 000000000..d048bd78a --- /dev/null +++ b/apps/contract-verification/test/unit/chain-registry.test.ts @@ -0,0 +1,358 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' + +import { ChainRegistry } from '#lib/chain-registry.ts' +import { staticChains } from '#wagmi.config.ts' + +const FAKE_REGISTRY_URL = 'https://fake-registry.test/chains' + +function makeFakeResponse( + chains: Record< + string, + { + chainId: number + rpc: string[] + hidden?: boolean + name?: string + nativeCurrency?: { name: string; symbol: string; decimals: number } + explorers?: Array<{ name: string; url: string; standard?: string }> + } + >, +) { + return new Response(JSON.stringify(chains), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) +} + +describe('ChainRegistry', () => { + const originalFetch = globalThis.fetch + let mockFetch: ReturnType> + + beforeEach(() => { + mockFetch = vi.fn() + globalThis.fetch = mockFetch + }) + + afterEach(() => { + globalThis.fetch = originalFetch + }) + + describe('fromStatic', () => { + it('creates a registry with only static chains', () => { + const registry = ChainRegistry.fromStatic(staticChains) + + for (const chain of staticChains) { + expect(registry.isSupported(chain.id)).toBe(true) + expect(registry.getChain(chain.id)).toBeDefined() + expect(registry.isHidden(chain.id)).toBe(false) + } + + expect(registry.isSupported(999999)).toBe(false) + expect(registry.getChain(999999)).toBeUndefined() + }) + + it('returns sourcify chains for all static chains', () => { + const registry = ChainRegistry.fromStatic(staticChains) + const sourcify = registry.getSourcifyChains() + + expect(sourcify).toHaveLength(staticChains.length) + for (const chain of staticChains) { + expect(sourcify.some((s) => s.chainId === chain.id)).toBe(true) + } + }) + }) + + describe('fromUrl', () => { + it('fetches and merges dynamic chains with static chains', async () => { + mockFetch.mockResolvedValue( + makeFakeResponse({ + '42161': { + chainId: 42161, + rpc: ['https://arb1.arbitrum.io/rpc'], + name: 'Arbitrum One', + }, + }), + ) + + const registry = await ChainRegistry.fromUrl({ + url: FAKE_REGISTRY_URL, + staticChains, + }) + + // static chains still present + for (const chain of staticChains) { + expect(registry.isSupported(chain.id)).toBe(true) + } + + // dynamic chain added + expect(registry.isSupported(42161)).toBe(true) + const chain = registry.getChain(42161) + expect(chain).toBeDefined() + expect(chain?.name).toBe('Arbitrum One') + expect(chain?.rpcUrls.default.http).toContain( + 'https://arb1.arbitrum.io/rpc', + ) + }) + + it('sends Authorization header when authToken is provided', async () => { + mockFetch.mockResolvedValue(makeFakeResponse({})) + + await ChainRegistry.fromUrl({ + url: FAKE_REGISTRY_URL, + authToken: 'test-secret-token', + staticChains, + }) + + expect(mockFetch).toHaveBeenCalledOnce() + const callHeaders = mockFetch.mock.calls[0]?.[1]?.headers as Headers + expect(callHeaders.get('Authorization')).toBe('Bearer test-secret-token') + }) + + it('does not send Authorization header when authToken is not provided', async () => { + mockFetch.mockResolvedValue(makeFakeResponse({})) + + await ChainRegistry.fromUrl({ + url: FAKE_REGISTRY_URL, + staticChains, + }) + + expect(mockFetch).toHaveBeenCalledOnce() + const callHeaders = mockFetch.mock.calls[0]?.[1]?.headers as Headers + expect(callHeaders.has('Authorization')).toBe(false) + }) + + it('hidden chains are functional but excluded from getSourcifyChains', async () => { + mockFetch.mockResolvedValue( + makeFakeResponse({ + '10': { + chainId: 10, + rpc: ['https://mainnet.optimism.io'], + name: 'Optimism', + hidden: true, + }, + '42161': { + chainId: 42161, + rpc: ['https://arb1.arbitrum.io/rpc'], + name: 'Arbitrum One', + hidden: false, + }, + }), + ) + + const registry = await ChainRegistry.fromUrl({ + url: FAKE_REGISTRY_URL, + staticChains, + }) + + // both chains are functional + expect(registry.isSupported(10)).toBe(true) + expect(registry.isSupported(42161)).toBe(true) + expect(registry.getChain(10)).toBeDefined() + expect(registry.getChain(42161)).toBeDefined() + + // hidden flag + expect(registry.isHidden(10)).toBe(true) + expect(registry.isHidden(42161)).toBe(false) + + // sourcify chains exclude hidden + const sourcify = registry.getSourcifyChains() + expect(sourcify.some((s) => s.chainId === 10)).toBe(false) + expect(sourcify.some((s) => s.chainId === 42161)).toBe(true) + }) + + it('static chains take precedence over dynamic chains with same ID', async () => { + const staticChain = staticChains[0] + if (!staticChain) throw new Error('Expected at least one static chain') + mockFetch.mockResolvedValue( + makeFakeResponse({ + [String(staticChain.id)]: { + chainId: staticChain.id, + rpc: ['https://should-not-override.example.com'], + name: 'Override Attempt', + }, + }), + ) + + const registry = await ChainRegistry.fromUrl({ + url: FAKE_REGISTRY_URL, + staticChains, + }) + + const chain = registry.getChain(staticChain.id) + expect(chain).toBeDefined() + // should keep the static chain's name, not the override + expect(chain?.name).toBe(staticChain.name) + expect(chain?.rpcUrls.default.http).not.toContain( + 'https://should-not-override.example.com', + ) + }) + + it('falls back to static-only registry on fetch failure', async () => { + mockFetch.mockResolvedValue( + new Response('Internal Server Error', { status: 500 }), + ) + + const registry = await ChainRegistry.fromUrl({ + url: FAKE_REGISTRY_URL, + staticChains, + }) + + // static chains still work + for (const chain of staticChains) { + expect(registry.isSupported(chain.id)).toBe(true) + } + + // no dynamic chains + expect(registry.isSupported(42161)).toBe(false) + }) + + it('falls back to static-only registry on network error', async () => { + mockFetch.mockRejectedValue(new Error('Network error')) + + const registry = await ChainRegistry.fromUrl({ + url: FAKE_REGISTRY_URL, + staticChains, + }) + + for (const chain of staticChains) { + expect(registry.isSupported(chain.id)).toBe(true) + } + expect(registry.isSupported(42161)).toBe(false) + }) + + it('rejects entries with invalid schema (missing rpc)', async () => { + mockFetch.mockResolvedValue( + new Response( + JSON.stringify({ + '42161': { chainId: 42161 }, // missing rpc + '10': { + chainId: 10, + rpc: ['https://mainnet.optimism.io'], + }, + }), + { status: 200 }, + ), + ) + + const registry = await ChainRegistry.fromUrl({ + url: FAKE_REGISTRY_URL, + staticChains, + }) + + // entire response fails validation because of the invalid entry + // registry falls back to static only + for (const chain of staticChains) { + expect(registry.isSupported(chain.id)).toBe(true) + } + }) + + it('rejects entries with empty rpc array', async () => { + mockFetch.mockResolvedValue( + makeFakeResponse({ + '42161': { + chainId: 42161, + rpc: [], + }, + }), + ) + + const registry = await ChainRegistry.fromUrl({ + url: FAKE_REGISTRY_URL, + staticChains, + }) + + // entire response fails Zod validation (rpc must have minLength 1) + for (const chain of staticChains) { + expect(registry.isSupported(chain.id)).toBe(true) + } + }) + + it('filters out websocket-only rpc entries', async () => { + mockFetch.mockResolvedValue( + makeFakeResponse({ + '42161': { + chainId: 42161, + rpc: ['wss://arb1.arbitrum.io/ws'], + }, + }), + ) + + const registry = await ChainRegistry.fromUrl({ + url: FAKE_REGISTRY_URL, + staticChains, + }) + + // chain should not be added (no HTTP RPC URLs) + expect(registry.isSupported(42161)).toBe(false) + }) + + it('defaults name and nativeCurrency when not provided', async () => { + mockFetch.mockResolvedValue( + makeFakeResponse({ + '42161': { + chainId: 42161, + rpc: ['https://arb1.arbitrum.io/rpc'], + }, + }), + ) + + const registry = await ChainRegistry.fromUrl({ + url: FAKE_REGISTRY_URL, + staticChains, + }) + + const chain = registry.getChain(42161) + expect(chain).toBeDefined() + expect(chain?.name).toBe('Chain 42161') + expect(chain?.nativeCurrency).toEqual({ + name: 'Ether', + symbol: 'ETH', + decimals: 18, + }) + }) + + it('returns sourcify-compliant shape for dynamic chains', async () => { + mockFetch.mockResolvedValue( + makeFakeResponse({ + '42161': { + chainId: 42161, + rpc: ['https://arb1.arbitrum.io/rpc'], + name: 'Arbitrum One', + explorers: [ + { + name: 'Arbiscan', + url: 'https://arbiscan.io', + standard: 'EIP3091', + }, + ], + }, + }), + ) + + const registry = await ChainRegistry.fromUrl({ + url: FAKE_REGISTRY_URL, + staticChains, + }) + + const sourcify = registry.getSourcifyChains() + const arb = sourcify.find((s) => s.chainId === 42161) + expect(arb).toBeDefined() + expect(arb).toMatchObject({ + name: 'Arbitrum One', + title: 'Arbitrum One', + chainId: 42161, + rpc: ['https://arb1.arbitrum.io/rpc'], + traceSupportedRPCs: [], + supported: true, + etherscanAPI: false, + }) + }) + }) + + describe('isHidden', () => { + it('returns false for unknown chain IDs', () => { + const registry = ChainRegistry.fromStatic(staticChains) + expect(registry.isHidden(999999)).toBe(false) + }) + }) +}) diff --git a/apps/contract-verification/wrangler.json b/apps/contract-verification/wrangler.json index 6448cafcc..e099e3df1 100644 --- a/apps/contract-verification/wrangler.json +++ b/apps/contract-verification/wrangler.json @@ -9,8 +9,17 @@ "preview_urls": true, "logpush": true, "vars": { - "WHITELISTED_ORIGINS": "https://tempo.xyz,https://*.tempo.xyz,https://*.porto.workers.dev" + "WHITELISTED_ORIGINS": "https://tempo.xyz,https://*.tempo.xyz,https://*.porto.workers.dev", + "CHAINS_CONFIG_URL": "", + "CHAINS_CONFIG_AUTH_TOKEN": "" }, + "secrets_store_secrets": [ + { + "binding": "CHAINS_CONFIG_AUTH_TOKEN_SECRET", + "store_id": "", + "secret_name": "CHAINS_CONFIG_AUTH_TOKEN" + } + ], "ratelimits": [ { "name": "RATE_LIMITER",