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
3 changes: 2 additions & 1 deletion apps/explorer/.env.example
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# GraphQL Endpoint for Filecoin Payments Subgraph
NEXT_PUBLIC_SUBGRAPH_URL_MAINNET=https://api.goldsky.com/api/public/<PROJECT_ID>/subgraphs/<SUBGRAPH_NAME>/<SUBGRAPH_VERSION/SUBGRAPH_TAG>/gn
NEXT_PUBLIC_SUBGRAPH_URL_CALIBRATION=https://api.goldsky.com/api/public/<PROJECT_ID>/subgraphs/<SUBGRAPH_NAME>/<SUBGRAPH_VERSION/SUBGRAPH_TAG>/gn
NEXT_PUBLIC_SQUID_INTEGRATOR_ID=filecoin-testing-94a4a25a-d40b-41cb-b148-e96098862
NEXT_PUBLIC_NOTIFICATIONS_ELIGIBLE_NETWORKS=mainnet
NEXT_PUBLIC_NOTIFICATIONS_API_URL=https://placeholder.invalid/notifications
NEXT_PUBLIC_NOTIFICATIONS_API_URL=https://placeholder.invalid/notifications
3 changes: 3 additions & 0 deletions apps/explorer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ This app requires the following environment variables:
| -------------------------------------- | --------------------------------------------- | -------- |
| `NEXT_PUBLIC_SUBGRAPH_URL_MAINNET` | Subgraph URL for Filecoin Mainnet (chain 314) | Yes |
| `NEXT_PUBLIC_SUBGRAPH_URL_CALIBRATION` | Subgraph URL for Calibration testnet (314159) | Yes |
| `NEXT_PUBLIC_SQUID_INTEGRATOR_ID` | Optional Squid integrator ID override | No |

Squid route quotes use the public `filecoin-testing-94a4a25a-d40b-41cb-b148-e96098862` integrator ID by default.

**Setup:**

Expand Down
10 changes: 8 additions & 2 deletions apps/explorer/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,15 @@
"build": "next build",
"start": "next start",
"lint": "biome check --write",
"format": "biome format --write"
"format": "biome format --write",
"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.3",
"@filoz/synapse-sdk": "^0.41.0",
"@phosphor-icons/react": "^2.1.10",
"@rainbow-me/rainbowkit": "^2.2.11",
Expand All @@ -37,8 +40,11 @@
"@types/node": "^25",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/react-test-renderer": "19.1.0",
"frontmatter-markdown-loader": "^3.7.0",
"react-test-renderer": "19.2.6",
"tailwindcss": "^4",
"typescript": "^6"
"typescript": "^6",
"vitest": "^4.1.0"
}
}
3 changes: 2 additions & 1 deletion apps/explorer/src/app/[network]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { GlobalSearchBar, Stats, TopAccounts, TopOperators } from "@/components/Home";
import { ConsoleHero, GlobalSearchBar, Stats, TopAccounts, TopOperators } from "@/components/Home";

function Page() {
return (
<>
<ConsoleHero />
<GlobalSearchBar />
<Stats />
<TopAccounts />
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 },
);
}
}
19 changes: 19 additions & 0 deletions apps/explorer/src/app/console/(console)/ConsoleContent.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import type { ReactNode } from "react";
import type { ConsoleAccessState } from "./console-access";

