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
1 change: 0 additions & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ name: Build

on:
pull_request:
branches: [main, staging]
paths:
- "apps/**"
- "packages/**"
Expand Down
55 changes: 55 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -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
1 change: 0 additions & 1 deletion .github/workflows/typecheck.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ name: Type Check

on:
pull_request:
branches: [main, staging]
paths:
- "apps/**"
- "packages/**"
Expand Down
5 changes: 3 additions & 2 deletions apps/explorer/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
55 changes: 55 additions & 0 deletions apps/explorer/src/app/api/squid/tokens/route.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
33 changes: 33 additions & 0 deletions apps/explorer/src/app/api/squid/tokens/route.ts
Original file line number Diff line number Diff line change
@@ -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 },
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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" }),
Expand All @@ -26,7 +28,13 @@ vi.mock("./components", () => ({
GuidedTopUpDialog: ({ onOpenChange, open }: { onOpenChange: (open: boolean) => void; open: boolean }) => {
dialog.onOpenChange = onOpenChange;
dialog.open = open;
return <div data-guided-top-up-open={open} />;
return (
<div data-guided-top-up-open={open} data-testid='dialog'>
<button onClick={() => onOpenChange(false)} type='button'>
Close
</button>
</div>
);
},
}));

Expand All @@ -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<typeof create>;
act(() => {
renderer = create(
<TopUpActivityProvider>
<TopUpDialogController accountId='0xabc' />
</TopUpActivityProvider>,
);
});
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<typeof create>;
act(() => {
Expand All @@ -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");
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand All @@ -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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -680,12 +701,19 @@ export function SquidQuoteReview({
</span>
<span className='text-muted-foreground'>Slippage</span>
<span className='text-right font-medium'>1%</span>
{quote.costs.some((cost) => cost.kind !== "gas" || cost.token.chainId !== plan.source.chainId) && (
{bridgeFeeLabel && maximumBridgeFeeLabel && (
<>
<span className='text-muted-foreground'>Bridge fee (estimated)</span>
<span className='text-right font-medium'>{bridgeFeeLabel}</span>
<span className='text-muted-foreground'>Bridge fee maximum</span>
<span className='text-right font-medium'>{maximumBridgeFeeLabel}</span>
</>
)}
{otherSquidFeeCosts.length > 0 && (
<>
<span className='text-muted-foreground'>Other route costs (estimated)</span>
<span className='text-muted-foreground'>Other Squid fees (estimated)</span>
<span className='text-right font-medium'>
{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(", ")}
</span>
Expand All @@ -699,17 +727,27 @@ export function SquidQuoteReview({
<span className='text-right font-medium'>{maximumNetworkFeeLabel}</span>
</>
)}
{otherNetworkGasCosts.length > 0 && (
<>
<span className='text-muted-foreground'>Other network gas (estimated)</span>
<span className='text-right font-medium'>
{otherNetworkGasCosts
.map((cost) => displayAmount(cost.amount, cost.token.decimals, cost.token.symbol))
.join(", ")}
</span>
</>
)}
{requiredNativeBalanceLabel && (
<>
<span className='text-muted-foreground'>Maximum native balance required</span>
<span className='text-right font-medium'>{requiredNativeBalanceLabel}</span>
</>
)}
</div>
{maximumNetworkFeeLabel && (
{maximumBridgeFeeLabel && maximumNetworkFeeLabel && (
<p className='text-xs text-muted-foreground'>
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.
</p>
)}
<p className='text-xs text-muted-foreground'>
Expand Down
Loading