Skip to content
Merged
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
8 changes: 2 additions & 6 deletions src/hooks/useContractVerification.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { useSettings } from "../context/SettingsContext";
import { useEtherscan } from "./useEtherscan";
import type { SourcifyContractDetails } from "./useSourcify";
import { useSourcify } from "./useSourcify";
Expand All @@ -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 = [
Expand Down
38 changes: 28 additions & 10 deletions src/hooks/useEtherscan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand Down
82 changes: 60 additions & 22 deletions src/utils/contractLookup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,51 @@ export interface ContractInfo {
// Session-level cache keyed by "chainId:address"
const cache = new Map<string, ContractInfo | null>();

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<any | null> {
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(
Expand Down Expand Up @@ -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);
Expand Down
11 changes: 11 additions & 0 deletions worker/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }>();

Expand All @@ -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" }));

Expand Down
49 changes: 49 additions & 0 deletions worker/src/middleware/rateLimitEtherscan.ts
Original file line number Diff line number Diff line change
@@ -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<string, RateLimitEntry>();

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();
}
24 changes: 24 additions & 0 deletions worker/src/middleware/validateEtherscan.ts
Original file line number Diff line number Diff line change
@@ -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<EtherscanVerifyRequestBody>();
} 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();
}
35 changes: 35 additions & 0 deletions worker/src/routes/etherscanVerify.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
6 changes: 6 additions & 0 deletions worker/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
6 changes: 4 additions & 2 deletions worker/wrangler.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 <NAME>`
# GROQ_API_KEY — Groq AI API key for /ai/analyze
# ETHERSCAN_API_KEY — Etherscan V2 API key for /etherscan/verify

Loading