export const ConsoleContent = ({
accessState,
children,
sidebar,
}: {
accessState: ConsoleAccessState;
children: ReactNode;
sidebar: ReactNode;
}) => (
<div className={accessState === "ready" ? "flex gap-8" : undefined}>
<div className={accessState === "ready" ? "hidden border-r pr-4 lg:flex" : "hidden"}>
{accessState === "ready" ? sidebar : null}
</div>
<div className={accessState === "ready" ? "min-w-0 flex-1" : undefined}>{children}</div>
</div>
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { AlertTriangle } from "lucide-react";
import Balance from "@/components/shared/Balance";
import ChainSwitcher from "@/components/shared/ChainSwitcher";
import { SQUID_SOURCE_CHAINS } from "@/constants/chains";
import type { ConsoleAccessState } from "./console-access";

type ConsoleWalletControlsProps = {
accessState: ConsoleAccessState;
chainId: number | undefined;
isTopUpActive: boolean;
};

export function ConsoleWalletControls({ accessState, chainId, isTopUpActive }: ConsoleWalletControlsProps) {
switch (accessState) {
case "not-connected":
return null;
case "unsupported-chain":
return <UnsupportedNetworkBadge />;
case "squid-source": {
const sourceChain = SQUID_SOURCE_CHAINS.find((chain) => chain.id === chainId);
return isTopUpActive ? (
<span className='inline-flex items-center rounded-md border px-3 py-1.5 text-sm font-medium'>
Wallet: {sourceChain?.name ?? "Source network"}
</span>
) : (
<UnsupportedNetworkBadge />
);
}
case "ready":
return (
<>
<Balance />
{chainId !== undefined ? <ChainSwitcher chainId={chainId} /> : null}
</>
);
}
}

function UnsupportedNetworkBadge() {
return (
<span className='inline-flex items-center gap-1.5 rounded-md border border-amber-200 bg-amber-50 px-3 py-1.5 text-sm font-medium text-amber-700'>
<AlertTriangle className='size-4' />
Unsupported Network
</span>
);
}
29 changes: 29 additions & 0 deletions apps/explorer/src/app/console/(console)/console-access.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { SQUID_SOURCE_CHAINS } from "@/constants/chains";
import { isSupportedChainId } from "@/utils/network";

export type ConsoleAccessState = "not-connected" | "unsupported-chain" | "squid-source" | "ready";

export const getConsoleDisplayAccessState = (
walletAccessState: ConsoleAccessState,
isTopUpActive: boolean,
): ConsoleAccessState => (walletAccessState === "squid-source" && isTopUpActive ? "ready" : walletAccessState);

export const getConsoleAccessState = ({
isConnected,
hasAddress,
chainId,
}: {
isConnected: boolean;
hasAddress: boolean;
chainId: number | undefined;
}): ConsoleAccessState => {
if (!isConnected || !hasAddress) {
return "not-connected";
}

if (chainId !== undefined && !isSupportedChainId(chainId)) {
return SQUID_SOURCE_CHAINS.some((chain) => chain.id === chainId) ? "squid-source" : "unsupported-chain";
}

return "ready";
};
71 changes: 71 additions & 0 deletions apps/explorer/src/app/console/(console)/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
"use client";
import { Container } from "@filecoin-foundation/ui-filecoin/Container";
import type { ReactNode } from "react";
import { useConnection } from "wagmi";
import { BetaWarning } from "@/components/UserConsole/BetaWarning";
import { ConsoleHeader } from "@/components/UserConsole/ConsoleHeader";
import { ConsoleNavDrawer } from "@/components/UserConsole/ConsoleNavDrawer";
import ConsoleProviders from "@/components/UserConsole/ConsoleProviders";
import { ConsoleSidebar } from "@/components/UserConsole/ConsoleSidebar";
import { NotConnected, UnsupportedChain } from "@/components/UserConsole/States";
import { useTopUpActivity } from "@/components/UserConsole/TopUpActivityContext";
import { ConsoleContent } from "./ConsoleContent";
import { ConsoleWalletControls } from "./ConsoleWalletControls";
import { type ConsoleAccessState, getConsoleAccessState, getConsoleDisplayAccessState } from "./console-access";

const ConsoleAccessGate = ({ accessState, children }: { accessState: ConsoleAccessState; children: ReactNode }) => {
switch (accessState) {
case "not-connected":
return <NotConnected />;
case "unsupported-chain":
return <UnsupportedChain />;
case "squid-source":
case "ready":
return children;
}
};

const ConsoleShell = ({ children }: { children: ReactNode }) => {
const { address, isConnected, chainId } = useConnection();
const { isTopUpActive } = useTopUpActivity();
const walletAccessState = getConsoleAccessState({
isConnected,
hasAddress: Boolean(address),
chainId,
});
const displayAccessState = getConsoleDisplayAccessState(walletAccessState, isTopUpActive);

return (
<div className='flex min-h-full flex-col bg-background text-foreground'>
<ConsoleHeader
walletControls={
<ConsoleWalletControls accessState={walletAccessState} chainId={chainId} isTopUpActive={isTopUpActive} />
}
navTrigger={displayAccessState === "ready" ? <ConsoleNavDrawer /> : null}
/>

<div className='flex-1 pt-4 pb-12'>
<Container>
<div className='flex flex-col gap-6'>
{/* BetaWarning sits above the row so it shows on every console page. */}
<BetaWarning />
<ConsoleAccessGate accessState={displayAccessState}>
<ConsoleContent accessState={displayAccessState} sidebar={<ConsoleSidebar />}>
{children}
</ConsoleContent>
</ConsoleAccessGate>
</div>
</Container>
</div>
</div>
);
};

// Kept separate from ConsoleShell: a component can't mount a provider and read from it.
const ConsoleLayout = ({ children }: { children: ReactNode }) => (
<ConsoleProviders>
<ConsoleShell>{children}</ConsoleShell>
</ConsoleProviders>
);

export default ConsoleLayout;
Loading