Skip to content
Open
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
10 changes: 10 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,16 @@ VITE_WALLETCONNECT_PROJECT_ID=your-walletconnect-project-id
# EDB_API_KEY — Secret key that the Vercel proxy injects into bridge requests
# EDB_CORS_ALLOWED_ORIGINS — Comma-separated extra origins for the edb proxy (e.g. https://yourdomain.com)

# Starknet Simulator Bridge (client-side override — defaults to /api/starknet-sim)
# For local dev, Vite proxies /api/starknet-sim to http://127.0.0.1:5790 automatically.
# Set to "disabled" to turn off the Starknet sim integration entirely.
# VITE_STARKNET_SIM_BRIDGE_URL=/api/starknet-sim

# Starknet Simulator Bridge (server-side, used by the Vercel proxy)
# STARKNET_SIM_BRIDGE_URL — Full URL of the bridge (e.g. https://sim-sn.your-domain:5790)
# STARKNET_SIM_API_KEY — Secret key injected by the Vercel proxy as X-API-Key
# STARKNET_SIM_CORS_ALLOWED_ORIGINS — Comma-separated extra origins for the starknet-sim proxy

# Etherscan
# ETHERSCAN_API_KEY — Default server-side explorer key used by the Etherscan-family proxy

Expand Down
200 changes: 200 additions & 0 deletions api/starknet-sim-proxy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
import type { VercelRequest, VercelResponse } from "@vercel/node";

export const config = {
api: { bodyParser: false },
maxDuration: 300,
};

const MAX_BODY_BYTES = 50 * 1024 * 1024; // 50 MB — matches EDB
const FETCH_TIMEOUT_MS = 120_000;
const ALLOWED_METHODS = new Set(["GET", "POST", "OPTIONS", "HEAD"]);

const DEFAULT_ALLOWED_ORIGINS = new Set([
"http://localhost:5173",
"http://127.0.0.1:5173",
"http://localhost:4173",
"http://127.0.0.1:4173",
]);

function resolveAllowedOrigin(
origin: string | undefined,
host?: string,
): string | null {
if (!origin) return null;
if (DEFAULT_ALLOWED_ORIGINS.has(origin)) return origin;
if (host && origin === `https://${host}`) return origin;
const extra = process.env.STARKNET_SIM_CORS_ALLOWED_ORIGINS;
if (extra) {
const list = extra
.split(",")
.map((s) => s.trim())
.filter(Boolean);
if (list.includes(origin)) return origin;
}
return null;
}

function applyCors(req: VercelRequest, res: VercelResponse) {
const origin =
typeof req.headers.origin === "string" ? req.headers.origin : undefined;
const host =
typeof req.headers.host === "string" ? req.headers.host : undefined;
const allowed = resolveAllowedOrigin(origin, host);
if (allowed) {
res.setHeader("Access-Control-Allow-Origin", allowed);
res.setHeader("Vary", "Origin");
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS, HEAD");
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Accept");
res.setHeader("Access-Control-Max-Age", "600");
}
}

function getRawBody(req: VercelRequest): Promise<Buffer> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
let total = 0;
req.on("data", (chunk: Buffer) => {
total += chunk.length;
if (total > MAX_BODY_BYTES) {
req.destroy();
reject(new Error("body_too_large"));
return;
}
chunks.push(chunk);
});
req.on("end", () => resolve(Buffer.concat(chunks)));
req.on("error", reject);
});
}

