diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index cce33966..3b09a22f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -2,7 +2,6 @@ name: Build on: pull_request: - branches: [main, staging] paths: - "apps/**" - "packages/**" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 00000000..510f92d5 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,55 @@ +name: Test + +on: + pull_request: + paths: + - "apps/**" + - "packages/**" + - "package.json" + - "pnpm-lock.yaml" + - "turbo.json" + - ".github/workflows/test.yml" + push: + branches: [main, staging] + paths: + - "apps/**" + - "packages/**" + - "package.json" + - "pnpm-lock.yaml" + - "turbo.json" + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + test: + name: Test + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + persist-credentials: false + + - name: Setup pnpm + uses: pnpm/action-setup@v6 + with: + version: 9.15.2 + + - name: Setup Node.js + uses: actions/setup-node@v7 + with: + node-version: 22 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Run tests + run: pnpm test diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml index c448028e..507a5f87 100644 --- a/.github/workflows/typecheck.yml +++ b/.github/workflows/typecheck.yml @@ -2,7 +2,6 @@ name: Type Check on: pull_request: - branches: [main, staging] paths: - "apps/**" - "packages/**" diff --git a/apps/explorer/package.json b/apps/explorer/package.json index b1e9dadc..5e371da4 100644 --- a/apps/explorer/package.json +++ b/apps/explorer/package.json @@ -8,13 +8,14 @@ "start": "next start", "lint": "biome check --write", "format": "biome format --write", - "test": "vitest run" + "test": "vitest run", + "type-check": "tsc --noEmit" }, "dependencies": { "@filecoin-foundation/ui-filecoin": "^0.9.0", "@filecoin-pay/types": "workspace:*", "@filecoin-pay/ui": "workspace:*", - "@filecoin-project/squid-evm-funding": "^0.3.1", + "@filecoin-project/squid-evm-funding": "^0.3.2", "@filoz/synapse-sdk": "^0.41.0", "@phosphor-icons/react": "^2.1.10", "@rainbow-me/rainbowkit": "^2.2.11", diff --git a/apps/explorer/src/app/api/squid/tokens/route.test.ts b/apps/explorer/src/app/api/squid/tokens/route.test.ts new file mode 100644 index 00000000..75dad536 --- /dev/null +++ b/apps/explorer/src/app/api/squid/tokens/route.test.ts @@ -0,0 +1,55 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { GET } from "./route"; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("GET /api/squid/tokens", () => { + it("forwards the catalog request from the server and returns a cacheable response", async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ tokens: [] }), { + headers: { "content-type": "application/json" }, + status: 200, + }), + ); + vi.stubGlobal("fetch", fetchMock); + + const response = await GET( + new Request("http://localhost/api/squid/tokens", { headers: { "x-integrator-id": "test" } }), + ); + + expect(fetchMock).toHaveBeenCalledWith( + "https://v2.api.squidrouter.com/v2/tokens", + expect.objectContaining({ headers: { "x-integrator-id": "test" } }), + ); + expect(response.status).toBe(200); + expect(response.headers.get("cache-control")).toBe("public, s-maxage=300, stale-while-revalidate=600"); + await expect(response.json()).resolves.toEqual({ tokens: [] }); + }); + + it("does not cache upstream failures", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue(new Response(JSON.stringify({ message: "busy" }), { status: 429 })), + ); + + const response = await GET( + new Request("http://localhost/api/squid/tokens", { headers: { "x-integrator-id": "test" } }), + ); + + expect(response.status).toBe(429); + expect(response.headers.get("cache-control")).toBe("no-store"); + }); + + it("rejects a request without an integrator ID before calling Squid", async () => { + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + + const response = await GET(new Request("http://localhost/api/squid/tokens")); + + expect(response.status).toBe(400); + expect(response.headers.get("cache-control")).toBe("no-store"); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/explorer/src/app/api/squid/tokens/route.ts b/apps/explorer/src/app/api/squid/tokens/route.ts new file mode 100644 index 00000000..1b5cf79f --- /dev/null +++ b/apps/explorer/src/app/api/squid/tokens/route.ts @@ -0,0 +1,33 @@ +const SQUID_TOKENS_URL = "https://v2.api.squidrouter.com/v2/tokens"; +const SQUID_TOKENS_CACHE_CONTROL = "public, s-maxage=300, stale-while-revalidate=600"; + +export async function GET(request: Request) { + const integratorId = request.headers.get("x-integrator-id")?.trim(); + if (!integratorId) { + return Response.json( + { error: "Squid integrator ID is required" }, + { headers: { "cache-control": "no-store" }, status: 400 }, + ); + } + + try { + const upstream = await fetch(SQUID_TOKENS_URL, { + headers: { "x-integrator-id": integratorId }, + signal: AbortSignal.timeout(10_000), + }); + const body = await upstream.arrayBuffer(); + return new Response(body, { + headers: { + "cache-control": upstream.ok ? SQUID_TOKENS_CACHE_CONTROL : "no-store", + "content-type": upstream.headers.get("content-type") ?? "application/json", + }, + status: upstream.status, + }); + } catch (error) { + console.error("Failed to proxy Squid token catalog:", error); + return Response.json( + { error: "Squid token catalog is unavailable" }, + { headers: { "cache-control": "no-store" }, status: 502 }, + ); + } +} diff --git a/apps/explorer/src/components/UserConsole/FundsSection/TopUpDialogController.test.tsx b/apps/explorer/src/components/UserConsole/FundsSection/TopUpDialogController.test.tsx index 45934f38..fbae6eec 100644 --- a/apps/explorer/src/components/UserConsole/FundsSection/TopUpDialogController.test.tsx +++ b/apps/explorer/src/components/UserConsole/FundsSection/TopUpDialogController.test.tsx @@ -3,6 +3,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { TopUpActivityProvider, useTopUpActivity } from "../TopUpActivityContext"; import { TopUpDialogController } from "./TopUpDialogController"; +const replace = vi.fn(); +let params = new URLSearchParams(); const dialog = vi.hoisted(() => ({ onOpenChange: undefined as ((open: boolean) => void) | undefined, open: false, @@ -13,8 +15,8 @@ vi.mock("@tanstack/react-query", () => ({ })); vi.mock("next/navigation", () => ({ usePathname: () => "/console", - useRouter: () => ({ replace: vi.fn() }), - useSearchParams: () => new URLSearchParams(), + useRouter: () => ({ replace }), + useSearchParams: () => params, })); vi.mock("wagmi", () => ({ useConnection: () => ({ address: "0x1111111111111111111111111111111111111111" }), @@ -26,7 +28,13 @@ vi.mock("./components", () => ({ GuidedTopUpDialog: ({ onOpenChange, open }: { onOpenChange: (open: boolean) => void; open: boolean }) => { dialog.onOpenChange = onOpenChange; dialog.open = open; - return
; + return ( +- The maximum is a conservative reviewed cap for the swap and any approvals. Execution stops before signing - if cumulative prepared network fees exceed it. + The route is refreshed before signing. Execution stops if its cumulative bridge fee exceeds the reviewed + bridge maximum or if cumulative prepared source-network gas exceeds the separate gas maximum.
)}
diff --git a/apps/explorer/src/components/UserConsole/FundsSection/data/guided-top-up.test.ts b/apps/explorer/src/components/UserConsole/FundsSection/data/guided-top-up.test.ts
index 3d85a173..f6ef60c8 100644
--- a/apps/explorer/src/components/UserConsole/FundsSection/data/guided-top-up.test.ts
+++ b/apps/explorer/src/components/UserConsole/FundsSection/data/guided-top-up.test.ts
@@ -2,6 +2,8 @@ import { NATIVE_TOKEN_ADDRESS, type SquidFundingPlan } from "@filecoin-project/s
import { QueryClient } from "@tanstack/react-query";
import { describe, expect, it } from "vitest";
import {
+ getBridgeNativeFee,
+ getMaximumBridgeNativeFee,
getPlanBridgeNativeFees,
getPlanNetworkGas,
getRequiredNativeBalance,
@@ -108,9 +110,40 @@ describe("guided top-up", () => {
const erc20Plan = plan();
const nativePlan = plan(NATIVE_TOKEN_ADDRESS);
- expect(getPlanBridgeNativeFees(erc20Plan)).toEqual({ estimated: 5n, maximum: 6n });
- expect(getRequiredNativeBalance(erc20Plan, 36n)).toBe(42n);
- expect(getRequiredNativeBalance(nativePlan, 36n)).toBe(142n);
+ expect(getPlanBridgeNativeFees(erc20Plan)).toEqual({ estimated: 5n, maximum: 8n });
+ expect(getRequiredNativeBalance(erc20Plan, 36n)).toBe(44n);
+ expect(getRequiredNativeBalance(nativePlan, 36n)).toBe(144n);
+ });
+
+ it("uses the dependency's rounded-up 50% bridge execution headroom", () => {
+ expect(getMaximumBridgeNativeFee(0n)).toBe(0n);
+ expect(getMaximumBridgeNativeFee(1n)).toBe(2n);
+ expect(getMaximumBridgeNativeFee(5_780_000_000_000n)).toBe(8_670_000_000_000n);
+ });
+
+ it("sums only source-chain native bridge fees", () => {
+ const fundingPlan = plan();
+ const costs = fundingPlan.quotes[0]?.costs ?? [];
+
+ expect(getBridgeNativeFee(costs, fundingPlan.source.chainId)).toBe(5n);
+ });
+
+ it("sums each route's rounded reviewed maximum for the cumulative execution cap", () => {
+ const fundingPlan = plan(token, 1);
+ const firstQuote = fundingPlan.quotes[0];
+ if (!firstQuote) throw new Error("Expected a quote fixture");
+ const nativeFee = (amount: bigint) => ({
+ amount,
+ kind: "fee" as const,
+ name: "Bridge fee",
+ token: { address: NATIVE_TOKEN_ADDRESS, chainId: 1, decimals: 18, symbol: "ETH" },
+ });
+ fundingPlan.quotes = [
+ { ...firstQuote, costs: [nativeFee(1n)], id: "one" },
+ { ...firstQuote, costs: [nativeFee(3n)], id: "two" },
+ ];
+
+ expect(getPlanBridgeNativeFees(fundingPlan)).toEqual({ estimated: 4n, maximum: 7n });
});
it("ignores cached separate-native errors after selecting the native token", () => {
diff --git a/apps/explorer/src/components/UserConsole/FundsSection/data/guided-top-up.ts b/apps/explorer/src/components/UserConsole/FundsSection/data/guided-top-up.ts
index ef6aaba1..6fd68bdd 100644
--- a/apps/explorer/src/components/UserConsole/FundsSection/data/guided-top-up.ts
+++ b/apps/explorer/src/components/UserConsole/FundsSection/data/guided-top-up.ts
@@ -1,14 +1,14 @@
-import { NATIVE_TOKEN_ADDRESS, type SquidFundingPlan, type SquidQuoteCost } from "@filecoin-project/squid-evm-funding";
+import {
+ maximumNativeRouteFee,
+ NATIVE_TOKEN_ADDRESS,
+ type SquidFundingPlan,
+ type SquidQuoteCost,
+} from "@filecoin-project/squid-evm-funding";
import type { QueryClient } from "@tanstack/react-query";
import { formatUnits } from "viem";
import { parseFundingAmount, USDFC_DECIMALS } from "./funding-runway";
import { applyNetworkFeeExecutionBuffer } from "./squid-execution";
-const BASIS_POINTS = 10_000n;
-// @filecoin-project/squid-evm-funding@0.3.1 accepts at most 1% native
-// route-fee drift from the reviewed quote. Keep the preflight balance check
-// aligned with that execution contract until the dependency exposes the cap.
-const NATIVE_ROUTE_FEE_HEADROOM_BPS = 100n;
// The reviewed source-chain gas estimate covers the Squid transaction. Three
// transaction-equivalents leave room for an exact ERC-20 approval reset and
// approval. The executor then applies this 20% buffer to prepared OP Stack
@@ -27,29 +27,28 @@ export function withoutTopUpSearchParam(searchParams: URLSearchParams): string {
}
function isNativeToken(address: string | undefined): boolean {
- return address?.toLowerCase() === NATIVE_TOKEN_ADDRESS;
+ return address?.toLowerCase() === NATIVE_TOKEN_ADDRESS.toLowerCase();
}
export function isBridgeNativeFee(cost: SquidQuoteCost, sourceChainId: number): boolean {
return cost.kind === "fee" && cost.token.chainId === sourceChainId && isNativeToken(cost.token.address);
}
-function maximumBridgeNativeFee(value: bigint): bigint {
- if (value === 0n) return 0n;
- const headroom = (value * NATIVE_ROUTE_FEE_HEADROOM_BPS + BASIS_POINTS - 1n) / BASIS_POINTS;
- return value + headroom;
+export function getBridgeNativeFee(costs: readonly SquidQuoteCost[], sourceChainId: number): bigint {
+ return costs.reduce((total, cost) => total + (isBridgeNativeFee(cost, sourceChainId) ? cost.amount : 0n), 0n);
+}
+
+export function getMaximumBridgeNativeFee(value: bigint): bigint {
+ return maximumNativeRouteFee(value);
}
export function getPlanBridgeNativeFees(plan: SquidFundingPlan): { estimated: bigint; maximum: bigint } {
return plan.quotes.reduce(
(total, quote) => {
- const estimated = quote.costs.reduce(
- (quoteTotal, cost) => quoteTotal + (isBridgeNativeFee(cost, plan.source.chainId) ? cost.amount : 0n),
- 0n,
- );
+ const estimated = getBridgeNativeFee(quote.costs, plan.source.chainId);
return {
estimated: total.estimated + estimated,
- maximum: total.maximum + maximumBridgeNativeFee(estimated),
+ maximum: total.maximum + getMaximumBridgeNativeFee(estimated),
};
},
{ estimated: 0n, maximum: 0n },
diff --git a/apps/explorer/src/components/UserConsole/FundsSection/data/squid-execution.test.ts b/apps/explorer/src/components/UserConsole/FundsSection/data/squid-execution.test.ts
index 6c6b1e28..cfb7b6b3 100644
--- a/apps/explorer/src/components/UserConsole/FundsSection/data/squid-execution.test.ts
+++ b/apps/explorer/src/components/UserConsole/FundsSection/data/squid-execution.test.ts
@@ -34,6 +34,7 @@ describe("executeSquidTopUp", () => {
destinationClient: {} as never,
integratorId: "test-integrator",
maxNativeFee: 30n,
+ maxTotalNativeRouteFee: 20n,
plan,
sourcePublicClient: {} as never,
sourceWalletClient: {} as never,
@@ -43,6 +44,7 @@ describe("executeSquidTopUp", () => {
expect.objectContaining({
feeMode: "op-stack",
maxNativeFee: 30n,
+ maxTotalNativeRouteFee: 20n,
trustedSpender: SQUID_ROUTER_ADDRESS,
trustedTarget: SQUID_ROUTER_ADDRESS,
plan,
@@ -73,6 +75,7 @@ describe("executeSquidTopUp", () => {
destinationClient: {} as never,
integratorId: "test-integrator",
maxNativeFee: 30n,
+ maxTotalNativeRouteFee: 20n,
onSwapAttempt,
onSwapBroadcast,
plan,
@@ -97,6 +100,7 @@ describe("executeSquidTopUp", () => {
destinationClient: {} as never,
integratorId: "test-integrator",
maxNativeFee: 30n,
+ maxTotalNativeRouteFee: 20n,
onSwapAttempt,
plan: { maxSourceAmount: 2n, owner, quotes: [], slippage: 1, source },
sourcePublicClient: {} as never,
@@ -118,6 +122,7 @@ describe("executeSquidTopUp", () => {
destinationClient: {} as never,
integratorId: "test-integrator",
maxNativeFee: 30n,
+ maxTotalNativeRouteFee: 20n,
onSwapAttempt,
plan: { maxSourceAmount: 2n, owner, quotes: [], slippage: 1, source },
sourcePublicClient: {} as never,
diff --git a/apps/explorer/src/components/UserConsole/FundsSection/data/squid-execution.ts b/apps/explorer/src/components/UserConsole/FundsSection/data/squid-execution.ts
index 8d0837bf..c5ba726c 100644
--- a/apps/explorer/src/components/UserConsole/FundsSection/data/squid-execution.ts
+++ b/apps/explorer/src/components/UserConsole/FundsSection/data/squid-execution.ts
@@ -20,6 +20,7 @@ export async function executeSquidTopUp({
destinationClient,
integratorId,
maxNativeFee,
+ maxTotalNativeRouteFee,
onSwapAttempt,
onSwapBroadcast,
plan,
@@ -29,6 +30,7 @@ export async function executeSquidTopUp({
destinationClient: SquidPublicClient;
integratorId: string;
maxNativeFee: bigint;
+ maxTotalNativeRouteFee: bigint;
onSwapAttempt?: () => void;
onSwapBroadcast?: (transactionHash: Hash) => void;
plan: SquidFundingPlan;
@@ -51,6 +53,7 @@ export async function executeSquidTopUp({
{
feeMode: OP_STACK_CHAIN_IDS.has(plan.source.chainId) ? "op-stack" : "standard",
maxNativeFee,
+ maxTotalNativeRouteFee,
maxPollAttempts: 30,
opStackFeeBuffer: OP_STACK_CHAIN_IDS.has(plan.source.chainId)
? (fee) => applyNetworkFeeExecutionBuffer(plan.source.chainId, fee)
diff --git a/apps/explorer/src/components/UserConsole/FundsSection/data/squid-quote.test.ts b/apps/explorer/src/components/UserConsole/FundsSection/data/squid-quote.test.ts
index 5bb5a8b6..b820ec1d 100644
--- a/apps/explorer/src/components/UserConsole/FundsSection/data/squid-quote.test.ts
+++ b/apps/explorer/src/components/UserConsole/FundsSection/data/squid-quote.test.ts
@@ -1,6 +1,6 @@
-import { describe, expect, it, vi } from "vitest";
+import { afterEach, describe, expect, it, vi } from "vitest";
import { SQUID_SOURCE_CHAINS } from "@/constants/chains";
-import { planSquidTopUp } from "./squid-quote";
+import { planSquidTopUp, squidFetch } from "./squid-quote";
const planSquidFunding = vi.hoisted(() => vi.fn());
@@ -8,10 +8,32 @@ vi.mock("@filecoin-project/squid-evm-funding", () => ({
planSquidFunding,
}));
+afterEach(() => {
+ vi.unstubAllGlobals();
+});
+
const owner = "0x1111111111111111111111111111111111111111" as const;
const usdfc = "0x2222222222222222222222222222222222222222" as const;
describe("Squid quote review", () => {
+ it("loads the token catalog through the same-origin proxy", async () => {
+ const fetchMock = vi
+ .fn()
+ .mockResolvedValue(
+ new Response(JSON.stringify({ tokens: [] }), { headers: { "content-type": "application/json" } }),
+ );
+ vi.stubGlobal("fetch", fetchMock);
+
+ const response = await squidFetch("https://v2.api.squidrouter.com/v2/tokens", {
+ headers: { "x-integrator-id": "test" },
+ });
+
+ expect(fetchMock).toHaveBeenCalledWith("/api/squid/tokens", {
+ headers: { "x-integrator-id": "test" },
+ });
+ await expect(response.json()).resolves.toEqual({ tokens: [] });
+ });
+
it("plans an explicit Filecoin source cap", async () => {
const quote = { id: "quote" };
planSquidFunding.mockResolvedValue({
diff --git a/apps/explorer/src/components/UserConsole/FundsSection/data/squid-quote.ts b/apps/explorer/src/components/UserConsole/FundsSection/data/squid-quote.ts
index 9617d3d1..0b13c575 100644
--- a/apps/explorer/src/components/UserConsole/FundsSection/data/squid-quote.ts
+++ b/apps/explorer/src/components/UserConsole/FundsSection/data/squid-quote.ts
@@ -2,9 +2,10 @@ import { planSquidFunding, type SourceToken, type SquidFundingPlan } from "@file
import { type Address, formatUnits } from "viem";
import { SQUID_SOURCE_CHAINS } from "@/constants/chains";
+const SQUID_TOKENS_PROXY_URL = "/api/squid/tokens";
// Squid's /tokens response is chain-independent, changes rarely, and gets
-// re-fetched by the planner on every estimate; caching it leaves the
-// rate-limit budget to /route calls.
+// re-fetched by the planner on every estimate. Route browser catalog reads
+// through the same-origin proxy and cache successful responses locally.
const TOKENS_CACHE_MS = 5 * 60_000;
let tokensCache: { body: string; expires: number } | null = null;
@@ -14,7 +15,7 @@ export const squidFetch: typeof globalThis.fetch = async (input, init) => {
if (tokensCache && tokensCache.expires > Date.now()) {
return new Response(tokensCache.body, { headers: { "content-type": "application/json" }, status: 200 });
}
- const response = await fetch(input, init);
+ const response = await fetch(SQUID_TOKENS_PROXY_URL, init);
if (!response.ok) return response;
const body = await response.text();
tokensCache = { body, expires: Date.now() + TOKENS_CACHE_MS };
diff --git a/apps/explorer/src/services/wagmi/config.tsx b/apps/explorer/src/services/wagmi/config.tsx
index 54f90db7..3e3c4f28 100644
--- a/apps/explorer/src/services/wagmi/config.tsx
+++ b/apps/explorer/src/services/wagmi/config.tsx
@@ -1,6 +1,6 @@
-import { http } from "viem";
import { createConfig } from "wagmi";
import { calibration, mainnet, SQUID_SOURCE_CHAINS } from "@/constants/chains";
+import { createChainTransport } from "./transports";
export const supportedChains = [mainnet, calibration] as const;
const walletChains = [calibration, ...SQUID_SOURCE_CHAINS] as const;
@@ -8,5 +8,5 @@ const walletChains = [calibration, ...SQUID_SOURCE_CHAINS] as const;
export const config = createConfig({
chains: walletChains,
ssr: true,
- transports: Object.fromEntries(walletChains.map((chain) => [chain.id, http()])),
+ transports: Object.fromEntries(walletChains.map((chain) => [chain.id, createChainTransport(chain.id)])),
});
diff --git a/apps/explorer/src/services/wagmi/transports.test.ts b/apps/explorer/src/services/wagmi/transports.test.ts
new file mode 100644
index 00000000..b9c1e141
--- /dev/null
+++ b/apps/explorer/src/services/wagmi/transports.test.ts
@@ -0,0 +1,26 @@
+import { describe, expect, it } from "vitest";
+import { SQUID_SOURCE_CHAINS } from "@/constants/chains";
+import { createChainTransport, SOURCE_RPC_URLS } from "./transports";
+
+const FILECOIN_MAINNET_ID = 314;
+
+describe("source-chain transports", () => {
+ it("covers every non-Filecoin Squid source chain with explicit endpoints", () => {
+ for (const chain of SQUID_SOURCE_CHAINS) {
+ if (chain.id === FILECOIN_MAINNET_ID) continue;
+ expect(SOURCE_RPC_URLS[chain.id], `chain ${chain.id} (${chain.name})`).toBeDefined();
+ expect(SOURCE_RPC_URLS[chain.id].length).toBeGreaterThanOrEqual(2);
+ }
+ });
+
+ it("uses only https endpoints", () => {
+ for (const urls of Object.values(SOURCE_RPC_URLS)) {
+ for (const url of urls) expect(url).toMatch(/^https:\/\//);
+ }
+ });
+
+ it("returns a transport for covered and uncovered chains", () => {
+ expect(createChainTransport(8453)).toBeTypeOf("function");
+ expect(createChainTransport(314)).toBeTypeOf("function");
+ });
+});
diff --git a/apps/explorer/src/services/wagmi/transports.ts b/apps/explorer/src/services/wagmi/transports.ts
new file mode 100644
index 00000000..0c402d49
--- /dev/null
+++ b/apps/explorer/src/services/wagmi/transports.ts
@@ -0,0 +1,19 @@
+import { fallback, http, type Transport } from "viem";
+
+// viem's default per-chain RPCs rate-limit by IP and fail CORS in the browser;
+// use explicit CORS-friendly endpoints, most reliable first, viem default last.
+export const SOURCE_RPC_URLS: Record