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 ( +
+ +
+ ); }, })); @@ -52,12 +60,26 @@ function Harness({ showController = true }: { showController?: boolean }) { ); } -describe("TopUpDialogController activity", () => { - beforeEach(() => { - dialog.onOpenChange = undefined; - dialog.open = false; +function renderController() { + let renderer!: ReturnType; + act(() => { + renderer = create( + + + , + ); }); + return renderer; +} + +beforeEach(() => { + dialog.onOpenChange = undefined; + dialog.open = false; + params = new URLSearchParams(); + replace.mockReset(); +}); +describe("TopUpDialogController activity", () => { it("propagates real open, close, and controller cleanup through the activity provider", () => { let renderer!: ReturnType; act(() => { @@ -79,3 +101,21 @@ describe("TopUpDialogController activity", () => { expect(renderer.root.findByProps({ "data-top-up-active": false }).children).toEqual(["false"]); }); }); + +describe("TopUpDialogController deep link", () => { + it("opens the dialog when ?topUp=1 is present", () => { + params = new URLSearchParams("topUp=1&utm_source=email"); + const renderer = renderController(); + const renderedDialog = renderer.root.findByProps({ "data-testid": "dialog" }); + expect(renderedDialog.props["data-guided-top-up-open"]).toBe(true); + }); + + it("closing strips only the topUp param and preserves the rest", () => { + params = new URLSearchParams("topUp=1&utm_source=email"); + const renderer = renderController(); + act(() => { + renderer.root.findByType("button").props.onClick(); + }); + expect(replace).toHaveBeenCalledWith("/console?utm_source=email"); + }); +}); diff --git a/apps/explorer/src/components/UserConsole/FundsSection/components/SquidQuoteReview.tsx b/apps/explorer/src/components/UserConsole/FundsSection/components/SquidQuoteReview.tsx index 4a92f2ce..0df33c3c 100644 --- a/apps/explorer/src/components/UserConsole/FundsSection/components/SquidQuoteReview.tsx +++ b/apps/explorer/src/components/UserConsole/FundsSection/components/SquidQuoteReview.tsx @@ -23,8 +23,10 @@ import { formatAddress } from "@/utils/formatter"; import { formatUsdfcAmount, USDFC_DECIMALS } from "../data/funding-runway"; import { formatNativeFee, + getPlanBridgeNativeFees, getPlanNetworkGas, getRequiredNativeBalance, + isBridgeNativeFee, shouldBlockOnSeparateNativeBalance, } from "../data/guided-top-up"; import { @@ -247,6 +249,14 @@ export function SquidQuoteReview({ }); const plan = isQuoteDebouncing ? undefined : quotedPlan; const quote = plan?.quotes[0]; + const quoteCosts = plan?.quotes.flatMap((item) => item.costs) ?? []; + const bridgeNativeFees = plan ? getPlanBridgeNativeFees(plan) : { estimated: 0n, maximum: 0n }; + const bridgeFeeLabel = sourceChainMeta + ? formatNativeFee(bridgeNativeFees.estimated, sourceChainMeta.nativeCurrency) + : null; + const maximumBridgeFeeLabel = sourceChainMeta + ? formatNativeFee(bridgeNativeFees.maximum, sourceChainMeta.nativeCurrency) + : null; const networkGas = plan ? getPlanNetworkGas(plan) : { estimated: 0n, maximum: 0n }; const estimatedNetworkFeeLabel = sourceChainMeta ? formatNativeFee(networkGas.estimated, sourceChainMeta.nativeCurrency) @@ -258,6 +268,16 @@ export function SquidQuoteReview({ const requiredNativeBalanceLabel = sourceChainMeta ? formatNativeFee(requiredNativeBalance, sourceChainMeta.nativeCurrency) : null; + const otherSquidFeeCosts = quoteCosts.filter( + (cost) => cost.kind === "fee" && (!sourceChainMeta || !isBridgeNativeFee(cost, sourceChainMeta.id)), + ); + const otherNetworkGasCosts = quoteCosts.filter( + (cost) => + cost.kind === "gas" && + (!sourceChainMeta || + cost.token.chainId !== sourceChainMeta.id || + cost.token.address?.toLowerCase() !== NATIVE_TOKEN_ADDRESS.toLowerCase()), + ); const isSeparateNativeBalanceBlocked = shouldBlockOnSeparateNativeBalance( isNativeSource === true, isNativeBalanceError, @@ -385,6 +405,7 @@ export function SquidQuoteReview({ destinationClient: destinationClient as unknown as SquidPublicClient, integratorId, maxNativeFee: networkGas.maximum, + maxTotalNativeRouteFee: bridgeNativeFees.maximum, onSwapBroadcast: (hash) => { didSwapBroadcast = true; acquisition = markSquidBroadcast(window.localStorage, acquisition, hash); @@ -680,12 +701,19 @@ export function SquidQuoteReview({ Slippage 1% - {quote.costs.some((cost) => cost.kind !== "gas" || cost.token.chainId !== plan.source.chainId) && ( + {bridgeFeeLabel && maximumBridgeFeeLabel && ( + <> + Bridge fee (estimated) + {bridgeFeeLabel} + Bridge fee maximum + {maximumBridgeFeeLabel} + + )} + {otherSquidFeeCosts.length > 0 && ( <> - Other route costs (estimated) + Other Squid fees (estimated) - {quote.costs - .filter((cost) => cost.kind !== "gas" || cost.token.chainId !== plan.source.chainId) + {otherSquidFeeCosts .map((cost) => displayAmount(cost.amount, cost.token.decimals, cost.token.symbol)) .join(", ")} @@ -699,6 +727,16 @@ export function SquidQuoteReview({ {maximumNetworkFeeLabel} )} + {otherNetworkGasCosts.length > 0 && ( + <> + Other network gas (estimated) + + {otherNetworkGasCosts + .map((cost) => displayAmount(cost.amount, cost.token.decimals, cost.token.symbol)) + .join(", ")} + + + )} {requiredNativeBalanceLabel && ( <> Maximum native balance required @@ -706,10 +744,10 @@ export function SquidQuoteReview({ )}
- {maximumNetworkFeeLabel && ( + {maximumBridgeFeeLabel && maximumNetworkFeeLabel && (

- 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 = { + 1: ["https://ethereum-rpc.publicnode.com", "https://eth.drpc.org"], + 10: ["https://optimism-rpc.publicnode.com", "https://optimism.drpc.org"], + 56: ["https://bsc-rpc.publicnode.com", "https://bsc.drpc.org"], + 137: ["https://polygon-bor-rpc.publicnode.com", "https://polygon.drpc.org"], + 8453: ["https://base-rpc.publicnode.com", "https://base.drpc.org"], + 42161: ["https://arbitrum-one-rpc.publicnode.com", "https://arbitrum.drpc.org"], + 43114: ["https://avalanche-c-chain-rpc.publicnode.com", "https://avalanche.drpc.org"], +}; + +export function createChainTransport(chainId: number): Transport { + const urls = SOURCE_RPC_URLS[chainId]; + if (!urls) return http(); + return fallback([...urls.map((url) => http(url)), http()]); +} diff --git a/apps/explorer/src/types/svg.d.ts b/apps/explorer/src/types/svg.d.ts new file mode 100644 index 00000000..819134ee --- /dev/null +++ b/apps/explorer/src/types/svg.d.ts @@ -0,0 +1,6 @@ +declare module "*.svg" { + import type { FunctionComponent, SVGProps } from "react"; + + const component: FunctionComponent>; + export default component; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1c595e0c..7db6cff7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -36,8 +36,8 @@ importers: specifier: workspace:* version: link:../../packages/ui '@filecoin-project/squid-evm-funding': - specifier: ^0.3.1 - version: 0.3.1(bufferutil@4.0.9)(typescript@6.0.3)(utf-8-validate@5.0.10)(zod@4.4.3) + specifier: ^0.3.2 + version: 0.3.2(bufferutil@4.0.9)(typescript@6.0.3)(utf-8-validate@5.0.10)(zod@4.4.3) '@filoz/synapse-sdk': specifier: ^0.41.0 version: 0.41.0(typescript@6.0.3)(viem@2.50.4(bufferutil@4.0.9)(typescript@6.0.3)(utf-8-validate@5.0.10)(zod@4.4.3)) @@ -1629,8 +1629,8 @@ packages: swr: ^2.4.0 tailwindcss: ^4.1.18 - '@filecoin-project/squid-evm-funding@0.3.1': - resolution: {integrity: sha512-MKipKkSruhTTNNIzQbXgkNUgP89Tx8Y+U2JYAs15xTKY7famHC/lv+WyHzG6PcFCvvRo9CguWPRX1iuOM8ffGw==} + '@filecoin-project/squid-evm-funding@0.3.2': + resolution: {integrity: sha512-Akt/WzrGd2S9cyoO5ZfsoMt1MaZD55kC8XnFxRZNxqywbjJYXJqY/r+At7v/Vz8vTDW8ing9U75ws3rHXwU7VQ==} engines: {node: '>=18'} '@filoz/synapse-core@0.5.2': @@ -10631,7 +10631,7 @@ snapshots: - typescript - utf-8-validate - '@filecoin-project/squid-evm-funding@0.3.1(bufferutil@4.0.9)(typescript@6.0.3)(utf-8-validate@5.0.10)(zod@4.4.3)': + '@filecoin-project/squid-evm-funding@0.3.2(bufferutil@4.0.9)(typescript@6.0.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: viem: 2.54.5(bufferutil@4.0.9)(typescript@6.0.3)(utf-8-validate@5.0.10)(zod@4.4.3) transitivePeerDependencies: