Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/contract-verification/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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)
5 changes: 5 additions & 0 deletions apps/contract-verification/env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 26 additions & 4 deletions apps/contract-verification/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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<AppEnv>()
export const app = factory.createApp()

Expand All @@ -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'],
Expand Down Expand Up @@ -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,
Expand Down
267 changes: 267 additions & 0 deletions apps/contract-verification/src/lib/chain-registry.ts
Original file line number Diff line number Diff line change
@@ -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<typeof zSourcifyChain>

// ---------------------------------------------------------------------------
// ChainRegistry
// ---------------------------------------------------------------------------

export class ChainRegistry {
private readonly entries: Map<number, ChainEntry>

constructor(entries: Map<number, ChainEntry>) {
this.entries = entries
}

/** Build a registry from static chains only (no external fetch). */
static fromStatic(staticChains: readonly Chain[]): ChainRegistry {
const entries = new Map<number, ChainEntry>()
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<ChainRegistry> {
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> } | string | undefined,
): Promise<string | undefined> {
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()
})
}
Loading
Loading