diff --git a/src/hooks/useContractVerification.ts b/src/hooks/useContractVerification.ts index 3a4936b1..ad88d51e 100644 --- a/src/hooks/useContractVerification.ts +++ b/src/hooks/useContractVerification.ts @@ -1,4 +1,3 @@ -import { useSettings } from "../context/SettingsContext"; import { useEtherscan } from "./useEtherscan"; import type { SourcifyContractDetails } from "./useSourcify"; import { useSourcify } from "./useSourcify"; @@ -23,21 +22,18 @@ export function useContractVerification( address: string | undefined, enabled: boolean = true, ): ContractVerificationResult { - const { settings } = useSettings(); - const hasEtherscanKey = !!settings.apiKeys?.etherscan; - const { data: sourcifyData, loading: sourcifyLoading, isVerified: sourcifyVerified, } = useSourcify(networkId, address, enabled); - // Run Etherscan in parallel whenever a key is configured + // Run Etherscan in parallel (uses worker proxy when no user key is configured) const { data: etherscanData, loading: etherscanLoading, isVerified: etherscanVerified, - } = useEtherscan(networkId, address, enabled && hasEtherscanKey); + } = useEtherscan(networkId, address, enabled); const loading = sourcifyLoading || etherscanLoading; const source: VerificationSource = [ diff --git a/src/hooks/useEtherscan.ts b/src/hooks/useEtherscan.ts index fc8a51c3..151ea62d 100644 --- a/src/hooks/useEtherscan.ts +++ b/src/hooks/useEtherscan.ts @@ -4,6 +4,10 @@ import { logger } from "../utils/logger"; import type { SourcifyContractDetails } from "./useSourcify"; const ETHERSCAN_V2_API = "https://api.etherscan.io/v2/api"; +const OPENSCAN_WORKER_URL = + // biome-ignore lint/complexity/useLiteralKeys: env var access + process.env["REACT_APP_OPENSCAN_WORKER_URL"] ?? + "https://openscan-groq-ai-proxy.openscan.workers.dev"; interface EtherscanSourceResult { SourceCode: string; @@ -81,7 +85,7 @@ export function useEtherscan( const [isVerified, setIsVerified] = useState(false); useEffect(() => { - if (!enabled || !address || !networkId || !apiKey) { + if (!enabled || !address || !networkId) { setData(null); setIsVerified(false); setLoading(false); @@ -93,15 +97,29 @@ export function useEtherscan( const fetchData = async () => { setLoading(true); try { - const params = new URLSearchParams({ - chainid: String(networkId), - module: "contract", - action: "getsourcecode", - address, - apikey: apiKey, - }); - const url = `${ETHERSCAN_V2_API}?${params.toString()}`; - const response = await fetch(url, { signal: controller.signal }); + let response: Response; + + if (apiKey) { + // Direct Etherscan call with user's key + const params = new URLSearchParams({ + chainid: String(networkId), + module: "contract", + action: "getsourcecode", + address, + apikey: apiKey, + }); + response = await fetch(`${ETHERSCAN_V2_API}?${params.toString()}`, { + signal: controller.signal, + }); + } else { + // Proxy through OpenScan Worker (free, no key needed) + response = await fetch(`${OPENSCAN_WORKER_URL}/etherscan/verify`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ chainId: networkId, address }), + signal: controller.signal, + }); + } if (!response.ok) { setData(null); diff --git a/src/utils/contractLookup.ts b/src/utils/contractLookup.ts index 9b20a155..e9ab5675 100644 --- a/src/utils/contractLookup.ts +++ b/src/utils/contractLookup.ts @@ -9,9 +9,51 @@ export interface ContractInfo { // Session-level cache keyed by "chainId:address" const cache = new Map(); +const OPENSCAN_WORKER_URL = + // biome-ignore lint/complexity/useLiteralKeys: env var access + process.env["REACT_APP_OPENSCAN_WORKER_URL"] ?? + "https://openscan-groq-ai-proxy.openscan.workers.dev"; + +/** + * Fetch contract verification from Etherscan V2 API. + * Uses user-provided key directly, or proxies through the OpenScan Worker. + */ +async function fetchEtherscanVerification( + address: string, + chainId: number, + signal?: AbortSignal, + etherscanKey?: string, + // biome-ignore lint/suspicious/noExplicitAny: Etherscan response shape varies +): Promise { + try { + let res: Response; + + if (etherscanKey) { + // Direct Etherscan call with user's key + const url = `https://api.etherscan.io/v2/api?chainid=${chainId}&module=contract&action=getsourcecode&address=${address}&apikey=${etherscanKey}`; + res = await fetch(url, { signal }); + } else { + // Proxy through OpenScan Worker (free, no key needed) + res = await fetch(`${OPENSCAN_WORKER_URL}/etherscan/verify`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ chainId, address }), + signal, + }); + } + + if (!res.ok) return null; + return await res.json(); + } catch (err) { + if (err instanceof Error && err.name === "AbortError") throw err; + logger.debug("Etherscan lookup failed for", address, err); + return null; + } +} + /** * Fetch contract name + ABI for a single address. - * Tries Sourcify first; falls back to Etherscan V2 API if a key is provided. + * Tries Sourcify first; falls back to Etherscan V2 API (via worker proxy or user key). * Results are cached in memory for the session. */ export async function fetchContractInfo( @@ -54,28 +96,24 @@ export async function fetchContractInfo( logger.debug("Sourcify lookup failed for", address, err); } - // ── Etherscan fallback ──────────────────────────────────────────────────── - if (etherscanKey) { - try { - const url = `https://api.etherscan.io/v2/api?chainid=${chainId}&module=contract&action=getsourcecode&address=${address}&apikey=${etherscanKey}`; - const res = await fetch(url, { signal }); - const json = await res.json(); - if ( - json.status === "1" && - Array.isArray(json.result) && - json.result[0]?.ABI && - json.result[0].ABI !== "Contract source code not verified" - ) { - const r = json.result[0]; - const abi = JSON.parse(r.ABI); - const info: ContractInfo = { name: r.ContractName || undefined, abi }; - cache.set(cacheKey, info); - return info; - } - } catch (err) { - if (err instanceof Error && err.name === "AbortError") return null; - logger.debug("Etherscan lookup failed for", address, err); + // ── Etherscan fallback (worker proxy or user key) ───────────────────────── + try { + const json = await fetchEtherscanVerification(address, chainId, signal, etherscanKey); + if ( + json?.status === "1" && + Array.isArray(json.result) && + json.result[0]?.ABI && + json.result[0].ABI !== "Contract source code not verified" + ) { + const r = json.result[0]; + const abi = JSON.parse(r.ABI); + const info: ContractInfo = { name: r.ContractName || undefined, abi }; + cache.set(cacheKey, info); + return info; } + } catch (err) { + if (err instanceof Error && err.name === "AbortError") return null; + logger.debug("Etherscan lookup failed for", address, err); } cache.set(cacheKey, null); diff --git a/worker/src/index.ts b/worker/src/index.ts index c180ba61..42a2e46b 100644 --- a/worker/src/index.ts +++ b/worker/src/index.ts @@ -2,8 +2,11 @@ import { Hono } from "hono"; import type { Env } from "./types"; import { corsMiddleware } from "./middleware/cors"; import { rateLimitMiddleware } from "./middleware/rateLimit"; +import { rateLimitEtherscanMiddleware } from "./middleware/rateLimitEtherscan"; import { validateMiddleware } from "./middleware/validate"; +import { validateEtherscanMiddleware } from "./middleware/validateEtherscan"; import { analyzeHandler } from "./routes/analyze"; +import { etherscanVerifyHandler } from "./routes/etherscanVerify"; const app = new Hono<{ Bindings: Env }>(); @@ -13,6 +16,14 @@ app.use("*", corsMiddleware); // POST /ai/analyze — rate limit, validate, then handle app.post("/ai/analyze", rateLimitMiddleware, validateMiddleware, analyzeHandler); +// POST /etherscan/verify — rate limit, validate, then proxy to Etherscan V2 API +app.post( + "/etherscan/verify", + rateLimitEtherscanMiddleware, + validateEtherscanMiddleware, + etherscanVerifyHandler, +); + // Health check app.get("/health", (c) => c.json({ status: "ok" })); diff --git a/worker/src/middleware/rateLimitEtherscan.ts b/worker/src/middleware/rateLimitEtherscan.ts new file mode 100644 index 00000000..8fd2fe16 --- /dev/null +++ b/worker/src/middleware/rateLimitEtherscan.ts @@ -0,0 +1,49 @@ +import type { Context, Next } from "hono"; +import type { Env } from "../types"; + +const WINDOW_MS = 60_000; // 1 minute +const MAX_REQUESTS = 30; // More generous than AI (10) + +interface RateLimitEntry { + timestamps: number[]; +} + +const store = new Map(); + +let lastCleanup = Date.now(); +const CLEANUP_INTERVAL_MS = 300_000; // 5 minutes + +function cleanup(now: number) { + if (now - lastCleanup < CLEANUP_INTERVAL_MS) return; + lastCleanup = now; + for (const [key, entry] of store) { + if (entry.timestamps.every((ts) => now - ts > WINDOW_MS)) { + store.delete(key); + } + } +} + +export async function rateLimitEtherscanMiddleware(c: Context<{ Bindings: Env }>, next: Next) { + const ip = c.req.header("CF-Connecting-IP") ?? c.req.header("X-Forwarded-For") ?? "unknown"; + const now = Date.now(); + + cleanup(now); + + let entry = store.get(ip); + if (!entry) { + entry = { timestamps: [] }; + store.set(ip, entry); + } + + entry.timestamps = entry.timestamps.filter((ts) => now - ts < WINDOW_MS); + + if (entry.timestamps.length >= MAX_REQUESTS) { + const oldestInWindow = entry.timestamps[0]!; + const retryAfterSec = Math.ceil((WINDOW_MS - (now - oldestInWindow)) / 1000); + c.header("Retry-After", String(retryAfterSec)); + return c.json({ error: "Rate limit exceeded. Try again later." }, 429); + } + + entry.timestamps.push(now); + await next(); +} diff --git a/worker/src/middleware/validateEtherscan.ts b/worker/src/middleware/validateEtherscan.ts new file mode 100644 index 00000000..4bfa6601 --- /dev/null +++ b/worker/src/middleware/validateEtherscan.ts @@ -0,0 +1,24 @@ +import type { Context, Next } from "hono"; +import type { EtherscanVerifyRequestBody, Env } from "../types"; + +const ADDRESS_RE = /^0x[0-9a-fA-F]{40}$/; + +export async function validateEtherscanMiddleware(c: Context<{ Bindings: Env }>, next: Next) { + let body: EtherscanVerifyRequestBody; + try { + body = await c.req.json(); + } catch { + return c.json({ error: "Invalid JSON" }, 400); + } + + if (typeof body.chainId !== "number" || !Number.isInteger(body.chainId) || body.chainId <= 0) { + return c.json({ error: "chainId must be a positive integer" }, 400); + } + + if (typeof body.address !== "string" || !ADDRESS_RE.test(body.address)) { + return c.json({ error: "address must be a valid 0x-prefixed Ethereum address" }, 400); + } + + c.set("validatedBody" as never, body as never); + await next(); +} diff --git a/worker/src/routes/etherscanVerify.ts b/worker/src/routes/etherscanVerify.ts new file mode 100644 index 00000000..23e4111b --- /dev/null +++ b/worker/src/routes/etherscanVerify.ts @@ -0,0 +1,35 @@ +import type { Context } from "hono"; +import type { EtherscanVerifyRequestBody, Env } from "../types"; + +const ETHERSCAN_V2_API = "https://api.etherscan.io/v2/api"; + +export async function etherscanVerifyHandler(c: Context<{ Bindings: Env }>) { + const body = c.get("validatedBody" as never) as unknown as EtherscanVerifyRequestBody; + + const params = new URLSearchParams({ + chainid: String(body.chainId), + module: "contract", + action: "getsourcecode", + address: body.address, + apikey: c.env.ETHERSCAN_API_KEY, + }); + + try { + const response = await fetch(`${ETHERSCAN_V2_API}?${params.toString()}`); + + if (!response.ok) { + const status = response.status; + if (status === 429) { + const retryAfter = response.headers.get("Retry-After"); + if (retryAfter) c.header("Retry-After", retryAfter); + return c.json({ error: "Etherscan rate limit exceeded" }, 429); + } + return c.json({ error: `Etherscan API error (HTTP ${status})` }, 502); + } + + const data = await response.json(); + return c.json(data); + } catch { + return c.json({ error: "Failed to connect to Etherscan API" }, 502); + } +} diff --git a/worker/src/types.ts b/worker/src/types.ts index 4ac5db72..bcd82948 100644 --- a/worker/src/types.ts +++ b/worker/src/types.ts @@ -15,8 +15,14 @@ export interface AnalyzeRequestBody { messages: Array<{ role: "system" | "user"; content: string }>; } +export interface EtherscanVerifyRequestBody { + chainId: number; + address: string; +} + export interface Env { GROQ_API_KEY: string; + ETHERSCAN_API_KEY: string; ALLOWED_ORIGINS: string; GROQ_MODEL: string; } diff --git a/worker/wrangler.toml b/worker/wrangler.toml index 70cf84c7..076fb635 100644 --- a/worker/wrangler.toml +++ b/worker/wrangler.toml @@ -3,8 +3,10 @@ main = "src/index.ts" compatibility_date = "2024-12-01" [vars] -ALLOWED_ORIGINS = "http://openscan.eth.link,https://openscan-explorer.github.io,https://openscan.netlify.app,*--openscan.netlify.app" +ALLOWED_ORIGINS = "https://openscan.eth.link,https://openscan.eth.limo,https://openscan-explorer.github.io,https://openscan.netlify.app,*--openscan.netlify.app" GROQ_MODEL = "groq/compound" -# Secret: GROQ_API_KEY — set via `wrangler secret put GROQ_API_KEY` +# Secrets — set via `wrangler secret put ` +# GROQ_API_KEY — Groq AI API key for /ai/analyze +# ETHERSCAN_API_KEY — Etherscan V2 API key for /etherscan/verify