export default async function handler(req: VercelRequest, res: VercelResponse) {
applyCors(req, res);

const bridgeUrl = process.env.STARKNET_SIM_BRIDGE_URL;
const apiKey = process.env.STARKNET_SIM_API_KEY;

if (!bridgeUrl || !apiKey) {
return res.status(503).json({ error: "bridge_not_configured" });
}

if (req.method === "OPTIONS") {
res.status(204).end();
return;
}

const reqOrigin =
typeof req.headers.origin === "string" ? req.headers.origin : undefined;
const reqHost =
typeof req.headers.host === "string" ? req.headers.host : undefined;
if (reqOrigin && !resolveAllowedOrigin(reqOrigin, reqHost)) {
return res.status(403).json({ error: "origin_required" });
}

if (!ALLOWED_METHODS.has(req.method || "GET")) {
return res.status(405).json({ error: "method_not_allowed" });
}

const pathParam = req.query?.path;
const subPath = Array.isArray(pathParam)
? pathParam.join("/")
: typeof pathParam === "string"
? pathParam
: "";

const parts = subPath ? subPath.split("/") : [];
for (const seg of parts) {
if (seg === "." || seg === ".." || /[^a-zA-Z0-9_\-:.]/.test(seg)) {
return res.status(400).json({ error: "invalid_path" });
}
}

const target = `${bridgeUrl.replace(/\/+$/, "")}/${subPath}`;

const upstreamHeaders: Record<string, string> = {
"X-API-Key": apiKey,
};
const ct = req.headers["content-type"];
if (ct) upstreamHeaders["Content-Type"] = Array.isArray(ct) ? ct[0] : ct;
const accept = req.headers["accept"];
if (accept) upstreamHeaders["Accept"] = Array.isArray(accept) ? accept[0] : accept;
const acceptEncoding = req.headers["accept-encoding"];
if (acceptEncoding)
upstreamHeaders["Accept-Encoding"] = Array.isArray(acceptEncoding)
? acceptEncoding[0]
: acceptEncoding;

try {
const rawBody =
req.method !== "GET" && req.method !== "HEAD"
? await getRawBody(req)
: undefined;

// SSE path (Sprint 3 step-through) — no hard timeout, abort on client disconnect
const isSSE = /^step\/[^/]+\/events$/.test(subPath);
const controller = new AbortController();

if (isSSE) {
req.on("close", () => controller.abort());
} else {
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
req.on("close", () => clearTimeout(timer));
}

const upstream = await fetch(target, {
method: req.method || "GET",
headers: upstreamHeaders,
body: rawBody,
signal: controller.signal,
redirect: "error",
});

const contentType = upstream.headers.get("content-type") || "";
if (contentType.includes("text/event-stream") && upstream.body) {
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
res.setHeader("X-Accel-Buffering", "no");

const reader = upstream.body.getReader();
const decoder = new TextDecoder();
try {
for (;;) {
const { done, value } = await reader.read();
if (done) break;
res.write(decoder.decode(value, { stream: true }));
}
} catch {
// client disconnected or upstream closed
} finally {
reader.cancel().catch(() => {});
res.end();
}
return;
}

res.status(upstream.status);

const upstreamContentType = upstream.headers.get("content-type");
if (upstreamContentType) res.setHeader("content-type", upstreamContentType);
const upstreamVary = upstream.headers.get("vary");
if (upstreamVary) {
const existing = res.getHeader("Vary");
res.setHeader(
"Vary",
existing ? `${existing}, ${upstreamVary}` : upstreamVary,
);
}

const buf = Buffer.from(await upstream.arrayBuffer());
res.send(buf);
} catch (err: unknown) {
if (err instanceof Error && err.message === "body_too_large") {
return res.status(413).json({ error: "body_too_large" });
}
if (err instanceof Error && err.name === "AbortError") {
return res.status(504).json({ error: "bridge_timeout" });
}
console.error("[starknet-sim] upstream error:", err);
res.status(502).json({ error: "bridge_unreachable" });
}
}
2 changes: 1 addition & 1 deletion index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<script src="/extension-conflict-fix.js"></script>
<script src="/error-handler.js"></script>
<meta charset="UTF-8" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' blob: https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdn.jsdelivr.net; font-src 'self' https://fonts.gstatic.com data:; img-src 'self' data: https: blob:; connect-src 'self' https://*.alchemy.com https://*.infura.io https://*.etherscan.io https://*.basescan.org https://*.polygonscan.com https://*.arbiscan.io https://*.bscscan.com https://*.blockscout.com https://sourcify.dev https://repo.sourcify.dev https://eth-bytecode-db.services.blockscout.com https://api.openchain.xyz https://www.4byte.directory https://coins.llama.fi https://cdn.jsdelivr.net https://cca-lite.coinbase.com https://*.coinbase.com https://*.publicnode.com https://polygon-rpc.com https://*.arbitrum.io https://*.optimism.io https://*.base.org https://api.avax.network https://*.binance.org https://rpc.gnosischain.com https://*.polygon.technology https://*.ethpandaops.io https://*.blastapi.io https://*.lisk.com https://*.thirdweb.com https://*.drpc.org https://*.tenderly.co https://rpc.scroll.io https://rpc.linea.build https://rpc.mantle.xyz https://rpc.blast.io https://rpc.berachain.com https://rpc.soniclabs.com https://rpc.frax.com https://opbnb-mainnet-rpc.bnbchain.org https://rpc.api.moonbeam.network https://evm-rpc.sei-apis.com https://rpc.mainnet.taiko.xyz https://rpc.soneium.org https://rpc.fuse.io https://api.roninchain.com https://rpc-quicknode.morphl2.io https://rpc.immutable.com https://flare-api.flare.network https://rpc.lens.xyz https://rpc.xdcscan.com https://rpc.viction.xyz https://rpc.gobob.xyz https://rpc-gel.inkonchain.com https://rpc.hemi.network https://rpc.gravity.xyz https://rpc.vana.org https://rpc.corn.fun https://rpc.sophon.xyz https://rpc.plasma.build https://rpc.stable.xyz https://rpc.plume.org https://rpc.tempo.xyz https://rpc.hyperliquid.xyz https://rpc.monad.xyz https://rpc.katana.farm https://rpc.superposition.so https://mainnet.unichain.org https://forno.celo.org https://evm.cronos.org https://mainnet.era.zksync.io https://mainnet.mode.network https://mainnet.boba.network https://andromeda.metis.io https://apechain.calderachain.xyz https://swell-mainnet.alt.technology https://api.mainnet.abs.xyz https://public-node.rsk.co https://mainnet.evm.nodes.onflow.org https://public-en.node.kaia.io https://mainnet.telos.net https://node.mainnet.etherlink.com https://sepolia.optimism.io https://sepolia.base.org https://polygon-amoy.gateway.tenderly.co https://*.vercel.app https://*.walletconnect.com https://*.walletconnect.org wss://*.walletconnect.com wss://*.walletconnect.org ws://localhost:* http://localhost:* http://127.0.0.1:* https://web3-toolkit.vercel.app; frame-src 'self' https://*.walletconnect.com https://*.walletconnect.org; worker-src 'self' blob:;" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' blob: https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdn.jsdelivr.net; font-src 'self' https://fonts.gstatic.com data:; img-src 'self' data: https: blob:; connect-src 'self' https://*.alchemy.com https://*.infura.io https://*.etherscan.io https://*.basescan.org https://*.polygonscan.com https://*.arbiscan.io https://*.bscscan.com https://*.blockscout.com https://sourcify.dev https://repo.sourcify.dev https://eth-bytecode-db.services.blockscout.com https://api.openchain.xyz https://www.4byte.directory https://coins.llama.fi https://cdn.jsdelivr.net https://cca-lite.coinbase.com https://*.coinbase.com https://*.publicnode.com https://polygon-rpc.com https://*.arbitrum.io https://*.optimism.io https://*.base.org https://api.avax.network https://*.binance.org https://rpc.gnosischain.com https://*.polygon.technology https://*.ethpandaops.io https://*.blastapi.io https://*.lisk.com https://*.thirdweb.com https://*.drpc.org https://*.tenderly.co https://rpc.scroll.io https://rpc.linea.build https://rpc.mantle.xyz https://rpc.blast.io https://rpc.berachain.com https://rpc.soniclabs.com https://rpc.frax.com https://opbnb-mainnet-rpc.bnbchain.org https://rpc.api.moonbeam.network https://evm-rpc.sei-apis.com https://rpc.mainnet.taiko.xyz https://rpc.soneium.org https://rpc.fuse.io https://api.roninchain.com https://rpc-quicknode.morphl2.io https://rpc.immutable.com https://flare-api.flare.network https://rpc.lens.xyz https://rpc.xdcscan.com https://rpc.viction.xyz https://rpc.gobob.xyz https://rpc-gel.inkonchain.com https://rpc.hemi.network https://rpc.gravity.xyz https://rpc.vana.org https://rpc.corn.fun https://rpc.sophon.xyz https://rpc.plasma.build https://rpc.stable.xyz https://rpc.plume.org https://rpc.tempo.xyz https://rpc.hyperliquid.xyz https://rpc.monad.xyz https://rpc.katana.farm https://rpc.superposition.so https://mainnet.unichain.org https://forno.celo.org https://evm.cronos.org https://mainnet.era.zksync.io https://mainnet.mode.network https://mainnet.boba.network https://andromeda.metis.io https://apechain.calderachain.xyz https://swell-mainnet.alt.technology https://api.mainnet.abs.xyz https://public-node.rsk.co https://mainnet.evm.nodes.onflow.org https://public-en.node.kaia.io https://mainnet.telos.net https://node.mainnet.etherlink.com https://sepolia.optimism.io https://sepolia.base.org https://polygon-amoy.gateway.tenderly.co https://*.vercel.app https://*.walletconnect.com https://*.walletconnect.org wss://*.walletconnect.com wss://*.walletconnect.org ws://localhost:* http://localhost:* http://127.0.0.1:* https://web3-toolkit.vercel.app https://api.cartridge.gg https://*.cartridge.gg wss://*.cartridge.gg https://api.mainnet-beta.solana.com https://api.devnet.solana.com https://api.testnet.solana.com https://*.solana.com https://starknet-mainnet.public.blastapi.io https://starknet-sepolia.public.blastapi.io https://*.blastapi.io https://*.nethermind.io https://*.helius-rpc.com wss://*.helius-rpc.com https://starknet-mainnet.infura.io https://starknet-sepolia.infura.io https://*.g.alchemy.com; frame-src 'self' https://*.walletconnect.com https://*.walletconnect.org https://*.cartridge.gg; worker-src 'self' blob:;" />
<meta name="referrer" content="strict-origin-when-cross-origin" />
<link rel="icon" type="image/svg+xml" href="/logos/hexkit-favicon.svg" />
<link rel="apple-touch-icon" sizes="180x180" href="/logos/apple-touch-icon.png" />
Expand Down
Loading