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/.env.example b/apps/explorer/.env.example index b5e642d6..17deeb82 100644 --- a/apps/explorer/.env.example +++ b/apps/explorer/.env.example @@ -1,5 +1,6 @@ # GraphQL Endpoint for Filecoin Payments Subgraph NEXT_PUBLIC_SUBGRAPH_URL_MAINNET=https://api.goldsky.com/api/public//subgraphs///gn NEXT_PUBLIC_SUBGRAPH_URL_CALIBRATION=https://api.goldsky.com/api/public//subgraphs///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 \ No newline at end of file +NEXT_PUBLIC_NOTIFICATIONS_API_URL=https://placeholder.invalid/notifications diff --git a/apps/explorer/README.md b/apps/explorer/README.md index 8f1d2b03..c00e68e1 100644 --- a/apps/explorer/README.md +++ b/apps/explorer/README.md @@ -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:** diff --git a/apps/explorer/package.json b/apps/explorer/package.json index 1b543fd7..471124d7 100644 --- a/apps/explorer/package.json +++ b/apps/explorer/package.json @@ -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", @@ -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" } } diff --git a/apps/explorer/src/app/[network]/page.tsx b/apps/explorer/src/app/[network]/page.tsx index 40adcc62..28cea55c 100644 --- a/apps/explorer/src/app/[network]/page.tsx +++ b/apps/explorer/src/app/[network]/page.tsx @@ -1,8 +1,9 @@ -import { GlobalSearchBar, Stats, TopAccounts, TopOperators } from "@/components/Home"; +import { ConsoleHero, GlobalSearchBar, Stats, TopAccounts, TopOperators } from "@/components/Home"; function Page() { return ( <> + 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/app/console/(console)/ConsoleContent.tsx b/apps/explorer/src/app/console/(console)/ConsoleContent.tsx new file mode 100644 index 00000000..816a3236 --- /dev/null +++ b/apps/explorer/src/app/console/(console)/ConsoleContent.tsx @@ -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; +}) => ( +
+
+ {accessState === "ready" ? sidebar : null} +
+
{children}
+
+); diff --git a/apps/explorer/src/app/console/(console)/ConsoleWalletControls.tsx b/apps/explorer/src/app/console/(console)/ConsoleWalletControls.tsx new file mode 100644 index 00000000..842a2900 --- /dev/null +++ b/apps/explorer/src/app/console/(console)/ConsoleWalletControls.tsx @@ -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 ; + case "squid-source": { + const sourceChain = SQUID_SOURCE_CHAINS.find((chain) => chain.id === chainId); + return isTopUpActive ? ( + + Wallet: {sourceChain?.name ?? "Source network"} + + ) : ( + + ); + } + case "ready": + return ( + <> + + {chainId !== undefined ? : null} + + ); + } +} + +function UnsupportedNetworkBadge() { + return ( + + + Unsupported Network + + ); +} diff --git a/apps/explorer/src/app/console/(console)/console-access.ts b/apps/explorer/src/app/console/(console)/console-access.ts new file mode 100644 index 00000000..151d7ca2 --- /dev/null +++ b/apps/explorer/src/app/console/(console)/console-access.ts @@ -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"; +}; diff --git a/apps/explorer/src/app/console/(console)/layout.tsx b/apps/explorer/src/app/console/(console)/layout.tsx new file mode 100644 index 00000000..97b8fef8 --- /dev/null +++ b/apps/explorer/src/app/console/(console)/layout.tsx @@ -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 ; + case "unsupported-chain": + return ; + 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 ( +
+ + } + navTrigger={displayAccessState === "ready" ? : null} + /> + +
+ +
+ {/* BetaWarning sits above the row so it shows on every console page. */} + + + }> + {children} + + +
+
+
+
+ ); +}; + +// Kept separate from ConsoleShell: a component can't mount a provider and read from it. +const ConsoleLayout = ({ children }: { children: ReactNode }) => ( + + {children} + +); + +export default ConsoleLayout; diff --git a/apps/explorer/src/app/console/notifications/page.tsx b/apps/explorer/src/app/console/(console)/notifications/page.tsx similarity index 89% rename from apps/explorer/src/app/console/notifications/page.tsx rename to apps/explorer/src/app/console/(console)/notifications/page.tsx index 8197fe3d..aaebe2f7 100644 --- a/apps/explorer/src/app/console/notifications/page.tsx +++ b/apps/explorer/src/app/console/(console)/notifications/page.tsx @@ -1,17 +1,14 @@ "use client"; import { Button } from "@filecoin-foundation/ui-filecoin/Button"; import { EmptyStateCard } from "@filecoin-foundation/ui-filecoin/EmptyStateCard"; -import { PageSection } from "@filecoin-foundation/ui-filecoin/PageSection"; import { WarningCircleIcon } from "@phosphor-icons/react"; import { useMutation } from "@tanstack/react-query"; -import { ArrowLeft, ChevronRight, WifiOff } from "lucide-react"; -import Link from "next/link"; +import { ChevronRight, WifiOff } from "lucide-react"; import type React from "react"; import { useCallback, useEffect, useRef, useState } from "react"; import { BaseError, UserRejectedRequestError } from "viem"; import { createSiweMessage, generateSiweNonce } from "viem/siwe"; import { useConnection, useSignMessage } from "wagmi"; -import ConsoleProviders from "@/components/UserConsole/ConsoleProviders"; import { AlertsActiveCard, AlertsOffCard, @@ -22,7 +19,6 @@ import { PendingVerificationCard, UnsubscribeDialog, } from "@/components/UserConsole/NotificationsSection/components"; -import { NotConnected } from "@/components/UserConsole/States"; import { useNotificationStatus } from "@/hooks/useNotificationStatus"; import { getNetworkFromChainId, @@ -415,7 +411,7 @@ const NotificationsContent = ({ } }; -const NotificationsMain = () => { +const NotificationsPage = () => { const { address, isConnected, chainId } = useConnection(); const walletNetwork = getNetworkFromChainId(chainId); const isEligibleNetwork = isSupportedChainId(chainId) && isNotificationsEligibleNetwork(walletNetwork); @@ -425,8 +421,8 @@ const NotificationsMain = () => { const showUpdateEmail = viewType === "subscribed" && isConnected && !!address && isEligibleNetwork; function renderContent() { - if (!isConnected || !address) return ; - if (chainId === undefined) return null; + // Connection gating lives in the console layout; by here the wallet is connected. + if (!address || chainId === undefined) return null; if (!isEligibleNetwork) return ; return ( { } return ( - -
-
- - - Back to console - -
-

- Email alerts -

- {showUpdateEmail && ( - - )} -
-

- Receive alerts when your account has less than 30 days of service runway remaining, so you can top up before - services are affected. -

+
+
+
+

+ Email alerts +

+ {showUpdateEmail ? ( + + ) : null}
- -
{renderContent()}
+

+ Receive alerts when your account has less than 30 days of service runway remaining, so you can top up before + services are affected. +

- + +
{renderContent()}
+
); }; -const NotificationsPage = () => ( - - - -); - export default NotificationsPage; diff --git a/apps/explorer/src/app/console/(console)/page.tsx b/apps/explorer/src/app/console/(console)/page.tsx new file mode 100644 index 00000000..2f6956ab --- /dev/null +++ b/apps/explorer/src/app/console/(console)/page.tsx @@ -0,0 +1,136 @@ +"use client"; +import { LoadingStateCard } from "@filecoin-foundation/ui-filecoin/LoadingStateCard"; +import type { Account } from "@filecoin-pay/types"; +import { useConnection } from "wagmi"; +import { + AlertsBanner, + FundsSection, + OperatorApprovalsSection, + RailsSection, + TopUpDialogController, +} from "@/components/UserConsole"; +import { AccountNotFound, ErrorState, UnsupportedChain } from "@/components/UserConsole/States"; +import { useTopUpActivity } from "@/components/UserConsole/TopUpActivityContext"; +import { SQUID_SOURCE_CHAINS } from "@/constants/chains"; +import { useAccountDetails } from "@/hooks/useAccountDetails"; +import { useNotificationStatus } from "@/hooks/useNotificationStatus"; +import type { Network } from "@/types"; +import { getNetworkFromChainId, isNotificationsEligibleNetwork, isSupportedChainId } from "@/utils/network"; + +type AccountSectionsProps = { + account: Account | null | undefined; + // React Query guarantees error is non-null exactly when the query has failed, + // so it doubles as the error flag. + error: Error | null; + isLoading: boolean; + network: Network; + onGuidedTopUp?: () => void; + userAddress: string; + /** + * Rendered below the funds overview rather than above the page: the prompt to + * enable alerts lands better once the reader has seen the balances it protects. + * Passed in as a node so the states that render no funds overview can still + * place it, keeping the banner visible exactly when it was before. + */ + alertsBanner: React.ReactNode; +}; + +const AccountSections = ({ + account, + error, + isLoading, + network, + onGuidedTopUp, + userAddress, + alertsBanner, +}: AccountSectionsProps) => { + if (isLoading) { + return ( + <> + + {alertsBanner} + + ); + } + + if (!account) { + return ( + <> + {error ? : } + {alertsBanner} + + ); + } + + return ( + <> +
+ + {alertsBanner} +
+ + + + {/* A failed background refetch still leaves the last good account on screen. */} + {error ? : null} + + ); +}; + +const UserConsole = () => { + const { address, chainId } = useConnection(); + const { isTopUpActive } = useTopUpActivity(); + const walletNetwork = getNetworkFromChainId(chainId); + const isFilecoinChain = isSupportedChainId(chainId); + const isSquidSourceChain = !isFilecoinChain && SQUID_SOURCE_CHAINS.some((chain) => chain.id === chainId); + const isFilecoinMainnet = (chainId === undefined || isFilecoinChain) && walletNetwork === "mainnet"; + const displayMainnetDuringTopUp = isTopUpActive && isSquidSourceChain; + const displayNetwork = displayMainnetDuringTopUp ? "mainnet" : walletNetwork; + const canLoadFilecoinConsole = chainId === undefined || isFilecoinChain || displayMainnetDuringTopUp; + const canMountTopUpController = isFilecoinMainnet || isSquidSourceChain; + + const { data: notificationStatus, isError: isNotificationStatusError } = useNotificationStatus( + canLoadFilecoinConsole ? address : undefined, + ); + const isSubscribed = notificationStatus?.subscribed === true; + const showAlertsBanner = + isNotificationsEligibleNetwork(displayNetwork) && !isSubscribed && !isNotificationStatusError; + + const accountQuery = useAccountDetails(canLoadFilecoinConsole ? (address ?? "") : "", { + networkOverride: displayNetwork, + }); + + const accountSections = (onGuidedTopUp?: () => void) => + address ? ( + : null} + /> + ) : null; + + const showTopUpTrigger = !accountQuery.isLoading && !accountQuery.error && !accountQuery.data; + + return ( +
+ {/* The (console) layout gates on a connected wallet, so address is set here. */} + {address && canMountTopUpController ? ( + + {(openTopUp, isOpen) => (isSquidSourceChain && !isOpen ? : accountSections(openTopUp))} + + ) : canLoadFilecoinConsole ? ( + accountSections() + ) : null} +
+ ); +}; + +export default UserConsole; diff --git a/apps/explorer/src/app/console/layout.test.tsx b/apps/explorer/src/app/console/layout.test.tsx new file mode 100644 index 00000000..6e117494 --- /dev/null +++ b/apps/explorer/src/app/console/layout.test.tsx @@ -0,0 +1,70 @@ +import { useState } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { act, create } from "react-test-renderer"; +import { describe, expect, it, vi } from "vitest"; +import { ConsoleContent } from "./(console)/ConsoleContent"; +import { ConsoleWalletControls } from "./(console)/ConsoleWalletControls"; +import { getConsoleAccessState, getConsoleDisplayAccessState } from "./(console)/console-access"; + +vi.mock("@/components/shared/Balance", () => ({ default: () => Filecoin balance })); +vi.mock("@/components/shared/ChainSwitcher", () => ({ default: () => Filecoin network })); + +describe("console access and continuity", () => { + it("keeps the console page mounted on a Squid source chain", () => { + expect(getConsoleAccessState({ isConnected: true, hasAddress: true, chainId: 8453 })).toBe("squid-source"); + }); + + it("continues to reject unrelated unsupported chains", () => { + expect(getConsoleAccessState({ isConnected: true, hasAddress: true, chainId: 12345 })).toBe("unsupported-chain"); + expect(getConsoleDisplayAccessState("unsupported-chain", true)).toBe("unsupported-chain"); + }); + + it("displays the console only for an active top-up on a recognized source chain", () => { + expect(getConsoleDisplayAccessState("squid-source", false)).toBe("squid-source"); + expect(getConsoleDisplayAccessState("squid-source", true)).toBe("ready"); + }); + + it("shows the actual source wallet network only while the top-up is active", () => { + const activeMarkup = renderToStaticMarkup( + , + ); + expect(activeMarkup).toContain("Wallet: Base"); + expect(activeMarkup).not.toContain("Unsupported Network"); + expect(activeMarkup).not.toContain("Filecoin balance"); + expect(activeMarkup).not.toContain("Filecoin network"); + + const inactiveMarkup = renderToStaticMarkup( + , + ); + expect(inactiveMarkup).toContain("Unsupported Network"); + expect(inactiveMarkup).not.toContain("Wallet: Base"); + }); + + it("preserves page state while switching to and from a Squid source chain", () => { + let increment = () => {}; + const StatefulPage = () => { + const [count, setCount] = useState(0); + increment = () => setCount((value) => value + 1); + return {count}; + }; + const content = (accessState: "ready" | "squid-source") => ( + Navigation}> + + + ); + + let renderer!: ReturnType; + act(() => { + renderer = create(content("ready")); + }); + act(increment); + act(() => { + renderer.update(content("squid-source")); + }); + expect(renderer.root.findByType("span").children).toEqual(["1"]); + act(() => { + renderer.update(content("ready")); + }); + expect(renderer.root.findByType("span").children).toEqual(["1"]); + }); +}); diff --git a/apps/explorer/src/app/console/notifications/verify/page.tsx b/apps/explorer/src/app/console/notifications/verify/page.tsx index e110ea80..9687463e 100644 --- a/apps/explorer/src/app/console/notifications/verify/page.tsx +++ b/apps/explorer/src/app/console/notifications/verify/page.tsx @@ -1,5 +1,5 @@ "use client"; -import { PageSection } from "@filecoin-foundation/ui-filecoin/PageSection"; +import { Container } from "@filecoin-foundation/ui-filecoin/Container"; import { Card } from "@filecoin-pay/ui/components/card"; import { useQueryClient } from "@tanstack/react-query"; import { AlertCircle, CheckCircle2, Clock, Link2Off, Loader2 } from "lucide-react"; @@ -7,6 +7,7 @@ import Link from "next/link"; import { useSearchParams } from "next/navigation"; import type { ReactNode } from "react"; import { Suspense, useCallback, useEffect, useRef, useState } from "react"; +import { ConsoleHeader } from "@/components/UserConsole/ConsoleHeader"; const API_URL = process.env.NEXT_PUBLIC_NOTIFICATIONS_API_URL; @@ -158,106 +159,110 @@ const VerifyContent = () => { }, [verifyState.type]); return ( - -
- - {verifyState.type === "loading" && } +
+ + {verifyState.type === "loading" && } - {verifyState.type === "success" && ( - } - color='green' - title='Alerts are now on' - description="Your email has been verified. We'll notify you when this account has less than 30 days of service runway remaining." - actions={ -
- - Back to Console - - - Manage alerts - -
- } - /> - )} + {verifyState.type === "success" && ( + } + color='green' + title='Alerts are now on' + description="Your email has been verified. We'll notify you when this account has less than 30 days of service runway remaining." + actions={ +
+ + Back to Console + + + Manage alerts + +
+ } + /> + )} - {verifyState.type === "not-found" && ( - } - color='amber' - title='This verification link is no longer available' - description='It may have expired or already been used. Return to alert settings to request a new verification email.' - actions={} - /> - )} + {verifyState.type === "not-found" && ( + } + color='amber' + title='This verification link is no longer available' + description='It may have expired or already been used. Return to alert settings to request a new verification email.' + actions={} + /> + )} - {verifyState.type === "rate-limited" && ( - } - color='amber' - title='Too many attempts' - description='Please wait a few minutes before trying again, then return to alert settings to request a new verification email.' - actions={} - /> - )} + {verifyState.type === "rate-limited" && ( + } + color='amber' + title='Too many attempts' + description='Please wait a few minutes before trying again, then return to alert settings to request a new verification email.' + actions={} + /> + )} - {verifyState.type === "error" && ( - } - color='red' - title="We couldn't verify your email" - description={ - verifyState.kind === "network" - ? "Check your internet connection and try again." - : "Something went wrong on our end. Try again in a moment, or return to alert settings to request a new verification email." - } - detail={verifyState.detail} - actions={ -
- - -
- } - /> - )} + {verifyState.type === "error" && ( + } + color='red' + title="We couldn't verify your email" + description={ + verifyState.kind === "network" + ? "Check your internet connection and try again." + : "Something went wrong on our end. Try again in a moment, or return to alert settings to request a new verification email." + } + detail={verifyState.detail} + actions={ +
+ + +
+ } + /> + )} - {verifyState.type === "missing-params" && ( - } - color='amber' - title='This verification link is incomplete' - description="We couldn't find the information needed to verify your email. Return to alert settings to request a new verification email." - actions={} - /> - )} -
-
- + {verifyState.type === "missing-params" && ( + } + color='amber' + title='This verification link is incomplete' + description="We couldn't find the information needed to verify your email. Return to alert settings to request a new verification email." + actions={} + /> + )} +
+
); }; const VerifyPage = () => ( - - - - -
- } - > - - +
+ + +
+ + + + + } + > + + + +
+
); export default VerifyPage; diff --git a/apps/explorer/src/app/console/page.test.tsx b/apps/explorer/src/app/console/page.test.tsx new file mode 100644 index 00000000..06d75241 --- /dev/null +++ b/apps/explorer/src/app/console/page.test.tsx @@ -0,0 +1,259 @@ +import { useEffect, useState } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { act, create } from "react-test-renderer"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import UserConsole from "./(console)/page"; + +const wallet = vi.hoisted(() => ({ + address: "0x1111111111111111111111111111111111111111", + chainId: 42161 as number | undefined, + isConnected: true, +})); +const accountState = vi.hoisted(() => ({ + data: { id: "0x1111111111111111111111111111111111111111" } as { id: string } | null, + error: null, + isError: false, + isLoading: false, + requestedAddress: "", + requestedNetwork: "" as string, +})); +const topUpState = vi.hoisted(() => ({ + close: undefined as (() => void) | undefined, + isTopUpActive: false, + mounts: 0, + unmounts: 0, +})); +const sectionNetworks = vi.hoisted(() => ({ + approvals: "" as string, + funds: "" as string, + rails: "" as string, +})); + +vi.mock("wagmi", async (importOriginal) => ({ + ...(await importOriginal()), + useConnection: () => wallet, +})); +vi.mock("@/components/shared", () => ({ + Balance: () =>
Filecoin balance
, + ChainSwitcher: () =>
Filecoin network
, +})); +vi.mock("@/components/UserConsole/ConsoleProviders", () => ({ + default: ({ children }: { children: React.ReactNode }) => children, +})); +vi.mock("@/components/UserConsole/TopUpActivityContext", () => ({ + useTopUpActivity: () => ({ + isTopUpActive: topUpState.isTopUpActive, + setTopUpActive: (active: boolean) => { + topUpState.isTopUpActive = active; + }, + }), +})); +vi.mock("@/components/UserConsole/States", () => ({ + AccountNotFound: () =>
Account not found
, + ErrorState: () =>
Account error
, + NotConnected: () =>
Not connected
, + UnsupportedChain: () =>
Unsupported network
, +})); +vi.mock("@/components/UserConsole", () => ({ + AlertsBanner: () => null, + BetaWarning: () => null, + FundsSection: ({ + account, + network, + onGuidedTopUp, + }: { + account: { id: string }; + network: string; + onGuidedTopUp?: () => void; + }) => { + sectionNetworks.funds = network; + return ( +
+ Funds + {onGuidedTopUp ? ( + + ) : null} +
+ ); + }, + OperatorApprovalsSection: ({ network }: { network: string }) => { + sectionNetworks.approvals = network; + return
Approvals
; + }, + RailsSection: ({ network }: { network: string }) => { + sectionNetworks.rails = network; + return
Rails
; + }, + TopUpDialogController: ({ + accountId, + children, + showTrigger, + }: { + accountId: string; + children?: (openTopUp: () => void, isOpen: boolean) => React.ReactNode; + showTrigger?: boolean; + }) => ( + + {children} + + ), +})); +vi.mock("@/hooks/useAccountDetails", () => ({ + useAccountDetails: (address: string, options: { networkOverride: string }) => { + accountState.requestedAddress = address; + accountState.requestedNetwork = options.networkOverride; + return accountState; + }, +})); + +function MockTopUpDialogController({ + accountId, + children, + showTrigger, +}: { + accountId: string; + children?: (openTopUp: () => void, isOpen: boolean) => React.ReactNode; + showTrigger?: boolean; +}) { + const [open, setOpen] = useState(false); + useEffect(() => { + topUpState.mounts += 1; + return () => { + topUpState.unmounts += 1; + }; + }, []); + const openTopUp = () => { + topUpState.isTopUpActive = true; + setOpen(true); + }; + topUpState.close = () => { + topUpState.isTopUpActive = false; + setOpen(false); + }; + + return ( +
+ {children?.(openTopUp, open)} + {showTrigger ? ( + + ) : null} +
+ ); +} +vi.mock("@/hooks/useNotificationStatus", () => ({ + useNotificationStatus: () => ({ data: undefined, isError: false }), +})); + +describe("UserConsole", () => { + beforeEach(() => { + wallet.chainId = 42161; + accountState.data = { id: "0x1111111111111111111111111111111111111111" }; + accountState.error = null; + accountState.isError = false; + accountState.isLoading = false; + accountState.requestedAddress = ""; + accountState.requestedNetwork = ""; + topUpState.close = undefined; + topUpState.isTopUpActive = false; + topUpState.mounts = 0; + topUpState.unmounts = 0; + sectionNetworks.approvals = ""; + sectionNetworks.funds = ""; + sectionNetworks.rails = ""; + }); + + it("keeps the unsupported-network console state on a Squid source chain", () => { + const markup = renderToStaticMarkup(); + + expect(markup).toContain("Unsupported network"); + expect(markup).toContain('data-top-up-account-id="0x1111111111111111111111111111111111111111"'); + expect(markup).not.toContain("Fund with another token"); + expect(markup).not.toContain("Funds"); + expect(markup).not.toContain("Filecoin balance"); + expect(markup).not.toContain("Approvals"); + expect(markup).not.toContain("Rails"); + }); + + it("keeps the full console on Filecoin", () => { + wallet.chainId = 314; + const markup = renderToStaticMarkup(); + + expect(markup).toContain("Funds"); + expect(markup).toContain('data-top-up-account-id="0x1111111111111111111111111111111111111111"'); + expect(markup).toContain("Approvals"); + expect(markup).toContain("Rails"); + }); + + it("keeps the default Filecoin console while the wallet chain resolves", () => { + wallet.chainId = undefined; + const markup = renderToStaticMarkup(); + + expect(markup).toContain("Funds"); + expect(markup).toContain('data-top-up-account-id="0x1111111111111111111111111111111111111111"'); + }); + + it("keeps direct deposit funding on Calibration", () => { + wallet.chainId = 314159; + const markup = renderToStaticMarkup(); + + expect(markup).toContain("Funds"); + expect(markup).not.toContain("data-top-up-account-id"); + }); + + it("allows an unindexed account to start Squid funding", () => { + accountState.data = null; + wallet.chainId = 314; + const filecoinMarkup = renderToStaticMarkup(); + + expect(filecoinMarkup).toContain("Account not found"); + expect(filecoinMarkup).toContain("Fund with another token"); + }); + + it("keeps one open controller and Filecoin mainnet data mounted across a Squid network switch", () => { + wallet.chainId = 314; + let renderer!: ReturnType; + act(() => { + renderer = create(); + }); + const openButton = renderer.root.findByProps({ "data-open-top-up": true }); + act(() => openButton.props.onClick()); + + wallet.chainId = 8453; + act(() => { + renderer.update(); + }); + const sourceMarkup = JSON.stringify(renderer.toJSON()); + expect(topUpState.mounts).toBe(1); + expect(topUpState.unmounts).toBe(0); + expect(sourceMarkup).toContain("Funds"); + expect(sourceMarkup).toContain("Approvals"); + expect(sourceMarkup).toContain("Rails"); + expect(sourceMarkup).not.toContain("Unsupported network"); + expect(accountState.requestedAddress).toBe(wallet.address); + expect(accountState.requestedNetwork).toBe("mainnet"); + expect(sectionNetworks).toEqual({ approvals: "mainnet", funds: "mainnet", rails: "mainnet" }); + + act(() => topUpState.close?.()); + act(() => { + renderer.update(); + }); + const closedMarkup = JSON.stringify(renderer.toJSON()); + expect(closedMarkup).toContain("Unsupported network"); + expect(closedMarkup).not.toContain("Funds"); + }); + + it("does not let an active flag bypass an unrelated unsupported chain", () => { + wallet.chainId = 12345; + topUpState.isTopUpActive = true; + + const markup = renderToStaticMarkup(); + + expect(markup).not.toContain("Funds"); + expect(markup).not.toContain("data-top-up-account-id"); + expect(accountState.requestedAddress).toBe(""); + }); +}); diff --git a/apps/explorer/src/app/console/page.tsx b/apps/explorer/src/app/console/page.tsx deleted file mode 100644 index 9e0096db..00000000 --- a/apps/explorer/src/app/console/page.tsx +++ /dev/null @@ -1,112 +0,0 @@ -"use client"; -import { LoadingStateCard } from "@filecoin-foundation/ui-filecoin/LoadingStateCard"; -import { PageSection } from "@filecoin-foundation/ui-filecoin/PageSection"; -import { AlertTriangle } from "lucide-react"; -import { useMemo } from "react"; -import { useConnection } from "wagmi"; -import { Balance, ChainSwitcher } from "@/components/shared"; -import { - AlertsBanner, - BetaWarning, - FundsSection, - OperatorApprovalsSection, - RailsSection, -} from "@/components/UserConsole"; -import ConsoleProviders from "@/components/UserConsole/ConsoleProviders"; -import { AccountNotFound, ErrorState, NotConnected, UnsupportedChain } from "@/components/UserConsole/States"; -import { useAccountDetails } from "@/hooks/useAccountDetails"; -import { useNotificationStatus } from "@/hooks/useNotificationStatus"; -import { getNetworkFromChainId, isNotificationsEligibleNetwork, isSupportedChainId } from "@/utils/network"; - -const UserConsoleContent = () => { - const { address, isConnected, chainId } = useConnection(); - const walletNetwork = useMemo(() => getNetworkFromChainId(chainId), [chainId]); - const isUnsupportedChain = isConnected && chainId && !isSupportedChainId(chainId); - - const isNotificationsEligible = isNotificationsEligibleNetwork(walletNetwork); - - const { data: notificationStatus, isError: notificationStatusError } = useNotificationStatus(address); - const subscribed = notificationStatus?.subscribed ?? false; - - const { - data: account, - isLoading, - isError, - error, - } = useAccountDetails(address || "", { networkOverride: walletNetwork }); - - return ( - -
-
-

- Filecoin Pay Console -

- {isConnected && ( -
- {isUnsupportedChain ? ( - - - Unsupported Network - - ) : ( - <> - - {chainId && } - - )} -
- )} -
- - {/* Beta Warning */} - - - {/* Alerts Banner — hidden once subscribed */} - {isConnected && !isUnsupportedChain && isNotificationsEligible && !subscribed && !notificationStatusError && ( -
- -
- )} - - {/* Not Connected */} - {(!isConnected || !address) && } - - {/* Unsupported Chain */} - {isUnsupportedChain && } - - {/* Only show content if connected to supported chain */} - {isConnected && !isUnsupportedChain && ( - <> - {/* Loading */} - {isLoading && } - - {/* Account Not Found */} - {!isError && !isLoading && !account && } - - {!isLoading && address && account && ( - <> - - - - - )} - - {/* Error */} - {isError && } - - )} -
-
- ); -}; - -const UserConsole = () => { - return ( - - - - ); -}; - -export default UserConsole; diff --git a/apps/explorer/src/components/Home/ConsoleHero.tsx b/apps/explorer/src/components/Home/ConsoleHero.tsx new file mode 100644 index 00000000..d6974ee0 --- /dev/null +++ b/apps/explorer/src/components/Home/ConsoleHero.tsx @@ -0,0 +1,43 @@ +import { PageSection } from "@filecoin-foundation/ui-filecoin/PageSection"; +import { ArrowRight } from "lucide-react"; +import Link from "next/link"; +import { PATHS } from "@/constants/paths"; + +/** + * Console entry point, rendered above search and stats on the network home page. + */ +const ConsoleHero = () => ( + +
+
+ +
+ +); + +export default ConsoleHero; diff --git a/apps/explorer/src/components/Home/TopAccounts/index.tsx b/apps/explorer/src/components/Home/TopAccounts/index.tsx index 7c4d466d..d6592551 100644 --- a/apps/explorer/src/components/Home/TopAccounts/index.tsx +++ b/apps/explorer/src/components/Home/TopAccounts/index.tsx @@ -5,7 +5,7 @@ import { LoadingStateCard } from "@filecoin-foundation/ui-filecoin/LoadingStateC import { PageSection } from "@filecoin-foundation/ui-filecoin/PageSection"; import { RefreshOverlay } from "@filecoin-foundation/ui-filecoin/RefreshOverlay"; import { AlertCircle, SearchIcon } from "lucide-react"; -import { StyledLink } from "@/components/shared"; +import { NetworkLink, StyledLink } from "@/components/shared"; import { calibration, mainnet } from "@/constants/chains"; import useAccountsLeaderboard from "@/hooks/useAccountsLeaderboard"; import useNetwork from "@/hooks/useNetwork"; @@ -24,8 +24,8 @@ const TopAccounts = () => {

Accounts Leaderboards

- - View All + + View All
diff --git a/apps/explorer/src/components/Home/TopOperators/index.tsx b/apps/explorer/src/components/Home/TopOperators/index.tsx index 4dc56707..95d99fe5 100644 --- a/apps/explorer/src/components/Home/TopOperators/index.tsx +++ b/apps/explorer/src/components/Home/TopOperators/index.tsx @@ -6,7 +6,7 @@ import { LoadingStateCard } from "@filecoin-foundation/ui-filecoin/LoadingStateC import { PageSection } from "@filecoin-foundation/ui-filecoin/PageSection"; import { RefreshOverlay } from "@filecoin-foundation/ui-filecoin/RefreshOverlay"; import { AlertCircle, SearchIcon } from "lucide-react"; -import { StyledLink } from "@/components/shared"; +import { NetworkLink, StyledLink } from "@/components/shared"; import { calibration, mainnet } from "@/constants/chains"; import useNetwork from "@/hooks/useNetwork"; import useOperatorsLeaderboard from "@/hooks/useOperatorsLeaderboard"; @@ -22,8 +22,8 @@ const TopOperators = () => {

Services Leaderboard

- - View All + + View All
diff --git a/apps/explorer/src/components/Home/index.ts b/apps/explorer/src/components/Home/index.ts index e7ddf503..8cc978de 100644 --- a/apps/explorer/src/components/Home/index.ts +++ b/apps/explorer/src/components/Home/index.ts @@ -1,3 +1,4 @@ +export { default as ConsoleHero } from "./ConsoleHero"; export { default as GlobalSearchBar } from "./GlobalSearchBar"; export { default as Stats } from "./Stats"; export { default as TopAccounts } from "./TopAccounts"; diff --git a/apps/explorer/src/components/Operator/OperatorRails.tsx b/apps/explorer/src/components/Operator/OperatorRails.tsx index df651b3b..53ab0206 100644 --- a/apps/explorer/src/components/Operator/OperatorRails.tsx +++ b/apps/explorer/src/components/Operator/OperatorRails.tsx @@ -15,7 +15,7 @@ import { AlertCircle } from "lucide-react"; import { useState } from "react"; import { useOperatorRails } from "@/hooks/useOperatorDetails"; import { formatDate, formatToken } from "@/utils/formatter"; -import { CopyableText, RailStateBadge, StyledLink } from "../shared"; +import { CopyableText, NetworkLink, RailStateBadge, StyledLink } from "../shared"; interface OperatorRailsProps { operator: Operator; @@ -106,7 +106,9 @@ export const OperatorRails: React.FC = ({ operator }) => { {data.rails.map((rail) => ( - {rail.railId.toString()} + + {rail.railId.toString()} + { - if (subscribed) { - return ( - - - Alerts on - - - ); - } - - return ( - - - Alerts off - - ); -}; diff --git a/apps/explorer/src/components/UserConsole/ConsoleHeader.tsx b/apps/explorer/src/components/UserConsole/ConsoleHeader.tsx new file mode 100644 index 00000000..6bae7013 --- /dev/null +++ b/apps/explorer/src/components/UserConsole/ConsoleHeader.tsx @@ -0,0 +1,38 @@ +import { Container } from "@filecoin-foundation/ui-filecoin/Container"; +import Link from "next/link"; +import type { ReactNode } from "react"; +import Logo from "@/public/foc-logo-dark.svg"; + +type ConsoleHeaderProps = { + /** + * Wallet controls for the right-hand side. Only the gated console shell passes + * these — the header itself must render without wagmi/RainbowKit providers so + * ungated pages (e.g. the email verification landing page) can reuse it. + */ + walletControls?: ReactNode; + /** + * Trigger for the mobile navigation drawer. Ungated pages that reuse this + * header (e.g. the email verification landing page) pass none. + */ + navTrigger?: ReactNode; +}; + +export const ConsoleHeader = ({ walletControls, navTrigger }: ConsoleHeaderProps) => ( +
+ +
+ + + + + {navTrigger ?
{navTrigger}
: null} + + {walletControls ? ( +
+ {walletControls} +
+ ) : null} +
+
+
+); diff --git a/apps/explorer/src/components/UserConsole/ConsoleNavDrawer.tsx b/apps/explorer/src/components/UserConsole/ConsoleNavDrawer.tsx new file mode 100644 index 00000000..b823556d --- /dev/null +++ b/apps/explorer/src/components/UserConsole/ConsoleNavDrawer.tsx @@ -0,0 +1,44 @@ +"use client"; +import { Button } from "@filecoin-pay/ui/components/button"; +import { Sheet, SheetContent, SheetTitle, SheetTrigger } from "@filecoin-pay/ui/components/sheet"; +import { Menu } from "lucide-react"; +import { usePathname } from "next/navigation"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { ConsoleSidebar } from "@/components/UserConsole/ConsoleSidebar"; + +export const ConsoleNavDrawer = () => { + const [isOpen, setIsOpen] = useState(false); + const pathname = usePathname(); + const previousPathnameRef = useRef(pathname); + + const closeDrawer = useCallback(() => { + setIsOpen(false); + }, []); + + // Back/forward (including the mobile edge-swipe gesture) changes the route + // without any click of ours to react to, and the console layout persists + // across those navigations, so the drawer would stay open over the new page. + useEffect(() => { + if (previousPathnameRef.current === pathname) { + return; + } + + previousPathnameRef.current = pathname; + setIsOpen(false); + }, [pathname]); + + return ( + + + + + + + Console navigation + + + + ); +}; diff --git a/apps/explorer/src/components/UserConsole/ConsoleProviders.tsx b/apps/explorer/src/components/UserConsole/ConsoleProviders.tsx index 4ba5b970..f7b4f2bc 100644 --- a/apps/explorer/src/components/UserConsole/ConsoleProviders.tsx +++ b/apps/explorer/src/components/UserConsole/ConsoleProviders.tsx @@ -1,18 +1,30 @@ import { midnightTheme, RainbowKitProvider } from "@rainbow-me/rainbowkit"; import { WagmiProvider } from "wagmi"; +import { mainnet } from "@/constants/chains"; import { SynapseProvider } from "@/context/Synapse"; import { config } from "@/services/wagmi/config"; +import { TopUpActivityProvider } from "./TopUpActivityContext"; const ConsoleProviders = ({ children }: { children: React.ReactNode }) => { return ( - {children} + + {children} + ); diff --git a/apps/explorer/src/components/UserConsole/ConsoleSidebar.tsx b/apps/explorer/src/components/UserConsole/ConsoleSidebar.tsx new file mode 100644 index 00000000..f0482efa --- /dev/null +++ b/apps/explorer/src/components/UserConsole/ConsoleSidebar.tsx @@ -0,0 +1,101 @@ +"use client"; +import { cn } from "@filecoin-pay/ui/lib/utils"; +import { Bell, BellOff, Compass, LayoutDashboard } from "lucide-react"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import type { ReactNode } from "react"; +import { useConnection } from "wagmi"; +import { useNotificationStatus } from "@/hooks/useNotificationStatus"; +import { getNetworkFromChainId, isNotificationsEligibleNetwork } from "@/utils/network"; + +type ConsoleSidebarProps = { + /** + * Called when a nav item is activated. The mobile drawer passes a closer here: + * Radix does not know about client-side navigation, so without it the sheet + * stays open on top of the page the user just navigated to. + */ + onNavigate?: () => void; +}; + +type SidebarLinkProps = { + href: string; + isActive: boolean; + onNavigate?: () => void; + children: ReactNode; +}; + +/** + * `undefined` means the status is not known yet — still loading, the request + * failed, or the notifications API is unconfigured. Those render a neutral bell + * with no ON/OFF label rather than claiming alerts are off. + */ +const AlertsIcon = ({ isSubscribed }: { isSubscribed: boolean | undefined }) => { + if (isSubscribed === undefined) { + return ; + } + + return isSubscribed ? : ; +}; + +const SidebarLink = ({ href, isActive, onNavigate, children }: SidebarLinkProps) => ( + + {children} + +); + +export const ConsoleSidebar = ({ onNavigate }: ConsoleSidebarProps) => { + const pathname = usePathname(); + const { address, chainId } = useConnection(); + + const walletNetwork = getNetworkFromChainId(chainId); + const isNotificationsEligible = isNotificationsEligibleNetwork(walletNetwork); + + // Also read by the console page; React Query dedupes the two subscriptions. + const { data: notificationStatus } = useNotificationStatus(address); + const isSubscribed = notificationStatus?.subscribed; + + const isAlertsActive = pathname.startsWith("/console/notifications"); + const isDashboardActive = pathname === "/console"; + + // Chrome (border, responsive visibility) belongs to the caller: this renders + // both as the desktop column and inside the mobile drawer. + return ( + + ); +}; diff --git a/apps/explorer/src/components/UserConsole/DepositDialog.tsx b/apps/explorer/src/components/UserConsole/DepositDialog.tsx index 7c23ddc2..4380cb3e 100644 --- a/apps/explorer/src/components/UserConsole/DepositDialog.tsx +++ b/apps/explorer/src/components/UserConsole/DepositDialog.tsx @@ -1,4 +1,3 @@ -import { Badge } from "@filecoin-foundation/ui-filecoin/Badge"; import { Button } from "@filecoin-foundation/ui-filecoin/Button"; import { Input } from "@filecoin-foundation/ui-filecoin/Input"; import type { UserToken } from "@filecoin-pay/types"; @@ -11,131 +10,279 @@ import { DialogTitle, } from "@filecoin-pay/ui/components/dialog"; import { Label } from "@filecoin-pay/ui/components/label"; -import { AlertCircle, CheckCircle2, Loader2, Wallet } from "lucide-react"; -import { useEffect, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { Loader2, Wallet } from "lucide-react"; +import { useEffect, useEffectEvent, useRef, useState } from "react"; import { erc20Abi, formatUnits, type Hex, isAddress, parseUnits } from "viem"; import { useAccount, usePublicClient, useReadContract, useReadContracts, useWalletClient } from "wagmi"; +import DepositTokenPicker, { + type CustomTokenStatus, + type PickerToken, + type TokenPickerMode, +} from "@/components/UserConsole/DepositTokenPicker"; +import { FundingRunwaySlider, RunwayCard } from "@/components/UserConsole/FundsSection/components/RunwayCard"; +import { + calculateFundingRunway, + calculateProjectedFundingRunway, + defaultTopUpSuggestion, + ONE_YEAR_EPOCHS, +} from "@/components/UserConsole/FundsSection/data/funding-runway"; +import { parseTopUpAmount } from "@/components/UserConsole/FundsSection/data/guided-top-up"; import { useContractTransaction } from "@/hooks/useContractTransaction"; import useSynapse from "@/hooks/useSynapse"; import { getPermitSignature } from "@/utils/permit"; -interface DepositDialogProps { - userToken?: UserToken | null; - open: boolean; - onOpenChange: (open: boolean) => void; -} - -interface TokenDetails { +const PERMIT_DEADLINE_SECONDS = 3600; + +/** + * How far the hand-entered address has got towards a usable token. + * + * The checks are ordered by precedence and each one assumes those above it + * passed, so an empty field never reports as invalid and an in-flight read never + * reports as an error. `loaded` is the only state left once every check clears. + */ +const getCustomTokenStatus = ({ + address, + isValidAddress, + isLoadingReads, + isReadsError, + token, +}: { + /** The trimmed contract address as typed. */ address: string; - symbol: string; - decimals: number; - name?: string; -} + isValidAddress: boolean; + isLoadingReads: boolean; + isReadsError: boolean; + /** The token those reads resolved to, or null if they did not resolve one. */ + token: PickerToken | null; +}): CustomTokenStatus => { + if (!address) return "idle"; + if (!isValidAddress) return "invalid"; + if (isLoadingReads) return "loading"; + // A well-formed address that resolves nothing is an error too: the reads came + // back, but not from something this dialog can deposit. + if (isReadsError || !token) return "error"; + return "loaded"; +}; -type LoadingState = "idle" | "loading" | "success" | "error"; +type DepositDialogProps = { + /** + * Seeds the initial selection only. The dialog owns its selection after that, + * so a later change to this prop must not swap the target of a part-filled form. + */ + depositToken?: UserToken | null; + /** Tokens already held by the account, resolved by the caller. */ + tokens: UserToken[]; + open: boolean; + onOpenChange: (open: boolean) => void; +}; -export const DepositDialog: React.FC = ({ userToken, open, onOpenChange }) => { +export const DepositDialog = ({ depositToken, tokens, open, onOpenChange }: DepositDialogProps) => { const { address: userAddress } = useAccount(); - // Form state const [amount, setAmount] = useState(""); - const [tokenAddress, setTokenAddress] = useState(""); + const [customAddress, setCustomAddress] = useState(""); + const [selectedUserToken, setSelectedUserToken] = useState(null); + const [pickerMode, setPickerMode] = useState("collapsed"); + const didPrefillAmount = useRef(false); + /** + * Covers the whole of `handleDeposit`, which `isExecuting` does not: that only + * turns true once `execute` reaches `writeContract`, leaving the permit + * signature — an open wallet prompt, for as long as the user takes — a window + * where the form still looked idle and every click started another prompt. + */ + const [isSubmitting, setIsSubmitting] = useState(false); const { synapse, constants } = useSynapse(); const { data: walletClient } = useWalletClient(); const publicClient = usePublicClient(); - // Use the contract transaction hook const { execute, isExecuting } = useContractTransaction({ contractAddress: constants.contracts.payments.address, abi: constants.contracts.payments.abi, explorerUrl: constants.chain.blockExplorers?.default.url, }); - // Reset state when dialog closes + + /** The form is locked from the first click through to the receipt. */ + const isBusy = isSubmitting || isExecuting; + + /** + * Reads `depositToken` at the moment the dialog opens without making it a + * dependency of the effect below. The prop seeds the selection once; it is not + * a live binding, so a later change to it must not swap the target of a + * part-filled form. + */ + const seedSelection = useEffectEvent(() => { + const seedToken = depositToken ?? null; + setSelectedUserToken(seedToken); + // Without a preselected token there is nothing to collapse, and the first + // thing the user has to do is pick one — so open on the list. + setPickerMode(seedToken ? "collapsed" : "list"); + }); + + // Seed on open, reset on close — including the picker's own expanded state. useEffect(() => { - if (!open) { - setAmount(""); - setTokenAddress(""); + if (open) { + seedSelection(); + return; } - }, [open]); - // Determine which token address to use for queries - const shouldFetchToken = tokenAddress.trim() && isAddress(tokenAddress.trim()); - const validatedTokenAddress = shouldFetchToken ? (tokenAddress.trim() as Hex) : null; - const activeTokenAddress = userToken ? (userToken.token.id as Hex) : validatedTokenAddress; + setAmount(""); + setCustomAddress(""); + setSelectedUserToken(null); + setPickerMode("collapsed"); + setIsSubmitting(false); + didPrefillAmount.current = false; + }, [open]); - // Fetch token details using useReadContracts for parallel queries + const trimmedCustomAddress = customAddress.trim(); + const isCustomAddressValid = isAddress(trimmedCustomAddress); + + /** + * Set only on the custom-address path. A token picked from the account list + * already carries its symbol and decimals from the subgraph, so reading those + * back off-chain would be a multicall that changes nothing on screen. + * + * The two sources are mutually exclusive (see the handlers below); the + * `selectedUserToken` guard states that here rather than relying on it. + */ + const customTokenAddress: Hex | null = + !selectedUserToken && isCustomAddressValid ? (trimmedCustomAddress as Hex) : null; + + /** Whichever token the deposit acts on. Its wallet balance is only knowable on-chain. */ + const activeTokenAddress: Hex | null = selectedUserToken ? (selectedUserToken.token.id as Hex) : customTokenAddress; + + /** + * `allowFailure` is left at its default of `true`, so results arrive as + * `{ status, result }` rather than bare values. + */ const { - data: tokenDetailsData, - isLoading: isLoadingTokenDetails, - isError: isTokenDetailsError, + data: tokenReads, + isLoading: isLoadingTokenReads, + isError: isTokenReadsError, } = useReadContracts({ - contracts: activeTokenAddress + contracts: customTokenAddress ? [ - { - address: activeTokenAddress, - abi: erc20Abi, - functionName: "symbol", - }, - { - address: activeTokenAddress, - abi: erc20Abi, - functionName: "decimals", - }, - { - address: activeTokenAddress, - abi: erc20Abi, - functionName: "name", - }, + { address: customTokenAddress, abi: erc20Abi, functionName: "symbol" }, + { address: customTokenAddress, abi: erc20Abi, functionName: "decimals" }, + { address: customTokenAddress, abi: erc20Abi, functionName: "name" }, ] : [], query: { - enabled: !!activeTokenAddress && open, + enabled: Boolean(customTokenAddress) && open, }, }); - // Parse token details from contract response - const tokenDetails: TokenDetails | null = - activeTokenAddress && tokenDetailsData && !isTokenDetailsError + // Results come back positionally, in the order the contracts are listed above. + const [symbolRead, decimalsRead, nameRead] = tokenReads ?? []; + + /** + * A token resolved purely from chain reads — the custom-address path. + * + * All three reads must succeed, `name` included. That is not a display + * preference: this dialog deposits through `depositWithPermit`, and the EIP-712 + * domain in `getPermitSignature` is built from the token's `name()`. A token + * that has none cannot be signed for, so resolving it here would only arm a + * Deposit button that fails after the click. + */ + const chainToken: PickerToken | null = + customTokenAddress && + symbolRead?.status === "success" && + decimalsRead?.status === "success" && + nameRead?.status === "success" ? { - address: activeTokenAddress, - symbol: (tokenDetailsData[0]?.result as string) || "", - decimals: Number(tokenDetailsData[1]?.result || 0), - name: (tokenDetailsData[2]?.result as string) || "", + address: customTokenAddress, + symbol: symbolRead.result as string, + decimals: Number(decimalsRead.result), + name: nameRead.result as string, } : null; - // Fetch balance using useReadContract + // A token from the account list already carries its metadata, so it renders + // immediately instead of waiting on the multicall. + const currentToken: PickerToken | null = selectedUserToken + ? { + address: selectedUserToken.token.id, + symbol: selectedUserToken.token.symbol, + decimals: Number(selectedUserToken.token.decimals), + } + : chainToken; + + const customTokenStatus = getCustomTokenStatus({ + address: trimmedCustomAddress, + isValidAddress: isCustomAddressValid, + isLoadingReads: isLoadingTokenReads, + isReadsError: isTokenReadsError, + token: chainToken, + }); + const { data: balance, isLoading: isLoadingBalance } = useReadContract({ address: activeTokenAddress || undefined, abi: erc20Abi, functionName: "balanceOf", args: userAddress ? [userAddress] : undefined, query: { - enabled: !!activeTokenAddress && !!userAddress && open, + enabled: Boolean(activeTokenAddress) && Boolean(userAddress) && open, }, }); - // Determine loading state for UI feedback - const loadingState: LoadingState = !shouldFetchToken - ? "idle" - : isLoadingTokenDetails - ? "loading" - : isTokenDetailsError || !tokenDetails - ? "error" - : "success"; + const isUsdfcDeposit = currentToken?.address.toLowerCase() === constants.contracts.usdfc.toLowerCase(); + const { data: accountSummary, isFetching: isAccountSummaryLoading } = useQuery({ + enabled: open && isUsdfcDeposit && Boolean(userAddress) && synapse?.chain.id === constants.chain.id, + queryFn: synapse ? () => synapse.payments.accountSummary() : undefined, + queryKey: ["payments", "account-summary", constants.chain.id, userAddress], + }); - const handleDeposit = async () => { - // Determine which token to use - const token = userToken - ? { - symbol: userToken.token.symbol, - address: userToken.token.id, - decimals: Number(userToken.token.decimals), - } - : tokenDetails; + // Amounts are denominated in the token that was on screen when they were + // typed, so any change of token clears the field rather than reinterpreting it. + const handleSelectToken = (userToken: UserToken) => { + setSelectedUserToken(userToken); + setCustomAddress(""); + setAmount(""); + setPickerMode("collapsed"); + didPrefillAmount.current = false; + }; + + const handleModeChange = (mode: TokenPickerMode) => { + if (mode === "custom") { + setSelectedUserToken(null); + setAmount(""); + didPrefillAmount.current = false; + } + + setPickerMode(mode); + }; + + const handleCustomAddressChange = (value: string) => { + setCustomAddress(value); + setAmount(""); + didPrefillAmount.current = false; + }; + + /** + * The single gate for every user-initiated close: the X, Escape, an outside + * click and Cancel all arrive here. + * + * Closing while busy is refused rather than ignored, because closing does not + * cancel anything — an in-flight permit signature resolves regardless and goes + * on to submit. A dismissed dialog must not be able to move funds. + * + * The close that follows a submitted transaction deliberately does not come + * through here; see `onSubmitOnChain` below. + */ + const handleDialogOpenChange = (nextOpen: boolean) => { + if (!nextOpen && isBusy) return; + onOpenChange(nextOpen); + }; - if (!token) { + const handleMaxClick = () => { + if (balance !== undefined && currentToken) { + setAmount(formatUnits(balance, currentToken.decimals)); + } + }; + + const handleDeposit = async () => { + if (!currentToken) { console.log("No token selected"); return; } @@ -160,16 +307,24 @@ export const DepositDialog: React.FC = ({ userToken, open, o return; } + setIsSubmitting(true); + try { - const amountInWei = parseUnits(amount, token.decimals); - const deadline = BigInt(Math.floor(Date.now() / 1000) + 3600); + const amountInWei = parseUnits(amount, currentToken.decimals); + const deadline = BigInt(Math.floor(Date.now() / 1000) + PERMIT_DEADLINE_SECONDS); console.log("[Deposit] Getting permit signature..."); - // Get permit signature const permitSignature = await getPermitSignature( { - tokenAddress: token.address as `0x${string}`, + tokenAddress: currentToken.address as Hex, + // Set only for a token resolved from chain reads, where this is the + // contract's own `name()` and so the exact string the EIP-712 domain + // needs. Account-list tokens leave it undefined on purpose: their name + // comes from the subgraph, which stores "Unknown" for a reverting + // `name()`, and a domain built on that would produce a signature the + // token rejects. `getPermitSignature` re-reads it from chain instead. + tokenName: currentToken.name, ownerAddress: userAddress, spenderAddress: constants.contracts.payments.address, amount: amountInWei, @@ -182,11 +337,10 @@ export const DepositDialog: React.FC = ({ userToken, open, o console.log("[Deposit] Permit signature obtained, submitting transaction..."); - // Execute transaction with rich metadata await execute({ functionName: "depositWithPermit", args: [ - token.address, + currentToken.address, userAddress, amountInWei, permitSignature.deadline, @@ -197,150 +351,93 @@ export const DepositDialog: React.FC = ({ userToken, open, o metadata: { type: "deposit", amount, - token: token.symbol, + token: currentToken.symbol, }, - onSubmitOnChain: () => handleClose(), + // The one close that must succeed while busy: the transaction is away and + // the toast tracks it from here. Bypasses the guard above on purpose. + onSubmitOnChain: () => onOpenChange(false), }); } catch (err) { console.error("Deposit failed:", err); + } finally { + // Releases the permit half of the lock. `isExecuting` carries `isBusy` on + // its own from here until the receipt lands. + setIsSubmitting(false); } }; - const handleClose = () => { - if (!isExecuting) { - onOpenChange(false); - // State will be reset by useEffect when open becomes false - } - }; + const canDeposit = Boolean(currentToken) && Boolean(amount) && !isBusy; - const handleMaxClick = () => { - if (balance !== undefined && currentToken) { - const formattedBalance = formatUnits(balance, currentToken.decimals); - setAmount(formattedBalance); - } - }; - - // Determine current token to display - const currentToken = userToken - ? { - symbol: userToken.token.symbol, - decimals: Number(userToken.token.decimals), - address: userToken.token.id, - } - : tokenDetails; + const runwayCurrent = + isUsdfcDeposit && accountSummary + ? calculateFundingRunway(accountSummary, ONE_YEAR_EPOCHS, constants.chain.genesisTimestamp) + : null; + const usdfcDepositAmount = isUsdfcDeposit ? parseTopUpAmount(amount) : null; + const runwayProjected = + accountSummary && runwayCurrent && usdfcDepositAmount !== null + ? calculateProjectedFundingRunway( + accountSummary, + usdfcDepositAmount, + ONE_YEAR_EPOCHS, + constants.chain.genesisTimestamp, + ) + : null; + const defaultSuggestion = + isUsdfcDeposit && accountSummary && balance !== undefined + ? defaultTopUpSuggestion(accountSummary, constants.chain.genesisTimestamp, balance) + : ""; - const canDeposit = currentToken && amount && !isExecuting; + useEffect(() => { + if (!open || !defaultSuggestion || didPrefillAmount.current) return; + didPrefillAmount.current = true; + setAmount((previous) => (previous === "" ? defaultSuggestion : previous)); + }, [defaultSuggestion, open]); return ( - - + + { + if (isBusy) event.preventDefault(); + }} + onPointerDownOutside={(event) => { + if (isBusy) event.preventDefault(); + }} + > - Deposit {currentToken?.symbol || "Tokens"} - - {userToken - ? `Deposit more ${userToken.token.symbol} tokens to your account.` - : "Enter a token contract address to deposit tokens."} - + Deposit tokens + Choose a token and deposit it into your Filecoin Pay account. -
- {/* Custom Token Address Input (only if no userToken) */} - {!userToken && ( -
- -
- -
- - {/* Token Validation & Details */} - {tokenAddress && ( -
- {!validatedTokenAddress ? ( -
- - Invalid token address -
- ) : isLoadingTokenDetails ? ( -
- - Loading token details... -
- ) : isTokenDetailsError ? ( -
- - Failed to load token details -
- ) : tokenDetails ? ( -
-
- - Token loaded successfully -
-
-
- Symbol:{" "} - {tokenDetails.symbol} -
-
- Decimals:{" "} - {tokenDetails.decimals} -
-
- Name:{" "} - {tokenDetails.name} -
-
-
- ) : null} -
- )} -
- )} - - {/* Token Info Display */} - {currentToken && ( -
-
- Token -
- {currentToken.symbol} - {`${currentToken.decimals} decimals`} -
-
- - {!userToken && tokenDetails && ( -
-
- Contract Address - {`${tokenDetails.address.slice(0, 6)}...${tokenDetails.address.slice(-4)}`} -
- {tokenDetails.name && ( -
- Token Name - {tokenDetails.name} -
- )} -
- )} -
- )} - - {/* Amount Input - Only show if token is selected */} - {currentToken && ( + {/* `min-h-0` lets this shrink below its content so `overflow-y-auto` engages. */} +
+ + + {currentToken ? (
- {(balance !== undefined || isLoadingBalance) && ( -
- - + {balance !== undefined || isLoadingBalance ? ( + // `min-w-0` down the chain so the symbol — arbitrary text from a + // hand-entered contract — clips instead of widening the row. +
+ + Balance:{" "} {isLoadingBalance || balance === undefined ? ( @@ -354,7 +451,7 @@ export const DepositDialog: React.FC = ({ userToken, open, o )}
- )} + ) : null}
= ({ userToken, open, o onChange={setAmount} min='0' step='any' - disabled={isExecuting} + disabled={isBusy} className='text-lg pr-16' />
-

+ {/* Wraps rather than truncates: this line has the width to spare, + and `break-words` keeps an unbroken symbol from widening it. */} +

Enter the amount of {currentToken.symbol} you want to deposit

- )} - - {/* Info Message for new users */} - {!userToken && !currentToken && ( -
-

- Enter a token contract address above to begin your deposit -

-
- )} + ) : null} + + {isUsdfcDeposit && accountSummary && runwayCurrent ? ( + <> + + +

Target deposit: {amount || "—"} USDFC.

+
+ + ) : isUsdfcDeposit && isAccountSummaryLoading ? ( +

Loading funding runway…

+ ) : null}
- +
+ +
+ +
+
+ ); + } + + return ( +
+ Token + + {/* Trigger and expanded panel share one bordered box, so the list reads as + a continuation of the selected row rather than a detached card. */} +
+ {token ? ( + + ) : null} + + {mode === "list" ? ( +
+ {/* Only the token rows scroll: the account can hold far more than fit + on screen, and bounding them here keeps the "Add supported token" + row below pinned in view instead of buried at the end. */} + {selectableTokens.length > 0 ? ( +
    + {selectableTokens.map((userToken) => { + const listToken = toPickerToken(userToken); + + return ( +
  • + +
  • + ); + })} +
+ ) : null} + + {tokens.length === 0 ?

No tokens deposited yet.

: null} + + {/* + * TODO: list the tokens supported by Filecoin Pay from the subgraph's + * `Token` entity so most users pick from a known set, and keep raw + * address entry only as the fallback for tokens not yet indexed. + */} + +
+ ) : null} +
+
+ ); +}; + +export default DepositTokenPicker; diff --git a/apps/explorer/src/components/UserConsole/FundsSection/TopUpDialogController.test.tsx b/apps/explorer/src/components/UserConsole/FundsSection/TopUpDialogController.test.tsx new file mode 100644 index 00000000..de504415 --- /dev/null +++ b/apps/explorer/src/components/UserConsole/FundsSection/TopUpDialogController.test.tsx @@ -0,0 +1,228 @@ +import { act, create } from "react-test-renderer"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { TopUpActivityProvider, useTopUpActivity } from "../TopUpActivityContext"; +import { beginSquidAcquisition, getSquidAcquisitionStorageKey } from "./data/squid-acquisition"; +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, + recoveryRevision: 0, +})); +let storageListener: ((event: StorageEvent) => void) | undefined; +const storedValues = new Map(); +const storage = { + getItem: (key: string) => storedValues.get(key) ?? null, + removeItem: (key: string) => storedValues.delete(key), + setItem: (key: string, value: string) => storedValues.set(key, value), +}; + +vi.mock("@tanstack/react-query", () => ({ + useQuery: () => ({ data: undefined, isFetching: false }), +})); +vi.mock("next/navigation", () => ({ + usePathname: () => "/console", + useRouter: () => ({ replace }), + useSearchParams: () => params, +})); +vi.mock("wagmi", () => ({ + useConnection: () => ({ address: "0x1111111111111111111111111111111111111111" }), +})); +vi.mock("@/hooks/useSynapse", () => ({ + default: () => ({ synapse: undefined }), +})); +vi.mock("./components", () => ({ + GuidedTopUpDialog: ({ + onOpenChange, + open, + recoveryRevision, + }: { + onOpenChange: (open: boolean) => void; + open: boolean; + recoveryRevision: number; + }) => { + dialog.onOpenChange = onOpenChange; + dialog.open = open; + dialog.recoveryRevision = recoveryRevision; + return ( +
+ +
+ ); + }, +})); + +function ActivityState() { + const { isTopUpActive } = useTopUpActivity(); + return {String(isTopUpActive)}; +} + +function Harness({ showController = true }: { showController?: boolean }) { + return ( + + + {showController ? ( + + {(openTopUp) => ( + + )} + + ) : null} + + ); +} + +function renderController() { + let renderer!: ReturnType; + act(() => { + renderer = create( + + + , + ); + }); + return renderer; +} + +beforeEach(() => { + dialog.onOpenChange = undefined; + dialog.open = false; + dialog.recoveryRevision = 0; + storageListener = undefined; + params = new URLSearchParams(); + replace.mockReset(); + storedValues.clear(); + vi.stubGlobal("window", { + addEventListener: vi.fn((type: string, listener: (event: StorageEvent) => void) => { + if (type === "storage") storageListener = listener; + }), + localStorage: storage, + removeEventListener: vi.fn(), + }); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("TopUpDialogController activity", () => { + it("propagates real open, close, and controller cleanup through the activity provider", () => { + let renderer!: ReturnType; + act(() => { + renderer = create(); + }); + expect(renderer.root.findByProps({ "data-top-up-active": false }).children).toEqual(["false"]); + + act(() => renderer.root.findByProps({ "data-open-top-up": true }).props.onClick()); + expect(dialog.open).toBe(true); + expect(renderer.root.findByProps({ "data-top-up-active": true }).children).toEqual(["true"]); + + act(() => dialog.onOpenChange?.(false)); + expect(dialog.open).toBe(false); + expect(renderer.root.findByProps({ "data-top-up-active": false }).children).toEqual(["false"]); + + act(() => renderer.root.findByProps({ "data-open-top-up": true }).props.onClick()); + expect(renderer.root.findByProps({ "data-top-up-active": true }).children).toEqual(["true"]); + act(() => renderer.update()); + 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"); + }); +}); + +describe("TopUpDialogController recovery", () => { + it("auto-opens a saved acquisition and leaves a persistent launcher after close", () => { + beginSquidAcquisition( + storage, + "0x1111111111111111111111111111111111111111", + 10n, + 100n, + 42161, + "11111111-1111-4111-8111-111111111111", + ); + const renderer = renderController(); + + expect(dialog.open).toBe(true); + act(() => dialog.onOpenChange?.(false)); + expect(dialog.open).toBe(false); + + const launcher = renderer.root.findByProps({ "aria-label": "View top-up in progress" }); + expect(JSON.stringify(renderer.toJSON())).toContain("Top-up in progress — view"); + expect(dialog.open).toBe(false); + + act(() => launcher.props.onClick()); + expect(dialog.open).toBe(true); + }); + + it("refreshes recovery state when another tab writes while the dialog is already open", () => { + renderController(); + act(() => dialog.onOpenChange?.(true)); + expect(dialog.open).toBe(true); + const initialRevision = dialog.recoveryRevision; + + beginSquidAcquisition( + storage, + "0x1111111111111111111111111111111111111111", + 10n, + 100n, + 42161, + "11111111-1111-4111-8111-111111111111", + ); + act(() => + storageListener?.({ + key: "unrelated-key", + storageArea: storage as unknown as Storage, + } as StorageEvent), + ); + expect(dialog.recoveryRevision).toBe(initialRevision); + + act(() => + storageListener?.({ + key: getSquidAcquisitionStorageKey("0x1111111111111111111111111111111111111111"), + storageArea: {} as Storage, + } as StorageEvent), + ); + expect(dialog.recoveryRevision).toBe(initialRevision); + + act(() => + storageListener?.({ + key: getSquidAcquisitionStorageKey("0x1111111111111111111111111111111111111111"), + storageArea: storage as unknown as Storage, + } as StorageEvent), + ); + + expect(dialog.open).toBe(true); + expect(dialog.recoveryRevision).toBe(initialRevision + 1); + + storedValues.clear(); + act(() => + storageListener?.({ + key: null, + storageArea: storage as unknown as Storage, + } as StorageEvent), + ); + expect(dialog.recoveryRevision).toBe(initialRevision + 2); + }); +}); diff --git a/apps/explorer/src/components/UserConsole/FundsSection/TopUpDialogController.tsx b/apps/explorer/src/components/UserConsole/FundsSection/TopUpDialogController.tsx new file mode 100644 index 00000000..fcf3e915 --- /dev/null +++ b/apps/explorer/src/components/UserConsole/FundsSection/TopUpDialogController.tsx @@ -0,0 +1,137 @@ +import { Button } from "@filecoin-foundation/ui-filecoin/Button"; +import { useQuery } from "@tanstack/react-query"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { type ReactNode, useCallback, useEffect, useRef, useState } from "react"; +import { useConnection } from "wagmi"; +import { getChain } from "@/constants/chains"; +import useSynapse from "@/hooks/useSynapse"; +import { useTopUpActivity } from "../TopUpActivityContext"; +import { GuidedTopUpDialog } from "./components"; +import { withoutTopUpSearchParam } from "./data/guided-top-up"; +import { getSquidAcquisitionStorageKey, hasSavedSquidAcquisition } from "./data/squid-acquisition"; + +interface TopUpDialogControllerProps { + accountId: string; + children?: (openTopUp: () => void, isOpen: boolean) => ReactNode; + showTrigger?: boolean; +} + +export function TopUpDialogController({ accountId, children, showTrigger = false }: TopUpDialogControllerProps) { + const [open, setOpen] = useState(false); + const [hasSavedAcquisition, setHasSavedAcquisition] = useState(false); + const [recoveryRevision, setRecoveryRevision] = useState(0); + const didAutoOpenSavedAcquisition = useRef(false); + const { setTopUpActive } = useTopUpActivity(); + const { address } = useConnection(); + const { synapse } = useSynapse(); + const pathname = usePathname(); + const router = useRouter(); + const searchParams = useSearchParams(); + const targetChain = getChain("mainnet"); + const { data: accountSummary, isFetching: isAccountSummaryLoading } = useQuery({ + enabled: open && !!address && synapse?.chain.id === targetChain.id, + queryFn: synapse ? () => synapse.payments.accountSummary() : undefined, + queryKey: ["payments", "account-summary", targetChain.id, address], + }); + + const openTopUp = useCallback(() => { + setOpen(true); + setTopUpActive(true); + }, [setTopUpActive]); + const handleOpenChange = useCallback( + (nextOpen: boolean) => { + setOpen(nextOpen); + setTopUpActive(nextOpen); + if (!nextOpen) { + let hasSaved = false; + try { + hasSaved = address !== undefined && hasSavedSquidAcquisition(window.localStorage, address); + } catch { + // The dialog reports unavailable storage. Do not advertise recovery + // when the controller cannot verify that a marker exists. + } + setHasSavedAcquisition(hasSaved); + didAutoOpenSavedAcquisition.current = hasSaved; + } + if (!nextOpen && searchParams.has("topUp")) { + router.replace(`${pathname}${withoutTopUpSearchParam(searchParams)}`); + } + }, + [address, pathname, router, searchParams, setTopUpActive], + ); + + useEffect(() => { + if (searchParams.get("topUp") === "1") openTopUp(); + }, [openTopUp, searchParams]); + + useEffect(() => { + const refreshSavedAcquisition = () => { + let hasSaved = false; + try { + hasSaved = address !== undefined && hasSavedSquidAcquisition(window.localStorage, address); + } catch { + // The dialog owns the storage-unavailable error state. + } + setHasSavedAcquisition(hasSaved); + if (!hasSaved) { + didAutoOpenSavedAcquisition.current = false; + return; + } + if (!didAutoOpenSavedAcquisition.current) { + didAutoOpenSavedAcquisition.current = true; + openTopUp(); + } + }; + + refreshSavedAcquisition(); + const handleStorage = (event: StorageEvent) => { + if ( + address === undefined || + event.storageArea !== window.localStorage || + (event.key !== null && event.key !== getSquidAcquisitionStorageKey(address)) + ) { + return; + } + setRecoveryRevision((revision) => revision + 1); + refreshSavedAcquisition(); + }; + + window.addEventListener("storage", handleStorage); + return () => window.removeEventListener("storage", handleStorage); + }, [address, openTopUp]); + + useEffect( + () => () => { + setTopUpActive(false); + }, + [setTopUpActive], + ); + + return ( + <> + {hasSavedAcquisition && !open && ( +
+ +
+ )} + {children?.(openTopUp, open)} + {showTrigger && ( +
+ +
+ )} + + + ); +} diff --git a/apps/explorer/src/components/UserConsole/FundsSection/components/AddFundsDialog.test.tsx b/apps/explorer/src/components/UserConsole/FundsSection/components/AddFundsDialog.test.tsx new file mode 100644 index 00000000..91fd7401 --- /dev/null +++ b/apps/explorer/src/components/UserConsole/FundsSection/components/AddFundsDialog.test.tsx @@ -0,0 +1,33 @@ +import { act, create } from "react-test-renderer"; +import { describe, expect, it, vi } from "vitest"; +import { AddFundsDialog } from "./AddFundsDialog"; + +vi.mock("@filecoin-pay/ui/components/dialog", () => ({ + Dialog: ({ children }: { children: React.ReactNode }) => children, + DialogContent: ({ children }: { children: React.ReactNode }) => children, + DialogDescription: ({ children }: { children: React.ReactNode }) => children, + DialogHeader: ({ children }: { children: React.ReactNode }) => children, + DialogTitle: ({ children }: { children: React.ReactNode }) => children, +})); + +describe("AddFundsDialog", () => { + it("names both funding actions by what they do and preserves their selection values", () => { + const onSelect = vi.fn(); + let renderer!: ReturnType; + + act(() => { + renderer = create( undefined} onSelect={onSelect} open squidAvailable />); + }); + + const deposit = renderer.root.findByProps({ "aria-label": "Deposit token" }); + const swap = renderer.root.findByProps({ "aria-label": "Swap to USDFC" }); + const visibleText = renderer.root.findAllByType("span").flatMap((node) => node.children); + expect(visibleText).toContain("Deposit token"); + expect(visibleText).toContain("Swap to USDFC"); + expect(visibleText).toContain("Already hold USDFC or another token on Filecoin? Deposit it directly."); + + act(() => deposit.props.onClick()); + act(() => swap.props.onClick()); + expect(onSelect.mock.calls).toEqual([["deposit"], ["squid"]]); + }); +}); diff --git a/apps/explorer/src/components/UserConsole/FundsSection/components/AddFundsDialog.tsx b/apps/explorer/src/components/UserConsole/FundsSection/components/AddFundsDialog.tsx new file mode 100644 index 00000000..a4721901 --- /dev/null +++ b/apps/explorer/src/components/UserConsole/FundsSection/components/AddFundsDialog.tsx @@ -0,0 +1,113 @@ +"use client"; + +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@filecoin-pay/ui/components/dialog"; +import { ArrowRight, Repeat, Wallet } from "lucide-react"; + +export type AddFundsMethod = "deposit" | "squid"; + +type AddFundsDialogProps = { + onOpenChange: (open: boolean) => void; + onSelect: (method: AddFundsMethod) => void; + open: boolean; + squidAvailable: boolean; + squidDisabledReason?: string; +}; + +const cardBase = "group relative flex items-start gap-4 rounded-lg border p-4 text-left transition-colors"; +const enabledCard = `${cardBase} hover:border-primary hover:bg-muted/50`; +const disabledCard = `${cardBase} border-dashed bg-muted/30`; +const iconEnabled = "mt-0.5 rounded-md bg-primary/10 p-2 text-primary"; +const iconDisabled = "mt-0.5 rounded-md bg-muted p-2 text-muted-foreground"; + +export function AddFundsDialog({ + onOpenChange, + onSelect, + open, + squidAvailable, + squidDisabledReason, +}: AddFundsDialogProps) { + return ( + + + + Add funds + Choose how you want to fund your Filecoin Pay account. + +
+
+ {/* Stretched button keeps the whole card clickable without nesting + interactive elements inside a
+ +
+
+
+
+
+ ); +} diff --git a/apps/explorer/src/components/UserConsole/FundsSection/components/FundsMeters.tsx b/apps/explorer/src/components/UserConsole/FundsSection/components/FundsMeters.tsx new file mode 100644 index 00000000..a4fa73a2 --- /dev/null +++ b/apps/explorer/src/components/UserConsole/FundsSection/components/FundsMeters.tsx @@ -0,0 +1,84 @@ +import { Card } from "@filecoin-pay/ui/components/card"; +import { maxUint256 } from "viem"; +import { formatFutureTimestamp } from "@/utils/formatter"; +import { formatDuration } from "../utils/formatDuration"; +import { formatTokenAmount } from "../utils/formatTokenAmount"; +import type { FundsHealth } from "../utils/fundsHealth"; +import { getLockedPercent, getRunwayPercent } from "../utils/meterPercent"; +import { TIER_BAR_CLASSNAME, TIER_TRACK_CLASSNAME, TIER_VALUE_CLASSNAME } from "../utils/tierStyles"; +import MeterRow from "./MeterRow"; + +type FundsMetersProps = { + /** Total deposited balance for the selected token. */ + funds: bigint; + /** `simulatedLockupCurrent` — lockup rolled forward to `currentTimestamp`. */ + lockedAmount: bigint; + tokenDecimals: bigint | number; + tokenSymbol: string; + fundedUntilTimestamp: bigint; + currentTimestamp: bigint; + health: FundsHealth; +}; + +const getRunwayLabel = ({ daysRemaining, isExpired }: FundsHealth): string => { + if (isExpired) return "Expired"; + // The exact phrase the Funded until card uses for the same state. + if (daysRemaining === null) return "No recurring charges"; + return formatDuration(daysRemaining); +}; + +/** + * The date behind the runway bar, or nothing when there is no date to give: + * an infinite runway has none, and an expired one would only echo the "Expired" + * reading already shown at the right of the row. + */ +const getRunwayDetail = (fundedUntilTimestamp: bigint, currentTimestamp: bigint, health: FundsHealth) => { + if (fundedUntilTimestamp === maxUint256 || health.isExpired) return undefined; + return `Funded until ${formatFutureTimestamp(fundedUntilTimestamp, currentTimestamp)}`; +}; + +/** + * The two proportional readings under the overview cards: how long the funds + * last, and how much of them is already spoken for. + * + * Everything here is derived from figures the overview already computed, this + * card reads no new data. + */ +const FundsMeters = ({ + funds, + lockedAmount, + tokenDecimals, + tokenSymbol, + fundedUntilTimestamp, + currentTimestamp, + health, +}: FundsMetersProps) => { + const lockedPercent = getLockedPercent(lockedAmount, funds); + + return ( + + + {/* Locked is a proportion, not a severity: a high locked share is normal for an + active account. Blue at every value keeps it from being read as an alert + the way the tier-tinted runway bar above is meant to be. */} + + + ); +}; + +export default FundsMeters; diff --git a/apps/explorer/src/components/UserConsole/FundsSection/components/FundsMetricCard.tsx b/apps/explorer/src/components/UserConsole/FundsSection/components/FundsMetricCard.tsx new file mode 100644 index 00000000..03fea3e3 --- /dev/null +++ b/apps/explorer/src/components/UserConsole/FundsSection/components/FundsMetricCard.tsx @@ -0,0 +1,37 @@ +import { Card } from "@filecoin-pay/ui/components/card"; +import { cn } from "@filecoin-pay/ui/lib/utils"; +import type { ReactNode } from "react"; + +type FundsMetricCardProps = { + label: string; + value: string; + /** Secondary line under the value — the plain-text half of any colour-coded state. */ + detail?: string; + icon?: ReactNode; + valueClassName?: string; + /** Tints the detail line, so a colour-coded card can carry it through. */ + detailClassName?: string; + /** Card-level styling, used by the tier-tinted Funded until card. */ + className?: string; +}; + +const FundsMetricCard = ({ + label, + value, + detail, + icon, + valueClassName, + detailClassName, + className, +}: FundsMetricCardProps) => ( + +

{label}

+

+ {icon} + {value} +

+ {detail ?

{detail}

: null} +
+); + +export default FundsMetricCard; diff --git a/apps/explorer/src/components/UserConsole/FundsSection/components/FundsOverview.tsx b/apps/explorer/src/components/UserConsole/FundsSection/components/FundsOverview.tsx new file mode 100644 index 00000000..8db977ed --- /dev/null +++ b/apps/explorer/src/components/UserConsole/FundsSection/components/FundsOverview.tsx @@ -0,0 +1,104 @@ +import type { UserToken } from "@filecoin-pay/types"; +import { AlertCircle } from "lucide-react"; +import { useMemo } from "react"; +import { maxUint256 } from "viem"; +import { formatFutureTimestamp } from "@/utils/formatter"; +import { calculateFundedUntil } from "../utils/calculateFundedUntil"; +import { formatTokenAmount } from "../utils/formatTokenAmount"; +import { deriveFundsHealth, type HealthTier } from "../utils/fundsHealth"; +import { TIER_CARD_CLASSNAME, TIER_VALUE_CLASSNAME } from "../utils/tierStyles"; +import FundsMeters from "./FundsMeters"; +import FundsMetricCard from "./FundsMetricCard"; + +type FundsOverviewProps = { + userToken: UserToken; + currentTimestamp: bigint; +}; + +const TIERS_WITH_ICON: ReadonlySet = new Set(["warning", "critical", "emergency"]); + +const formatFundedUntil = (fundedUntilTimestamp: bigint, currentTimestamp: bigint) => { + if (fundedUntilTimestamp === maxUint256) return "Infinity"; + return formatFutureTimestamp(fundedUntilTimestamp, currentTimestamp); +}; + +const formatRunwayDetail = (daysRemaining: number | null, isExpired: boolean) => { + if (isExpired) return "Funding expired"; + // Infinite runway means no rate is charging the account — it does not mean nothing + // is locked. Fixed lockup (a CDN rail, say) still shows on the Locked card, so this + // must not read as "no lockup". Scoped to *recurring* so it stays true if a fixed + // lockup is later settled as a one-off payment. Avoid "burn": in this product that + // already means FIL destroyed for fees (`filBurned`), not paid to a provider. + if (daysRemaining === null) return "No recurring charges"; + if (daysRemaining === 0) return "in less than a day"; + if (daysRemaining === 1) return "in 1 day"; + return `in ${daysRemaining} days`; +}; + +const FundsOverview = ({ userToken, currentTimestamp }: FundsOverviewProps) => { + const { token } = userToken; + + const { availableFunds, debt, fundedUntilTimestamp, simulatedLockupCurrent } = useMemo( + () => calculateFundedUntil(userToken, currentTimestamp), + [userToken, currentTimestamp], + ); + + const health = useMemo( + () => deriveFundsHealth(fundedUntilTimestamp, currentTimestamp), + [fundedUntilTimestamp, currentTimestamp], + ); + + const isInDebt = debt > 0n; + const decimals = token.decimals; + + return ( + // The meters read the same figures as the cards, so they live under the same + // `calculateFundedUntil` call rather than repeating it a component away. +
+
+ + + {/* Debt rounds up so the figure never flatters what is owed; balances truncate. */} + +
+ + +
+ ); +}; + +export default FundsOverview; diff --git a/apps/explorer/src/components/UserConsole/FundsSection/components/FundsSectionLayout.tsx b/apps/explorer/src/components/UserConsole/FundsSection/components/FundsSectionLayout.tsx index 816c20dd..ac6a4d12 100644 --- a/apps/explorer/src/components/UserConsole/FundsSection/components/FundsSectionLayout.tsx +++ b/apps/explorer/src/components/UserConsole/FundsSection/components/FundsSectionLayout.tsx @@ -1,17 +1,42 @@ import { Button } from "@filecoin-foundation/ui-filecoin/Button"; +import { ArrowCircleDownIcon, ArrowCircleUpIcon } from "@phosphor-icons/react"; +import type { ReactNode } from "react"; interface FundsSectionLayoutProps { - children: React.ReactNode; + children: ReactNode; handleOpenDeposit: () => void; + /** + * Token picker rendered beside the heading. Only the loaded view has a token + * to select — the loading, error and empty states pass none. + */ + tokenSelector?: ReactNode; + /** Omitted by the loading, error and empty states: there is nothing to withdraw yet. */ + handleOpenWithdraw?: () => void; } -const FundsSectionLayout = ({ children, handleOpenDeposit }: FundsSectionLayoutProps) => ( +const FundsSectionLayout = ({ + children, + handleOpenDeposit, + tokenSelector, + handleOpenWithdraw, +}: FundsSectionLayoutProps) => (
-
-

Funds

- +
+
+

Funds overview

+ {tokenSelector} +
+
+ {/* Arrows point the way the funds move: in on deposit, out on withdraw. */} + + {handleOpenWithdraw ? ( + + ) : null} +
{children}
diff --git a/apps/explorer/src/components/UserConsole/FundsSection/components/FundsTable.tsx b/apps/explorer/src/components/UserConsole/FundsSection/components/FundsTable.tsx deleted file mode 100644 index dcb40820..00000000 --- a/apps/explorer/src/components/UserConsole/FundsSection/components/FundsTable.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { TanstackTable } from "@filecoin-foundation/ui-filecoin/Table/TanstackTable"; -import { getCoreRowModel, useReactTable } from "@tanstack/react-table"; -import { columns, type FundsTableRow } from "../data/columnDefinitions"; - -export type FundsTableProps = { - data: FundsTableRow[]; -}; - -function FundsTable({ data }: FundsTableProps) { - const table = useReactTable({ - data, - columns, - getCoreRowModel: getCoreRowModel(), - enableSorting: false, - }); - - return ; -} - -export default FundsTable; diff --git a/apps/explorer/src/components/UserConsole/FundsSection/components/GuidedTopUpDialog.test.tsx b/apps/explorer/src/components/UserConsole/FundsSection/components/GuidedTopUpDialog.test.tsx new file mode 100644 index 00000000..13f72166 --- /dev/null +++ b/apps/explorer/src/components/UserConsole/FundsSection/components/GuidedTopUpDialog.test.tsx @@ -0,0 +1,509 @@ +import { act, create } from "react-test-renderer"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + beginSquidAcquisition, + hasSavedSquidAcquisition, + loadSquidAcquisition, + markSquidAcquired, + markSquidBroadcast, + markSquidSwapRequested, + type SquidAcquisition, +} from "../data/squid-acquisition"; +import { GuidedTopUpDialog } from "./GuidedTopUpDialog"; + +const wallet = vi.hoisted(() => ({ + address: undefined as `0x${string}` | undefined, + chainId: 314 as number | undefined, +})); +const switchChainAsync = vi.hoisted(() => vi.fn(async (): Promise => undefined)); +const dialog = vi.hoisted(() => ({ onOpenChange: undefined as ((open: boolean) => void) | undefined })); +const sdk = vi.hoisted(() => ({ + fundSync: vi.fn(), + invalidateQueries: vi.fn().mockResolvedValue(undefined), + synapse: undefined as { payments: { fundSync: ReturnType } } | undefined, +})); +const quoteReview = vi.hoisted(() => ({ + onAcquired: undefined as ((acquisition: SquidAcquisition) => void) | undefined, + onNetworkSwitchingChange: undefined as ((isSwitching: boolean) => void) | undefined, +})); +const automaticRecovery = vi.hoisted(() => ({ + data: undefined as bigint | null | undefined, + dataUpdatedAt: 0, + error: null as Error | null, + isEligible: false, + isFetching: false, + isPermanentError: false, + refetch: vi.fn(), +})); +const lockManager = vi.hoisted(() => ({ + request: vi.fn(async (_name: string, _options: LockOptions, callback: (lock: Lock | null) => unknown) => + callback({} as Lock), + ), +})); + +vi.mock("wagmi", () => ({ + useConnection: () => wallet, + usePublicClient: () => undefined, + useSwitchChain: () => ({ switchChainAsync }), +})); +vi.mock("@tanstack/react-query", () => ({ + useQueryClient: () => ({ invalidateQueries: sdk.invalidateQueries }), +})); +vi.mock("@/hooks/useSynapse", () => ({ + default: () => ({ + constants: { + chain: { genesisTimestamp: 0 }, + contracts: { usdfc: "0x3333333333333333333333333333333333333333" }, + }, + synapse: sdk.synapse, + }), +})); +vi.mock("sonner", () => ({ toast: { error: vi.fn(), info: vi.fn(), success: vi.fn(), warning: vi.fn() } })); +vi.mock("../hooks/useSquidAcquisitionRecovery", () => ({ + useSquidAcquisitionRecovery: () => automaticRecovery, +})); +vi.mock("@filecoin-foundation/ui-filecoin/Button", () => ({ + Button: ({ + children, + disabled, + onClick, + }: { + children: React.ReactNode; + disabled?: boolean; + onClick?: () => void; + }) => ( + + ), +})); +vi.mock("@filecoin-foundation/ui-filecoin/Input", () => ({ Input: () => })); +vi.mock("@filecoin-pay/ui/components/label", () => ({ + Label: ({ children }: { children: React.ReactNode }) => children, +})); +vi.mock("@filecoin-pay/ui/components/dialog", () => ({ + Dialog: ({ children, onOpenChange }: { children: React.ReactNode; onOpenChange: (open: boolean) => void }) => { + dialog.onOpenChange = onOpenChange; + return children; + }, + DialogContent: ({ children }: { children: React.ReactNode }) => children, + DialogDescription: ({ children }: { children: React.ReactNode }) => children, + DialogFooter: ({ children }: { children: React.ReactNode }) => children, + DialogHeader: ({ children }: { children: React.ReactNode }) => children, + DialogTitle: ({ children }: { children: React.ReactNode }) => children, +})); +vi.mock("./RunwayCard", () => ({ + FundingRunwaySlider: () => null, + RunwayCard: ({ children }: { children: React.ReactNode }) => children, +})); +vi.mock("./SquidQuoteReview", () => ({ + SquidQuoteReview: ({ + onAcquired, + onNetworkSwitchingChange, + }: { + onAcquired: NonNullable; + onNetworkSwitchingChange: (isSwitching: boolean) => void; + }) => { + quoteReview.onAcquired = onAcquired; + quoteReview.onNetworkSwitchingChange = onNetworkSwitchingChange; + return null; + }, +})); + +beforeEach(() => { + lockManager.request + .mockReset() + .mockImplementation(async (_name: string, _options: LockOptions, callback: (lock: Lock | null) => unknown) => + callback({} as Lock), + ); + vi.stubGlobal("navigator", { locks: lockManager }); +}); + +afterEach(() => { + wallet.address = undefined; + wallet.chainId = 314; + sdk.synapse = undefined; + automaticRecovery.data = undefined; + automaticRecovery.dataUpdatedAt = 0; + automaticRecovery.error = null; + automaticRecovery.isEligible = false; + automaticRecovery.isFetching = false; + automaticRecovery.isPermanentError = false; + automaticRecovery.refetch.mockReset(); + vi.unstubAllGlobals(); +}); +describe("GuidedTopUpDialog", () => { + it("restores the wallet network captured when the dialog opened", async () => { + const onOpenChange = vi.fn(); + const props = { + accountId: "account", + isAccountSummaryLoading: false, + onOpenChange, + open: true, + }; + let renderer!: ReturnType; + + await act(async () => { + renderer = create(); + }); + await act(async () => { + quoteReview.onNetworkSwitchingChange?.(true); + }); + await act(async () => { + dialog.onOpenChange?.(false); + }); + expect(onOpenChange).not.toHaveBeenCalled(); + + await act(async () => { + quoteReview.onNetworkSwitchingChange?.(false); + }); + wallet.chainId = 8453; + await act(async () => { + renderer.update(); + }); + await act(async () => { + dialog.onOpenChange?.(false); + }); + + expect(onOpenChange).toHaveBeenCalledWith(false); + expect(switchChainAsync).toHaveBeenCalledWith({ chainId: 314 }); + }); + + it("captures the first defined wallet network when opening before hydration", async () => { + const onOpenChange = vi.fn(); + const props = { + accountId: "account", + isAccountSummaryLoading: false, + onOpenChange, + open: true, + }; + wallet.chainId = undefined; + let renderer!: ReturnType; + + await act(async () => { + renderer = create(); + }); + wallet.chainId = 8453; + await act(async () => { + renderer.update(); + }); + wallet.chainId = 314; + await act(async () => { + renderer.update(); + }); + await act(async () => { + dialog.onOpenChange?.(false); + }); + + expect(onOpenChange).toHaveBeenCalledWith(false); + expect(switchChainAsync).toHaveBeenCalledWith({ chainId: 8453 }); + }); + + it("keeps the dialog open until the Filecoin network switch settles", async () => { + const onOpenChange = vi.fn(); + const props = { + accountId: "account", + isAccountSummaryLoading: false, + onOpenChange, + open: true, + }; + let resolveSwitch!: () => void; + switchChainAsync.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveSwitch = resolve; + }), + ); + wallet.chainId = 314; + let renderer!: ReturnType; + + await act(async () => { + renderer = create(); + }); + wallet.chainId = 8453; + await act(async () => { + renderer.update(); + quoteReview.onAcquired?.({ + destinationAmount: 1n, + owner: "0x0000000000000000000000000000000000000001", + sourceChainId: 8453, + status: "acquired", + transactionHashes: [], + }); + }); + const switchButton = renderer.root + .findAllByType("button") + .find((button) => button.children.includes("Switch to Filecoin to deposit")); + expect(switchButton).toBeDefined(); + + await act(async () => { + void switchButton?.props.onClick(); + }); + expect(switchButton?.props.disabled).toBe(true); + await act(async () => { + dialog.onOpenChange?.(false); + }); + expect(onOpenChange).not.toHaveBeenCalled(); + + wallet.chainId = 314; + await act(async () => { + renderer.update(); + }); + const depositButton = renderer.root + .findAllByType("button") + .find((button) => button.children.includes("Deposit acquired USDFC")); + expect(depositButton?.props.disabled).toBe(true); + + await act(async () => { + resolveSwitch(); + }); + await act(async () => { + dialog.onOpenChange?.(false); + }); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); + + it("deposits the frozen delivered balance increase instead of the reviewed minimum", async () => { + const values = new Map(); + const storage = { + getItem: (key: string) => values.get(key) ?? null, + removeItem: (key: string) => values.delete(key), + setItem: (key: string, value: string) => values.set(key, value), + }; + const owner = "0x1111111111111111111111111111111111111111" as const; + const oneUsdfc = 10n ** 18n; + const processing = beginSquidAcquisition( + storage, + owner, + 10n * oneUsdfc, + 100n * oneUsdfc, + 42161, + "11111111-1111-4111-8111-111111111111", + ); + const acquired = markSquidAcquired( + storage, + markSquidBroadcast(storage, markSquidSwapRequested(storage, processing), `0x${"3".repeat(64)}`), + 15n * oneUsdfc, + ); + sdk.fundSync.mockImplementation(async ({ onHash }: { onHash: (hash: `0x${string}`) => void }) => { + onHash(`0x${"4".repeat(64)}`); + return { receipt: { status: "success" } }; + }); + sdk.synapse = { payments: { fundSync: sdk.fundSync } }; + vi.stubGlobal("window", { confirm: vi.fn(), localStorage: storage }); + wallet.address = owner; + + let renderer!: ReturnType; + await act(async () => { + renderer = create( + , + ); + }); + const depositButton = renderer.root + .findAllByType("button") + .find((button) => button.children.includes("Deposit acquired USDFC")); + expect(JSON.stringify(renderer.toJSON())).toContain('"15"'); + await act(async () => { + await depositButton?.props.onClick(); + }); + + expect(sdk.fundSync).toHaveBeenCalledWith(expect.objectContaining({ amount: 15n * oneUsdfc })); + expect(loadSquidAcquisition(storage, acquired.owner)).toBeNull(); + }); + + it("offers a confirmed recovery path for malformed saved state", async () => { + const values = new Map(); + const storage = { + getItem: (key: string) => values.get(key) ?? null, + removeItem: (key: string) => values.delete(key), + setItem: (key: string, value: string) => values.set(key, value), + }; + const owner = "0x1111111111111111111111111111111111111111" as const; + storage.setItem(`filecoin-pay:squid-acquisition:v1:${owner.toLowerCase()}`, "not json"); + vi.stubGlobal("window", { confirm: vi.fn().mockReturnValue(true), localStorage: storage }); + wallet.address = owner; + + let renderer!: ReturnType; + await act(async () => { + renderer = create( + , + ); + }); + const clearButton = renderer.root + .findAllByType("button") + .find((button) => button.children.includes("Clear invalid saved acquisition")); + expect(clearButton).toBeDefined(); + + await act(async () => { + await clearButton?.props.onClick(); + }); + expect(hasSavedSquidAcquisition(storage, owner)).toBe(false); + expect(JSON.stringify(renderer.toJSON())).not.toContain("invalid and must be cleared"); + }); + + it("automatically continues with the verified delivered amount after refresh", async () => { + const values = new Map(); + const storage = { + getItem: (key: string) => values.get(key) ?? null, + removeItem: (key: string) => values.delete(key), + setItem: (key: string, value: string) => values.set(key, value), + }; + const owner = "0x1111111111111111111111111111111111111111" as const; + const processing = markSquidBroadcast( + storage, + markSquidSwapRequested( + storage, + beginSquidAcquisition( + storage, + owner, + 10n * 10n ** 18n, + 100n * 10n ** 18n, + 42161, + "11111111-1111-4111-8111-111111111111", + ), + ), + `0x${"3".repeat(64)}`, + ); + automaticRecovery.data = 15n * 10n ** 18n; + automaticRecovery.dataUpdatedAt = 1; + automaticRecovery.isEligible = true; + vi.stubGlobal("window", { confirm: vi.fn(), localStorage: storage }); + wallet.address = owner; + + let renderer!: ReturnType; + await act(async () => { + renderer = create( + , + ); + }); + + expect(loadSquidAcquisition(storage, processing.owner)).toEqual( + expect.objectContaining({ deliveredAmount: 15n * 10n ** 18n, status: "acquired" }), + ); + expect(JSON.stringify(renderer.toJSON())).toContain('"15"'); + expect(JSON.stringify(renderer.toJSON())).not.toContain("USDFC arrived, continue to deposit"); + }); + + it("keeps a safe preflight marker until recovery is visible, then restarts cleanly", async () => { + const values = new Map(); + const storage = { + getItem: (key: string) => values.get(key) ?? null, + removeItem: (key: string) => values.delete(key), + setItem: (key: string, value: string) => values.set(key, value), + }; + const owner = "0x1111111111111111111111111111111111111111" as const; + beginSquidAcquisition( + storage, + owner, + 10n * 10n ** 18n, + 100n * 10n ** 18n, + 42161, + "11111111-1111-4111-8111-111111111111", + ); + vi.stubGlobal("window", { confirm: vi.fn(), localStorage: storage }); + wallet.address = owner; + + let renderer!: ReturnType; + await act(async () => { + renderer = create( + , + ); + }); + expect(hasSavedSquidAcquisition(storage, owner)).toBe(true); + + await act(async () => { + renderer.update( + , + ); + }); + expect(hasSavedSquidAcquisition(storage, owner)).toBe(false); + expect(JSON.stringify(renderer.toJSON())).not.toContain("A saved transaction needs verification"); + }); + + it("preserves a preflight marker while another tab owns the acquisition lock", async () => { + const values = new Map(); + const storage = { + getItem: (key: string) => values.get(key) ?? null, + removeItem: (key: string) => values.delete(key), + setItem: (key: string, value: string) => values.set(key, value), + }; + const owner = "0x1111111111111111111111111111111111111111" as const; + const preparing = beginSquidAcquisition( + storage, + owner, + 10n * 10n ** 18n, + 100n * 10n ** 18n, + 42161, + "11111111-1111-4111-8111-111111111111", + ); + lockManager.request.mockImplementationOnce( + async (_name: string, _options: LockOptions, callback: (lock: Lock | null) => unknown) => callback(null), + ); + vi.stubGlobal("window", { confirm: vi.fn(), localStorage: storage }); + wallet.address = owner; + + let renderer!: ReturnType; + await act(async () => { + renderer = create( + , + ); + }); + + expect(loadSquidAcquisition(storage, owner)).toEqual(preparing); + expect(JSON.stringify(renderer.toJSON())).toContain("already active in another tab"); + expect(markSquidSwapRequested(storage, preparing)).toEqual( + expect.objectContaining({ executionStage: "swap-requested" }), + ); + }); + + it("reloads a saved acquisition after a cross-tab storage revision", async () => { + const values = new Map(); + const storage = { + getItem: (key: string) => values.get(key) ?? null, + removeItem: (key: string) => values.delete(key), + setItem: (key: string, value: string) => values.set(key, value), + }; + const owner = "0x1111111111111111111111111111111111111111" as const; + vi.stubGlobal("window", { confirm: vi.fn(), localStorage: storage }); + wallet.address = owner; + + let renderer!: ReturnType; + await act(async () => { + renderer = create( + , + ); + }); + expect(JSON.stringify(renderer.toJSON())).not.toContain("A saved transaction needs verification"); + + markSquidSwapRequested( + storage, + beginSquidAcquisition( + storage, + owner, + 10n * 10n ** 18n, + 100n * 10n ** 18n, + 42161, + "11111111-1111-4111-8111-111111111111", + ), + ); + await act(async () => { + renderer.update( + , + ); + }); + + expect(JSON.stringify(renderer.toJSON())).toContain("A saved transaction needs verification"); + }); +}); diff --git a/apps/explorer/src/components/UserConsole/FundsSection/components/GuidedTopUpDialog.tsx b/apps/explorer/src/components/UserConsole/FundsSection/components/GuidedTopUpDialog.tsx new file mode 100644 index 00000000..5aa646d2 --- /dev/null +++ b/apps/explorer/src/components/UserConsole/FundsSection/components/GuidedTopUpDialog.tsx @@ -0,0 +1,753 @@ +"use client"; + +import { Button } from "@filecoin-foundation/ui-filecoin/Button"; +import { Input } from "@filecoin-foundation/ui-filecoin/Input"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@filecoin-pay/ui/components/dialog"; +import { Label } from "@filecoin-pay/ui/components/label"; +import { useQueryClient } from "@tanstack/react-query"; +import { Check, Loader2 } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { toast } from "sonner"; +import type { Address } from "viem"; +import { useConnection, usePublicClient, useSwitchChain } from "wagmi"; +import { mainnet, SQUID_SOURCE_CHAINS } from "@/constants/chains"; +import useSynapse from "@/hooks/useSynapse"; +import { + calculateFundingRunway, + calculateProjectedFundingRunway, + defaultTopUpSuggestion, + type FundingAccountSummary, + formatUsdfcAmount, + ONE_YEAR_EPOCHS, +} from "../data/funding-runway"; +import { invalidateTopUpQueries, parseTopUpAmount } from "../data/guided-top-up"; +import { + clearInvalidSquidAcquisition, + clearSquidAcquisition, + getSquidDepositAmount, + hasSameSquidAcquisitionSnapshot, + hasSavedSquidAcquisition, + loadSquidAcquisition, + markSquidAcquired, + markSquidAcquiredFromBalance, + markSquidDepositPending, + resetSquidDeposit, + type SquidAcquisition, +} from "../data/squid-acquisition"; +import { withSquidAcquisitionLock } from "../data/squid-acquisition-lock"; +import { isAutomaticSquidRecoveryCandidate } from "../data/squid-acquisition-recovery"; +import { isUserRejectedRequest, walletErrorMessage } from "../data/squid-execution"; +import { readUsdfcBalance } from "../data/usdfc-balance"; +import { useSquidAcquisitionRecovery } from "../hooks/useSquidAcquisitionRecovery"; +import { FundingRunwaySlider, RunwayCard } from "./RunwayCard"; +import { SquidQuoteReview } from "./SquidQuoteReview"; + +function StepIndicator({ step }: { step: 1 | 2 }) { + const steps = ["Acquire USDFC", "Deposit to Filecoin Pay"] as const; + return ( +
    + {steps.map((label, index) => { + const position = index + 1; + const isDone = step > position; + const isActive = step === position; + return ( +
  1. + + {isDone ? : position} + + {label} + {position < steps.length && } +
  2. + ); + })} +
+ ); +} + +type GuidedTopUpDialogProps = { + accountId: string; + accountSummary?: FundingAccountSummary; + isAccountSummaryLoading: boolean; + onOpenChange: (open: boolean) => void; + open: boolean; + recoveryRevision?: number; +}; + +export function GuidedTopUpDialog({ + accountId, + accountSummary, + isAccountSummaryLoading, + onOpenChange, + open, + recoveryRevision = 0, +}: GuidedTopUpDialogProps) { + const { constants, synapse } = useSynapse(); + const { address, chainId } = useConnection(); + const { switchChainAsync } = useSwitchChain(); + const destinationClient = usePublicClient({ chainId: mainnet.id }); + const queryClient = useQueryClient(); + const [amount, setAmount] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + const [acquiredAmount, setAcquiredAmount] = useState(null); + const [acquisitionOwner, setAcquisitionOwner] = useState
(null); + const [savedAcquisition, setSavedAcquisition] = useState(null); + const [hasInvalidAcquisition, setHasInvalidAcquisition] = useState(false); + const [acquisitionCoordinationError, setAcquisitionCoordinationError] = useState(null); + const [automaticRecoveryError, setAutomaticRecoveryError] = useState(null); + const [acquisitionState, setAcquisitionState] = useState<"acquired" | "blocked" | "idle" | "processing">("idle"); + const [isSwitchingNetwork, setIsSwitchingNetwork] = useState(false); + const originalChainId = useRef(undefined); + const isAwaitingOriginalChainId = useRef(false); + const wasOpen = useRef(false); + // Set when the amount was prefilled for this open, so clearing the field + // doesn't refill it (see the prefill effect below). + const didPrefillAmount = useRef(false); + const latestAddress = useRef(address); + latestAddress.current = address; + // The runway duration only affects the slider's suggestions (computed inside + // FundingRunwaySlider); the displayed funded-through dates are duration-agnostic. + const current = accountSummary + ? calculateFundingRunway(accountSummary, ONE_YEAR_EPOCHS, constants.chain.genesisTimestamp) + : null; + const parsedAmount = parseTopUpAmount(amount); + const depositAmount = acquiredAmount ?? parsedAmount; + const projected = + accountSummary && depositAmount !== null + ? calculateProjectedFundingRunway( + accountSummary, + depositAmount, + ONE_YEAR_EPOCHS, + constants.chain.genesisTimestamp, + ) + : null; + const step: 1 | 2 = acquiredAmount === null ? 1 : 2; + const acquisitionOwnerMatches = + acquisitionOwner === null || (address !== undefined && acquisitionOwner.toLowerCase() === address.toLowerCase()); + const savedSourceChain = SQUID_SOURCE_CHAINS.find( + (sourceChain) => sourceChain.id === savedAcquisition?.sourceChainId, + ); + const automaticRecovery = useSquidAcquisitionRecovery(savedAcquisition, address); + useEffect(() => { + // The controller advances this value when another tab changes recovery + // storage, forcing the snapshot below to be reloaded even while open. + void recoveryRevision; + let cancelled = false; + const applySavedAcquisition = (saved: SquidAcquisition | null, hasSaved: boolean) => { + if (cancelled) return; + const hasInvalidSavedAcquisition = hasSaved && saved === null; + setSavedAcquisition(saved); + setHasInvalidAcquisition(hasInvalidSavedAcquisition); + setAcquisitionCoordinationError(null); + setAutomaticRecoveryError(null); + setAcquisitionOwner(saved?.owner ?? null); + setAcquiredAmount(saved?.status === "acquired" ? getSquidDepositAmount(saved) : null); + setAcquisitionState( + saved?.status === "acquired" ? "acquired" : saved || hasInvalidSavedAcquisition ? "blocked" : "idle", + ); + }; + + setIsSubmitting(false); + if (!address) { + setAcquiredAmount(null); + setAcquisitionOwner(null); + setSavedAcquisition(null); + setHasInvalidAcquisition(false); + setAcquisitionCoordinationError(null); + setAutomaticRecoveryError(null); + setAcquisitionState("idle"); + return; + } + + try { + const hasSavedAcquisition = hasSavedSquidAcquisition(window.localStorage, address); + const saved = loadSquidAcquisition(window.localStorage, address); + applySavedAcquisition(saved, hasSavedAcquisition); + if (!open || saved?.status !== "processing" || saved.executionStage !== "preparing") return; + + void withSquidAcquisitionLock(globalThis.navigator?.locks, saved.owner, () => { + const current = loadSquidAcquisition(window.localStorage, saved.owner); + if ( + current?.status === "processing" && + current.executionStage === "preparing" && + hasSameSquidAcquisitionSnapshot(current, saved) + ) { + clearSquidAcquisition(window.localStorage, current); + } + }) + .then(() => { + const hasCurrent = hasSavedSquidAcquisition(window.localStorage, saved.owner); + applySavedAcquisition(loadSquidAcquisition(window.localStorage, saved.owner), hasCurrent); + }) + .catch((error) => { + if (cancelled) return; + try { + const hasCurrent = hasSavedSquidAcquisition(window.localStorage, saved.owner); + applySavedAcquisition(loadSquidAcquisition(window.localStorage, saved.owner), hasCurrent); + } catch { + setSavedAcquisition(null); + setHasInvalidAcquisition(false); + setAcquisitionOwner(null); + setAcquiredAmount(null); + setAcquisitionState("blocked"); + } + setAcquisitionCoordinationError( + error instanceof Error ? error.message : "Funding coordination is unavailable in this tab", + ); + }); + } catch { + setAcquiredAmount(null); + setAcquisitionOwner(null); + setSavedAcquisition(null); + setHasInvalidAcquisition(false); + setAcquisitionCoordinationError(null); + setAutomaticRecoveryError(null); + setAcquisitionState("blocked"); + } + return () => { + cancelled = true; + }; + }, [address, open, recoveryRevision]); + + useEffect(() => { + const pending = savedAcquisition; + const deliveredAmount = automaticRecovery.data; + if ( + !isAutomaticSquidRecoveryCandidate(pending) || + deliveredAmount === undefined || + deliveredAmount === null || + automaticRecovery.dataUpdatedAt === 0 + ) { + return; + } + let cancelled = false; + setAutomaticRecoveryError(null); + void withSquidAcquisitionLock(globalThis.navigator?.locks, pending.owner, () => + markSquidAcquired(window.localStorage, pending, deliveredAmount), + ) + .then((acquired) => { + if (cancelled || latestAddress.current?.toLowerCase() !== acquired.owner.toLowerCase()) return; + setSavedAcquisition(acquired); + setAcquisitionOwner(acquired.owner); + setAcquiredAmount(getSquidDepositAmount(acquired)); + setAcquisitionState("acquired"); + }) + .catch((error) => { + if (cancelled || latestAddress.current?.toLowerCase() !== pending.owner.toLowerCase()) return; + try { + const latest = loadSquidAcquisition(window.localStorage, pending.owner); + if (latest && !hasSameSquidAcquisitionSnapshot(latest, pending)) { + setSavedAcquisition(latest); + setAcquisitionOwner(latest.owner); + setAcquiredAmount(latest.status === "acquired" ? getSquidDepositAmount(latest) : null); + setAcquisitionState(latest.status === "acquired" ? "acquired" : "blocked"); + setAutomaticRecoveryError(null); + return; + } + } catch { + // Surface the original transition error below. The next poll retries + // both the storage read and the exact-snapshot transition. + } + setAutomaticRecoveryError(error instanceof Error ? error.message : "Automatic recovery could not continue"); + }); + return () => { + cancelled = true; + }; + }, [automaticRecovery.data, automaticRecovery.dataUpdatedAt, savedAcquisition]); + + useEffect(() => { + // Reset the amount on open; the prefill effect below fills it once the + // on-chain summary is available. + if (open && !wasOpen.current) { + originalChainId.current = chainId; + isAwaitingOriginalChainId.current = chainId === undefined; + if (acquiredAmount === null) { + setAmount(""); + didPrefillAmount.current = false; + } + } + // An auto-opened dialog can render before Wagmi hydrates. Capture only the + // first defined chain so later route-driven switches cannot replace it. + if (open && isAwaitingOriginalChainId.current && chainId !== undefined) { + originalChainId.current = chainId; + isAwaitingOriginalChainId.current = false; + } + if (!open) isAwaitingOriginalChainId.current = false; + wasOpen.current = open; + }, [acquiredAmount, chainId, open]); + + // Prefill the amount with the slider's default suggestion once per open, so + // the projection is live immediately instead of dashes until the user acts. + // The summary loads async, so this fires whenever it arrives while open. + const defaultSuggestion = accountSummary + ? defaultTopUpSuggestion(accountSummary, constants.chain.genesisTimestamp) + : ""; + useEffect(() => { + if (!open || !defaultSuggestion || didPrefillAmount.current || acquiredAmount !== null) return; + didPrefillAmount.current = true; + setAmount((previous) => (previous === "" ? defaultSuggestion : previous)); + }, [acquiredAmount, defaultSuggestion, open]); + + const handleConfirm = async () => { + if ( + !synapse || + acquiredAmount === null || + isSubmitting || + !acquisitionOwner || + !acquisitionOwnerMatches || + !savedAcquisition + ) + return; + + try { + await withSquidAcquisitionLock(globalThis.navigator?.locks, savedAcquisition.owner, async () => { + let pendingAcquisition: SquidAcquisition; + try { + pendingAcquisition = markSquidDepositPending(window.localStorage, savedAcquisition); + setSavedAcquisition(pendingAcquisition); + } catch { + toast.error("Browser storage is unavailable. The deposit cannot start safely without recovery state."); + return; + } + const depositOwner = acquisitionOwner; + const isCurrentDepositOwner = () => latestAddress.current?.toLowerCase() === depositOwner.toLowerCase(); + let didBroadcast = false; + setIsSubmitting(true); + try { + const { receipt } = await synapse.payments.fundSync({ + amount: acquiredAmount, + onHash: (hash) => { + didBroadcast = true; + try { + pendingAcquisition = markSquidDepositPending(window.localStorage, pendingAcquisition, hash); + if (isCurrentDepositOwner()) setSavedAcquisition(pendingAcquisition); + } catch { + if (isCurrentDepositOwner()) { + toast.error("The transaction was submitted, but its recovery state could not be updated."); + } + } + if (isCurrentDepositOwner()) toast.info("Top-up transaction submitted"); + }, + }); + if (receipt.status !== "success") throw new Error("Top-up transaction reverted"); + await invalidateTopUpQueries(queryClient, accountId, depositOwner); + try { + clearSquidAcquisition(window.localStorage, pendingAcquisition); + } catch { + if (isCurrentDepositOwner()) { + toast.warning("Top-up succeeded, but the saved acquisition could not be cleared."); + } + } + if (isCurrentDepositOwner()) { + toast.success("USDFC top-up confirmed"); + setAcquiredAmount(null); + setAcquisitionOwner(null); + setSavedAcquisition(null); + setHasInvalidAcquisition(false); + setAcquisitionState("idle"); + closeDialog(); + } + } catch (error) { + if (!didBroadcast && isUserRejectedRequest(error)) { + try { + const acquired = resetSquidDeposit(window.localStorage, pendingAcquisition); + if (isCurrentDepositOwner()) { + setSavedAcquisition(acquired); + setAcquisitionState("acquired"); + } + } catch { + if (isCurrentDepositOwner()) { + setAcquiredAmount(null); + setAcquisitionState("blocked"); + } + } + } else if (isCurrentDepositOwner()) { + setAcquiredAmount(null); + setAcquisitionState("blocked"); + } + if (isCurrentDepositOwner()) { + toast.error("USDFC top-up failed", { + description: walletErrorMessage(error, "Your wallet did not complete the request."), + }); + } + } finally { + if (isCurrentDepositOwner()) setIsSubmitting(false); + } + }); + } catch (error) { + toast.error("The deposit cannot start safely.", { + description: error instanceof Error ? error.message : undefined, + }); + } + }; + + const clearBlockedAcquisition = async () => { + if (!address) return; + const clearMessage = + savedAcquisition?.status === "depositing" + ? "Only clear this after confirming the Filecoin deposit completed." + : "Only clear this after confirming USDFC did not arrive and no source transaction is pending."; + if (!window.confirm(clearMessage)) { + return; + } + try { + if (!savedAcquisition) return; + await withSquidAcquisitionLock(globalThis.navigator?.locks, savedAcquisition.owner, () => + clearSquidAcquisition(window.localStorage, savedAcquisition), + ); + } catch { + toast.error("Browser storage is unavailable. The saved acquisition could not be cleared."); + return; + } + setAcquiredAmount(null); + setAcquisitionOwner(null); + const completedDeposit = savedAcquisition?.status === "depositing"; + setSavedAcquisition(null); + setHasInvalidAcquisition(false); + setAutomaticRecoveryError(null); + setAcquisitionState("idle"); + if (completedDeposit) closeDialog(); + }; + + const clearInvalidAcquisition = async () => { + if (!address || !window.confirm("Clear the invalid saved acquisition data from this browser?")) return; + try { + await withSquidAcquisitionLock(globalThis.navigator?.locks, address, () => + clearInvalidSquidAcquisition(window.localStorage, address), + ); + setHasInvalidAcquisition(false); + setAutomaticRecoveryError(null); + setAcquisitionState("idle"); + } catch (error) { + toast.error("The invalid saved acquisition could not be cleared.", { + description: error instanceof Error ? error.message : undefined, + }); + } + }; + + const continueWithAcquiredUsdfc = async () => { + if (savedAcquisition?.status !== "processing") return; + try { + await withSquidAcquisitionLock(globalThis.navigator?.locks, savedAcquisition.owner, async () => { + if (savedAcquisition.destinationBalanceBefore !== undefined) { + if (!destinationClient) throw new Error("Filecoin balance client is unavailable"); + const currentBalance = await readUsdfcBalance( + destinationClient, + mainnet.contracts.usdfc.address, + savedAcquisition.owner, + ); + const acquired = markSquidAcquiredFromBalance(window.localStorage, savedAcquisition, currentBalance); + setSavedAcquisition(acquired); + setAcquisitionOwner(acquired.owner); + setAcquiredAmount(getSquidDepositAmount(acquired)); + setAcquisitionState("acquired"); + return; + } + const acquired = markSquidAcquired(window.localStorage, savedAcquisition); + setSavedAcquisition(acquired); + setAcquisitionOwner(acquired.owner); + setAcquiredAmount(getSquidDepositAmount(acquired)); + setAcquisitionState("acquired"); + }); + } catch (error) { + toast.error("The acquisition could not be recovered safely.", { + description: error instanceof Error ? error.message : undefined, + }); + } + }; + + const retryFilecoinDeposit = async () => { + if (savedAcquisition?.status !== "depositing") return; + if (!window.confirm("Retry only after confirming the saved Filecoin transaction did not complete.")) return; + try { + const acquired = await withSquidAcquisitionLock(globalThis.navigator?.locks, savedAcquisition.owner, () => + resetSquidDeposit(window.localStorage, savedAcquisition), + ); + setSavedAcquisition(acquired); + setAcquisitionOwner(acquired.owner); + setAcquiredAmount(getSquidDepositAmount(acquired)); + setAcquisitionState("acquired"); + } catch { + toast.error("Browser storage is unavailable. The deposit could not be recovered safely."); + } + }; + + const switchToFilecoin = async () => { + setIsSwitchingNetwork(true); + try { + await switchChainAsync({ chainId: mainnet.id }); + } catch (error) { + toast.error("Could not switch to Filecoin", { + description: error instanceof Error ? error.message : "Your wallet did not switch networks.", + }); + } finally { + setIsSwitchingNetwork(false); + } + }; + + const closeDialog = () => { + const chainIdToRestore = originalChainId.current; + originalChainId.current = undefined; + isAwaitingOriginalChainId.current = false; + onOpenChange(false); + if (chainIdToRestore === undefined || chainIdToRestore === chainId) return; + void switchChainAsync({ chainId: chainIdToRestore }).catch((error) => { + toast.error("Could not restore your wallet network", { + description: walletErrorMessage(error, "Your wallet did not switch back to its original network."), + }); + }); + }; + + const handleOpenChange = (nextOpen: boolean) => { + if (!nextOpen && acquisitionState === "processing") { + toast.info("Wait for the acquisition request to finish before closing this dialog."); + return; + } + if (!nextOpen && isSwitchingNetwork) { + toast.info("Wait for the wallet network switch to finish before closing this dialog."); + return; + } + if (nextOpen) onOpenChange(true); + else closeDialog(); + }; + + return ( + + + + Fund with another token + + Acquire Filecoin USDFC through{" "} + + Squid + + , then deposit it into Filecoin Pay. + + + +
+
+ + + {amount !== "" && parsedAmount === null && acquiredAmount === null && ( +

Enter an amount greater than zero.

+ )} + {acquiredAmount !== null && ( +

Ready to deposit: {formatUsdfcAmount(acquiredAmount)} USDFC.

+ )} + {accountSummary && acquiredAmount === null && ( + + )} + {!accountSummary && acquiredAmount === null && ( +

+ {isAccountSummaryLoading + ? "Loading on-chain funding status…" + : "On-chain funding status is unavailable. Enter an amount manually."} +

+ )} +
+ {current && ( + +

+ {acquiredAmount === null ? "Target deposit" : "Ready to deposit"}:{" "} + {depositAmount === null ? "—" : formatUsdfcAmount(depositAmount)} USDFC. +

+
+ )} + {acquisitionState === "blocked" && ( +
+ {savedAcquisition ? ( + <> +

A saved transaction needs verification.

+ {savedAcquisition.status === "depositing" ? ( +

+ Check the Filecoin deposit transaction before retrying or clearing it: + {savedAcquisition.depositTransactionHash ? ( + {savedAcquisition.depositTransactionHash} + ) : ( + The wallet request may not have returned a transaction hash. + )} +

+ ) : ( + <> +

Check {savedSourceChain?.name ?? `chain ${savedAcquisition.sourceChainId}`} for the swap.

+ {acquisitionCoordinationError && ( +

+ {acquisitionCoordinationError} +

+ )} + {savedAcquisition.transactionHashes.map((hash) => ( + + {hash} + + ))} + {savedAcquisition.transactionHashes.length === 0 && ( +

The wallet request may have been submitted without returning a transaction hash.

+ )} + {automaticRecovery.isEligible && !automaticRecovery.error && !automaticRecoveryError && ( +

+ {automaticRecovery.isFetching && } + Automatically checking the source transaction and Filecoin USDFC balance… +

+ )} + {automaticRecovery.isEligible && (automaticRecovery.error || automaticRecoveryError) && ( +
+

+ Automatic recovery {automaticRecovery.isPermanentError ? "stopped" : "will retry"}:{" "} + {automaticRecoveryError || automaticRecovery.error?.message} +

+ {!automaticRecovery.isPermanentError && ( + + )} +
+ )} + + )} +
+ {savedAcquisition.status === "processing" && !automaticRecovery.isEligible && ( + + )} + {savedAcquisition.status === "depositing" && ( + + )} + +
+ + ) : ( + <> +

+ {hasInvalidAcquisition + ? "The saved acquisition data is invalid and must be cleared before funding can continue." + : "Browser storage is unavailable, so funding cannot continue safely."} +

+ {hasInvalidAcquisition && ( + + )} + + )} +
+ )} + {acquiredAmount === null && acquisitionState !== "blocked" && ( + { + setSavedAcquisition(acquired); + setAcquiredAmount(getSquidDepositAmount(acquired)); + setAcquisitionOwner(acquired.owner); + }} + onAcquisitionStateChange={setAcquisitionState} + onBlocked={setSavedAcquisition} + onNetworkSwitchingChange={setIsSwitchingNetwork} + /> + )} + {acquiredAmount !== null && !acquisitionOwnerMatches && ( +

+ Switch back to {acquisitionOwner} before depositing the acquired USDFC. +

+ )} +
+ + + {acquisitionState === "processing" ? ( + + ) : acquisitionState === "blocked" ? ( + + ) : acquiredAmount !== null && chainId !== mainnet.id ? ( + + ) : acquiredAmount !== null ? ( + + ) : null} + +
+
+ ); +} diff --git a/apps/explorer/src/components/UserConsole/FundsSection/components/MeterRow.tsx b/apps/explorer/src/components/UserConsole/FundsSection/components/MeterRow.tsx new file mode 100644 index 00000000..6c99a3a7 --- /dev/null +++ b/apps/explorer/src/components/UserConsole/FundsSection/components/MeterRow.tsx @@ -0,0 +1,45 @@ +import { cn } from "@filecoin-pay/ui/lib/utils"; + +type MeterRowProps = { + /** Names the metric, and is the bar's accessible name. */ + label: string; + /** Optional smaller line under the label, spelling out the figures behind the bar. */ + detail?: string; + /** The reading, shown at the right of the row. Also the bar's `aria-valuetext`. */ + value: string; + /** Fill width, 0–100. Callers clamp; this component does not rescale. */ + percent: number; + fillClassName: string; + trackClassName: string; + valueClassName?: string; +}; + +/** + * One horizontal metric: label, optional detail, a proportional bar, and the + * reading. + * + * The bar is decoration for `value`, and the row states its meaning in text, so + * nothing here depends on a user distinguishing the fill colors. + */ +const MeterRow = ({ label, detail, value, percent, fillClassName, trackClassName, valueClassName }: MeterRowProps) => ( +
+
+

{label}

+ {detail ?

{detail}

: null} +
+
+
+
+

{value}

+
+); + +export default MeterRow; diff --git a/apps/explorer/src/components/UserConsole/FundsSection/components/RunwayCard.test.tsx b/apps/explorer/src/components/UserConsole/FundsSection/components/RunwayCard.test.tsx new file mode 100644 index 00000000..f55ad1a2 --- /dev/null +++ b/apps/explorer/src/components/UserConsole/FundsSection/components/RunwayCard.test.tsx @@ -0,0 +1,31 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; +import { RunwayCard } from "./RunwayCard"; + +describe("RunwayCard", () => { + it("shows the current and projected funding statuses beside the runway", () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain("Current funded through:"); + expect(markup).toContain("(Critical)"); + expect(markup).toContain("Projected funded through:"); + expect(markup).toContain("(Funded)"); + expect(markup.match(/Approximately /g)).toHaveLength(2); + expect(markup.match(/~/g)).toHaveLength(2); + }); +}); diff --git a/apps/explorer/src/components/UserConsole/FundsSection/components/RunwayCard.tsx b/apps/explorer/src/components/UserConsole/FundsSection/components/RunwayCard.tsx new file mode 100644 index 00000000..1ca54296 --- /dev/null +++ b/apps/explorer/src/components/UserConsole/FundsSection/components/RunwayCard.tsx @@ -0,0 +1,162 @@ +import type { ReactNode } from "react"; +import { + calculateFundingRunway, + DEFAULT_FUNDING_MONTHS, + EPOCHS_PER_MONTH, + FUNDING_ESTIMATE_DISCLAIMER, + type FundingAccountSummary, + type FundingRunway, + type FundingStatus, + formatFundedThrough, + formatSuggestedTopUp, + MAX_FUNDING_MONTHS, + minTopUpMonths, + monthsForTopUp, +} from "../data/funding-runway"; +import { parseTopUpAmount } from "../data/guided-top-up"; + +// Shared by the guided top-up and deposit dialogs so the two funding surfaces +// cannot drift: one slider to pick an amount by runway duration, one card to +// show the resulting projection. + +function formatMonths(months: number): string { + if (months % 12 === 0) { + const years = months / 12; + return years === 1 ? "1 year" : `${years} years`; + } + return months === 1 ? "1 month" : `${months} months`; +} + +const FUNDING_STATUS_LABELS: Record = { + critical: "Critical", + urgent: "Urgent", + low: "Low", + funded: "Funded", + "long-term-funded": "Long-term funded", + "no-active-spend": "No active spend", +}; + +type FundingRunwaySliderProps = { + accountSummary: FundingAccountSummary; + amount: string; + disabled?: boolean; + genesisTimestamp: number; + maxAmount?: bigint; + onSelect: (amount: string) => void; +}; + +// "Fund for" slider: drag a duration and the USDFC amount fills in; type an +// amount and the thumb tracks the runway it buys. The floor starts at the +// first month the account is not already funded through, so a covered target +// is never offered; the ceiling stops at what `maxAmount` can pay, so an +// unaffordable target is never offered either. +export function FundingRunwaySlider({ + accountSummary, + amount, + disabled = false, + genesisTimestamp, + maxAmount, + onSelect, +}: FundingRunwaySliderProps) { + const minMonths = minTopUpMonths(accountSummary); + // No recurring spend to project, or already funded past the max target. + if (minMonths === null) return null; + const affordableMonths = maxAmount === undefined ? null : monthsForTopUp(accountSummary, maxAmount); + const maxMonths = + affordableMonths === null ? MAX_FUNDING_MONTHS : Math.min(MAX_FUNDING_MONTHS, Math.floor(affordableMonths)); + if (maxMonths < minMonths) return null; + + const parsed = parseTopUpAmount(amount); + const exactMonths = parsed === null ? null : monthsForTopUp(accountSummary, parsed); + const position = + exactMonths === null + ? Math.min(Math.max(DEFAULT_FUNDING_MONTHS, minMonths), maxMonths) + : Math.min(Math.max(Math.round(exactMonths), minMonths), maxMonths); + const label = + exactMonths === null + ? formatMonths(position) + : // Half-month slack absorbs the round-UP in formatSuggestedTopUp, so a + // slider-filled max target reads "~5 years", not "over 5 years". + exactMonths > MAX_FUNDING_MONTHS + 0.5 + ? "over 5 years" + : exactMonths < 1 + ? "less than a month" + : `~${formatMonths(Math.min(Math.max(Math.round(exactMonths), 1), MAX_FUNDING_MONTHS))}`; + + return ( +
+
+ Fund for + {label} +
+ { + const months = BigInt(event.target.value); + onSelect( + formatSuggestedTopUp( + calculateFundingRunway(accountSummary, months * EPOCHS_PER_MONTH, genesisTimestamp).suggestedTopUp, + ), + ); + }} + step={1} + type='range' + value={position} + /> +
+ {formatMonths(minMonths)} + {formatMonths(maxMonths)} +
+
+ ); +} + +type RunwayCardProps = { + current: FundingRunway; + projected: FundingRunway | null; + children?: ReactNode; +}; + +export function RunwayCard({ children, current, projected }: RunwayCardProps) { + const currentLabel = formatFundedThrough(current, true); + const projectedLabel = projected ? formatFundedThrough(projected, true) : "—"; + + return ( +
+

+ Current funded through:{" "} + + {currentLabel.startsWith("~") ? ( + <> + + Approximately {currentLabel.slice(1)} + + ) : ( + currentLabel + )} + {" "} + ({FUNDING_STATUS_LABELS[current.status]}) +

+

+ Projected funded through:{" "} + + {projectedLabel.startsWith("~") ? ( + <> + + Approximately {projectedLabel.slice(1)} + + ) : ( + projectedLabel + )} + {" "} + {projected ? ({FUNDING_STATUS_LABELS[projected.status]}) : null} +

+ {children} +

{FUNDING_ESTIMATE_DISCLAIMER}

+
+ ); +} diff --git a/apps/explorer/src/components/UserConsole/FundsSection/components/SquidQuoteReview.test.ts b/apps/explorer/src/components/UserConsole/FundsSection/components/SquidQuoteReview.test.ts new file mode 100644 index 00000000..4b2926f8 --- /dev/null +++ b/apps/explorer/src/components/UserConsole/FundsSection/components/SquidQuoteReview.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; +import { + excludeDestinationUsdfc, + nativeTokenFirst, + resolveSearchableOption, + sourceTokenCatalogMessage, +} from "./SquidQuoteReview"; + +describe("searchable option resolution", () => { + const options = [ + { aliases: ["USDC"], label: "USDC (0x123…456)", value: "0x123" }, + { aliases: ["USDC"], label: "USDC (0x789…abc)", value: "0x789" }, + { aliases: ["ETH"], label: "ETH (0xeee…eee)", value: "0xeee" }, + ]; + + it("resolves a selected label or an unambiguous alias", () => { + expect(resolveSearchableOption(options, "usdc (0x123…456)")).toBe("0x123"); + expect(resolveSearchableOption(options, " eth ")).toBe("0xeee"); + }); + + it("does not select free text or an ambiguous alias", () => { + expect(resolveSearchableOption(options, "usd")).toBe(""); + expect(resolveSearchableOption(options, "USDC")).toBe(""); + }); +}); + +describe("source token catalog messages", () => { + it.each([ + [false, false, "Squid funding is not configured for this deployment."], + [true, true, "Could not load tokens from Squid. Check the configuration or try again."], + [true, false, "No supported tokens on this network."], + ])("distinguishes configuration, request, and support states", (isConfigured, hasError, expected) => { + expect(sourceTokenCatalogMessage(isConfigured, hasError)).toBe(expected); + }); +}); + +describe("source token ordering", () => { + it("puts the native token first without changing the other catalog entries", () => { + const tokens = [ + { token: "0x123", symbol: "USDC" }, + { token: "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", symbol: "ETH" }, + { token: "0x456", symbol: "USDT" }, + ]; + + expect(nativeTokenFirst(tokens).map(({ symbol }) => symbol)).toEqual(["ETH", "USDC", "USDT"]); + }); +}); + +describe("source token safety", () => { + it("excludes destination USDFC only when Filecoin is the source chain", () => { + const tokens = [ + { token: "0x80B98d3aa09ffff255c3ba4A241111Ff1262F045", symbol: "USDFC" }, + { token: "0x1111111111111111111111111111111111111111", symbol: "OTHER" }, + ]; + + expect(excludeDestinationUsdfc(tokens, 314)).toEqual([tokens[1]]); + expect(excludeDestinationUsdfc(tokens, 8453)).toEqual(tokens); + }); +}); diff --git a/apps/explorer/src/components/UserConsole/FundsSection/components/SquidQuoteReview.tsx b/apps/explorer/src/components/UserConsole/FundsSection/components/SquidQuoteReview.tsx new file mode 100644 index 00000000..6f5c2a1b --- /dev/null +++ b/apps/explorer/src/components/UserConsole/FundsSection/components/SquidQuoteReview.tsx @@ -0,0 +1,870 @@ +"use client"; + +import { Button } from "@filecoin-foundation/ui-filecoin/Button"; +import { Input } from "@filecoin-foundation/ui-filecoin/Input"; +import { Label } from "@filecoin-pay/ui/components/label"; +import { + fetchSourceTokens, + NATIVE_TOKEN_ADDRESS, + SQUID_ROUTER_ADDRESS, + type SquidPublicClient, + type SquidWalletClient, +} from "@filecoin-project/squid-evm-funding"; +import { useQuery } from "@tanstack/react-query"; +import { AlertCircle, Loader2 } from "lucide-react"; +import { useEffect, useId, useRef, useState } from "react"; +import { useDebounce } from "use-debounce"; +import { erc20Abi, formatUnits } from "viem"; +import { estimateTotalFee } from "viem/op-stack"; +import { useAccount, usePublicClient, useSwitchChain, useWalletClient } from "wagmi"; +import CopyButton from "@/components/shared/CopyButton"; +import { mainnet, SQUID_SOURCE_CHAINS } from "@/constants/chains"; +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 type { SquidAcquisition } from "../data/squid-acquisition"; +import { runSquidAcquisition } from "../data/squid-acquisition-flow"; +import { withSquidAcquisitionLock } from "../data/squid-acquisition-lock"; +import { executeSquidTopUp, isUserRejectedRequest, walletErrorMessage } from "../data/squid-execution"; +import { planSquidTopUp, squidFetch } from "../data/squid-quote"; +import { readUsdfcBalance } from "../data/usdfc-balance"; + +const QUOTE_DEBOUNCE_MS = 500; + +// Squid 429s recover slowly, so the quote fails fast with copy telling the +// user when to refresh; only the token catalog retries a burst. +const isRateLimited = (error: unknown) => error instanceof Error && error.message.includes("(429)"); +const rateLimitRetry = (failureCount: number, error: unknown) => isRateLimited(error) && failureCount < 2; +const rateLimitRetryDelay = (failureCount: number) => 15_000 * (failureCount + 1); + +type SearchableOption = { + aliases?: readonly string[]; + label: string; + value: string; +}; + +const SOURCE_CHAIN_OPTIONS: readonly SearchableOption[] = SQUID_SOURCE_CHAINS.map((chain) => ({ + label: chain.name, + value: String(chain.id), +})); + +type SquidQuoteReviewProps = { + acquisitionState: "acquired" | "blocked" | "idle" | "processing"; + destinationAmount: bigint | null; + onAcquired: (acquisition: SquidAcquisition) => void; + onAcquisitionStateChange: (state: "acquired" | "blocked" | "idle" | "processing") => void; + onBlocked: (acquisition: SquidAcquisition) => void; + onNetworkSwitchingChange: (isSwitching: boolean) => void; +}; + +function displayAmount(amount: bigint, decimals: number, symbol: string) { + return `${formatUnits(amount, decimals)} ${symbol}`; +} + +export function sourceTokenCatalogMessage(isConfigured: boolean, hasError: boolean) { + if (!isConfigured) return "Squid funding is not configured for this deployment."; + if (hasError) return "Could not load tokens from Squid. Check the configuration or try again."; + return "No supported tokens on this network."; +} + +export function nativeTokenFirst(tokens: readonly T[]): T[] { + const nativeAddress = NATIVE_TOKEN_ADDRESS.toLowerCase(); + return [...tokens].sort( + (left, right) => + Number(right.token.toLowerCase() === nativeAddress) - Number(left.token.toLowerCase() === nativeAddress), + ); +} + +export function excludeDestinationUsdfc(tokens: readonly T[], sourceChainId: number) { + return sourceChainId === mainnet.id + ? tokens.filter((token) => token.token.toLowerCase() !== mainnet.contracts.usdfc.address.toLowerCase()) + : [...tokens]; +} + +export function resolveSearchableOption(options: readonly SearchableOption[], query: string) { + const normalizedQuery = query.trim().toLowerCase(); + const labelMatch = options.find((option) => option.label.toLowerCase() === normalizedQuery); + if (labelMatch) return labelMatch.value; + + const aliasMatches = options.filter((option) => + option.aliases?.some((alias) => alias.toLowerCase() === normalizedQuery), + ); + return aliasMatches.length === 1 ? aliasMatches[0].value : ""; +} + +export function SquidQuoteReview({ + acquisitionState, + destinationAmount, + onAcquired, + onAcquisitionStateChange, + onBlocked, + onNetworkSwitchingChange, +}: SquidQuoteReviewProps) { + // The flow is deliberately split into read-only route review and wallet execution. + // Any execution that may have broadcast remains blocked until it is recovered or explicitly cleared. + const { address, chainId } = useAccount(); + const [sourceChainId, setSourceChainId] = useState(""); + const [sourceTokenAddress, setSourceTokenAddress] = useState(""); + const [sourceChainQuery, setSourceChainQuery] = useState(""); + const [sourceChainQueryTouched, setSourceChainQueryTouched] = useState(false); + const [sourceTokenQuery, setSourceTokenQuery] = useState(""); + const [sourceTokenQueryTouched, setSourceTokenQueryTouched] = useState(false); + const [error, setError] = useState(null); + const [switchError, setSwitchError] = useState(null); + const sourceChainListId = useId(); + const sourceTokenListId = useId(); + const latestAddress = useRef(address); + latestAddress.current = address; + const [debouncedDestinationAmount] = useDebounce(destinationAmount, QUOTE_DEBOUNCE_MS); + const sourceChain = Number(sourceChainId); + const sourcePublicClient = usePublicClient({ chainId: sourceChain || undefined }); + // Follow the connected chain so switching networks refreshes a wallet-client + // query that may previously have failed because the selected chain differed. + const { data: sourceWalletClient, isPending: isPreparingWallet } = useWalletClient(); + const { isPending: isSwitchingChain, switchChainAsync } = useSwitchChain(); + const destinationClient = usePublicClient({ chainId: mainnet.id }); + const integratorId = + process.env.NEXT_PUBLIC_SQUID_INTEGRATOR_ID?.trim() || "filecoin-testing-94a4a25a-d40b-41cb-b148-e96098862"; + const quotesUnavailable = integratorId === ""; + const sourceChainMeta = SQUID_SOURCE_CHAINS.find((chain) => chain.id === sourceChain); + const { + data: tokens = [], + error: tokenLoadError, + isError: isTokenLoadError, + isFetching: isLoadingTokens, + refetch: refetchTokens, + } = useQuery({ + enabled: !quotesUnavailable && SQUID_SOURCE_CHAINS.some((chain) => chain.id === sourceChain), + queryFn: () => fetchSourceTokens(sourceChain, { fetch: squidFetch, integratorId }), + queryKey: ["squid", "source-tokens", sourceChain], + retry: rateLimitRetry, + retryDelay: rateLimitRetryDelay, + staleTime: 300_000, + }); + const tokenLoadFailed = isTokenLoadError && tokens.length === 0; + const selectableTokens = excludeDestinationUsdfc(tokens, sourceChain); + const sourceTokenOptions: readonly SearchableOption[] = nativeTokenFirst(selectableTokens).map((token) => ({ + aliases: [token.symbol, token.token], + label: `${token.symbol} (${formatAddress(token.token)})`, + value: token.token, + })); + const sourceChainQueryInvalid = sourceChainQueryTouched && sourceChainQuery.trim() !== "" && sourceChainId === ""; + const sourceTokenQueryInvalid = + sourceTokenQueryTouched && sourceTokenQuery.trim() !== "" && sourceTokenAddress === ""; + const source = selectableTokens.find((token) => token.token.toLowerCase() === sourceTokenAddress.toLowerCase()); + const isBusy = acquisitionState !== "idle"; + const isNativeSource = source?.token.toLowerCase() === NATIVE_TOKEN_ADDRESS.toLowerCase(); + const isQuoteDebouncing = destinationAmount !== debouncedDestinationAmount; + const { + data: sourceBalance, + isError: isSourceBalanceError, + isFetching: isLoadingSourceBalance, + refetch: refetchSourceBalance, + } = useQuery({ + enabled: !!address && !!source && !!sourcePublicClient, + queryFn: async () => { + if (!sourcePublicClient || !address || !source) throw new Error("Source network client is unavailable"); + if (source.token.toLowerCase() === NATIVE_TOKEN_ADDRESS.toLowerCase()) { + return sourcePublicClient.getBalance({ address }); + } + return sourcePublicClient.readContract({ + abi: erc20Abi, + address: source.token, + args: [address], + functionName: "balanceOf", + }); + }, + queryKey: ["squid", "source-balance", sourceChain, sourceTokenAddress, address], + }); + const { + data: separateNativeBalance, + isError: isNativeBalanceError, + isFetching: isLoadingNativeBalance, + refetch: refetchNativeBalance, + } = useQuery({ + enabled: !!address && !!source && !isNativeSource && !!sourcePublicClient, + queryFn: async () => { + if (!sourcePublicClient || !address) throw new Error("Source network client is unavailable"); + return sourcePublicClient.getBalance({ address }); + }, + queryKey: ["squid", "native-balance", sourceChain, address], + }); + const { + data: sourceAllowance, + isError: isSourceAllowanceError, + isFetching: isLoadingSourceAllowance, + refetch: refetchSourceAllowance, + } = useQuery({ + enabled: !!address && !!source && !isNativeSource && !!sourcePublicClient, + queryFn: async () => { + if (!sourcePublicClient || !address || !source) throw new Error("Source network client is unavailable"); + return sourcePublicClient.readContract({ + abi: erc20Abi, + address: source.token, + args: [address, SQUID_ROUTER_ADDRESS], + functionName: "allowance", + }); + }, + queryKey: ["squid", "source-allowance", sourceChain, sourceTokenAddress, address, SQUID_ROUTER_ADDRESS], + }); + const sourceAmount = sourceBalance ?? null; + const nativeBalance = isNativeSource ? sourceBalance : separateNativeBalance; + const insufficientBalance = + source && debouncedDestinationAmount !== null + ? `You don't have enough ${source.symbol} to receive ${formatUsdfcAmount(debouncedDestinationAmount)} USDFC.` + : null; + const { + data: quotedPlan, + error: quoteError, + isFetching: isReviewing, + refetch: refetchQuote, + } = useQuery({ + enabled: + !quotesUnavailable && + !isBusy && + !isQuoteDebouncing && + !!address && + !!source && + debouncedDestinationAmount !== null && + debouncedDestinationAmount > 0n && + sourceAmount !== null && + sourceAmount > 0n, + queryFn: async () => { + if (!address || !source || debouncedDestinationAmount === null || sourceAmount === null) { + throw new Error("Select a source token and enter the USDFC amount."); + } + return planSquidTopUp({ + destinationAmount: debouncedDestinationAmount, + destinationToken: mainnet.contracts.usdfc.address, + integratorId, + owner: address, + source, + sourceAmount, + }); + }, + queryKey: [ + "squid", + "top-up-plan", + address, + mainnet.contracts.usdfc.address, + debouncedDestinationAmount?.toString() ?? "", + sourceChain, + sourceTokenAddress, + sourceAmount?.toString() ?? "", + ], + refetchOnWindowFocus: false, + retry: false, + staleTime: 30_000, + }); + 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, isNativeSource ? undefined : sourceAllowance) + : { estimated: 0n, maximum: null, transactionCount: null }; + const estimatedNetworkFeeLabel = sourceChainMeta + ? formatNativeFee(networkGas.estimated, sourceChainMeta.nativeCurrency) + : null; + const maximumNetworkFeeLabel = + sourceChainMeta && networkGas.maximum !== null + ? formatNativeFee(networkGas.maximum, sourceChainMeta.nativeCurrency) + : null; + const requiredNativeBalance = + plan && networkGas.maximum !== null ? getRequiredNativeBalance(plan, networkGas.maximum) : 0n; + const approvalTransactionCount = + plan && networkGas.transactionCount !== null ? networkGas.transactionCount - plan.quotes.length : null; + 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, + isLoadingNativeBalance, + ); + const isSourceAllowanceBlocked = + source !== undefined && + !isNativeSource && + (isSourceAllowanceError || isLoadingSourceAllowance || sourceAllowance === undefined); + const nativeBalanceBlockedMessage = + plan && networkGas.maximum === 0n + ? "Squid did not provide a source-network gas estimate. Refresh the quote before acquiring." + : plan && nativeBalance !== undefined && nativeBalance < requiredNativeBalance && sourceChainMeta + ? `Your ${sourceChainMeta.nativeCurrency.symbol} balance does not cover the reviewed maximum native requirement.` + : null; + const quoteErrorMessage = + !isQuoteDebouncing && quoteError + ? quoteError instanceof Error && quoteError.message.includes("exceed the source-token cap") + ? insufficientBalance + : isRateLimited(quoteError) + ? "Squid is rate-limiting quote requests. Wait a moment, then refresh the estimate." + : quoteError instanceof Error + ? quoteError.message + : "Squid could not provide a route." + : null; + // A zero spend cap never reaches the planner (query disabled), so surface it without a click. + const capBlockedMessage = + !isQuoteDebouncing && + debouncedDestinationAmount !== null && + debouncedDestinationAmount > 0n && + sourceAmount !== null && + sourceAmount <= 0n + ? insufficientBalance + : null; + + useEffect(() => { + if (tokenLoadError) console.error("Failed to load Squid token catalog:", tokenLoadError); + }, [tokenLoadError]); + + useEffect(() => { + if (chainId === sourceChain) { + setSwitchError(null); + } + }, [chainId, sourceChain]); + + const review = () => { + setError(null); + if (quotesUnavailable) return setError("Squid quotes are not configured for this deployment."); + if (!address || !source || destinationAmount === null) + return setError("Select a source token and enter the USDFC amount."); + if (isSourceBalanceError) return setError("Could not load your source-token balance. Retry the balance read."); + if (sourceBalance === undefined) return setError("Your source-token balance is still loading. Try again shortly."); + if (sourceAmount === null || sourceAmount <= 0n) return setError(insufficientBalance); + void refetchQuote(); + }; + + const switchToSourceNetwork = async () => { + setError(null); + setSwitchError(null); + if (!source || !sourceChainMeta) return setError("Select a source network and token first."); + onNetworkSwitchingChange(true); + try { + await switchChainAsync({ chainId: source.chainId }); + } catch (switchError) { + setSwitchError( + isUserRejectedRequest(switchError) + ? "Network switch cancelled in your wallet." + : walletErrorMessage(switchError, `Could not switch your wallet to ${sourceChainMeta.name}.`), + ); + } finally { + onNetworkSwitchingChange(false); + } + }; + + const acquire = async () => { + setError(null); + if (acquisitionState === "blocked") + return setError("Check your source wallet activity before starting another acquisition."); + if (acquisitionState !== "idle") return setError("This acquisition is already complete or in progress."); + if (!address || !source || !plan || !quote || destinationAmount === null) + return setError("Review a route before acquiring USDFC."); + if (chainId !== source.chainId) + return setError("Switch your wallet to the selected source network before confirming."); + if (!sourcePublicClient || !sourceWalletClient || !destinationClient) + return setError("Wallet or network client is unavailable."); + if (!sourceWalletClient.account || sourceWalletClient.account.address.toLowerCase() !== address.toLowerCase()) + return setError("Wallet account changed before confirming."); + const latestBalanceResult = await refetchSourceBalance(); + if (latestBalanceResult.isError || latestBalanceResult.data === undefined) { + return setError("Could not refresh your source-token balance. Try again before confirming."); + } + if (quote.sourceAmount > latestBalanceResult.data) { + return setError(`Your ${source.symbol} balance no longer covers the quote. Refresh the quote.`); + } + const latestNativeBalanceResult = isNativeSource ? latestBalanceResult : await refetchNativeBalance(); + if (latestNativeBalanceResult.isError || latestNativeBalanceResult.data === undefined) { + return setError("Could not refresh your source-network gas balance. Try again before confirming."); + } + if (networkGas.maximum === 0n) { + return setError("The reviewed source-network gas maximum is unavailable. Refresh the quote."); + } + if (networkGas.maximum === null) { + return setError("Your source-token allowance is still loading. Try again shortly."); + } + const reviewedNetworkGasMaximum = networkGas.maximum; + if (!isNativeSource) { + const latestAllowanceResult = await refetchSourceAllowance(); + if (latestAllowanceResult.isError || latestAllowanceResult.data === undefined) { + return setError("Could not refresh your source-token allowance. Try again before confirming."); + } + const latestNetworkGas = getPlanNetworkGas(plan, latestAllowanceResult.data); + if (latestNetworkGas.maximum !== reviewedNetworkGasMaximum) { + return setError( + "Your source-token allowance changed. Review the updated network-gas maximum before acquiring.", + ); + } + } + if (latestNativeBalanceResult.data < requiredNativeBalance) { + return setError( + `Your ${sourceChainMeta?.nativeCurrency.symbol ?? "native-token"} balance does not cover the reviewed maximum native requirement.`, + ); + } + + const publicClient = + source.chainId === 10 || source.chainId === 8453 + ? { + ...sourcePublicClient, + estimateTotalFee: (request: Parameters[1]) => + estimateTotalFee(sourcePublicClient, request), + } + : sourcePublicClient; + const isCurrentExecutionOwner = () => latestAddress.current?.toLowerCase() === address.toLowerCase(); + try { + const outcome = await withSquidAcquisitionLock(globalThis.navigator?.locks, address, () => + runSquidAcquisition({ + execute: ({ onSwapAttempt, onSwapBroadcast }) => + executeSquidTopUp({ + destinationClient: destinationClient as unknown as SquidPublicClient, + integratorId, + maxNativeFee: reviewedNetworkGasMaximum, + maxTotalNativeRouteFee: bridgeNativeFees.maximum, + onSwapAttempt, + onSwapBroadcast, + plan, + sourcePublicClient: publicClient as unknown as SquidPublicClient, + sourceWalletClient: sourceWalletClient as SquidWalletClient, + }), + minimumDestinationAmount: quote.requirement.amount, + onStarted: () => { + if (isCurrentExecutionOwner()) onAcquisitionStateChange("processing"); + }, + owner: address, + readDestinationBalance: () => readUsdfcBalance(destinationClient, mainnet.contracts.usdfc.address, address), + sourceChainId: source.chainId, + storage: window.localStorage, + }), + ); + if (!isCurrentExecutionOwner()) return; + if (outcome.status === "acquired") { + onAcquisitionStateChange("acquired"); + onAcquired(outcome.acquisition); + return; + } + if (outcome.status === "blocked") { + onBlocked(outcome.acquisition); + onAcquisitionStateChange("blocked"); + } else { + onAcquisitionStateChange("idle"); + } + setError(walletErrorMessage(outcome.error, "Squid could not complete the acquisition.")); + } catch (executionError) { + if (isCurrentExecutionOwner()) setError(walletErrorMessage(executionError, "Squid could not start safely.")); + } + }; + + return ( +
+

+ Supported via{" "} + + Squid + +

+ + {quotesUnavailable && ( +

{sourceTokenCatalogMessage(false, false)}

+ )} + +
+
+ + {sourceChain === chainId && ( + + Connected + + )} +
+ setSourceChainQueryTouched(true)} + onChange={(value) => { + setError(null); + setSwitchError(null); + setSourceChainQuery(value); + setSourceChainQueryTouched(false); + const nextSourceChainId = resolveSearchableOption(SOURCE_CHAIN_OPTIONS, value); + if (nextSourceChainId !== sourceChainId) { + setSourceChainId(nextSourceChainId); + setSourceTokenAddress(""); + setSourceTokenQuery(""); + setSourceTokenQueryTouched(false); + } + }} + placeholder='Search networks' + type='search' + value={sourceChainQuery} + /> + + {SOURCE_CHAIN_OPTIONS.map((option) => ( + + {sourceChainQueryInvalid && ( +

+ Choose a source network from the suggestions. +

+ )} +
+ +
+ + setSourceTokenQueryTouched(true)} + onChange={(value) => { + setError(null); + setSourceTokenQuery(value); + setSourceTokenQueryTouched(false); + setSourceTokenAddress(resolveSearchableOption(sourceTokenOptions, value)); + }} + placeholder={isLoadingTokens ? "Loading tokens…" : "Search tokens"} + type='search' + value={sourceTokenQuery} + /> + + {sourceTokenOptions.map((option) => ( + + {sourceTokenQueryInvalid && ( +

+ Choose a source token from the suggestions. +

+ )} + {sourceChainId !== "" && !quotesUnavailable && tokens.length === 0 && !isLoadingTokens && !tokenLoadFailed && ( +

+ {sourceTokenCatalogMessage(!quotesUnavailable, isTokenLoadError)} +

+ )} + {tokenLoadFailed && ( +
+ {sourceTokenCatalogMessage(true, true)} + +
+ )} + {source && !isNativeSource && ( +
+ Token address + + {formatAddress(source.token)} + + +
+ )} + {source && !isSourceBalanceError && ( +
+ Balance + + {isLoadingSourceBalance || sourceBalance === undefined + ? "Loading…" + : displayAmount(sourceBalance, source.decimals, source.symbol)} + +
+ )} + {isSourceBalanceError && ( +
+ Could not load your source-token balance. + +
+ )} + {source && !isNativeSource && !isNativeBalanceError && sourceChainMeta && ( +
+ Network fee balance + + {isLoadingNativeBalance || nativeBalance === undefined + ? "Loading…" + : displayAmount( + nativeBalance, + sourceChainMeta.nativeCurrency.decimals, + sourceChainMeta.nativeCurrency.symbol, + )} + +
+ )} + {source && !isNativeSource && isNativeBalanceError && ( +
+ Could not load your source-network gas balance. + +
+ )} + {source && !isNativeSource && !isSourceAllowanceError && ( +
+ Squid approval + + {isLoadingSourceAllowance || sourceAllowance === undefined + ? "Loading…" + : approvalTransactionCount === null + ? "Waiting for quote" + : approvalTransactionCount === 0 + ? "No approval needed" + : approvalTransactionCount === 1 + ? "Approval required" + : `${approvalTransactionCount} approval transactions expected`} + +
+ )} + {source && !isNativeSource && isSourceAllowanceError && ( +
+ Could not load your Squid token allowance. + +
+ )} +
+ + + + {(error || switchError || quoteErrorMessage || capBlockedMessage || nativeBalanceBlockedMessage) && ( +
+ + {error || switchError || quoteErrorMessage || capBlockedMessage || nativeBalanceBlockedMessage} +
+ )} + + {quote && ( +
+
+ Spend (estimated) + + {displayAmount(quote.sourceAmount, plan.source.decimals, plan.source.symbol)} + + Estimated received + + {displayAmount(quote.destinationAmount, USDFC_DECIMALS, "USDFC")} + + Execution minimum + + {displayAmount(quote.requirement.amount, USDFC_DECIMALS, "USDFC")} + + Slippage + 1% + {bridgeFeeLabel && maximumBridgeFeeLabel && ( + <> + Bridge fee (estimated) + {bridgeFeeLabel} + Bridge fee maximum + {maximumBridgeFeeLabel} + + )} + {otherSquidFeeCosts.length > 0 && ( + <> + Other Squid fees (estimated) + + {otherSquidFeeCosts + .map((cost) => displayAmount(cost.amount, cost.token.decimals, cost.token.symbol)) + .join(", ")} + + + )} + {estimatedNetworkFeeLabel && maximumNetworkFeeLabel && networkGas.transactionCount !== null && ( + <> + Source-network gas (estimated) + {estimatedNetworkFeeLabel} + Source-network gas maximum + {maximumNetworkFeeLabel} + Expected source transactions + {networkGas.transactionCount} + + )} + {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 + {requiredNativeBalanceLabel} + + )} +
+ {maximumBridgeFeeLabel && maximumNetworkFeeLabel && ( +

+ 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. +

+ )} +

+ Route: {quote.actions.map((action) => action.description ?? action.type).join(" → ")} +

+ + +
+ )} +
+ ); +} diff --git a/apps/explorer/src/components/UserConsole/FundsSection/components/TokenSelect.tsx b/apps/explorer/src/components/UserConsole/FundsSection/components/TokenSelect.tsx new file mode 100644 index 00000000..6c8594c6 --- /dev/null +++ b/apps/explorer/src/components/UserConsole/FundsSection/components/TokenSelect.tsx @@ -0,0 +1,31 @@ +import type { UserToken } from "@filecoin-pay/types"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@filecoin-pay/ui/components/select"; +import TokenIcon from "@/components/shared/TokenIcon"; + +type TokenSelectProps = { + tokens: UserToken[]; + selectedToken: UserToken; + onSelect: (tokenId: string) => void; +}; + +/** Picks which token the overview cards, Deposit and Withdraw all act on. */ +const TokenSelect = ({ tokens, selectedToken, onSelect }: TokenSelectProps) => ( + +); + +export default TokenSelect; diff --git a/apps/explorer/src/components/UserConsole/FundsSection/components/index.ts b/apps/explorer/src/components/UserConsole/FundsSection/components/index.ts index 24dfb550..5491cf34 100644 --- a/apps/explorer/src/components/UserConsole/FundsSection/components/index.ts +++ b/apps/explorer/src/components/UserConsole/FundsSection/components/index.ts @@ -1,4 +1,9 @@ +export { AddFundsDialog, type AddFundsMethod } from "./AddFundsDialog"; export { default as FundsEmptyState } from "./FundsEmptyState"; export { default as FundsErrorState } from "./FundsErrorState"; export { default as FundsLoadingState } from "./FundsLoadingState"; -export { default as FundsTable } from "./FundsTable"; +export { default as FundsOverview } from "./FundsOverview"; +export { default as FundsSectionLayout } from "./FundsSectionLayout"; +export { GuidedTopUpDialog } from "./GuidedTopUpDialog"; +export { SquidQuoteReview } from "./SquidQuoteReview"; +export { default as TokenSelect } from "./TokenSelect"; diff --git a/apps/explorer/src/components/UserConsole/FundsSection/data/columnDefinitions.tsx b/apps/explorer/src/components/UserConsole/FundsSection/data/columnDefinitions.tsx deleted file mode 100644 index 88630165..00000000 --- a/apps/explorer/src/components/UserConsole/FundsSection/data/columnDefinitions.tsx +++ /dev/null @@ -1,261 +0,0 @@ -import { Button } from "@filecoin-foundation/ui-filecoin/Button"; -import type { UserToken } from "@filecoin-pay/types"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@filecoin-pay/ui/components/tooltip"; -import { ArrowDownLeftIcon, ArrowUpRightIcon } from "@phosphor-icons/react"; -import { createColumnHelper } from "@tanstack/react-table"; -import { AlertCircle, Info } from "lucide-react"; -import { maxUint256 } from "viem"; -import USDFCLogo from "@/assests/USDFCLogo"; -import { EPOCH_DURATION, FUNDING_WARNING_THRESHOLD_SECONDS } from "@/utils/constants"; -import { formatFutureTimestamp, formatTimestampToTime, formatToken } from "@/utils/formatter"; - -export type FundsTableRow = UserToken & { - currentTimestamp: bigint; - onDeposit: (token: UserToken) => void; - onWithdraw: (token: UserToken) => void; -}; - -type FundingStatus = "infinity" | "expired" | "warning" | "funded"; - -type FundedUntilPresentation = { - detail: string | null; - showWarningIcon: boolean; - timeColor: string; -}; - -// Helper function to calculate funded until data -const calculateFundedUntil = (userToken: FundsTableRow) => { - const funds = BigInt(userToken.funds); - const lockupCurrent = BigInt(userToken.lockupCurrent); - const lastSettledAt = BigInt(userToken.lockupLastSettledUntilEpoch); - const lastSettledTimestamp = BigInt(userToken.lockupLastSettledUntilTimestamp); - const lockupRate = BigInt(userToken.lockupRate); - - let elapsedEpochs = 0n; - if (userToken.currentTimestamp > lastSettledTimestamp) { - elapsedEpochs = (userToken.currentTimestamp - lastSettledTimestamp) / BigInt(EPOCH_DURATION); - } - - const currentEpoch = lastSettledAt + elapsedEpochs; - - const fundedUntilEpoch = lockupRate === 0n ? maxUint256 : lastSettledAt + (funds - lockupCurrent) / lockupRate; - const simulatedSettledAt = fundedUntilEpoch < currentEpoch ? fundedUntilEpoch : currentEpoch; - const simulatedLockupCurrent = lockupCurrent + lockupRate * (simulatedSettledAt - lastSettledAt); - - const rawAvailable = funds - simulatedLockupCurrent; - const availableFunds = rawAvailable > 0n ? rawAvailable : 0n; - - const fundedUntilTimestamp = - lockupRate === 0n ? maxUint256 : lastSettledTimestamp + (fundedUntilEpoch - lastSettledAt) * BigInt(EPOCH_DURATION); - - const totalOwed = lockupCurrent + lockupRate * elapsedEpochs; - let debt = 0n; - if (totalOwed > funds) { - debt = totalOwed - funds; - } - - return { - availableFunds, - debt, - fundedUntilTimestamp, - simulatedLockupCurrent, - }; -}; - -/** Checks whether the account is funded, running low, expired, or has no ongoing spending. */ -const getFundingStatus = (fundedUntilTimestamp: bigint, currentTimestamp: bigint) => { - if (fundedUntilTimestamp === maxUint256) return "infinity"; - else if (fundedUntilTimestamp <= currentTimestamp) return "expired"; - else if (fundedUntilTimestamp - currentTimestamp <= BigInt(FUNDING_WARNING_THRESHOLD_SECONDS)) return "warning"; - - return "funded"; -}; - -/** Formats when the account's funds are expected to run out. */ -const formatFundedUntilTimestamp = (fundedUntilTimestamp: bigint, currentTimestamp: bigint) => { - if (fundedUntilTimestamp === maxUint256) return "Infinity"; - return formatFutureTimestamp(fundedUntilTimestamp, currentTimestamp); -}; - -function TokenIcon({ token }: { token: UserToken["token"] }) { - if (token.symbol === "USDFC") { - return ; - } - - return ( -
- {token.symbol.charAt(0)} -
- ); -} - -function formatDebtDetail(userToken: FundsTableRow, debt: bigint) { - return `Debt: ${formatToken(debt, userToken.token.decimals, userToken.token.symbol, 6)}`; -} - -function getFundedUntilPresentation( - fundingStatus: FundingStatus, - userToken: FundsTableRow, - fundedUntilTimestamp: bigint, - debt: bigint, -): FundedUntilPresentation { - const fundedUntilDetail = formatTimestampToTime(fundedUntilTimestamp); - - switch (fundingStatus) { - case "infinity": - return { - detail: null, - showWarningIcon: false, - timeColor: "text-green-600 dark:text-green-400", - }; - case "expired": - return { - detail: formatDebtDetail(userToken, debt), - showWarningIcon: false, - timeColor: "text-red-600 dark:text-red-400", - }; - case "warning": - return { - detail: fundedUntilDetail, - showWarningIcon: true, - timeColor: "text-amber-600 dark:text-amber-400", - }; - case "funded": - return { - detail: fundedUntilDetail, - showWarningIcon: false, - timeColor: "text-foreground", - }; - } -} - -// Create column helper -const columnHelper = createColumnHelper(); - -export const columns = [ - columnHelper.accessor("token.symbol", { - header: "Token", - cell: (info) => { - const userToken = info.row.original; - return ( -
- - {userToken.token.symbol} -
- ); - }, - }), - columnHelper.accessor("funds", { - header: () =>
Available
, - cell: (info) => { - const userToken = info.row.original; - const { availableFunds } = calculateFundedUntil(userToken); - return ( -
- {formatToken(availableFunds.toString(), userToken.token.decimals, "", 6)} -
- ); - }, - }), - columnHelper.accessor("lockupCurrent", { - header: () =>
Locked
, - cell: (info) => { - const userToken = info.row.original; - const { simulatedLockupCurrent } = calculateFundedUntil(userToken); - return ( -
- {formatToken(simulatedLockupCurrent, userToken.token.decimals, "", 6)} -
- ); - }, - }), - columnHelper.accessor("payout", { - header: () => ( -
- Paid out - - - - - - Total paid to service providers - - -
- ), - cell: (info) => { - const userToken = info.row.original; - return ( -
- - - {formatToken(userToken.payout, userToken.token.decimals, "", 0)} - -
- ); - }, - }), - columnHelper.accessor("fundsCollected", { - header: () => ( -
- Earned - - - - - - Total earned from services - - -
- ), - cell: (info) => { - const userToken = info.row.original; - return ( -
- - - {formatToken(userToken.fundsCollected, userToken.token.decimals, "", 0)} - -
- ); - }, - }), - columnHelper.display({ - id: "fundedUntil", - header: () =>
Funded until
, - cell: (info) => { - const userToken = info.row.original; - const { debt, fundedUntilTimestamp } = calculateFundedUntil(userToken); - const fundingStatus = getFundingStatus(fundedUntilTimestamp, userToken.currentTimestamp); - const presentation = getFundedUntilPresentation(fundingStatus, userToken, fundedUntilTimestamp, debt); - - return ( -
-
- {presentation.showWarningIcon && } - {formatFundedUntilTimestamp(fundedUntilTimestamp, userToken.currentTimestamp)} -
- {presentation.detail &&
{presentation.detail}
} -
- ); - }, - }), - columnHelper.display({ - id: "actions", - header: "Actions", - cell: (info) => { - const userToken = info.row.original; - return ( -
- - -
- ); - }, - }), -]; diff --git a/apps/explorer/src/components/UserConsole/FundsSection/data/funding-runway.test.ts b/apps/explorer/src/components/UserConsole/FundsSection/data/funding-runway.test.ts new file mode 100644 index 00000000..020170cc --- /dev/null +++ b/apps/explorer/src/components/UserConsole/FundsSection/data/funding-runway.test.ts @@ -0,0 +1,130 @@ +import { TIME_CONSTANTS } from "@filoz/synapse-sdk"; +import { describe, expect, it } from "vitest"; +import { + calculateFundingRunway, + calculateProjectedFundingRunway, + defaultTopUpSuggestion, + EPOCHS_PER_DAY, + EPOCHS_PER_MONTH, + FUNDING_TARGETS, + type FundingAccountSummary, + formatFundedThrough, + formatSuggestedTopUp, + formatUsdfcAmount, + ONE_YEAR_EPOCHS, + parseFundingAmount, + roundUpUnits, +} from "./funding-runway"; + +const rate = 10n; +const epoch = 1_000n; +const genesisTimestamp = 1_598_306_400; +const bufferEpochs = TIME_CONSTANTS.EPOCHS_PER_HOUR / 4n; + +function summary(runwayInEpochs: bigint, overrides: Partial = {}): FundingAccountSummary { + return { + availableFunds: rate * runwayInEpochs, + debt: 0n, + epoch, + lockupRatePerEpoch: rate, + runwayInEpochs, + ...overrides, + }; +} + +describe("calculateFundingRunway", () => { + it.each([ + [ONE_YEAR_EPOCHS, "long-term-funded"], + [ONE_YEAR_EPOCHS - 1n, "funded"], + [30n * EPOCHS_PER_DAY, "funded"], + [30n * EPOCHS_PER_DAY - 1n, "low"], + [7n * EPOCHS_PER_DAY, "low"], + [7n * EPOCHS_PER_DAY - 1n, "urgent"], + [EPOCHS_PER_DAY, "urgent"], + [EPOCHS_PER_DAY - 1n, "critical"], + ] as const)("maps %i epochs to %s", (runwayInEpochs, status) => { + expect(calculateFundingRunway(summary(runwayInEpochs), ONE_YEAR_EPOCHS, genesisTimestamp).status).toBe(status); + }); + + it("uses the SDK summary and selected target", () => { + const current = summary(EPOCHS_PER_DAY); + const month = calculateFundingRunway(current, FUNDING_TARGETS.month.epochs, genesisTimestamp); + const year = calculateFundingRunway(current, FUNDING_TARGETS.year.epochs, genesisTimestamp); + + expect(month.suggestedTopUp).toBe(rate * (FUNDING_TARGETS.month.epochs - EPOCHS_PER_DAY + bufferEpochs)); + expect(year.suggestedTopUp).toBe(rate * (FUNDING_TARGETS.year.epochs - EPOCHS_PER_DAY + bufferEpochs)); + expect(year.fundedThroughTimestamp).toBe( + BigInt(genesisTimestamp) + (epoch + EPOCHS_PER_DAY) * BigInt(TIME_CONSTANTS.EPOCH_DURATION), + ); + }); + + it("includes debt and handles accounts without active spend", () => { + const underfunded = summary(0n, { availableFunds: 0n, debt: 50n }); + expect(calculateFundingRunway(underfunded, FUNDING_TARGETS.month.epochs, genesisTimestamp).suggestedTopUp).toBe( + 50n + rate * (FUNDING_TARGETS.month.epochs + bufferEpochs), + ); + + expect( + calculateFundingRunway( + summary(0n, { availableFunds: 0n, lockupRatePerEpoch: 0n }), + FUNDING_TARGETS.year.epochs, + genesisTimestamp, + ), + ).toMatchObject({ fundedThroughTimestamp: null, status: "no-active-spend", suggestedTopUp: 0n }); + }); + + it("projects a deposit without reimplementing settlement", () => { + const projected = calculateProjectedFundingRunway( + summary(EPOCHS_PER_DAY), + rate * ONE_YEAR_EPOCHS, + FUNDING_TARGETS.year.epochs, + genesisTimestamp, + ); + + expect(projected.status).toBe("long-term-funded"); + expect(projected.suggestedTopUp).toBe(0n); + expect(formatFundedThrough(projected, true)).toMatch(/^~/); + }); +}); + +describe("suggested top-up formatting", () => { + it("rounds the suggestion up so the runway still reaches the target", () => { + expect(formatSuggestedTopUp(1_562_290_695_640_047_227n)).toBe("1.57"); + expect(formatSuggestedTopUp(1_500_000_000_000_000_000n)).toBe("1.5"); + expect(formatSuggestedTopUp(0n)).toBe(""); + expect(formatSuggestedTopUp(-5n)).toBe(""); + }); + + it("rounds up to a chosen precision without dropping below the input", () => { + expect(roundUpUnits(1_562_290_695_640_047_227n, 18, 2)).toBe(1_570_000_000_000_000_000n); + expect(roundUpUnits(1_000_000_000_000_000_000n, 18, 2)).toBe(1_000_000_000_000_000_000n); + }); + + it("caps display precision without changing the deposited amount", () => { + expect(formatUsdfcAmount(1_562_290_695_640_047_227n)).toBe("1.562291"); + }); + + it("parses only positive funding amounts at the token precision", () => { + expect(parseFundingAmount("1.25", 6)).toBe(1_250_000n); + expect(parseFundingAmount("0", 18)).toBeNull(); + expect(parseFundingAmount("not-a-number", 18)).toBeNull(); + }); +}); + +describe("defaultTopUpSuggestion", () => { + const current = summary(EPOCHS_PER_DAY); + const cost = (months: bigint) => + calculateFundingRunway(current, months * EPOCHS_PER_MONTH, genesisTimestamp).suggestedTopUp; + + it("suggests one year when unconstrained", () => { + expect(defaultTopUpSuggestion(current, genesisTimestamp)).toBe(formatSuggestedTopUp(cost(12n))); + }); + + it("clamps the suggestion to what maxAmount can pay", () => { + expect(defaultTopUpSuggestion(current, genesisTimestamp, cost(3n))).toBe(formatSuggestedTopUp(cost(3n))); + }); + + it("suggests nothing when maxAmount cannot cover the first unfunded month", () => { + expect(defaultTopUpSuggestion(current, genesisTimestamp, 0n)).toBe(""); + }); +}); diff --git a/apps/explorer/src/components/UserConsole/FundsSection/data/funding-runway.ts b/apps/explorer/src/components/UserConsole/FundsSection/data/funding-runway.ts new file mode 100644 index 00000000..4cc4b720 --- /dev/null +++ b/apps/explorer/src/components/UserConsole/FundsSection/data/funding-runway.ts @@ -0,0 +1,173 @@ +import { TIME_CONSTANTS } from "@filoz/synapse-sdk"; +import { formatUnits, parseUnits } from "viem"; +import { formatDate } from "@/utils/formatter"; + +export const USDFC_DECIMALS = 18; +export const EPOCHS_PER_DAY = TIME_CONSTANTS.EPOCHS_PER_DAY; +export const EPOCHS_PER_MONTH = TIME_CONSTANTS.EPOCHS_PER_MONTH; +export const ONE_YEAR_EPOCHS = 365n * EPOCHS_PER_DAY; +// Runway slider bounds: fund for anywhere between one month and five years, +// defaulting to one year. +export const MAX_FUNDING_MONTHS = 60; +export const DEFAULT_FUNDING_MONTHS = 12; +export const FUNDING_TARGETS = { + month: { epochs: TIME_CONSTANTS.EPOCHS_PER_MONTH, label: "1 month" }, + year: { epochs: ONE_YEAR_EPOCHS, label: "1 year" }, +} as const; +export type FundingTarget = keyof typeof FUNDING_TARGETS; +export const FUNDING_ESTIMATE_DISCLAIMER = + "Estimates assume your current recurring spend rate; one-time charges are not included."; +const TOP_UP_BUFFER_EPOCHS = TIME_CONSTANTS.EPOCHS_PER_HOUR / 4n; + +export type FundingStatus = "long-term-funded" | "funded" | "low" | "urgent" | "critical" | "no-active-spend"; + +export type FundingAccountSummary = { + availableFunds: bigint; + debt: bigint; + epoch: bigint; + lockupRatePerEpoch: bigint; + runwayInEpochs: bigint; +}; + +export type FundingRunway = { + fundedThroughTimestamp: bigint | null; + runwayInEpochs: bigint; + status: FundingStatus; + suggestedTopUp: bigint; +}; + +export function calculateFundingRunway( + summary: FundingAccountSummary, + targetEpochs: bigint, + genesisTimestamp: number, +): FundingRunway { + const shortfallEpochs = targetEpochs > summary.runwayInEpochs ? targetEpochs - summary.runwayInEpochs : 0n; + const needsTopUp = summary.debt > 0n || shortfallEpochs > 0n; + const suggestedTopUp = needsTopUp + ? summary.debt + (shortfallEpochs + TOP_UP_BUFFER_EPOCHS) * summary.lockupRatePerEpoch + : 0n; + const fundedThroughTimestamp = + summary.lockupRatePerEpoch === 0n + ? null + : BigInt(genesisTimestamp) + (summary.epoch + summary.runwayInEpochs) * BigInt(TIME_CONSTANTS.EPOCH_DURATION); + + return { + fundedThroughTimestamp, + runwayInEpochs: summary.runwayInEpochs, + status: fundingStatus(summary.runwayInEpochs, summary.debt, summary.lockupRatePerEpoch), + suggestedTopUp, + }; +} + +export function calculateProjectedFundingRunway( + summary: FundingAccountSummary, + amount: bigint, + targetEpochs: bigint, + genesisTimestamp: number, +): FundingRunway { + const remainingDebt = summary.debt > amount ? summary.debt - amount : 0n; + const availableFunds = summary.availableFunds + (amount > summary.debt ? amount - summary.debt : 0n); + const runwayInEpochs = + summary.lockupRatePerEpoch === 0n ? summary.runwayInEpochs : availableFunds / summary.lockupRatePerEpoch; + + return calculateFundingRunway( + { ...summary, availableFunds, debt: remainingDebt, runwayInEpochs }, + targetEpochs, + genesisTimestamp, + ); +} + +// Inverse of the suggested-top-up curve: the TOTAL runway (in months from now) +// that a deposit of `amount` roughly buys. Float math is fine here — it only +// positions a slider thumb; the exact bigint is still what gets deposited. +export function monthsForTopUp(summary: FundingAccountSummary, amount: bigint): number | null { + if (summary.lockupRatePerEpoch === 0n) return null; + const epochsAfter = + Number(summary.runwayInEpochs) - + Number(TOP_UP_BUFFER_EPOCHS) + + (Number(amount) - Number(summary.debt)) / Number(summary.lockupRatePerEpoch); + return epochsAfter / Number(EPOCHS_PER_MONTH); +} + +// First whole-month runway target that still needs a top-up; null when the +// account has no recurring spend to project or is funded past `maxMonths`. +export function minTopUpMonths(summary: FundingAccountSummary, maxMonths = MAX_FUNDING_MONTHS): number | null { + if (summary.lockupRatePerEpoch === 0n) return null; + if (summary.debt > 0n) return 1; + const coveredMonths = Number(summary.runwayInEpochs / EPOCHS_PER_MONTH); + const min = coveredMonths + 1; + return min > maxMonths ? null : min; +} + +// Prefill for the funding dialogs: the suggestion at the slider's initial +// position (one year, clamped up to the first unfunded month so an account +// already covered past a year still opens with a live projection, and down to +// what `maxAmount` can pay). Empty when there is nothing to suggest. +export function defaultTopUpSuggestion( + summary: FundingAccountSummary, + genesisTimestamp: number, + maxAmount?: bigint, +): string { + const min = minTopUpMonths(summary); + if (min === null) return ""; + let months = Math.min(Math.max(DEFAULT_FUNDING_MONTHS, min), MAX_FUNDING_MONTHS); + if (maxAmount !== undefined) { + const affordableMonths = monthsForTopUp(summary, maxAmount); + if (affordableMonths === null || Math.floor(affordableMonths) < min) return ""; + months = Math.min(months, Math.floor(affordableMonths)); + } + return formatSuggestedTopUp( + calculateFundingRunway(summary, BigInt(months) * EPOCHS_PER_MONTH, genesisTimestamp).suggestedTopUp, + ); +} + +export function parseFundingAmount(amount: string, decimals: number): bigint | null { + try { + const parsedAmount = parseUnits(amount, decimals); + return parsedAmount > 0n ? parsedAmount : null; + } catch { + return null; + } +} + +export function formatFundedThrough( + runway: Pick, + approximate = false, +): string { + if (runway.fundedThroughTimestamp === null) { + return runway.status === "critical" ? "Underfunded" : "No active spend"; + } + if (runway.runwayInEpochs === 0n) return "Underfunded"; + return `${approximate ? "~" : ""}${formatDate(runway.fundedThroughTimestamp)}`; +} + +function fundingStatus(runwayInEpochs: bigint, debt: bigint, lockupRate: bigint): FundingStatus { + if (lockupRate === 0n) return debt > 0n ? "critical" : "no-active-spend"; + if (debt > 0n || runwayInEpochs < EPOCHS_PER_DAY) return "critical"; + if (runwayInEpochs < 7n * EPOCHS_PER_DAY) return "urgent"; + if (runwayInEpochs < 30n * EPOCHS_PER_DAY) return "low"; + if (runwayInEpochs < ONE_YEAR_EPOCHS) return "funded"; + return "long-term-funded"; +} + +// Precision the suggested top-up is presented (and prefilled) at. +export const SUGGESTED_TOPUP_DISPLAY_DECIMALS = 2; + +// Round a base-unit amount UP to `displayDecimals` places. Rounding up keeps the +// suggested top-up at or above the selected target instead of falling just short. +export function roundUpUnits(amount: bigint, decimals: number, displayDecimals: number): bigint { + const factor = 10n ** BigInt(decimals - displayDecimals); + return factor <= 1n ? amount : ((amount + factor - 1n) / factor) * factor; +} + +// A clean, prefill-ready suggested top-up string (e.g. "1.57"). Empty when nothing is owed. +export function formatSuggestedTopUp(amount: bigint): string { + if (amount <= 0n) return ""; + return formatUnits(roundUpUnits(amount, USDFC_DECIMALS, SUGGESTED_TOPUP_DISPLAY_DECIMALS), USDFC_DECIMALS); +} + +// Display-only USDFC formatting with capped fractional digits. The exact bigint is +// still what gets deposited and confirmed in the wallet. +export function formatUsdfcAmount(amount: bigint): string { + return Number(formatUnits(amount, USDFC_DECIMALS)).toLocaleString(undefined, { maximumFractionDigits: 6 }); +} 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 new file mode 100644 index 00000000..e136759a --- /dev/null +++ b/apps/explorer/src/components/UserConsole/FundsSection/data/guided-top-up.test.ts @@ -0,0 +1,183 @@ +import { NATIVE_TOKEN_ADDRESS, type SquidFundingPlan } from "@filecoin-project/squid-evm-funding"; +import { QueryClient } from "@tanstack/react-query"; +import { describe, expect, it } from "vitest"; +import { + getBridgeNativeFee, + getMaximumBridgeNativeFee, + getPlanBridgeNativeFees, + getPlanNetworkGas, + getRequiredNativeBalance, + invalidateTopUpQueries, + parseTopUpAmount, + shouldBlockOnSeparateNativeBalance, +} from "./guided-top-up"; + +const owner = "0x1111111111111111111111111111111111111111" as const; +const token = "0x2222222222222222222222222222222222222222" as const; + +function plan(sourceToken: SquidFundingPlan["source"]["token"] = token, sourceChainId = 8453): SquidFundingPlan { + return { + maxSourceAmount: 100n, + owner, + quotes: [ + { + actions: [], + costs: [ + { + amount: 10n, + kind: "gas", + name: "Source gas", + token: { address: NATIVE_TOKEN_ADDRESS, chainId: sourceChainId, decimals: 18, symbol: "ETH" }, + }, + { + amount: 5n, + kind: "fee", + name: "Bridge fee", + token: { address: NATIVE_TOKEN_ADDRESS, chainId: sourceChainId, decimals: 18, symbol: "ETH" }, + }, + { + amount: 99n, + kind: "gas", + name: "Destination gas", + token: { address: NATIVE_TOKEN_ADDRESS, chainId: 314, decimals: 18, symbol: "FIL" }, + }, + { + amount: 77n, + kind: "fee", + name: "ERC-20 fee", + token: { address: token, chainId: sourceChainId, decimals: 6, symbol: "USDC" }, + }, + ], + destinationAmount: 1n, + id: "quote", + requirement: { amount: 1n, chainId: 314, id: "requirement", recipient: owner, token }, + sourceAmount: 100n, + }, + ], + slippage: 1, + source: { chainId: sourceChainId, decimals: 18, symbol: "ETH", token: sourceToken }, + }; +} + +describe("guided top-up", () => { + it("parses an editable 18-decimal USDFC amount", () => { + expect(parseTopUpAmount("1.25")).toBe(1_250_000_000_000_000_000n); + expect(parseTopUpAmount("0")).toBeNull(); + expect(parseTopUpAmount("not-a-number")).toBeNull(); + }); + + it("invalidates account and balance data after a top-up", async () => { + const queryClient = new QueryClient(); + const accountId = "indexed-account"; + const accountOwner = "0x1111111111111111111111111111111111111111"; + const affectedKeys = [ + ["account", accountOwner, "mainnet"], + ["account", accountId, "tokens", 1, "mainnet"], + ["payments", "account-summary", 314, accountOwner], + ["balance", accountOwner], + ["readContract", "payments"], + ] as const; + const unaffectedKey = ["account", "another-owner", "mainnet"] as const; + + for (const queryKey of [...affectedKeys, unaffectedKey]) queryClient.setQueryData(queryKey, "cached"); + + await invalidateTopUpQueries(queryClient, accountId, accountOwner); + + expect(affectedKeys.map((queryKey) => queryClient.getQueryState(queryKey)?.isInvalidated)).toEqual([ + true, + true, + true, + true, + true, + ]); + expect(queryClient.getQueryState(unaffectedKey)?.isInvalidated).toBe(false); + }); + + it("derives the reviewed gas cap from source type and the current allowance", () => { + expect(getPlanNetworkGas(plan(NATIVE_TOKEN_ADDRESS))).toEqual({ + estimated: 10n, + maximum: 12n, + transactionCount: 1, + }); + expect(getPlanNetworkGas(plan(), 100n)).toEqual({ estimated: 10n, maximum: 12n, transactionCount: 1 }); + expect(getPlanNetworkGas(plan(), 0n)).toEqual({ estimated: 10n, maximum: 24n, transactionCount: 2 }); + expect(getPlanNetworkGas(plan(), 1n)).toEqual({ estimated: 10n, maximum: 36n, transactionCount: 3 }); + expect(getPlanNetworkGas(plan(token, 1), 1n)).toEqual({ + estimated: 10n, + maximum: 30n, + transactionCount: 3, + }); + }); + + it("buffers each modeled OP Stack transaction before summing", () => { + const lowValuePlan = plan(); + const sourceGas = lowValuePlan.quotes[0].costs[0]; + if (sourceGas) sourceGas.amount = 3n; + + expect(getPlanNetworkGas(lowValuePlan, 1n)).toEqual({ estimated: 3n, maximum: 12n, transactionCount: 3 }); + }); + + it("waits for the ERC-20 allowance before publishing a hard gas maximum", () => { + expect(getPlanNetworkGas(plan())).toEqual({ estimated: 10n, maximum: null, transactionCount: null }); + }); + + it("models an exact allowance as consumed before a later route", () => { + const fundingPlan = plan(); + const firstQuote = fundingPlan.quotes[0]; + if (!firstQuote) throw new Error("Expected a quote fixture"); + fundingPlan.quotes = [firstQuote, { ...firstQuote, id: "second", sourceAmount: 50n }]; + + expect(getPlanNetworkGas(fundingPlan, 100n)).toEqual({ + estimated: 20n, + maximum: 36n, + transactionCount: 3, + }); + }); + + it("includes bridge headroom and the exact reviewed gas cap in the native balance requirement", () => { + const erc20Plan = plan(); + const nativePlan = plan(NATIVE_TOKEN_ADDRESS); + + 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", () => { + expect(shouldBlockOnSeparateNativeBalance(true, true, false)).toBe(false); + expect(shouldBlockOnSeparateNativeBalance(true, false, true)).toBe(false); + expect(shouldBlockOnSeparateNativeBalance(false, true, false)).toBe(true); + expect(shouldBlockOnSeparateNativeBalance(false, false, true)).toBe(true); + }); +}); 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 new file mode 100644 index 00000000..54039597 --- /dev/null +++ b/apps/explorer/src/components/UserConsole/FundsSection/data/guided-top-up.ts @@ -0,0 +1,135 @@ +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"; + +export function parseTopUpAmount(amount: string): bigint | null { + return parseFundingAmount(amount, USDFC_DECIMALS); +} + +export function withoutTopUpSearchParam(searchParams: URLSearchParams): string { + const nextSearchParams = new URLSearchParams(searchParams); + nextSearchParams.delete("topUp"); + const query = nextSearchParams.toString(); + return query ? `?${query}` : ""; +} + +function isNativeToken(address: string | undefined): boolean { + 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); +} + +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 = getBridgeNativeFee(quote.costs, plan.source.chainId); + return { + estimated: total.estimated + estimated, + maximum: total.maximum + getMaximumBridgeNativeFee(estimated), + }; + }, + { estimated: 0n, maximum: 0n }, + ); +} + +export function getPlanNetworkGas( + plan: SquidFundingPlan, + currentAllowance?: bigint, +): { estimated: bigint; maximum: bigint | null; transactionCount: number | null } { + const isNativeSource = isNativeToken(plan.source.token); + if (!isNativeSource && currentAllowance === undefined) { + return { + estimated: plan.quotes.reduce((total, quote) => total + getQuoteNetworkGas(quote.costs, plan.source.chainId), 0n), + maximum: null, + transactionCount: null, + }; + } + + let allowance = currentAllowance ?? 0n; + let estimated = 0n; + let maximum = 0n; + let transactionCount = 0; + for (const quote of plan.quotes) { + const routeGas = getQuoteNetworkGas(quote.costs, plan.source.chainId); + // Squid only supplies the route estimate. Model each approval as one + // route-gas equivalent, but only when the exact allowance policy will + // actually execute it. The executor still fails closed against this cap + // after preparing each real transaction. + const bufferedTransactionGas = applyNetworkFeeExecutionBuffer(plan.source.chainId, routeGas); + estimated += routeGas; + + if (!isNativeSource && allowance !== quote.sourceAmount) { + if (allowance > 0n) { + maximum += bufferedTransactionGas; + transactionCount += 1; + } + maximum += bufferedTransactionGas; + transactionCount += 1; + allowance = quote.sourceAmount; + } + + maximum += bufferedTransactionGas; + transactionCount += 1; + // The executor grants exactly sourceAmount, which the route consumes. + if (!isNativeSource) allowance = 0n; + } + return { estimated, maximum, transactionCount }; +} + +function getQuoteNetworkGas(costs: readonly SquidQuoteCost[], sourceChainId: number): bigint { + return costs.reduce( + (total, cost) => + total + + (cost.kind === "gas" && cost.token.chainId === sourceChainId && isNativeToken(cost.token.address) + ? cost.amount + : 0n), + 0n, + ); +} + +export function getRequiredNativeBalance(plan: SquidFundingPlan, maximumNetworkFee: bigint): bigint { + const sourceAmount = isNativeToken(plan.source.token) + ? plan.quotes.reduce((total, quote) => total + quote.sourceAmount, 0n) + : 0n; + return sourceAmount + getPlanBridgeNativeFees(plan).maximum + maximumNetworkFee; +} + +export function shouldBlockOnSeparateNativeBalance( + isNativeSource: boolean, + isSeparateNativeBalanceError: boolean, + isSeparateNativeBalanceLoading: boolean, +): boolean { + return !isNativeSource && (isSeparateNativeBalanceError || isSeparateNativeBalanceLoading); +} + +export function formatNativeFee(value: bigint, currency: { decimals: number; symbol: string }): string | null { + if (value === 0n) return null; + return `${formatUnits(value, currency.decimals)} ${currency.symbol}`; +} + +export function invalidateTopUpQueries(queryClient: QueryClient, accountId: string, accountOwner: string) { + return Promise.all([ + queryClient.invalidateQueries({ queryKey: ["account", accountOwner] }), + queryClient.invalidateQueries({ queryKey: ["account", accountId, "tokens"] }), + queryClient.invalidateQueries({ queryKey: ["payments", "account-summary"] }), + queryClient.invalidateQueries({ queryKey: ["balance"] }), + queryClient.invalidateQueries({ queryKey: ["readContract"] }), + ]); +} diff --git a/apps/explorer/src/components/UserConsole/FundsSection/data/squid-acquisition-flow.test.ts b/apps/explorer/src/components/UserConsole/FundsSection/data/squid-acquisition-flow.test.ts new file mode 100644 index 00000000..38251510 --- /dev/null +++ b/apps/explorer/src/components/UserConsole/FundsSection/data/squid-acquisition-flow.test.ts @@ -0,0 +1,246 @@ +import { describe, expect, it, vi } from "vitest"; +import { loadSquidAcquisition } from "./squid-acquisition"; +import { runSquidAcquisition } from "./squid-acquisition-flow"; + +const owner = "0x1111111111111111111111111111111111111111" as const; +const sourceHash = `0x${"3".repeat(64)}` as const; + +function createStorage() { + const values = new Map(); + return { + getItem: (key: string) => values.get(key) ?? null, + removeItem: (key: string) => values.delete(key), + setItem: (key: string, value: string) => values.set(key, value), + }; +} + +describe("runSquidAcquisition", () => { + it("persists the baseline and freezes the exact delivered balance increase", async () => { + const storage = createStorage(); + const readDestinationBalance = vi.fn().mockResolvedValueOnce(100n).mockResolvedValueOnce(115n); + const onStarted = vi.fn(); + + const outcome = await runSquidAcquisition({ + execute: async ({ onSwapAttempt, onSwapBroadcast }) => { + onSwapAttempt(); + onSwapBroadcast(sourceHash); + }, + minimumDestinationAmount: 10n, + onStarted, + owner, + readDestinationBalance, + sourceChainId: 42161, + storage, + }); + + expect(onStarted).toHaveBeenCalledWith(expect.objectContaining({ destinationBalanceBefore: 100n })); + expect(outcome).toEqual({ + acquisition: expect.objectContaining({ deliveredAmount: 15n, transactionHashes: [sourceHash] }), + status: "acquired", + }); + expect(loadSquidAcquisition(storage, owner)).toEqual( + expect.objectContaining({ deliveredAmount: 15n, status: "acquired" }), + ); + }); + + it("does not execute or create a marker when the initial balance read fails", async () => { + const storage = createStorage(); + const execute = vi.fn(); + const error = new Error("destination RPC unavailable"); + + await expect( + runSquidAcquisition({ + execute, + minimumDestinationAmount: 10n, + owner, + readDestinationBalance: vi.fn().mockRejectedValue(error), + sourceChainId: 42161, + storage, + }), + ).resolves.toEqual({ error, status: "failed" }); + expect(execute).not.toHaveBeenCalled(); + expect(loadSquidAcquisition(storage, owner)).toBeNull(); + }); + + it.each([ + ["post-broadcast balance read failure", vi.fn().mockResolvedValueOnce(100n).mockRejectedValue(new Error("RPC"))], + ["delivery below the reviewed minimum", vi.fn().mockResolvedValueOnce(100n).mockResolvedValueOnce(109n)], + ])("keeps the marker and hash after %s", async (_name, readDestinationBalance) => { + const storage = createStorage(); + + const outcome = await runSquidAcquisition({ + execute: async ({ onSwapAttempt, onSwapBroadcast }) => { + onSwapAttempt(); + onSwapBroadcast(sourceHash); + }, + minimumDestinationAmount: 10n, + owner, + readDestinationBalance, + sourceChainId: 42161, + storage, + }); + + expect(outcome).toEqual({ + acquisition: expect.objectContaining({ + executionStage: "swap-broadcast", + status: "processing", + transactionHashes: [sourceHash], + }), + error: expect.any(Error), + status: "blocked", + }); + expect(loadSquidAcquisition(storage, owner)).toEqual( + expect.objectContaining({ status: "processing", transactionHashes: [sourceHash] }), + ); + }); + + it("clears only the exact marker after a pre-swap failure", async () => { + const storage = createStorage(); + const error = new Error("quote refresh failed"); + + await expect( + runSquidAcquisition({ + execute: vi.fn().mockRejectedValue(error), + minimumDestinationAmount: 10n, + owner, + readDestinationBalance: vi.fn().mockResolvedValue(100n), + sourceChainId: 42161, + storage, + }), + ).resolves.toEqual({ error, status: "failed" }); + expect(loadSquidAcquisition(storage, owner)).toBeNull(); + }); + + it("persists an ambiguous swap request without a returned hash", async () => { + const storage = createStorage(); + const error = new Error("wallet response lost"); + + const outcome = await runSquidAcquisition({ + execute: async ({ onSwapAttempt }) => { + onSwapAttempt(); + throw error; + }, + minimumDestinationAmount: 10n, + owner, + readDestinationBalance: vi.fn().mockResolvedValue(100n), + sourceChainId: 42161, + storage, + }); + + expect(outcome).toEqual({ + acquisition: expect.objectContaining({ executionStage: "swap-requested", transactionHashes: [] }), + error, + status: "blocked", + }); + expect(loadSquidAcquisition(storage, owner)).toEqual( + expect.objectContaining({ executionStage: "swap-requested", transactionHashes: [] }), + ); + }); + + it("clears an unbroadcast swap request only after an explicit wallet rejection", async () => { + const storage = createStorage(); + const error = { code: 4001 }; + + await expect( + runSquidAcquisition({ + execute: async ({ onSwapAttempt }) => { + onSwapAttempt(); + throw error; + }, + minimumDestinationAmount: 10n, + owner, + readDestinationBalance: vi.fn().mockResolvedValue(100n), + sourceChainId: 42161, + storage, + }), + ).resolves.toEqual({ error, status: "failed" }); + expect(loadSquidAcquisition(storage, owner)).toBeNull(); + }); + + it("preserves an earlier route hash when a later wallet request is rejected", async () => { + const storage = createStorage(); + const error = { code: 4001 }; + + const outcome = await runSquidAcquisition({ + execute: async ({ onSwapAttempt, onSwapBroadcast }) => { + onSwapAttempt(); + onSwapBroadcast(sourceHash); + onSwapAttempt(); + throw error; + }, + minimumDestinationAmount: 10n, + owner, + readDestinationBalance: vi.fn().mockResolvedValue(100n), + sourceChainId: 42161, + storage, + }); + + expect(outcome).toEqual({ + acquisition: expect.objectContaining({ + executionStage: "swap-requested", + transactionHashes: [sourceHash], + }), + error, + status: "blocked", + }); + expect(loadSquidAcquisition(storage, owner)).toEqual( + expect.objectContaining({ executionStage: "swap-requested", transactionHashes: [sourceHash] }), + ); + }); + + it("returns the in-memory marker when storage becomes unreadable after it is saved", async () => { + const values = new Map(); + let readsFail = false; + const storage = { + getItem: (key: string) => { + if (readsFail) throw new Error("storage unavailable"); + return values.get(key) ?? null; + }, + removeItem: (key: string) => values.delete(key), + setItem: (key: string, value: string) => { + values.set(key, value); + readsFail = true; + }, + }; + + const outcome = await runSquidAcquisition({ + execute: async ({ onSwapAttempt, onSwapBroadcast }) => { + onSwapAttempt(); + onSwapBroadcast(sourceHash); + }, + minimumDestinationAmount: 10n, + owner, + readDestinationBalance: vi.fn().mockResolvedValue(100n), + sourceChainId: 42161, + storage, + }); + + expect(outcome).toEqual({ + acquisition: expect.objectContaining({ destinationBalanceBefore: 100n, status: "processing" }), + error: expect.objectContaining({ message: "storage unavailable" }), + status: "blocked", + }); + }); + + it("clears the marker and returns failed when the start callback throws before execution", async () => { + const storage = createStorage(); + const execute = vi.fn(); + const error = new Error("render callback failed"); + + const outcome = await runSquidAcquisition({ + execute, + minimumDestinationAmount: 10n, + onStarted: () => { + throw error; + }, + owner, + readDestinationBalance: vi.fn().mockResolvedValue(100n), + sourceChainId: 42161, + storage, + }); + + expect(outcome).toEqual({ error, status: "failed" }); + expect(execute).not.toHaveBeenCalled(); + expect(loadSquidAcquisition(storage, owner)).toBeNull(); + }); +}); diff --git a/apps/explorer/src/components/UserConsole/FundsSection/data/squid-acquisition-flow.ts b/apps/explorer/src/components/UserConsole/FundsSection/data/squid-acquisition-flow.ts new file mode 100644 index 00000000..f7f28a41 --- /dev/null +++ b/apps/explorer/src/components/UserConsole/FundsSection/data/squid-acquisition-flow.ts @@ -0,0 +1,92 @@ +import type { Address, Hash } from "viem"; +import { + beginSquidAcquisition, + clearSquidAcquisition, + loadSquidAcquisition, + markSquidAcquiredFromBalance, + markSquidBroadcast, + markSquidSwapRequested, + type SquidAcquisition, +} from "./squid-acquisition"; +import { canClearSquidAcquisitionAfterError } from "./squid-execution"; + +type AcquisitionStorage = Pick; + +export type SquidAcquisitionOutcome = + | { acquisition: SquidAcquisition; status: "acquired" } + | { acquisition: SquidAcquisition; error: unknown; status: "blocked" } + | { error: unknown; status: "failed" }; + +export async function runSquidAcquisition({ + execute, + minimumDestinationAmount, + onStarted, + owner, + readDestinationBalance, + sourceChainId, + storage, +}: { + execute: (callbacks: { onSwapAttempt: () => void; onSwapBroadcast: (hash: Hash) => void }) => Promise; + minimumDestinationAmount: bigint; + onStarted?: (acquisition: SquidAcquisition) => void; + owner: Address; + readDestinationBalance: () => Promise; + sourceChainId: number; + storage: AcquisitionStorage; +}): Promise { + let destinationBalanceBefore: bigint; + try { + destinationBalanceBefore = await readDestinationBalance(); + } catch (error) { + return { error, status: "failed" }; + } + + let acquisition: SquidAcquisition; + try { + acquisition = beginSquidAcquisition( + storage, + owner, + minimumDestinationAmount, + destinationBalanceBefore, + sourceChainId, + ); + } catch (error) { + return { error, status: "failed" }; + } + + try { + onStarted?.(acquisition); + await execute({ + onSwapAttempt: () => { + acquisition = markSquidSwapRequested(storage, acquisition); + }, + onSwapBroadcast: (hash) => { + acquisition = markSquidBroadcast(storage, acquisition, hash); + }, + }); + const acquired = markSquidAcquiredFromBalance(storage, acquisition, await readDestinationBalance()); + return { acquisition: acquired, status: "acquired" }; + } catch (error) { + if ( + acquisition.transactionHashes.length === 0 && + canClearSquidAcquisitionAfterError(acquisition.executionStage, error) + ) { + try { + clearSquidAcquisition(storage, acquisition); + return { error, status: "failed" }; + } catch { + // The marker advanced concurrently or storage became unavailable. + // Preserve the latest recoverable state below. + } + } + let latestAcquisition = acquisition; + try { + latestAcquisition = loadSquidAcquisition(storage, owner) ?? acquisition; + } catch { + // Storage may have become unavailable after the durable marker was + // created. Return the in-memory marker so the UI still leaves its + // uncloseable processing state and surfaces manual recovery. + } + return { acquisition: latestAcquisition, error, status: "blocked" }; + } +} diff --git a/apps/explorer/src/components/UserConsole/FundsSection/data/squid-acquisition-lock.test.ts b/apps/explorer/src/components/UserConsole/FundsSection/data/squid-acquisition-lock.test.ts new file mode 100644 index 00000000..a4ccd1ce --- /dev/null +++ b/apps/explorer/src/components/UserConsole/FundsSection/data/squid-acquisition-lock.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { withSquidAcquisitionLock } from "./squid-acquisition-lock"; + +const owner = "0x1111111111111111111111111111111111111111" as const; + +function createLockManager() { + let active = false; + return { + request: async (_name: string, _options: LockOptions, callback: (lock: Lock | null) => T | PromiseLike) => { + if (active) return callback(null); + active = true; + try { + return await callback({} as Lock); + } finally { + active = false; + } + }, + } as LockManager; +} + +describe("withSquidAcquisitionLock", () => { + it("fails closed when the browser has no Web Locks implementation", async () => { + await expect(withSquidAcquisitionLock(undefined, owner, () => undefined)).rejects.toThrow( + "cannot safely coordinate", + ); + }); + + it("rejects an interleaved operation for the same owner", async () => { + const lockManager = createLockManager(); + let release!: () => void; + const first = withSquidAcquisitionLock( + lockManager, + owner, + () => + new Promise((resolve) => { + release = resolve; + }), + ); + + await expect(withSquidAcquisitionLock(lockManager, owner, () => undefined)).rejects.toThrow( + "already active in another tab", + ); + release(); + await expect(first).resolves.toBeUndefined(); + }); +}); diff --git a/apps/explorer/src/components/UserConsole/FundsSection/data/squid-acquisition-lock.ts b/apps/explorer/src/components/UserConsole/FundsSection/data/squid-acquisition-lock.ts new file mode 100644 index 00000000..e07f1a55 --- /dev/null +++ b/apps/explorer/src/components/UserConsole/FundsSection/data/squid-acquisition-lock.ts @@ -0,0 +1,19 @@ +import type { Address } from "viem"; + +const LOCK_PREFIX = "filecoin-pay:squid-acquisition"; + +export async function withSquidAcquisitionLock( + lockManager: LockManager | undefined, + owner: Address, + operation: () => Promise | T, +): Promise { + if (!lockManager) throw new Error("This browser cannot safely coordinate funding across tabs"); + return lockManager.request( + `${LOCK_PREFIX}:${owner.toLowerCase()}`, + { ifAvailable: true, mode: "exclusive" }, + (lock) => { + if (!lock) throw new Error("This funding account is already active in another tab"); + return operation(); + }, + ); +} diff --git a/apps/explorer/src/components/UserConsole/FundsSection/data/squid-acquisition-recovery.test.ts b/apps/explorer/src/components/UserConsole/FundsSection/data/squid-acquisition-recovery.test.ts new file mode 100644 index 00000000..8bd5efb1 --- /dev/null +++ b/apps/explorer/src/components/UserConsole/FundsSection/data/squid-acquisition-recovery.test.ts @@ -0,0 +1,143 @@ +import { SQUID_ROUTER_ADDRESS } from "@filecoin-project/squid-evm-funding"; +import { TransactionReceiptNotFoundError } from "viem"; +import { describe, expect, it, vi } from "vitest"; +import { + beginSquidAcquisition, + loadSquidAcquisition, + markSquidAcquired, + markSquidBroadcast, + markSquidSwapRequested, + type SquidAcquisition, +} from "./squid-acquisition"; +import { + checkAutomaticSquidRecovery, + isAutomaticSquidRecoveryCandidate, + type SquidRecoveryCandidate, + SquidRecoveryTrustError, +} from "./squid-acquisition-recovery"; + +const owner = "0x1111111111111111111111111111111111111111" as const; +const other = "0x2222222222222222222222222222222222222222" as const; +const sourceHash = `0x${"3".repeat(64)}` as const; +const otherHash = `0x${"4".repeat(64)}` as const; +const candidate: SquidRecoveryCandidate = { + acquisitionId: "11111111-1111-4111-8111-111111111111", + destinationAmount: 10n, + destinationBalanceBefore: 100n, + executionStage: "swap-broadcast", + owner, + sourceChainId: 42161, + status: "processing", + transactionHashes: [sourceHash], +}; + +const successfulReceipt = { + from: owner, + status: "success" as const, + to: SQUID_ROUTER_ADDRESS, + transactionHash: sourceHash, +}; + +describe("automatic Squid acquisition recovery", () => { + it("requires a new processing marker with a baseline and source transaction hash", () => { + expect(isAutomaticSquidRecoveryCandidate(candidate)).toBe(true); + expect(isAutomaticSquidRecoveryCandidate({ ...candidate, acquisitionId: undefined } as SquidAcquisition)).toBe( + false, + ); + expect( + isAutomaticSquidRecoveryCandidate({ ...candidate, destinationBalanceBefore: undefined } as SquidAcquisition), + ).toBe(false); + expect(isAutomaticSquidRecoveryCandidate({ ...candidate, transactionHashes: [] })).toBe(false); + expect(isAutomaticSquidRecoveryCandidate({ ...candidate, status: "acquired" } as SquidAcquisition)).toBe(false); + }); + + it("returns the exact delivered balance increase after verifying the source receipt", async () => { + const readDestinationBalance = vi.fn().mockResolvedValue(115n); + + await expect( + checkAutomaticSquidRecovery({ + acquisition: candidate, + getSourceReceipt: vi.fn().mockResolvedValue(successfulReceipt), + readDestinationBalance, + }), + ).resolves.toBe(15n); + expect(readDestinationBalance).toHaveBeenCalledOnce(); + }); + + it("keeps polling while a receipt or the reviewed minimum is pending", async () => { + const notFound = new TransactionReceiptNotFoundError({ hash: sourceHash }); + await expect( + checkAutomaticSquidRecovery({ + acquisition: candidate, + getSourceReceipt: vi.fn().mockRejectedValue(notFound), + readDestinationBalance: vi.fn(), + }), + ).resolves.toBeNull(); + + await expect( + checkAutomaticSquidRecovery({ + acquisition: candidate, + getSourceReceipt: vi.fn().mockResolvedValue(successfulReceipt), + readDestinationBalance: vi.fn().mockResolvedValue(109n), + }), + ).resolves.toBeNull(); + }); + + it.each([ + ["reverted", { ...successfulReceipt, status: "reverted" as const }], + ["different account", { ...successfulReceipt, from: other }], + ["untrusted router", { ...successfulReceipt, to: other }], + ["different hash", { ...successfulReceipt, transactionHash: otherHash }], + ])("rejects a %s source receipt", async (_name, receipt) => { + await expect( + checkAutomaticSquidRecovery({ + acquisition: candidate, + getSourceReceipt: vi.fn().mockResolvedValue(receipt), + readDestinationBalance: vi.fn(), + }), + ).rejects.toBeInstanceOf(SquidRecoveryTrustError); + }); + + it("re-verifies every hash when the persisted marker advances before recovery commits", async () => { + const values = new Map(); + const storage = { + getItem: (key: string) => values.get(key) ?? null, + removeItem: (key: string) => values.delete(key), + setItem: (key: string, value: string) => values.set(key, value), + }; + const firstSnapshot = markSquidBroadcast( + storage, + markSquidSwapRequested(storage, beginSquidAcquisition(storage, owner, 10n, 100n, 42161, candidate.acquisitionId)), + sourceHash, + ) as SquidRecoveryCandidate; + const firstDelivered = await checkAutomaticSquidRecovery({ + acquisition: firstSnapshot, + getSourceReceipt: vi.fn().mockResolvedValue(successfulReceipt), + readDestinationBalance: vi.fn().mockResolvedValue(115n), + }); + const latest = markSquidBroadcast( + storage, + markSquidSwapRequested(storage, firstSnapshot), + otherHash, + ) as SquidRecoveryCandidate; + + expect(() => markSquidAcquired(storage, firstSnapshot, firstDelivered ?? undefined)).toThrow("changed"); + expect(loadSquidAcquisition(storage, owner)).toEqual(latest); + + const getSourceReceipt = vi.fn(async (hash: typeof sourceHash | typeof otherHash) => ({ + ...successfulReceipt, + transactionHash: hash, + })); + const latestDelivered = await checkAutomaticSquidRecovery({ + acquisition: latest, + getSourceReceipt, + readDestinationBalance: vi.fn().mockResolvedValue(115n), + }); + expect(getSourceReceipt).toHaveBeenCalledTimes(2); + expect(getSourceReceipt).toHaveBeenNthCalledWith(1, sourceHash); + expect(getSourceReceipt).toHaveBeenNthCalledWith(2, otherHash); + expect(markSquidAcquired(storage, latest, latestDelivered ?? undefined)).toEqual( + expect.objectContaining({ deliveredAmount: 15n, status: "acquired" }), + ); + }); +}); diff --git a/apps/explorer/src/components/UserConsole/FundsSection/data/squid-acquisition-recovery.ts b/apps/explorer/src/components/UserConsole/FundsSection/data/squid-acquisition-recovery.ts new file mode 100644 index 00000000..f78ddc0b --- /dev/null +++ b/apps/explorer/src/components/UserConsole/FundsSection/data/squid-acquisition-recovery.ts @@ -0,0 +1,70 @@ +import { SQUID_ROUTER_ADDRESS } from "@filecoin-project/squid-evm-funding"; +import { type Address, type Hash, TransactionReceiptNotFoundError } from "viem"; +import { getDeliveredSquidAmount, type SquidAcquisition } from "./squid-acquisition"; + +export type SquidRecoveryCandidate = SquidAcquisition & { + acquisitionId: string; + destinationBalanceBefore: bigint; + executionStage: "swap-broadcast"; + status: "processing"; +}; + +type SquidRouteReceipt = { + from: Address; + status: "reverted" | "success"; + to: Address | null; + transactionHash: Hash; +}; + +export class SquidRecoveryTrustError extends Error { + override name = "SquidRecoveryTrustError"; +} + +export function isAutomaticSquidRecoveryCandidate( + acquisition: SquidAcquisition | null, +): acquisition is SquidRecoveryCandidate { + return ( + acquisition?.status === "processing" && + acquisition.executionStage === "swap-broadcast" && + acquisition.acquisitionId !== undefined && + acquisition.destinationBalanceBefore !== undefined && + acquisition.transactionHashes.length > 0 + ); +} + +export async function checkAutomaticSquidRecovery({ + acquisition, + getSourceReceipt, + readDestinationBalance, +}: { + acquisition: SquidRecoveryCandidate; + getSourceReceipt: (hash: Hash) => Promise; + readDestinationBalance: () => Promise; +}): Promise { + for (const hash of acquisition.transactionHashes) { + let receipt: SquidRouteReceipt; + try { + receipt = await getSourceReceipt(hash); + } catch (error) { + if ( + error instanceof TransactionReceiptNotFoundError || + (error instanceof Error && error.name === "TransactionReceiptNotFoundError") + ) { + return null; + } + throw error; + } + if (receipt.transactionHash.toLowerCase() !== hash.toLowerCase()) { + throw new SquidRecoveryTrustError("Source transaction receipt hash does not match the saved Squid transaction"); + } + if (receipt.status !== "success") throw new SquidRecoveryTrustError("A saved Squid source transaction reverted"); + if (receipt.from.toLowerCase() !== acquisition.owner.toLowerCase()) { + throw new SquidRecoveryTrustError("A saved Squid source transaction was sent by a different account"); + } + if (receipt.to?.toLowerCase() !== SQUID_ROUTER_ADDRESS.toLowerCase()) { + throw new SquidRecoveryTrustError("A saved Squid source transaction did not target the trusted router"); + } + } + + return getDeliveredSquidAmount(acquisition, await readDestinationBalance()); +} diff --git a/apps/explorer/src/components/UserConsole/FundsSection/data/squid-acquisition.test.ts b/apps/explorer/src/components/UserConsole/FundsSection/data/squid-acquisition.test.ts new file mode 100644 index 00000000..af747293 --- /dev/null +++ b/apps/explorer/src/components/UserConsole/FundsSection/data/squid-acquisition.test.ts @@ -0,0 +1,253 @@ +import type { Address } from "viem"; +import { describe, expect, it } from "vitest"; +import { + beginSquidAcquisition, + clearInvalidSquidAcquisition, + clearSquidAcquisition, + getDeliveredSquidAmount, + getSquidDepositAmount, + hasSavedSquidAcquisition, + loadSquidAcquisition, + markSquidAcquired, + markSquidAcquiredFromBalance, + markSquidBroadcast, + markSquidDepositPending, + markSquidSwapRequested, + resetSquidDeposit, + type SquidAcquisition, +} from "./squid-acquisition"; + +const owner = "0x1111111111111111111111111111111111111111" as Address; +const otherOwner = "0x2222222222222222222222222222222222222222" as Address; +const acquisitionId = "11111111-1111-4111-8111-111111111111"; +const replacementId = "22222222-2222-4222-8222-222222222222"; +const sourceHash = `0x${"3".repeat(64)}` as const; +const depositHash = `0x${"4".repeat(64)}` as const; + +function createStorage() { + const values = new Map(); + return { + getItem: (key: string) => values.get(key) ?? null, + removeItem: (key: string) => values.delete(key), + setItem: (key: string, value: string) => values.set(key, value), + }; +} + +describe("persisted Squid acquisition", () => { + it("persists the verified delivery through deposit retry and clears only the same acquisition", () => { + const storage = createStorage(); + const processing = beginSquidAcquisition(storage, owner, 10n, 100n, 42161, acquisitionId); + expect(loadSquidAcquisition(storage, owner)).toEqual(processing); + + const requested = markSquidSwapRequested(storage, processing); + const broadcast = markSquidBroadcast(storage, requested, sourceHash); + const acquired = markSquidAcquired(storage, broadcast, 15n); + expect(loadSquidAcquisition(storage, owner)).toEqual(acquired); + expect(getSquidDepositAmount(acquired)).toBe(15n); + expect(loadSquidAcquisition(storage, otherOwner)).toBeNull(); + + const depositPreflight = markSquidDepositPending(storage, acquired); + const depositing = markSquidDepositPending(storage, depositPreflight, depositHash); + expect(getSquidDepositAmount(depositing)).toBe(15n); + expect(resetSquidDeposit(storage, depositing)).toEqual(acquired); + + clearSquidAcquisition(storage, acquired); + expect(loadSquidAcquisition(storage, owner)).toBeNull(); + }); + + it("loads a legacy marker without treating it as automatically verified", () => { + const storage = createStorage(); + storage.setItem( + `filecoin-pay:squid-acquisition:v1:${owner.toLowerCase()}`, + JSON.stringify({ + destinationAmount: "10", + owner, + sourceChainId: 42161, + status: "processing", + transactionHashes: [sourceHash], + }), + ); + + const legacy = loadSquidAcquisition(storage, owner); + expect(legacy).toEqual({ + acquisitionId: undefined, + deliveredAmount: undefined, + depositTransactionHash: undefined, + destinationAmount: 10n, + destinationBalanceBefore: undefined, + executionStage: "swap-broadcast", + owner, + sourceChainId: 42161, + status: "processing", + transactionHashes: [sourceHash], + }); + expect(getSquidDepositAmount(legacy as SquidAcquisition)).toBe(10n); + + storage.setItem( + `filecoin-pay:squid-acquisition:v1:${owner.toLowerCase()}`, + JSON.stringify({ + destinationAmount: "10", + owner, + sourceChainId: 42161, + status: "processing", + transactionHashes: [], + }), + ); + expect(loadSquidAcquisition(storage, owner)).toEqual( + expect.objectContaining({ executionStage: "swap-requested", transactionHashes: [] }), + ); + }); + + it("recovers only after the Filecoin balance increase reaches the reviewed minimum", () => { + const acquisition: SquidAcquisition = { + acquisitionId, + destinationAmount: 10n, + destinationBalanceBefore: 100n, + owner, + sourceChainId: 42161, + status: "processing", + transactionHashes: [sourceHash], + executionStage: "swap-broadcast", + }; + + expect(getDeliveredSquidAmount(acquisition, 99n)).toBeNull(); + expect(getDeliveredSquidAmount(acquisition, 109n)).toBeNull(); + expect(getDeliveredSquidAmount(acquisition, 110n)).toBe(10n); + expect(getDeliveredSquidAmount(acquisition, 115n)).toBe(15n); + expect(getDeliveredSquidAmount({ ...acquisition, destinationBalanceBefore: undefined }, 115n)).toBeNull(); + }); + + it("freezes the exact balance increase only after it reaches the reviewed minimum", () => { + const storage = createStorage(); + const processing = beginSquidAcquisition(storage, owner, 10n, 100n, 42161, acquisitionId); + const broadcast = markSquidBroadcast(storage, markSquidSwapRequested(storage, processing), sourceHash); + + expect(() => markSquidAcquiredFromBalance(storage, broadcast, 109n)).toThrow( + "reviewed USDFC minimum has not arrived", + ); + expect(loadSquidAcquisition(storage, owner)).toEqual(broadcast); + + const acquired = markSquidAcquiredFromBalance(storage, broadcast, 115n); + expect(acquired.deliveredAmount).toBe(15n); + expect(getSquidDepositAmount(acquired)).toBe(15n); + }); + + it("rejects stale or regressive state mutations", () => { + const storage = createStorage(); + const stale = beginSquidAcquisition(storage, owner, 10n, 100n, 42161, acquisitionId); + const acquired = markSquidAcquired( + storage, + markSquidBroadcast(storage, markSquidSwapRequested(storage, stale), sourceHash), + 15n, + ); + + expect(() => markSquidBroadcast(storage, stale, sourceHash)).toThrow("no longer processing"); + expect(markSquidAcquired(storage, stale, 15n)).toEqual(acquired); + expect(markSquidAcquired(storage, stale, 16n)).toEqual(acquired); + expect(loadSquidAcquisition(storage, owner)).toEqual(acquired); + + clearSquidAcquisition(storage, acquired); + const replacement = beginSquidAcquisition(storage, owner, 20n, 200n, 8453, replacementId); + expect(() => clearSquidAcquisition(storage, acquired)).toThrow("changed"); + expect(loadSquidAcquisition(storage, owner)).toEqual(replacement); + }); + + it("does not acquire a processing marker whose source hashes advanced after verification", () => { + const storage = createStorage(); + const firstBroadcast = markSquidBroadcast( + storage, + markSquidSwapRequested(storage, beginSquidAcquisition(storage, owner, 10n, 100n, 42161, acquisitionId)), + sourceHash, + ); + const secondHash = `0x${"5".repeat(64)}` as const; + const latest = markSquidBroadcast(storage, markSquidSwapRequested(storage, firstBroadcast), secondHash); + + expect(() => markSquidAcquired(storage, firstBroadcast, 15n)).toThrow("changed"); + expect(loadSquidAcquisition(storage, owner)).toEqual(latest); + expect(markSquidAcquired(storage, latest, 15n)).toEqual( + expect.objectContaining({ + deliveredAmount: 15n, + status: "acquired", + transactionHashes: [sourceHash, secondHash], + }), + ); + }); + + it("does not clear, retry, or duplicate a deposit after another tab advances it", () => { + const storage = createStorage(); + const processing = beginSquidAcquisition(storage, owner, 10n, 100n, 42161, acquisitionId); + const acquired = markSquidAcquired( + storage, + markSquidBroadcast(storage, markSquidSwapRequested(storage, processing), sourceHash), + 15n, + ); + const depositPreflight = markSquidDepositPending(storage, acquired); + + expect(() => markSquidDepositPending(storage, acquired)).toThrow("changed"); + const depositing = markSquidDepositPending(storage, depositPreflight, depositHash); + expect(() => resetSquidDeposit(storage, depositPreflight)).toThrow("expected pending transaction"); + expect(() => clearSquidAcquisition(storage, depositPreflight)).toThrow("changed"); + expect(loadSquidAcquisition(storage, owner)).toEqual(depositing); + }); + + it("rejects malformed verified amounts", () => { + const storage = createStorage(); + const key = `filecoin-pay:squid-acquisition:v1:${owner.toLowerCase()}`; + const record = { + acquisitionId, + destinationAmount: "10", + destinationBalanceBefore: "100", + owner, + sourceChainId: 42161, + status: "acquired", + transactionHashes: [sourceHash], + }; + + storage.setItem(key, JSON.stringify({ ...record, deliveredAmount: "9" })); + expect(loadSquidAcquisition(storage, owner)).toBeNull(); + storage.setItem(key, JSON.stringify({ ...record, deliveredAmount: "15", destinationBalanceBefore: "-1" })); + expect(loadSquidAcquisition(storage, owner)).toBeNull(); + storage.setItem(key, JSON.stringify({ ...record, deliveredAmount: "15", status: "processing" })); + expect(loadSquidAcquisition(storage, owner)).toBeNull(); + }); + + it("persists swap-requested before a hash and rejects invalid stage snapshots", () => { + const storage = createStorage(); + const preparing = beginSquidAcquisition(storage, owner, 10n, 100n, 42161, acquisitionId); + expect(preparing.executionStage).toBe("preparing"); + + const requested = markSquidSwapRequested(storage, preparing); + expect(loadSquidAcquisition(storage, owner)).toEqual( + expect.objectContaining({ executionStage: "swap-requested", transactionHashes: [] }), + ); + expect(markSquidBroadcast(storage, requested, sourceHash)).toEqual( + expect.objectContaining({ executionStage: "swap-broadcast", transactionHashes: [sourceHash] }), + ); + + const key = `filecoin-pay:squid-acquisition:v1:${owner.toLowerCase()}`; + storage.setItem( + key, + JSON.stringify({ + ...preparing, + destinationAmount: "10", + destinationBalanceBefore: "100", + executionStage: "swap-broadcast", + transactionHashes: [], + }), + ); + expect(loadSquidAcquisition(storage, owner)).toBeNull(); + }); + + it("clears malformed state without clearing a valid acquisition", () => { + const storage = createStorage(); + storage.setItem(`filecoin-pay:squid-acquisition:v1:${owner.toLowerCase()}`, "not json"); + expect(hasSavedSquidAcquisition(storage, owner)).toBe(true); + + clearInvalidSquidAcquisition(storage, owner); + expect(hasSavedSquidAcquisition(storage, owner)).toBe(false); + + const processing = beginSquidAcquisition(storage, owner, 10n, 100n, 42161, acquisitionId); + expect(() => clearInvalidSquidAcquisition(storage, owner)).toThrow("is valid"); + expect(loadSquidAcquisition(storage, owner)).toEqual(processing); + }); +}); diff --git a/apps/explorer/src/components/UserConsole/FundsSection/data/squid-acquisition.ts b/apps/explorer/src/components/UserConsole/FundsSection/data/squid-acquisition.ts new file mode 100644 index 00000000..362ea131 --- /dev/null +++ b/apps/explorer/src/components/UserConsole/FundsSection/data/squid-acquisition.ts @@ -0,0 +1,292 @@ +import { type Address, type Hash, isAddress } from "viem"; + +const STORAGE_PREFIX = "filecoin-pay:squid-acquisition:v1"; + +type AcquisitionStorage = Pick; + +export type SquidAcquisition = { + acquisitionId?: string; + depositTransactionHash?: Hash; + deliveredAmount?: bigint; + destinationBalanceBefore?: bigint; + destinationAmount: bigint; + executionStage?: SquidAcquisitionExecutionStage; + owner: Address; + sourceChainId: number; + status: "acquired" | "depositing" | "processing"; + transactionHashes: Hash[]; +}; + +export type SquidAcquisitionExecutionStage = "preparing" | "swap-broadcast" | "swap-requested"; + +export function getSquidAcquisitionStorageKey(owner: Address) { + return `${STORAGE_PREFIX}:${owner.toLowerCase()}`; +} + +export function hasSavedSquidAcquisition(storage: AcquisitionStorage, owner: Address) { + return storage.getItem(getSquidAcquisitionStorageKey(owner)) !== null; +} + +function save(storage: AcquisitionStorage, acquisition: SquidAcquisition) { + storage.setItem( + getSquidAcquisitionStorageKey(acquisition.owner), + JSON.stringify({ + ...acquisition, + deliveredAmount: acquisition.deliveredAmount?.toString(), + destinationAmount: acquisition.destinationAmount.toString(), + destinationBalanceBefore: acquisition.destinationBalanceBefore?.toString(), + }), + ); + return acquisition; +} + +export function loadSquidAcquisition(storage: AcquisitionStorage, expectedOwner: Address): SquidAcquisition | null { + const value = storage.getItem(getSquidAcquisitionStorageKey(expectedOwner)); + if (value === null) return null; + + try { + const acquisition = JSON.parse(value) as Record; + if ( + typeof acquisition.owner !== "string" || + !isAddress(acquisition.owner) || + acquisition.owner.toLowerCase() !== expectedOwner.toLowerCase() || + (acquisition.status !== "acquired" && + acquisition.status !== "depositing" && + acquisition.status !== "processing") || + (acquisition.acquisitionId !== undefined && + (typeof acquisition.acquisitionId !== "string" || !/^[0-9a-f-]{36}$/i.test(acquisition.acquisitionId))) || + typeof acquisition.sourceChainId !== "number" || + !Number.isSafeInteger(acquisition.sourceChainId) || + acquisition.sourceChainId <= 0 || + typeof acquisition.destinationAmount !== "string" || + !/^\d+$/.test(acquisition.destinationAmount) || + BigInt(acquisition.destinationAmount) <= 0n || + (acquisition.destinationBalanceBefore !== undefined && + (typeof acquisition.destinationBalanceBefore !== "string" || + !/^\d+$/.test(acquisition.destinationBalanceBefore))) || + (acquisition.deliveredAmount !== undefined && + (typeof acquisition.deliveredAmount !== "string" || + !/^\d+$/.test(acquisition.deliveredAmount) || + BigInt(acquisition.deliveredAmount) < BigInt(acquisition.destinationAmount) || + acquisition.status === "processing")) || + (acquisition.destinationBalanceBefore !== undefined && + acquisition.status !== "processing" && + acquisition.deliveredAmount === undefined) || + !Array.isArray(acquisition.transactionHashes) || + !acquisition.transactionHashes.every(isTransactionHash) || + (acquisition.depositTransactionHash !== undefined && + (!isTransactionHash(acquisition.depositTransactionHash) || acquisition.status !== "depositing")) + ) { + return null; + } + const executionStage = + acquisition.status !== "processing" + ? (acquisition.executionStage as SquidAcquisitionExecutionStage | undefined) + : acquisition.executionStage === undefined + ? acquisition.transactionHashes.length > 0 + ? "swap-broadcast" + : "swap-requested" + : acquisition.executionStage; + if ( + executionStage !== undefined && + executionStage !== "preparing" && + executionStage !== "swap-requested" && + executionStage !== "swap-broadcast" + ) { + return null; + } + if ( + acquisition.status === "processing" && + (executionStage === undefined || + (executionStage === "preparing" && acquisition.transactionHashes.length > 0) || + (executionStage === "swap-broadcast" && acquisition.transactionHashes.length === 0)) + ) { + return null; + } + return { + acquisitionId: acquisition.acquisitionId as string | undefined, + deliveredAmount: acquisition.deliveredAmount === undefined ? undefined : BigInt(acquisition.deliveredAmount), + destinationBalanceBefore: + acquisition.destinationBalanceBefore === undefined ? undefined : BigInt(acquisition.destinationBalanceBefore), + destinationAmount: BigInt(acquisition.destinationAmount), + depositTransactionHash: acquisition.depositTransactionHash as Hash | undefined, + executionStage, + owner: acquisition.owner, + sourceChainId: acquisition.sourceChainId, + status: acquisition.status, + transactionHashes: acquisition.transactionHashes as Hash[], + }; + } catch { + return null; + } +} + +export function beginSquidAcquisition( + storage: AcquisitionStorage, + owner: Address, + destinationAmount: bigint, + destinationBalanceBefore: bigint, + sourceChainId: number, + acquisitionId = globalThis.crypto.randomUUID(), +) { + if (storage.getItem(getSquidAcquisitionStorageKey(owner)) !== null) + throw new Error("A saved Squid acquisition already exists"); + return save(storage, { + acquisitionId, + destinationAmount, + destinationBalanceBefore, + executionStage: "preparing", + owner, + sourceChainId, + status: "processing", + transactionHashes: [], + }); +} + +export function markSquidSwapRequested(storage: AcquisitionStorage, acquisition: SquidAcquisition) { + const current = requireCurrent(storage, acquisition); + if (current.status !== "processing" || !hasSameSquidAcquisitionSnapshot(current, acquisition)) { + throw new Error("Saved Squid acquisition changed"); + } + return save(storage, { ...current, executionStage: "swap-requested" }); +} + +export function getDeliveredSquidAmount(acquisition: SquidAcquisition, currentDestinationBalance: bigint) { + if ( + acquisition.destinationBalanceBefore === undefined || + currentDestinationBalance < acquisition.destinationBalanceBefore + ) { + return null; + } + const deliveredAmount = currentDestinationBalance - acquisition.destinationBalanceBefore; + return deliveredAmount >= acquisition.destinationAmount ? deliveredAmount : null; +} + +export function markSquidAcquiredFromBalance( + storage: AcquisitionStorage, + acquisition: SquidAcquisition, + currentDestinationBalance: bigint, +) { + const deliveredAmount = getDeliveredSquidAmount(acquisition, currentDestinationBalance); + if (deliveredAmount === null) throw new Error("The reviewed USDFC minimum has not arrived yet"); + return markSquidAcquired(storage, acquisition, deliveredAmount); +} + +export function markSquidBroadcast(storage: AcquisitionStorage, acquisition: SquidAcquisition, hash: Hash) { + const current = requireCurrent(storage, acquisition); + if (current.status !== "processing") throw new Error("Squid acquisition is no longer processing"); + if (current.executionStage === "swap-broadcast" && current.transactionHashes.includes(hash)) return current; + if (current.executionStage !== "swap-requested" || !hasSameSquidAcquisitionSnapshot(current, acquisition)) { + throw new Error("Saved Squid acquisition changed"); + } + return save(storage, { + ...current, + executionStage: "swap-broadcast", + transactionHashes: current.transactionHashes.includes(hash) + ? current.transactionHashes + : [...current.transactionHashes, hash], + }); +} + +export function markSquidAcquired( + storage: AcquisitionStorage, + acquisition: SquidAcquisition, + deliveredAmount?: bigint, +) { + const current = requireCurrent(storage, acquisition); + if (current.status === "acquired") return current; + if (current.status !== "processing") throw new Error("Squid acquisition is no longer processing"); + if (!hasSameSquidAcquisitionSnapshot(current, acquisition)) { + throw new Error("Saved Squid acquisition changed"); + } + if (deliveredAmount !== undefined && deliveredAmount < current.destinationAmount) { + throw new Error("Delivered USDFC is below the reviewed minimum"); + } + if (current.destinationBalanceBefore !== undefined && deliveredAmount === undefined) { + throw new Error("Delivered USDFC must be verified for this acquisition"); + } + return save(storage, { ...current, deliveredAmount, status: "acquired" }); +} + +export function markSquidDepositPending(storage: AcquisitionStorage, acquisition: SquidAcquisition, hash?: Hash) { + const current = requireCurrent(storage, acquisition); + if (current.status !== acquisition.status) throw new Error("Saved Squid acquisition changed"); + if (current.status !== "acquired" && current.status !== "depositing") { + throw new Error("Squid acquisition is not ready to deposit"); + } + if (current.depositTransactionHash !== undefined && hash !== undefined && current.depositTransactionHash !== hash) { + throw new Error("A different Filecoin deposit is already pending"); + } + return save(storage, { + ...current, + depositTransactionHash: hash ?? current.depositTransactionHash, + status: "depositing", + }); +} + +export function resetSquidDeposit(storage: AcquisitionStorage, acquisition: SquidAcquisition) { + const current = requireCurrent(storage, acquisition); + if (current.status !== "depositing" || !isSameState(current, acquisition)) { + throw new Error("Squid deposit is not the expected pending transaction"); + } + const { depositTransactionHash: _, ...acquired } = current; + return save(storage, { ...acquired, status: "acquired" }); +} + +export function getSquidDepositAmount(acquisition: SquidAcquisition) { + return acquisition.deliveredAmount ?? acquisition.destinationAmount; +} + +export function clearSquidAcquisition(storage: AcquisitionStorage, acquisition: SquidAcquisition) { + const current = requireCurrent(storage, acquisition); + if (!isSameState(current, acquisition)) throw new Error("Saved Squid acquisition changed"); + storage.removeItem(getSquidAcquisitionStorageKey(current.owner)); +} + +export function clearInvalidSquidAcquisition(storage: AcquisitionStorage, owner: Address) { + if (!hasSavedSquidAcquisition(storage, owner)) return; + if (loadSquidAcquisition(storage, owner) !== null) throw new Error("The saved Squid acquisition is valid"); + storage.removeItem(getSquidAcquisitionStorageKey(owner)); +} + +function isTransactionHash(value: unknown): value is Hash { + return typeof value === "string" && /^0x[0-9a-fA-F]{64}$/.test(value); +} + +function requireCurrent(storage: AcquisitionStorage, expected: SquidAcquisition) { + const current = loadSquidAcquisition(storage, expected.owner); + if (!current || !isSameAcquisition(current, expected)) throw new Error("Saved Squid acquisition changed"); + return current; +} + +function isSameAcquisition(current: SquidAcquisition, expected: SquidAcquisition) { + if (current.acquisitionId !== undefined || expected.acquisitionId !== undefined) { + return current.acquisitionId !== undefined && current.acquisitionId === expected.acquisitionId; + } + return ( + current.owner.toLowerCase() === expected.owner.toLowerCase() && + current.sourceChainId === expected.sourceChainId && + current.destinationAmount === expected.destinationAmount && + current.destinationBalanceBefore === expected.destinationBalanceBefore + ); +} + +function isSameState(current: SquidAcquisition, expected: SquidAcquisition) { + return hasSameSquidAcquisitionSnapshot(current, expected); +} + +export function hasSameSquidAcquisitionSnapshot(current: SquidAcquisition, expected: SquidAcquisition) { + return ( + current.acquisitionId === expected.acquisitionId && + current.owner.toLowerCase() === expected.owner.toLowerCase() && + current.sourceChainId === expected.sourceChainId && + current.destinationAmount === expected.destinationAmount && + current.destinationBalanceBefore === expected.destinationBalanceBefore && + current.executionStage === expected.executionStage && + current.status === expected.status && + current.depositTransactionHash === expected.depositTransactionHash && + current.deliveredAmount === expected.deliveredAmount && + current.transactionHashes.length === expected.transactionHashes.length && + current.transactionHashes.every((hash, index) => hash === expected.transactionHashes[index]) + ); +} 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 new file mode 100644 index 00000000..a79e9bd2 --- /dev/null +++ b/apps/explorer/src/components/UserConsole/FundsSection/data/squid-execution.test.ts @@ -0,0 +1,152 @@ +import { executeSquidFunding, SQUID_ROUTER_ADDRESS } from "@filecoin-project/squid-evm-funding"; +import { describe, expect, it, vi } from "vitest"; +import { + applyNetworkFeeExecutionBuffer, + canClearSquidAcquisitionAfterError, + executeSquidTopUp, + isUserRejectedRequest, + walletErrorMessage, +} from "./squid-execution"; + +vi.mock("@filecoin-project/squid-evm-funding", () => ({ + executeSquidFunding: vi.fn(), + SQUID_ROUTER_ADDRESS: "0x1111111111111111111111111111111111111111", +})); + +const source = { + chainId: 10, + decimals: 18, + symbol: "ETH", + token: "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", +} as const; +const owner = "0x2222222222222222222222222222222222222222" as const; +describe("executeSquidTopUp", () => { + it("applies the same rounded OP Stack buffer used by reviewed fee caps", () => { + expect(applyNetworkFeeExecutionBuffer(8453, 3n)).toBe(4n); + expect(applyNetworkFeeExecutionBuffer(1, 3n)).toBe(3n); + }); + + it("executes the reviewed OP Stack plan with an explicit fee cap and a trusted router", async () => { + const plan = { maxSourceAmount: 2_000_000_000_000_000_000n, owner, quotes: [], slippage: 1, source }; + vi.mocked(executeSquidFunding).mockResolvedValue({ nativeFee: 1n, routes: [], sourceAmount: 2n }); + + await executeSquidTopUp({ + destinationClient: {} as never, + integratorId: "test-integrator", + maxNativeFee: 30n, + maxTotalNativeRouteFee: 20n, + plan, + sourcePublicClient: {} as never, + sourceWalletClient: {} as never, + }); + + expect(executeSquidFunding).toHaveBeenCalledWith( + expect.objectContaining({ + feeMode: "op-stack", + maxNativeFee: 30n, + maxTotalNativeRouteFee: 20n, + trustedSpender: SQUID_ROUTER_ADDRESS, + trustedTarget: SQUID_ROUTER_ADDRESS, + plan, + }), + expect.objectContaining({ + destinationClient: expect.anything(), + publicClient: expect.anything(), + walletClient: expect.anything(), + }), + ); + }); + + it("reports only the Squid transaction as attempted and broadcast", async () => { + const transactionHash = `0x${"4".repeat(64)}` as const; + const approvalHash = `0x${"3".repeat(64)}` as const; + const onSwapAttempt = vi.fn(); + const onSwapBroadcast = vi.fn(); + const sendTransaction = vi.fn().mockResolvedValue(transactionHash); + const plan = { maxSourceAmount: 2n, owner, quotes: [], slippage: 1, source }; + vi.mocked(executeSquidFunding).mockImplementation(async (_input, dependencies) => { + sendTransaction.mockResolvedValueOnce(approvalHash).mockResolvedValueOnce(transactionHash); + await dependencies.walletClient.sendTransaction({ to: source.token } as never); + await dependencies.walletClient.sendTransaction({ to: SQUID_ROUTER_ADDRESS } as never); + return { nativeFee: 1n, routes: [], sourceAmount: 2n }; + }); + + await executeSquidTopUp({ + destinationClient: {} as never, + integratorId: "test-integrator", + maxNativeFee: 30n, + maxTotalNativeRouteFee: 20n, + onSwapAttempt, + onSwapBroadcast, + plan, + sourcePublicClient: {} as never, + sourceWalletClient: { sendTransaction } as never, + }); + + expect(onSwapAttempt).toHaveBeenCalledOnce(); + expect(onSwapBroadcast).toHaveBeenCalledOnce(); + expect(onSwapBroadcast).toHaveBeenCalledWith(transactionHash); + }); + + it("reports an attempted swap even when the wallet loses the response", async () => { + const onSwapAttempt = vi.fn(); + vi.mocked(executeSquidFunding).mockImplementation(async (_input, dependencies) => { + await dependencies.walletClient.sendTransaction({ to: SQUID_ROUTER_ADDRESS } as never); + return { nativeFee: 1n, routes: [], sourceAmount: 2n }; + }); + + await expect( + executeSquidTopUp({ + destinationClient: {} as never, + integratorId: "test-integrator", + maxNativeFee: 30n, + maxTotalNativeRouteFee: 20n, + onSwapAttempt, + plan: { maxSourceAmount: 2n, owner, quotes: [], slippage: 1, source }, + sourcePublicClient: {} as never, + sourceWalletClient: { sendTransaction: vi.fn().mockRejectedValue(new Error("response lost")) } as never, + }), + ).rejects.toThrow("response lost"); + expect(onSwapAttempt).toHaveBeenCalledOnce(); + }); + + it("does not report an approval attempt when its response is lost", async () => { + const onSwapAttempt = vi.fn(); + vi.mocked(executeSquidFunding).mockImplementation(async (_input, dependencies) => { + await dependencies.walletClient.sendTransaction({ to: source.token } as never); + return { nativeFee: 1n, routes: [], sourceAmount: 2n }; + }); + + await expect( + executeSquidTopUp({ + destinationClient: {} as never, + integratorId: "test-integrator", + maxNativeFee: 30n, + maxTotalNativeRouteFee: 20n, + onSwapAttempt, + plan: { maxSourceAmount: 2n, owner, quotes: [], slippage: 1, source }, + sourcePublicClient: {} as never, + sourceWalletClient: { sendTransaction: vi.fn().mockRejectedValue(new Error("response lost")) } as never, + }), + ).rejects.toThrow("response lost"); + expect(onSwapAttempt).not.toHaveBeenCalled(); + }); + + it("clears recovery state only before a swap attempt or after an unbroadcast rejection", () => { + expect(canClearSquidAcquisitionAfterError("preparing", new Error("approval response lost"))).toBe(true); + expect(canClearSquidAcquisitionAfterError("swap-requested", { code: 4001 })).toBe(true); + expect(canClearSquidAcquisitionAfterError("swap-broadcast", { code: 4001 })).toBe(false); + expect(canClearSquidAcquisitionAfterError("swap-requested", new Error("response lost"))).toBe(false); + }); + + it("recognizes nested wallet rejection errors", () => { + expect(isUserRejectedRequest({ cause: { code: 4001 } })).toBe(true); + expect(isUserRejectedRequest(new Error("response lost"))).toBe(false); + }); + + it("shortens wallet rejection errors without hiding other failures", () => { + expect(walletErrorMessage({ cause: { code: 4001 } }, "fallback")).toBe("Transaction cancelled in your wallet."); + expect(walletErrorMessage(new Error("response lost"), "fallback")).toBe("response lost"); + expect(walletErrorMessage(null, "fallback")).toBe("fallback"); + }); +}); diff --git a/apps/explorer/src/components/UserConsole/FundsSection/data/squid-execution.ts b/apps/explorer/src/components/UserConsole/FundsSection/data/squid-execution.ts new file mode 100644 index 00000000..e994550b --- /dev/null +++ b/apps/explorer/src/components/UserConsole/FundsSection/data/squid-execution.ts @@ -0,0 +1,100 @@ +import { + executeSquidFunding, + SQUID_ROUTER_ADDRESS, + type SquidExecutionResult, + type SquidFundingPlan, + type SquidPublicClient, + type SquidWalletClient, +} from "@filecoin-project/squid-evm-funding"; +import type { Hash } from "viem"; +import type { SquidAcquisitionExecutionStage } from "./squid-acquisition"; + +const OP_STACK_CHAIN_IDS = new Set([10, 8453]); +const OP_STACK_FEE_BUFFER_BPS = 12_000n; +const BPS = 10_000n; + +export function applyNetworkFeeExecutionBuffer(chainId: number, fee: bigint): bigint { + return OP_STACK_CHAIN_IDS.has(chainId) ? (fee * OP_STACK_FEE_BUFFER_BPS + BPS - 1n) / BPS : fee; +} + +export async function executeSquidTopUp({ + destinationClient, + integratorId, + maxNativeFee, + maxTotalNativeRouteFee, + onSwapAttempt, + onSwapBroadcast, + plan, + sourcePublicClient, + sourceWalletClient, +}: { + destinationClient: SquidPublicClient; + integratorId: string; + maxNativeFee: bigint; + maxTotalNativeRouteFee: bigint; + onSwapAttempt?: () => void; + onSwapBroadcast?: (transactionHash: Hash) => void; + plan: SquidFundingPlan; + sourcePublicClient: SquidPublicClient; + sourceWalletClient: SquidWalletClient; +}): Promise { + const trackedWalletClient = { + ...sourceWalletClient, + sendTransaction: async (...args: Parameters) => { + const [request] = args; + const isSwap = request.to?.toLowerCase() === SQUID_ROUTER_ADDRESS.toLowerCase(); + if (isSwap) onSwapAttempt?.(); + const transactionHash = await sourceWalletClient.sendTransaction(...args); + if (isSwap) onSwapBroadcast?.(transactionHash); + return transactionHash; + }, + } as SquidWalletClient; + + return executeSquidFunding( + { + 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) + : undefined, + plan, + pollIntervalMs: 10_000, + trustedSpender: SQUID_ROUTER_ADDRESS, + trustedTarget: SQUID_ROUTER_ADDRESS, + }, + { + destinationClient, + publicClient: sourcePublicClient, + squid: { integratorId }, + walletClient: trackedWalletClient, + }, + ); +} + +export function canClearSquidAcquisitionAfterError( + executionStage: SquidAcquisitionExecutionStage | undefined, + error: unknown, +): boolean { + return executionStage === "preparing" || (executionStage === "swap-requested" && isUserRejectedRequest(error)); +} + +export function isUserRejectedRequest(error: unknown): boolean { + let current = error; + for (let depth = 0; depth < 5 && current && typeof current === "object"; depth += 1) { + if ( + ("code" in current && current.code === 4001) || + ("name" in current && current.name === "UserRejectedRequestError") + ) { + return true; + } + current = "cause" in current ? current.cause : null; + } + return false; +} + +export function walletErrorMessage(error: unknown, fallback: string): string { + if (isUserRejectedRequest(error)) return "Transaction cancelled in your wallet."; + return error instanceof Error ? error.message : fallback; +} 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 new file mode 100644 index 00000000..b820ec1d --- /dev/null +++ b/apps/explorer/src/components/UserConsole/FundsSection/data/squid-quote.test.ts @@ -0,0 +1,85 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { SQUID_SOURCE_CHAINS } from "@/constants/chains"; +import { planSquidTopUp, squidFetch } from "./squid-quote"; + +const planSquidFunding = vi.hoisted(() => vi.fn()); + +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({ + maxSourceAmount: 2_000_000_000_000_000_000n, + owner, + quotes: [quote], + slippage: 1, + source: { chainId: 314, decimals: 18, symbol: "FIL", token: "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" }, + }); + + await expect( + planSquidTopUp({ + destinationAmount: 1_000_000_000_000_000_000n, + destinationToken: usdfc, + integratorId: "test", + owner, + source: { + chainId: 314, + decimals: 18, + symbol: "FIL", + token: "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + }, + sourceAmount: 2_000_000_000_000_000_000n, + }), + ).resolves.toMatchObject({ quotes: [{ id: "quote" }] }); + expect(planSquidFunding).toHaveBeenCalledWith( + expect.objectContaining({ + maxSourceAmount: "2", + sourceChainId: 314, + sourceToken: "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + }), + { fetch: expect.any(Function), integratorId: "test" }, + ); + expect(SQUID_SOURCE_CHAINS.map((chain) => chain.id)).toEqual([314, 42161, 1, 8453, 10, 137, 43114, 56]); + }); + + it("rejects a source outside the selected networks before requesting a route", async () => { + await expect( + planSquidTopUp({ + destinationAmount: 1n, + destinationToken: usdfc, + integratorId: "test", + owner, + source: { chainId: 5, decimals: 18, symbol: "ETH", token: "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" }, + sourceAmount: 1n, + }), + ).rejects.toThrow("Select a supported source network"); + }); +}); diff --git a/apps/explorer/src/components/UserConsole/FundsSection/data/squid-quote.ts b/apps/explorer/src/components/UserConsole/FundsSection/data/squid-quote.ts new file mode 100644 index 00000000..0b13c575 --- /dev/null +++ b/apps/explorer/src/components/UserConsole/FundsSection/data/squid-quote.ts @@ -0,0 +1,64 @@ +import { planSquidFunding, type SourceToken, type SquidFundingPlan } from "@filecoin-project/squid-evm-funding"; +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. 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; + +export const squidFetch: typeof globalThis.fetch = async (input, init) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + if (!url.includes("/tokens")) return fetch(input, init); + if (tokensCache && tokensCache.expires > Date.now()) { + return new Response(tokensCache.body, { headers: { "content-type": "application/json" }, status: 200 }); + } + 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 }; + return new Response(body, { headers: { "content-type": "application/json" }, status: 200 }); +}; + +export async function planSquidTopUp({ + destinationAmount, + destinationToken, + integratorId, + owner, + source, + sourceAmount, +}: { + destinationAmount: bigint; + destinationToken: Address; + integratorId: string; + owner: Address; + source: SourceToken; + sourceAmount: bigint; +}): Promise { + if (!SQUID_SOURCE_CHAINS.some((chain) => chain.id === source.chainId)) { + throw new Error("Select a supported source network"); + } + if (integratorId.trim() === "") throw new Error("Squid quotes are unavailable"); + + return planSquidFunding( + { + maxSourceAmount: formatUnits(sourceAmount, source.decimals), + owner, + requirements: [ + { + amount: destinationAmount, + chainId: 314, + id: "filecoin-usdfc-top-up", + recipient: owner, + token: destinationToken, + }, + ], + slippage: 1, + sourceChainId: source.chainId, + sourceToken: source.token, + }, + { fetch: squidFetch, integratorId }, + ); +} diff --git a/apps/explorer/src/components/UserConsole/FundsSection/data/usdfc-balance.test.ts b/apps/explorer/src/components/UserConsole/FundsSection/data/usdfc-balance.test.ts new file mode 100644 index 00000000..6db73260 --- /dev/null +++ b/apps/explorer/src/components/UserConsole/FundsSection/data/usdfc-balance.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it, vi } from "vitest"; +import { readUsdfcBalance } from "./usdfc-balance"; + +describe("readUsdfcBalance", () => { + it("reads the owner's exact ERC-20 balance", async () => { + const token = "0x1111111111111111111111111111111111111111" as const; + const owner = "0x2222222222222222222222222222222222222222" as const; + const readContract = vi.fn().mockResolvedValue(123n); + + await expect(readUsdfcBalance({ readContract } as never, token, owner)).resolves.toBe(123n); + expect(readContract).toHaveBeenCalledWith( + expect.objectContaining({ address: token, args: [owner], functionName: "balanceOf" }), + ); + }); +}); diff --git a/apps/explorer/src/components/UserConsole/FundsSection/data/usdfc-balance.ts b/apps/explorer/src/components/UserConsole/FundsSection/data/usdfc-balance.ts new file mode 100644 index 00000000..0af86823 --- /dev/null +++ b/apps/explorer/src/components/UserConsole/FundsSection/data/usdfc-balance.ts @@ -0,0 +1,10 @@ +import { type Address, erc20Abi, type PublicClient } from "viem"; + +export function readUsdfcBalance(client: Pick, token: Address, owner: Address) { + return client.readContract({ + abi: erc20Abi, + address: token, + args: [owner], + functionName: "balanceOf", + }); +} diff --git a/apps/explorer/src/components/UserConsole/FundsSection/hooks/useSquidAcquisitionRecovery.ts b/apps/explorer/src/components/UserConsole/FundsSection/hooks/useSquidAcquisitionRecovery.ts new file mode 100644 index 00000000..0f984341 --- /dev/null +++ b/apps/explorer/src/components/UserConsole/FundsSection/hooks/useSquidAcquisitionRecovery.ts @@ -0,0 +1,54 @@ +import { useQuery } from "@tanstack/react-query"; +import type { Address } from "viem"; +import { usePublicClient } from "wagmi"; +import { mainnet } from "@/constants/chains"; +import type { SquidAcquisition } from "../data/squid-acquisition"; +import { + checkAutomaticSquidRecovery, + isAutomaticSquidRecoveryCandidate, + SquidRecoveryTrustError, +} from "../data/squid-acquisition-recovery"; +import { readUsdfcBalance } from "../data/usdfc-balance"; + +const RECOVERY_POLL_INTERVAL_MS = 10_000; + +export function useSquidAcquisitionRecovery(acquisition: SquidAcquisition | null, connectedOwner?: Address) { + const sourceClient = usePublicClient({ chainId: acquisition?.sourceChainId }); + const destinationClient = usePublicClient({ chainId: mainnet.id }); + const isEligible = isAutomaticSquidRecoveryCandidate(acquisition); + const ownerMatches = + connectedOwner !== undefined && + acquisition !== null && + acquisition.owner.toLowerCase() === connectedOwner.toLowerCase(); + + const query = useQuery({ + enabled: isEligible && ownerMatches && !!sourceClient && !!destinationClient, + queryFn: async () => { + if (!isAutomaticSquidRecoveryCandidate(acquisition) || !sourceClient || !destinationClient) { + throw new Error("Automatic Squid recovery is unavailable"); + } + return checkAutomaticSquidRecovery({ + acquisition, + getSourceReceipt: (hash) => sourceClient.getTransactionReceipt({ hash }), + readDestinationBalance: () => + readUsdfcBalance(destinationClient, mainnet.contracts.usdfc.address, acquisition.owner), + }); + }, + queryKey: [ + "squid", + "acquisition-recovery", + acquisition?.acquisitionId ?? "legacy", + acquisition?.owner.toLowerCase() ?? "", + acquisition?.sourceChainId ?? 0, + acquisition?.destinationAmount.toString() ?? "", + acquisition?.destinationBalanceBefore?.toString() ?? "", + acquisition?.transactionHashes.join(",") ?? "", + ], + refetchInterval: (activeQuery) => + activeQuery.state.error instanceof SquidRecoveryTrustError ? false : RECOVERY_POLL_INTERVAL_MS, + refetchIntervalInBackground: true, + retry: false, + }); + + return { ...query, isEligible, isPermanentError: query.error instanceof SquidRecoveryTrustError }; +} diff --git a/apps/explorer/src/components/UserConsole/FundsSection/index.test.tsx b/apps/explorer/src/components/UserConsole/FundsSection/index.test.tsx new file mode 100644 index 00000000..bb9e0acc --- /dev/null +++ b/apps/explorer/src/components/UserConsole/FundsSection/index.test.tsx @@ -0,0 +1,86 @@ +import type { Account, UserToken } from "@filecoin-pay/types"; +import type { ReactNode } from "react"; +import { act, create } from "react-test-renderer"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { FundsSection } from "."; + +const USDFC = "0x3333333333333333333333333333333333333333"; +const OTHER_TOKEN = "0x4444444444444444444444444444444444444444"; + +const tokenState = vi.hoisted(() => ({ userTokens: [] as UserToken[] })); + +vi.mock("@/hooks/useAccountDetails", () => ({ + useAccountTokens: () => ({ data: { userTokens: tokenState.userTokens }, isError: false, isLoading: false }), +})); +vi.mock("@/hooks/useSynapse", () => ({ + default: () => ({ constants: { contracts: { usdfc: USDFC } } }), +})); +vi.mock("@/components/UserConsole/DepositDialog", () => ({ + DepositDialog: ({ open }: { open: boolean }) => (open ?
: null), +})); +vi.mock("@/components/UserConsole/WithdrawDialog", () => ({ WithdrawDialog: () => null })); +vi.mock("./components", () => ({ + AddFundsDialog: ({ onSelect, open }: { onSelect: (method: "deposit" | "squid") => void; open: boolean }) => + open ?
+ ), + TokenSelect: () => null, +})); + +const account = { id: "account" } as unknown as Account; + +beforeEach(() => { + tokenState.userTokens = []; + vi.stubGlobal("window", { clearInterval: vi.fn(), setInterval: vi.fn(() => 1) }); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +async function expectGuidedTopUpFrom(buttonLabel: string) { + const onGuidedTopUp = vi.fn(); + let renderer!: ReturnType; + await act(async () => { + renderer = create(); + }); + + await act(async () => { + renderer.root.findByProps({ "aria-label": buttonLabel }).props.onClick(); + }); + expect(renderer.root.findAllByProps({ "data-direct-deposit": true })).toHaveLength(0); + + await act(async () => { + renderer.root.findByProps({ "aria-label": "Choose Squid funding" }).props.onClick(); + }); + expect(onGuidedTopUp).toHaveBeenCalledOnce(); + + await act(async () => renderer.unmount()); +} + +describe("FundsSection guided funding", () => { + it("offers guided funding when an existing account has no indexed tokens", async () => { + await expectGuidedTopUpFrom("Add funds to empty account"); + }); + + it("offers guided funding when the visible token list does not contain USDFC", async () => { + tokenState.userTokens = [ + { + id: "account-other-token", + token: { id: OTHER_TOKEN }, + } as unknown as UserToken, + ]; + + await expectGuidedTopUpFrom("Add funds to populated account"); + }); +}); diff --git a/apps/explorer/src/components/UserConsole/FundsSection/index.tsx b/apps/explorer/src/components/UserConsole/FundsSection/index.tsx index e5ebb051..6bf22b2a 100644 --- a/apps/explorer/src/components/UserConsole/FundsSection/index.tsx +++ b/apps/explorer/src/components/UserConsole/FundsSection/index.tsx @@ -1,29 +1,60 @@ import type { Account, UserToken } from "@filecoin-pay/types"; -import { Plus } from "lucide-react"; import { useCallback, useEffect, useMemo, useState } from "react"; -import { useConnection } from "wagmi"; -import { AlertsStatus } from "@/components/UserConsole/AlertsStatus"; import { DepositDialog } from "@/components/UserConsole/DepositDialog"; import { WithdrawDialog } from "@/components/UserConsole/WithdrawDialog"; import { useAccountTokens } from "@/hooks/useAccountDetails"; +import useSynapse from "@/hooks/useSynapse"; +import type { Network } from "@/types"; import { EPOCH_DURATION } from "@/utils/constants"; -import { getNetworkFromChainId, isNotificationsEligibleNetwork } from "@/utils/network"; -import { FundsEmptyState, FundsErrorState, FundsLoadingState, FundsTable } from "./components"; - -interface FundsSectionProps { +import { + AddFundsDialog, + type AddFundsMethod, + FundsEmptyState, + FundsErrorState, + FundsLoadingState, + FundsOverview, + FundsSectionLayout, + TokenSelect, +} from "./components"; + +type FundsSectionProps = { account: Account; - subscribed: boolean; -} + network: Network; + onGuidedTopUp?: () => void; +}; + +/** + * Temporary bounded fetch, not an exhaustive one. The console shows one token at + * a time but must be able to select any of them, and the subgraph orders by + * balance descending, so the default page of ten would drop a zero-balance USDFC + * off the end and silently default the overview to the wrong token. A wider + * single page makes that unreachable in practice; an account holding more than + * this many tokens still truncates. Replace with paging driven by + * `account.totalTokens` when that becomes realistic. + */ +const TOKEN_SELECTOR_PAGE_SIZE = 100; + +/** + * Picks the token the overview opens on: USDFC matched by contract address, so a + * look-alike symbol can't win, falling back to the first token on the account. + */ +const findDefaultToken = (userTokens: UserToken[], usdfcAddress: string): UserToken => { + const usdfc = userTokens.find((userToken) => userToken.token.id.toLowerCase() === usdfcAddress.toLowerCase()); + return usdfc ?? userTokens[0]; +}; -export const FundsSection: React.FC = ({ account, subscribed }) => { +export const FundsSection = ({ account, network, onGuidedTopUp }: FundsSectionProps) => { + const [addFundsOpen, setAddFundsOpen] = useState(false); const [depositDialogOpen, setDepositDialogOpen] = useState(false); + const [depositToken, setDepositToken] = useState(null); + const [withdrawDialogOpen, setWithdrawDialogOpen] = useState(false); - const [selectedToken, setSelectedToken] = useState(null); + const [withdrawToken, setWithdrawToken] = useState(null); + + const [selectedTokenId, setSelectedTokenId] = useState(null); const [currentTimestamp, setCurrentTimestamp] = useState(() => BigInt(Math.floor(Date.now() / 1_000))); - const { chainId } = useConnection(); - const walletNetwork = getNetworkFromChainId(chainId); - const isNotificationsEligible = isNotificationsEligibleNetwork(walletNetwork); + const { constants } = useSynapse(); useEffect(() => { const intervalId = window.setInterval(() => { @@ -33,74 +64,111 @@ export const FundsSection: React.FC = ({ account, subscribed return () => window.clearInterval(intervalId); }, []); - // Fetch all tokens for this account (no pagination for console view) - const { data, isLoading, isError } = useAccountTokens(account.id, 1, { networkOverride: walletNetwork }); - - const handleDeposit = useCallback((userToken: UserToken) => { - setSelectedToken(userToken); + // Fetch up to 100 tokens for this account (single page, no pagination for console view) + const { data, isLoading, isError } = useAccountTokens(account.id, 1, { + networkOverride: network, + pageSize: TOKEN_SELECTOR_PAGE_SIZE, + }); + + const userTokens = data?.userTokens; + + /** + * Selection is held as an id and resolved against the current list, so a + * refetch can't leave a stale token object on screen. + */ + const selectedToken = useMemo(() => { + if (!userTokens || userTokens.length === 0) return null; + const selected = userTokens.find((userToken) => userToken.id === selectedTokenId); + return selected ?? findDefaultToken(userTokens, constants.contracts.usdfc); + }, [userTokens, selectedTokenId, constants.contracts.usdfc]); + + // Snapshot each transaction token when its dialog opens so query and selector + // updates cannot change a part-filled form's target. Keep the snapshots after + // close so WithdrawDialog remains mounted while tracking its receipt and both + // dialogs retain a consistent lifecycle. The next open replaces the snapshot. + const openDirectDeposit = useCallback(() => { + setDepositToken(selectedToken); setDepositDialogOpen(true); - }, []); + }, [selectedToken]); - const handleWithdraw = useCallback((userToken: UserToken) => { - setSelectedToken(userToken); - setWithdrawDialogOpen(true); - }, []); + const canUseGuidedTopUp = network === "mainnet" && Boolean(onGuidedTopUp); const handleOpenDeposit = useCallback(() => { - setDepositDialogOpen(true); - }, []); - - // Prepare data with action handlers - const tableData = useMemo( - () => - data?.userTokens.map((token) => ({ - ...token, - currentTimestamp, - onDeposit: handleDeposit, - onWithdraw: handleWithdraw, - })) || [], - [currentTimestamp, data?.userTokens, handleDeposit, handleWithdraw], + if (canUseGuidedTopUp) { + setAddFundsOpen(true); + return; + } + openDirectDeposit(); + }, [canUseGuidedTopUp, openDirectDeposit]); + + const handleChooseMethod = useCallback( + (method: AddFundsMethod) => { + setAddFundsOpen(false); + if (method === "deposit") { + openDirectDeposit(); + return; + } + onGuidedTopUp?.(); + }, + [onGuidedTopUp, openDirectDeposit], ); - if (isLoading) { - return ; - } - - if (isError) { - return ; - } + const handleOpenWithdraw = useCallback(() => { + if (!selectedToken) return; - if (!data || data.userTokens.length === 0) { - return ; - } + setWithdrawToken(selectedToken); + setWithdrawDialogOpen(true); + }, [selectedToken]); + + const renderSection = () => { + if (isLoading) { + return ; + } + + if (isError) { + return ; + } + + if (!selectedToken || !userTokens) { + return ; + } + + return ( + } + > + + + ); + }; return ( <> -
-
-

Funds

- {isNotificationsEligible && } -
- - - - -
- - {/* Deposit Dialogs */} - - - {selectedToken && ( - - )} + {renderSection()} + + {canUseGuidedTopUp ? ( + + ) : null} + + {/* A null token opens the picker expanded, the first-deposit path for an empty account. */} + + + {/* Mounted only once a token is captured, so WithdrawDialog keeps a non-nullable prop. */} + {withdrawToken ? ( + + ) : null} ); }; diff --git a/apps/explorer/src/components/UserConsole/FundsSection/utils/calculateFundedUntil.test.ts b/apps/explorer/src/components/UserConsole/FundsSection/utils/calculateFundedUntil.test.ts new file mode 100644 index 00000000..57e3c1e3 --- /dev/null +++ b/apps/explorer/src/components/UserConsole/FundsSection/utils/calculateFundedUntil.test.ts @@ -0,0 +1,162 @@ +import type { UserToken } from "@filecoin-pay/types"; +import { maxUint256 } from "viem"; +import { describe, expect, it } from "vitest"; +import { EPOCH_DURATION } from "@/utils/constants"; +import { calculateFundedUntil } from "./calculateFundedUntil"; + +/** + * Characterization tests. This derivation predates the funds overview and was + * moved here unchanged, so these pin the behaviour that already shipped rather + * than asserting the formula is the right one. + */ + +const EPOCH = BigInt(EPOCH_DURATION); +const SETTLED_EPOCH = 1000n; +const SETTLED_TIMESTAMP = 1_700_000_000n; + +/** Only the five fields the derivation reads; the rest of UserToken is irrelevant here. */ +const userToken = (fields: { funds: bigint; lockupCurrent: bigint; lockupRate: bigint }): UserToken => + ({ + funds: fields.funds, + lockupCurrent: fields.lockupCurrent, + lockupRate: fields.lockupRate, + lockupLastSettledUntilEpoch: SETTLED_EPOCH, + lockupLastSettledUntilTimestamp: SETTLED_TIMESTAMP, + }) as UserToken; + +/** Timestamp `epochs` after the last settled point. */ +const at = (epochs: bigint) => SETTLED_TIMESTAMP + epochs * EPOCH; + +describe("calculateFundedUntil", () => { + describe("no active spend", () => { + it("reports an unbounded funded-until and no debt when the rate is zero", () => { + const result = calculateFundedUntil(userToken({ funds: 100n, lockupCurrent: 40n, lockupRate: 0n }), at(500n)); + + expect(result).toEqual({ + availableFunds: 60n, + debt: 0n, + fundedUntilTimestamp: maxUint256, + simulatedLockupCurrent: 40n, + }); + }); + + it("leaves lockup unchanged however far time has advanced", () => { + const token = userToken({ funds: 100n, lockupCurrent: 40n, lockupRate: 0n }); + + expect(calculateFundedUntil(token, at(1n)).simulatedLockupCurrent).toBe(40n); + expect(calculateFundedUntil(token, at(1_000_000n)).simulatedLockupCurrent).toBe(40n); + }); + }); + + describe("active spend, still solvent", () => { + it("rolls lockup forward at the rate and reports the remaining balance", () => { + // 100 funds, 40 locked, 2/epoch: 30 epochs of runway from the settled point. + const result = calculateFundedUntil(userToken({ funds: 100n, lockupCurrent: 40n, lockupRate: 2n }), at(10n)); + + expect(result).toEqual({ + availableFunds: 40n, + debt: 0n, + fundedUntilTimestamp: at(30n), + simulatedLockupCurrent: 60n, + }); + }); + + it("truncates a partial epoch rather than rounding up", () => { + const token = userToken({ funds: 100n, lockupCurrent: 40n, lockupRate: 2n }); + + // One second short of the 10th epoch still counts as 9 elapsed. + expect(calculateFundedUntil(token, at(10n) - 1n).simulatedLockupCurrent).toBe(58n); + expect(calculateFundedUntil(token, at(10n)).simulatedLockupCurrent).toBe(60n); + }); + + it("treats a timestamp at or before the last settled point as zero elapsed epochs", () => { + const token = userToken({ funds: 100n, lockupCurrent: 40n, lockupRate: 2n }); + + expect(calculateFundedUntil(token, SETTLED_TIMESTAMP).simulatedLockupCurrent).toBe(40n); + expect(calculateFundedUntil(token, SETTLED_TIMESTAMP - 10_000n).simulatedLockupCurrent).toBe(40n); + }); + }); + + describe("at and beyond the funded-until boundary", () => { + it("reports zero available with no debt at the exact moment funds run out", () => { + const result = calculateFundedUntil(userToken({ funds: 100n, lockupCurrent: 40n, lockupRate: 2n }), at(30n)); + + expect(result).toEqual({ + availableFunds: 0n, + debt: 0n, + fundedUntilTimestamp: at(30n), + simulatedLockupCurrent: 100n, + }); + }); + + it("clamps lockup at the funded-until epoch once it is passed", () => { + // simulatedSettledAt stops at fundedUntilEpoch, so lockup never exceeds funds. + const result = calculateFundedUntil(userToken({ funds: 100n, lockupCurrent: 40n, lockupRate: 2n }), at(50n)); + + expect(result.simulatedLockupCurrent).toBe(100n); + expect(result.availableFunds).toBe(0n); + }); + + it("reports the shortfall as debt once obligations outrun funds", () => { + // totalOwed = 40 + 2*50 = 140 against 100 funds. + const result = calculateFundedUntil(userToken({ funds: 100n, lockupCurrent: 40n, lockupRate: 2n }), at(50n)); + + expect(result.debt).toBe(40n); + }); + + it("grows debt with elapsed time while available funds stay clamped at zero", () => { + const token = userToken({ funds: 100n, lockupCurrent: 40n, lockupRate: 2n }); + + // totalOwed = 40 + 2*elapsed, against 100 funds. + expect(calculateFundedUntil(token, at(40n)).debt).toBe(20n); + expect(calculateFundedUntil(token, at(60n)).debt).toBe(60n); + expect(calculateFundedUntil(token, at(60n)).availableFunds).toBe(0n); + }); + }); + + describe("edge cases", () => { + it("reports an already-exhausted account as expired at the settled point", () => { + const result = calculateFundedUntil(userToken({ funds: 40n, lockupCurrent: 40n, lockupRate: 2n }), at(10n)); + + expect(result).toEqual({ + availableFunds: 0n, + debt: 20n, + fundedUntilTimestamp: SETTLED_TIMESTAMP, + simulatedLockupCurrent: 40n, + }); + }); + + it("truncates fractional runway when funds do not divide evenly by the rate", () => { + // (100 - 40) / 7 = 8.57 → 8 epochs. + const result = calculateFundedUntil(userToken({ funds: 100n, lockupCurrent: 40n, lockupRate: 7n }), at(1n)); + + expect(result.fundedUntilTimestamp).toBe(at(8n)); + }); + + it("handles values beyond Number.MAX_SAFE_INTEGER without precision loss", () => { + const funds = 10n ** 24n; + const lockupCurrent = 10n ** 23n; + const result = calculateFundedUntil(userToken({ funds, lockupCurrent, lockupRate: 10n ** 18n }), at(100n)); + + expect(result.simulatedLockupCurrent).toBe(lockupCurrent + 100n * 10n ** 18n); + expect(result.availableFunds).toBe(funds - result.simulatedLockupCurrent); + expect(result.debt).toBe(0n); + }); + + it("accepts the string amounts the subgraph returns", () => { + const result = calculateFundedUntil( + { + funds: "100", + lockupCurrent: "40", + lockupRate: "2", + lockupLastSettledUntilEpoch: SETTLED_EPOCH, + lockupLastSettledUntilTimestamp: SETTLED_TIMESTAMP, + } as unknown as UserToken, + at(10n), + ); + + expect(result.simulatedLockupCurrent).toBe(60n); + expect(result.availableFunds).toBe(40n); + }); + }); +}); diff --git a/apps/explorer/src/components/UserConsole/FundsSection/utils/calculateFundedUntil.ts b/apps/explorer/src/components/UserConsole/FundsSection/utils/calculateFundedUntil.ts new file mode 100644 index 00000000..71750d1d --- /dev/null +++ b/apps/explorer/src/components/UserConsole/FundsSection/utils/calculateFundedUntil.ts @@ -0,0 +1,65 @@ +import type { UserToken } from "@filecoin-pay/types"; +import { maxUint256 } from "viem"; +import { EPOCH_DURATION } from "@/utils/constants"; + +export type FundedUntil = { + /** Withdrawable balance, clamped to `0n` when the account is in deficit. */ + availableFunds: bigint; + /** Outstanding obligation the account can't cover; `0n` when it is still solvent. */ + debt: bigint; + /** Unix seconds at which funds run out; `maxUint256` when there is no active spend. */ + fundedUntilTimestamp: bigint; + /** Lockup rolled forward from the last settled epoch to now. */ + simulatedLockupCurrent: bigint; +}; + +/** + * Rolls a token's on-chain lockup forward to `currentTimestamp` and derives the + * four figures the funds overview reports. Call once per selected token. + */ +export const calculateFundedUntil = (userToken: UserToken, currentTimestamp: bigint): FundedUntil => { + const funds = BigInt(userToken.funds); + const lockupCurrent = BigInt(userToken.lockupCurrent); + const lastSettledAt = BigInt(userToken.lockupLastSettledUntilEpoch); + const lastSettledTimestamp = BigInt(userToken.lockupLastSettledUntilTimestamp); + const lockupRate = BigInt(userToken.lockupRate); + + let elapsedEpochs = 0n; + if (currentTimestamp > lastSettledTimestamp) { + elapsedEpochs = (currentTimestamp - lastSettledTimestamp) / BigInt(EPOCH_DURATION); + } + + const currentEpoch = lastSettledAt + elapsedEpochs; + + const fundedUntilEpoch = lockupRate === 0n ? maxUint256 : lastSettledAt + (funds - lockupCurrent) / lockupRate; + const simulatedSettledAt = fundedUntilEpoch < currentEpoch ? fundedUntilEpoch : currentEpoch; + const simulatedLockupCurrent = lockupCurrent + lockupRate * (simulatedSettledAt - lastSettledAt); + + const rawAvailable = funds - simulatedLockupCurrent; + const availableFunds = rawAvailable > 0n ? rawAvailable : 0n; + + const fundedUntilTimestamp = + lockupRate === 0n ? maxUint256 : lastSettledTimestamp + (fundedUntilEpoch - lastSettledAt) * BigInt(EPOCH_DURATION); + + // Debt is a streaming-rate deficit signal only. The snapshot is taken at + // `lockupLastSettledUntilEpoch` and projected forward, and only the + // `lockupRate * elapsedEpochs` term can push `totalOwed` past `funds`. + // + // At `lockupRate === 0n` that term is zero, so `totalOwed` collapses to the + // snapshotted `lockupCurrent` — and the contract enforces `funds >= lockupCurrent` + // at every entry point, at every settlement. Debt is therefore structurally `0n` + // whenever the rate is zero: the rate term is dead code there, not anticipation + // of a reachable state. Don't special-case zero-rate debt; it cannot occur. + const totalOwed = lockupCurrent + lockupRate * elapsedEpochs; + let debt = 0n; + if (totalOwed > funds) { + debt = totalOwed - funds; + } + + return { + availableFunds, + debt, + fundedUntilTimestamp, + simulatedLockupCurrent, + }; +}; diff --git a/apps/explorer/src/components/UserConsole/FundsSection/utils/formatDuration.test.ts b/apps/explorer/src/components/UserConsole/FundsSection/utils/formatDuration.test.ts new file mode 100644 index 00000000..147a8b89 --- /dev/null +++ b/apps/explorer/src/components/UserConsole/FundsSection/utils/formatDuration.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { formatDuration } from "./formatDuration"; + +describe("formatDuration", () => { + it("reports sub-day runway without claiming it is gone", () => { + expect(formatDuration(0)).toBe("less than a day"); + }); + + it("reports a single day in the singular", () => { + expect(formatDuration(1)).toBe("1 day"); + }); + + it("reports exact days below the month boundary", () => { + expect(formatDuration(12)).toBe("12 days"); + }); + + it("still reports exact days at 29", () => { + expect(formatDuration(29)).toBe("29 days"); + }); + + it("switches to truncated months at 30", () => { + expect(formatDuration(30)).toBe("+1 month"); + }); + + it("truncates months down rather than rounding to the nearest", () => { + expect(formatDuration(89)).toBe("+2 months"); + }); + + it("still reports months at 364", () => { + expect(formatDuration(364)).toBe("+12 months"); + }); + + it("switches to truncated years at 365", () => { + expect(formatDuration(365)).toBe("+1 year"); + }); + + it("truncates years down rather than rounding to the nearest", () => { + expect(formatDuration(5 * 365 + 364)).toBe("+5 years"); + }); +}); diff --git a/apps/explorer/src/components/UserConsole/FundsSection/utils/formatDuration.ts b/apps/explorer/src/components/UserConsole/FundsSection/utils/formatDuration.ts new file mode 100644 index 00000000..200f5439 --- /dev/null +++ b/apps/explorer/src/components/UserConsole/FundsSection/utils/formatDuration.ts @@ -0,0 +1,29 @@ +/** Calendar-free approximations: the label is a rough magnitude, not a date calculation. */ +const DAYS_PER_MONTH = 30; +const DAYS_PER_YEAR = 365; + +const pluralize = (count: number, unit: string) => `${count} ${unit}${count === 1 ? "" : "s"}`; + +/** + * Formats a whole number of days as a single truncated-down unit. + * + * Days stay exact below a month because every health tier is sub-month + * (30/7/3 days), so that is the range where a day either way changes what the + * user should do. Past a month the exact day count stops being actionable and + * the `+` earns its place: above the meter's 90-day horizon the bar is pinned + * full, leaving the label as the only thing separating `+3 months` from + * `+5 years`. + * + * Truncating down matches the funds cards' convention — never state the user's + * position more favourably than it is. + * + * Expects a non-negative day count; expiry is a state the caller labels itself. + */ +export const formatDuration = (days: number): string => { + // Sub-day runway is real but unquantifiable at this precision; "0 days" would + // read as expired, which it is not. + if (days < 1) return "less than a day"; + if (days < DAYS_PER_MONTH) return pluralize(days, "day"); + if (days < DAYS_PER_YEAR) return `+${pluralize(Math.floor(days / DAYS_PER_MONTH), "month")}`; + return `+${pluralize(Math.floor(days / DAYS_PER_YEAR), "year")}`; +}; diff --git a/apps/explorer/src/components/UserConsole/FundsSection/utils/formatTokenAmount.test.ts b/apps/explorer/src/components/UserConsole/FundsSection/utils/formatTokenAmount.test.ts new file mode 100644 index 00000000..2e22e590 --- /dev/null +++ b/apps/explorer/src/components/UserConsole/FundsSection/utils/formatTokenAmount.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from "vitest"; +import { formatTokenAmount } from "./formatTokenAmount"; + +/** 18 decimals, matching USDFC. */ +const token = (whole: string) => BigInt(whole); + +describe("formatTokenAmount", () => { + it("renders an exact zero as a bare 0, not a padded one", () => { + expect(formatTokenAmount(0n, 18)).toBe("0"); + }); + + it("pads a whole number out to three decimals", () => { + expect(formatTokenAmount(token("38000000000000000000"), 18)).toBe("38.000"); + }); + + it("keeps the trailing zero on a two-decimal value", () => { + expect(formatTokenAmount(token("38880000000000000000"), 18)).toBe("38.880"); + }); + + it("groups thousands", () => { + expect(formatTokenAmount(token("1500500000000000000000"), 18)).toBe("1,500.500"); + }); + + describe("rounding direction", () => { + // 38.8809… — the fourth decimal decides which way it breaks. + const value = token("38880900000000000000"); + + it("truncates by default, so a balance is never overstated", () => { + expect(formatTokenAmount(value, 18)).toBe("38.880"); + expect(formatTokenAmount(value, 18, "down")).toBe("38.880"); + }); + + it("rounds up when asked, so an amount owed is never understated", () => { + expect(formatTokenAmount(value, 18, "up")).toBe("38.881"); + }); + + it("leaves an exact value alone in both directions", () => { + const exact = token("38880000000000000000"); + expect(formatTokenAmount(exact, 18, "down")).toBe("38.880"); + expect(formatTokenAmount(exact, 18, "up")).toBe("38.880"); + }); + }); + + describe("amounts below the shown precision", () => { + // 0.0004 — real, but smaller than the last decimal shown. + const dust = token("400000000000000"); + + it("flags a truncated dust balance instead of claiming it is zero", () => { + expect(formatTokenAmount(dust, 18)).toBe("< 0.001"); + }); + + it("flags the smallest possible non-zero balance", () => { + expect(formatTokenAmount(1n, 18)).toBe("< 0.001"); + }); + + it("lifts dust owed to the first shown decimal rather than hiding it", () => { + expect(formatTokenAmount(dust, 18, "up")).toBe("0.001"); + expect(formatTokenAmount(1n, 18, "up")).toBe("0.001"); + }); + }); + + describe("tokens with unusual decimals", () => { + it("handles a token with fewer decimals than we display", () => { + // 2-decimal token holding 12.34. + expect(formatTokenAmount(1234n, 2)).toBe("12.340"); + }); + + it("handles a token with no decimals", () => { + expect(formatTokenAmount(7n, 0)).toBe("7.000"); + }); + + it("handles a token with exactly three decimals", () => { + expect(formatTokenAmount(7005n, 3)).toBe("7.005"); + }); + + it("accepts the bigint decimals the subgraph types declare", () => { + expect(formatTokenAmount(token("38880000000000000000"), 18n)).toBe("38.880"); + }); + }); + + it("accepts the raw strings the GraphQL layer actually returns", () => { + expect(formatTokenAmount("38880000000000000000", 18)).toBe("38.880"); + }); + + it("stays exact past the float precision limit", () => { + // 123456789012.345678901234567890 — unrepresentable as a double. + expect(formatTokenAmount(token("123456789012345678901234567890"), 18)).toBe("123,456,789,012.345"); + }); +}); diff --git a/apps/explorer/src/components/UserConsole/FundsSection/utils/formatTokenAmount.ts b/apps/explorer/src/components/UserConsole/FundsSection/utils/formatTokenAmount.ts new file mode 100644 index 00000000..6e5a4ddc --- /dev/null +++ b/apps/explorer/src/components/UserConsole/FundsSection/utils/formatTokenAmount.ts @@ -0,0 +1,70 @@ +/** Decimal places shown on the overview cards. */ +export const FRACTION_DIGITS = 3; + +/** + * Which way to break a value that doesn't land exactly on the last shown decimal. + * + * - `down` (truncate) for anything the user *holds*. Rounding a balance up + * overstates what they can act on: a withdrawable 38.8809 shown as 38.881 + * invites a withdrawal that the contract will reject. + * - `up` for anything the user *owes*. Rounding debt down understates the + * obligation, which is the same mistake pointing the other way. + * + * Both directions follow one rule: never state the user's position more + * favourably than it is. + */ +export type AmountRounding = "down" | "up"; + +/** Non-zero, but smaller than the last decimal we show. */ +const BELOW_PRECISION = `< 0.${"0".repeat(FRACTION_DIGITS - 1)}1`; + +/** + * Formats a raw on-chain amount for display: fixed decimals, grouped thousands, + * no token symbol. + * + * Works entirely in bigint space rather than converting to a float first, so the + * digits shown are the digits on chain even for balances past `Number`'s 2^53 + * precision limit. + * + * Expects a non-negative amount — callers render the sign themselves (the debt + * figure is stored positive and displayed negative). + */ +export const formatTokenAmount = ( + value: bigint | string, + tokenDecimals: bigint | number, + rounding: AmountRounding = "down", +): string => { + const raw = BigInt(value); + const magnitude = raw < 0n ? -raw : raw; + + // Exactly nothing reads as "0", not "0.000" — a padded zero looks like a + // rounded-away dust balance, which is the one thing it isn't. + if (magnitude === 0n) return "0"; + + const decimals = Number(tokenDecimals); + + // Rescale to units of the smallest shown decimal, keeping the discarded + // remainder so we know whether the value was exact. + let scaled: bigint; + let remainder = 0n; + if (decimals > FRACTION_DIGITS) { + const divisor = 10n ** BigInt(decimals - FRACTION_DIGITS); + scaled = magnitude / divisor; + remainder = magnitude % divisor; + } else { + scaled = magnitude * 10n ** BigInt(FRACTION_DIGITS - decimals); + } + + if (rounding === "up" && remainder > 0n) { + scaled += 1n; + } + + // Truncation wiped out a real, non-zero amount. Saying "0.000" would claim the + // balance is empty; the threshold says "you have some, just less than we show". + if (scaled === 0n) return BELOW_PRECISION; + + const unit = scaled / 10n ** BigInt(FRACTION_DIGITS); + const fraction = scaled % 10n ** BigInt(FRACTION_DIGITS); + + return `${unit.toLocaleString("en-US")}.${fraction.toString().padStart(FRACTION_DIGITS, "0")}`; +}; diff --git a/apps/explorer/src/components/UserConsole/FundsSection/utils/fundsHealth.test.ts b/apps/explorer/src/components/UserConsole/FundsSection/utils/fundsHealth.test.ts new file mode 100644 index 00000000..96cf2dcd --- /dev/null +++ b/apps/explorer/src/components/UserConsole/FundsSection/utils/fundsHealth.test.ts @@ -0,0 +1,98 @@ +import { maxUint256 } from "viem"; +import { describe, expect, it } from "vitest"; +import { deriveFundsHealth } from "./fundsHealth"; + +const NOW = 1_700_000_000n; +const DAY = 24n * 60n * 60n; + +const healthAt = (secondsFromNow: bigint) => deriveFundsHealth(NOW + secondsFromNow, NOW); + +describe("deriveFundsHealth", () => { + it("reports healthy with no runway figure when there is no active spend", () => { + expect(deriveFundsHealth(maxUint256, NOW)).toEqual({ + tier: "healthy", + daysRemaining: null, + isExpired: false, + }); + }); + + it("reports emergency and expired when funding runs out exactly now", () => { + expect(deriveFundsHealth(NOW, NOW)).toEqual({ + tier: "emergency", + daysRemaining: 0, + isExpired: true, + }); + }); + + it("reports emergency and expired when funding already ran out", () => { + expect(healthAt(-DAY)).toEqual({ + tier: "emergency", + daysRemaining: 0, + isExpired: true, + }); + }); + + it("reports emergency just under the 3 day threshold", () => { + expect(healthAt(3n * DAY - 1n)).toEqual({ + tier: "emergency", + daysRemaining: 2, + isExpired: false, + }); + }); + + it("reports critical exactly at the 3 day threshold", () => { + expect(healthAt(3n * DAY)).toEqual({ + tier: "critical", + daysRemaining: 3, + isExpired: false, + }); + }); + + it("reports critical just under the 7 day threshold", () => { + expect(healthAt(7n * DAY - 1n)).toEqual({ + tier: "critical", + daysRemaining: 6, + isExpired: false, + }); + }); + + it("reports warning exactly at the 7 day threshold", () => { + expect(healthAt(7n * DAY)).toEqual({ + tier: "warning", + daysRemaining: 7, + isExpired: false, + }); + }); + + it("reports warning just under the 30 day threshold", () => { + expect(healthAt(30n * DAY - 1n)).toEqual({ + tier: "warning", + daysRemaining: 29, + isExpired: false, + }); + }); + + it("reports healthy exactly at the 30 day threshold", () => { + expect(healthAt(30n * DAY)).toEqual({ + tier: "healthy", + daysRemaining: 30, + isExpired: false, + }); + }); + + it("reports healthy well beyond the 30 day threshold", () => { + expect(healthAt(365n * DAY)).toEqual({ + tier: "healthy", + daysRemaining: 365, + isExpired: false, + }); + }); + + it("floors partial days rather than rounding them up", () => { + expect(healthAt(10n * DAY + DAY / 2n)).toEqual({ + tier: "warning", + daysRemaining: 10, + isExpired: false, + }); + }); +}); diff --git a/apps/explorer/src/components/UserConsole/FundsSection/utils/fundsHealth.ts b/apps/explorer/src/components/UserConsole/FundsSection/utils/fundsHealth.ts new file mode 100644 index 00000000..dcbfd7ea --- /dev/null +++ b/apps/explorer/src/components/UserConsole/FundsSection/utils/fundsHealth.ts @@ -0,0 +1,67 @@ +import { maxUint256 } from "viem"; + +/** Runway-based health tiers: "healthy" (nothing to act on) plus the actionable alert levels. */ +export type HealthTier = "healthy" | "warning" | "critical" | "emergency"; + +/** + * Runway thresholds in whole days. A tier applies when the remaining runway is + * strictly below its value. + * + * Mirrors `DEFAULT_HEALTH_THRESHOLDS` in the notification service so the console + * and the alert emails agree about when an account is at risk. + */ +export const HEALTH_THRESHOLD_DAYS = { + warning: 30, + critical: 7, + emergency: 3, +} as const; + +export type FundsHealth = { + tier: HealthTier; + /** Whole days of runway (floored); `null` when there is no active spend. */ + daysRemaining: number | null; + /** True once funds have run out at `currentTimestamp`. */ + isExpired: boolean; +}; + +const SECONDS_PER_DAY = 24n * 60n * 60n; + +/** + * Derives the health tier of a token balance from when its funds run out. + * + * Compared in timestamp-space (unix seconds), the same space + * `calculateFundedUntil` produces. + */ +export const deriveFundsHealth = (fundedUntilTimestamp: bigint, currentTimestamp: bigint): FundsHealth => { + // `maxUint256` means "no clock is ticking" — not "nothing is locked". A zero-rate + // account can still hold fixed lockup, which the Locked card reports separately. + // + // Calling it healthy is safe rather than merely convenient: debt is structurally + // `0n` whenever `lockupRate === 0n` (see `calculateFundedUntil`), so no + // debt-bearing account can hide behind this branch. That is also why this + // derivation needs no debt-first branch to agree with the notification service, + // which checks debt before spend rate. + if (fundedUntilTimestamp === maxUint256) { + return { tier: "healthy", daysRemaining: null, isExpired: false }; + } + + // Funds already exhausted → termination imminent. + if (fundedUntilTimestamp <= currentTimestamp) { + return { tier: "emergency", daysRemaining: 0, isExpired: true }; + } + + const secondsRemaining = fundedUntilTimestamp - currentTimestamp; + const daysRemaining = Number(secondsRemaining / SECONDS_PER_DAY); + + if (secondsRemaining < BigInt(HEALTH_THRESHOLD_DAYS.emergency) * SECONDS_PER_DAY) { + return { tier: "emergency", daysRemaining, isExpired: false }; + } + if (secondsRemaining < BigInt(HEALTH_THRESHOLD_DAYS.critical) * SECONDS_PER_DAY) { + return { tier: "critical", daysRemaining, isExpired: false }; + } + if (secondsRemaining < BigInt(HEALTH_THRESHOLD_DAYS.warning) * SECONDS_PER_DAY) { + return { tier: "warning", daysRemaining, isExpired: false }; + } + + return { tier: "healthy", daysRemaining, isExpired: false }; +}; diff --git a/apps/explorer/src/components/UserConsole/FundsSection/utils/meterPercent.test.ts b/apps/explorer/src/components/UserConsole/FundsSection/utils/meterPercent.test.ts new file mode 100644 index 00000000..2d5b1679 --- /dev/null +++ b/apps/explorer/src/components/UserConsole/FundsSection/utils/meterPercent.test.ts @@ -0,0 +1,73 @@ +import { maxUint256 } from "viem"; +import { describe, expect, it } from "vitest"; +import { getLockedPercent, getRunwayPercent } from "./meterPercent"; + +const NOW = 1_700_000_000n; +const DAY = 24n * 60n * 60n; + +const runwayIn = (days: bigint) => getRunwayPercent(NOW + days * DAY, NOW); + +describe("getRunwayPercent", () => { + it("pins full when there is no active spend", () => { + expect(getRunwayPercent(maxUint256, NOW)).toBe(100); + }); + + it("reads empty once funding has run out", () => { + // Pinning full here would contradict the "Expired" reading the row shows. + expect(getRunwayPercent(NOW, NOW)).toBe(0); + expect(runwayIn(-1n)).toBe(0); + }); + + it("fills proportionally inside the 90 day horizon", () => { + expect(runwayIn(45n)).toBe(50); + expect(runwayIn(9n)).toBe(10); + expect(runwayIn(30n)).toBe(33); + }); + + it("pins full at the horizon and beyond", () => { + expect(runwayIn(90n)).toBe(100); + expect(runwayIn(365n)).toBe(100); + }); + + it("truncates a partial percent down rather than rounding", () => { + // 30/90 is 33.33…, and one second short of 45 days is still under half. + expect(runwayIn(30n)).toBe(33); + expect(getRunwayPercent(NOW + 45n * DAY - 1n, NOW)).toBe(49); + }); + + it("stays within 0 and 100 either side of the horizon", () => { + for (const days of [-100n, 0n, 1n, 44n, 89n, 90n, 10_000n]) { + const percent = runwayIn(days); + expect(percent).toBeGreaterThanOrEqual(0); + expect(percent).toBeLessThanOrEqual(100); + } + }); +}); + +describe("getLockedPercent", () => { + it("reads zero when the account holds no balance", () => { + expect(getLockedPercent(0n, 0n)).toBe(0); + // Guards a divide by zero rather than describing a reachable state. + expect(getLockedPercent(10n, 0n)).toBe(0); + }); + + it("reads the share of the balance held in lockup", () => { + expect(getLockedPercent(50n, 100n)).toBe(50); + expect(getLockedPercent(0n, 100n)).toBe(0); + expect(getLockedPercent(100n, 100n)).toBe(100); + }); + + it("truncates a fractional percent down", () => { + // 2.181 of 2.277 is 95.78…, shown as 95. + expect(getLockedPercent(2_181n, 2_277n)).toBe(95); + expect(getLockedPercent(999n, 1_000n)).toBe(99); + }); + + it("clamps above the balance so the fill cannot outrun its track", () => { + expect(getLockedPercent(150n, 100n)).toBe(100); + }); + + it("handles values beyond Number.MAX_SAFE_INTEGER", () => { + expect(getLockedPercent(10n ** 24n / 4n, 10n ** 24n)).toBe(25); + }); +}); diff --git a/apps/explorer/src/components/UserConsole/FundsSection/utils/meterPercent.ts b/apps/explorer/src/components/UserConsole/FundsSection/utils/meterPercent.ts new file mode 100644 index 00000000..040fd0be --- /dev/null +++ b/apps/explorer/src/components/UserConsole/FundsSection/utils/meterPercent.ts @@ -0,0 +1,46 @@ +import { maxUint256 } from "viem"; + +/** + * Runway past this point is filed under "plenty": the bar pins full and the + * duration label carries the difference. Wide enough that a healthy account + * (30 days or more) still has visible bar left to lose. + */ +const RUNWAY_HORIZON_DAYS = 90n; + +const SECONDS_PER_DAY = 24n * 60n * 60n; + +const FULL_BAR = 100; + +/** + * Fill for the runway bar, 0–100. + * + * No active spend pins full: the runway is as long as the scale can show, which + * is what `aria-valuenow` should then report. Exhausted funds read empty for the + * same reason — the bar measures runway remaining, and pinning it full would + * hand assistive tech `aria-valuenow="100"` next to `aria-valuetext="Expired"`. + * The emergency-tinted track stays visible at zero, so the row is still a rail + * with a reading rather than a blank. + */ +export const getRunwayPercent = (fundedUntilTimestamp: bigint, currentTimestamp: bigint): number => { + if (fundedUntilTimestamp === maxUint256) return FULL_BAR; + if (fundedUntilTimestamp <= currentTimestamp) return 0; + + const secondsRemaining = fundedUntilTimestamp - currentTimestamp; + const horizonSeconds = RUNWAY_HORIZON_DAYS * SECONDS_PER_DAY; + if (secondsRemaining >= horizonSeconds) return FULL_BAR; + + return Number((secondsRemaining * 100n) / horizonSeconds); +}; + +/** + * Share of the balance held in lockup, 0–100, truncated down. + * + * Clamped because the meter must stay a meter: the contract holds + * `funds >= lockupCurrent`, but a bar wider than its track would be a rendering + * bug rather than a reading. + */ +export const getLockedPercent = (lockedAmount: bigint, funds: bigint): number => { + if (funds <= 0n) return 0; + const percent = Number((lockedAmount * 100n) / funds); + return Math.min(percent, FULL_BAR); +}; diff --git a/apps/explorer/src/components/UserConsole/FundsSection/utils/tierStyles.ts b/apps/explorer/src/components/UserConsole/FundsSection/utils/tierStyles.ts new file mode 100644 index 00000000..447af312 --- /dev/null +++ b/apps/explorer/src/components/UserConsole/FundsSection/utils/tierStyles.ts @@ -0,0 +1,53 @@ +import type { HealthTier } from "./fundsHealth"; + +/** + * The one palette for runway severity, shared by the overview cards and the + * runway meter so the two never drift apart on screen. + * + * Color is never the sole signal: each surface also communicates the + * remaining duration, expiry, or no-recurring-charge state in text. + */ + +/** + * Text color for a tier. Runs two steps darker than the palette's fill hue. + * + * Dark mode keeps the light tints: there the same hues sit on a dark fill, where + * a light value is the legible one. + */ +export const TIER_VALUE_CLASSNAME: Record = { + healthy: "text-[#15803D] dark:text-[#4ADE80]", + warning: "text-[#B45309] dark:text-[#FCD34D]", + critical: "text-[#9A3412] dark:text-[#FDBA74]", + emergency: "text-[#B91C1C] dark:text-[#FCA5A5]", +}; + +/** + * Card tint for a tier. Fills are the palette hues at partial alpha rather than + * lighter hex values, so the tint stays the design's color and its strength is + * one number to turn. + * + * No border override here on purpose: a tinted card keeps the shared + * `border-border` token, so it sits in the same frame as its untinted siblings. + */ +export const TIER_CARD_CLASSNAME: Record = { + healthy: "bg-[#DCFCE7]/60 dark:bg-[#16A34A]/15", + warning: "bg-[#FEF3C7]/60 dark:bg-[#F59E0B]/15", + critical: "bg-[#FECAB5]/60 dark:bg-[#F97316]/15", + emergency: "bg-[#FEE2E2]/60 dark:bg-[#DC2626]/15", +}; + +/** Solid meter fill: the palette hue itself, light-valued in dark mode like the text. */ +export const TIER_BAR_CLASSNAME: Record = { + healthy: "bg-[#16A34A] dark:bg-[#4ADE80]", + warning: "bg-[#F59E0B] dark:bg-[#FCD34D]", + critical: "bg-[#F97316] dark:bg-[#FDBA74]", + emergency: "bg-[#DC2626] dark:bg-[#FCA5A5]", +}; + +/** Meter track: the same hue as its fill at low alpha, so a part-filled bar reads as one bar. */ +export const TIER_TRACK_CLASSNAME: Record = { + healthy: "bg-[#16A34A]/15 dark:bg-[#4ADE80]/20", + warning: "bg-[#F59E0B]/15 dark:bg-[#FCD34D]/20", + critical: "bg-[#F97316]/15 dark:bg-[#FDBA74]/20", + emergency: "bg-[#DC2626]/15 dark:bg-[#FCA5A5]/20", +}; diff --git a/apps/explorer/src/components/UserConsole/OperatorApprovalsSection/index.tsx b/apps/explorer/src/components/UserConsole/OperatorApprovalsSection/index.tsx index e5b7e633..8d6e2abe 100644 --- a/apps/explorer/src/components/UserConsole/OperatorApprovalsSection/index.tsx +++ b/apps/explorer/src/components/UserConsole/OperatorApprovalsSection/index.tsx @@ -2,26 +2,23 @@ import { Button } from "@filecoin-foundation/ui-filecoin/Button"; import type { Account, OperatorApproval } from "@filecoin-pay/types"; import { Plus } from "lucide-react"; import { useCallback, useMemo, useState } from "react"; -import { useAccount } from "wagmi"; import { ApproveOperatorDialog } from "@/components/UserConsole/ApproveOperatorDialog"; import { IncreaseApprovalDialog } from "@/components/UserConsole/IncreaseApprovalDialog"; import { useAccountApprovals } from "@/hooks/useAccountDetails"; -import { getNetworkFromChainId } from "@/utils/network"; +import type { Network } from "@/types"; import { ApprovalsEmptyState, ApprovalsErrorState, ApprovalsLoadingState, ApprovalsTable } from "./components"; interface OperatorApprovalsSectionProps { account: Account; + network: Network; } -export const OperatorApprovalsSection: React.FC = ({ account }) => { +export const OperatorApprovalsSection: React.FC = ({ account, network }) => { const [approveDialogOpen, setApproveDialogOpen] = useState(false); const [increaseDialogOpen, setIncreaseDialogOpen] = useState(false); const [selectedApproval, setSelectedApproval] = useState(null); - const { chainId } = useAccount(); - const walletNetwork = getNetworkFromChainId(chainId); - - const { data, isLoading, isError } = useAccountApprovals(account.id, 1, { networkOverride: walletNetwork }); + const { data, isLoading, isError } = useAccountApprovals(account.id, 1, { networkOverride: network }); const handleIncrease = useCallback((approval: OperatorApproval) => { setSelectedApproval(approval); diff --git a/apps/explorer/src/components/UserConsole/RailsSection/components/RailActions.tsx b/apps/explorer/src/components/UserConsole/RailsSection/components/RailActions.tsx index d7507b4d..6627f447 100644 --- a/apps/explorer/src/components/UserConsole/RailsSection/components/RailActions.tsx +++ b/apps/explorer/src/components/UserConsole/RailsSection/components/RailActions.tsx @@ -1,6 +1,7 @@ import { Button } from "@filecoin-foundation/ui-filecoin/Button"; import { Tooltip, TooltipContent, TooltipTrigger } from "@filecoin-pay/ui/components/tooltip"; import { InlineTextLoader } from "@/components/shared"; +import { getRailSettlementEligibility, getRailSettlementUnavailableReason } from "@/utils/railSettlement"; import { useSettleRail } from "../context/SettleRailContext"; import type { RailTableRow } from "../types"; @@ -9,14 +10,12 @@ type RailActionsProps = { }; const RailActions = ({ rail }: RailActionsProps) => { - const { openSettleDialog } = useSettleRail(); - const isFinalized = rail.state === "FINALIZED"; - const isDisabled = isFinalized || rail.isSettling; + const { currentEpoch, openSettleDialog } = useSettleRail(); + const settlementEligibility = getRailSettlementEligibility(rail, currentEpoch); + const isDisabled = settlementEligibility.status !== "allowed" || rail.isSettling; - let tooltipContent = ""; - if (isFinalized) { - tooltipContent = "Rail is finalized and cannot be settled"; - } else if (rail.isSettling) { + let tooltipContent = getRailSettlementUnavailableReason(settlementEligibility); + if (settlementEligibility.status === "allowed" && rail.isSettling) { tooltipContent = "Settlement in progress..."; } @@ -30,7 +29,10 @@ const RailActions = ({ rail }: RailActionsProps) => { return (
- {button} + + {/* biome-ignore lint/a11y/noNoninteractiveTabindex: makes the disabled action explanation keyboard-accessible */} + {button} +

{tooltipContent}

diff --git a/apps/explorer/src/components/UserConsole/RailsSection/context/SettleRailContext.tsx b/apps/explorer/src/components/UserConsole/RailsSection/context/SettleRailContext.tsx index cb44a6b1..3f39c060 100644 --- a/apps/explorer/src/components/UserConsole/RailsSection/context/SettleRailContext.tsx +++ b/apps/explorer/src/components/UserConsole/RailsSection/context/SettleRailContext.tsx @@ -1,7 +1,10 @@ import type { Rail } from "@filecoin-pay/types"; -import { createContext, type ReactNode, useContext, useMemo } from "react"; +import { createContext, type ReactNode, useCallback, useContext, useMemo } from "react"; +import { useBlockNumber } from "wagmi"; +import type { supportedChains } from "@/services/wagmi/config"; interface SettleRailContextValue { + currentEpoch: bigint | undefined; openSettleDialog: (rail: Rail) => void; } @@ -17,10 +20,14 @@ export const useSettleRail = () => { interface SettleRailProviderProps { children: ReactNode; - onSettle: (rail: Rail) => void; + chainId: (typeof supportedChains)[number]["id"]; + onSettle: (rail: Rail, currentEpoch: bigint | undefined) => void; } -export const SettleRailProvider = ({ children, onSettle }: SettleRailProviderProps) => { - const value = useMemo(() => ({ openSettleDialog: onSettle }), [onSettle]); +export const SettleRailProvider = ({ children, chainId, onSettle }: SettleRailProviderProps) => { + const { data: currentEpoch } = useBlockNumber({ chainId, watch: true }); + const openSettleDialog = useCallback((rail: Rail) => onSettle(rail, currentEpoch), [currentEpoch, onSettle]); + const value = useMemo(() => ({ currentEpoch, openSettleDialog }), [currentEpoch, openSettleDialog]); + return {children}; }; diff --git a/apps/explorer/src/components/UserConsole/RailsSection/index.test.tsx b/apps/explorer/src/components/UserConsole/RailsSection/index.test.tsx new file mode 100644 index 00000000..5e730470 --- /dev/null +++ b/apps/explorer/src/components/UserConsole/RailsSection/index.test.tsx @@ -0,0 +1,58 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { RailsSection } from "."; + +const observed = vi.hoisted(() => ({ chainId: 0 })); + +vi.mock("@/hooks/useAccountDetails", () => ({ + useAccountRails: () => ({ + data: { + rails: [ + { + operator: { address: "0x2222222222222222222222222222222222222222" }, + payee: { address: "0x3333333333333333333333333333333333333333" }, + payer: { address: "0x1111111111111111111111111111111111111111" }, + railId: 1n, + }, + ], + }, + isError: false, + isLoading: false, + }), +})); +vi.mock("@/hooks/useRailSettlements", () => ({ + useRailSettlements: () => ({ isSettling: () => false, settleRail: vi.fn(), settlements: new Set() }), +})); +vi.mock("../RailsSearch", () => ({ RailsSearch: () => null })); +vi.mock("../SettleRailDialog", () => ({ SettleRailDialog: () => null })); +vi.mock("./components", () => ({ + RailsEmptyInitial: () => null, + RailsEmptyNoResults: () => null, + RailsErrorState: () => null, + RailsLoadingState: () => null, + RailsTable: () =>
Rails
, +})); +vi.mock("./context/SettleRailContext", () => ({ + SettleRailProvider: ({ chainId, children }: { chainId: number; children: React.ReactNode }) => { + observed.chainId = chainId; + return children; + }, +})); + +describe("RailsSection display network", () => { + beforeEach(() => { + observed.chainId = 0; + }); + + it("uses the explicit display chain for rail epochs", () => { + renderToStaticMarkup( + , + ); + + expect(observed.chainId).toBe(314); + }); +}); diff --git a/apps/explorer/src/components/UserConsole/RailsSection/index.tsx b/apps/explorer/src/components/UserConsole/RailsSection/index.tsx index 444614cb..dd103c3e 100644 --- a/apps/explorer/src/components/UserConsole/RailsSection/index.tsx +++ b/apps/explorer/src/components/UserConsole/RailsSection/index.tsx @@ -7,12 +7,11 @@ import { PaginationNext, PaginationPrevious, } from "@filecoin-pay/ui/components/pagination"; -import { useMemo, useState } from "react"; -import { useChainId } from "wagmi"; +import { useCallback, useMemo, useState } from "react"; import { getChain } from "@/constants/chains"; import { useAccountRails } from "@/hooks/useAccountDetails"; import { useRailSettlements } from "@/hooks/useRailSettlements"; -import { getNetworkFromChainId } from "@/utils/network"; +import type { Network } from "@/types"; import { RailsSearch, type SearchFilterType } from "../RailsSearch"; import { SettleRailDialog } from "../SettleRailDialog"; import { RailsEmptyInitial, RailsEmptyNoResults, RailsErrorState, RailsLoadingState, RailsTable } from "./components"; @@ -21,26 +20,21 @@ import type { RailTableRow } from "./types"; interface RailsSectionProps { account: Account; + network: Network; userAddress: string; } -export const RailsSection: React.FC = ({ account, userAddress }) => { +export const RailsSection: React.FC = ({ account, network, userAddress }) => { const [page, setPage] = useState(1); const [searchQuery, setSearchQuery] = useState(""); const [searchFilter, setSearchFilter] = useState("railId"); const [settleDialogOpen, setSettleDialogOpen] = useState(false); const [selectedRail, setSelectedRail] = useState(null); + const [currentEpoch, setCurrentEpoch] = useState(); - const chainId = useChainId(); - const { chain, walletNetwork } = useMemo(() => { - const walletNetwork = getNetworkFromChainId(chainId); - return { - walletNetwork, - chain: getChain(walletNetwork), - }; - }, [chainId]); + const chain = useMemo(() => getChain(network), [network]); - const { data, isLoading, isError } = useAccountRails(account.id, page, { networkOverride: walletNetwork }); + const { data, isLoading, isError } = useAccountRails(account.id, page, { networkOverride: network }); const { settleRail, isSettling, settlements } = useRailSettlements({ contractAddress: chain.contracts.payments.address, @@ -48,10 +42,11 @@ export const RailsSection: React.FC = ({ account, userAddress explorerUrl: chain.blockExplorers?.default.url, }); - const handleSettle = (rail: Rail) => { + const handleSettle = useCallback((rail: Rail, epoch: bigint | undefined) => { setSelectedRail(rail); + setCurrentEpoch(epoch); setSettleDialogOpen(true); - }; + }, []); const handleSearch = (query: string, filterType: SearchFilterType) => { setSearchQuery(query.toLowerCase()); @@ -84,12 +79,11 @@ export const RailsSection: React.FC = ({ account, userAddress }); }, [data, searchQuery, searchFilter]); - // Prepare table data with userAddress, settlement state, and isPayer calculation + // Prepare table data with settlement state and the user's role. const tableData = useMemo( () => filteredRails.map((rail) => ({ ...rail, - userAddress, isPayer: rail.payer.address.toLowerCase() === userAddress.toLowerCase(), isSettling: settlements.has(rail.railId.toString()), })), @@ -125,7 +119,7 @@ export const RailsSection: React.FC = ({ account, userAddress ) : ( <> - + @@ -173,6 +167,7 @@ export const RailsSection: React.FC = ({ account, userAddress void; - isSettling?: boolean; - settleRail: (params: SettleRailParams) => Promise; -} - -export const SettleRailDialog: React.FC = ({ - rail, - userAddress, - open, - onOpenChange, - isSettling = false, - settleRail, -}) => { - const { - isPayer, - currentEpoch, - settledUptoEpoch, - epochsSinceLastSettlement, - expectedSettleAmount, - isLoadingBlockNumber, - } = useRailSettlementCalculations(rail, userAddress); - - const canSettle = !isSettling && !isLoadingBlockNumber && epochsSinceLastSettlement > 0n; - const readyCurrentEpoch = isLoadingBlockNumber || currentEpoch === 0n ? undefined : currentEpoch; - const epochsToSettleText = - readyCurrentEpoch === undefined ? "Loading..." : formatEpochDuration(epochsSinceLastSettlement); - - const handleSettle = async () => { - if (isLoadingBlockNumber || currentEpoch === 0n) { - return; - } - try { - await settleRail({ - railId: rail.railId, - paymentRate: BigInt(rail.paymentRate), - tokenSymbol: rail.token.symbol, - tokenDecimals: Number(rail.token.decimals), - settledUpto: settledUptoEpoch, - }); - // Close dialog immediately after initiating settlement - onOpenChange(false); - } catch { - // Error already handled by useRailSettlements hook with toast notifications - } - }; - - return ( - - - - Settle Rail #{rail.railId.toString()} - Confirm settlement for all pending payments up to the current epoch. - - -
- {/* Rail Overview */} -
-
- - {isPayer ? "Payer" : "Payee"} - - -
-
Operator: {formatAddress(rail.operator.address)}
-
- - {/* Settlement Information */} -
-
-
- Current Epoch: -
- - {readyCurrentEpoch !== undefined && ( -
Epoch {currentEpoch.toString()}
- )} -
-
-
- Settled Up To: -
- - {readyCurrentEpoch !== undefined && ( -
Epoch {settledUptoEpoch.toString()}
- )} -
-
-
- Epochs to Settle: -
-
{epochsToSettleText}
- {readyCurrentEpoch !== undefined && ( -
{epochsSinceLastSettlement.toString()} epochs
- )} -
-
-
-
- Payment Rate: - - {formatToken( - BigInt(rail.paymentRate) * TIME_CONSTANTS.EPOCHS_PER_DAY, - rail.token.decimals, - `${rail.token.symbol}/day`, - 12, - )} - -
-
- Historical Settled: - - {formatToken(rail.totalSettledAmount, rail.token.decimals, rail.token.symbol, 8)} - -
-
-
- Expected Amount: - - {isLoadingBlockNumber - ? "Loading..." - : formatToken(expectedSettleAmount, rail.token.decimals, rail.token.symbol, 8)} - -
-
-
- - {/* Estimate Disclaimer */} -
- -

- Amount is an estimate and may vary on-chain based on rate changes and network conditions. -

-
- - {/* Warning */} - {epochsSinceLastSettlement === 0n && ( -
- -

- The rail is already settled up to the current epoch. -

-
- )} -
- - - - - - -
- ); -}; diff --git a/apps/explorer/src/components/UserConsole/SettleRailDialog/SettlementDetails.tsx b/apps/explorer/src/components/UserConsole/SettleRailDialog/SettlementDetails.tsx new file mode 100644 index 00000000..269ee3fe --- /dev/null +++ b/apps/explorer/src/components/UserConsole/SettleRailDialog/SettlementDetails.tsx @@ -0,0 +1,106 @@ +import type { Rail } from "@filecoin-pay/types"; +import { TIME_CONSTANTS } from "@filoz/synapse-sdk"; +import { formatEpochDuration, formatToken, formatTokenTruncated } from "@/utils/formatter"; +import { EpochTimeCell } from "../../shared"; +import type { SettlementAmountState } from "./useSettleRailDialog"; + +interface EpochRowProps { + label: string; + epoch: bigint; + currentEpoch: bigint | undefined; +} + +const EpochRow = ({ label, epoch, currentEpoch }: EpochRowProps) => ( +
+ {label}: +
+ + {currentEpoch !== undefined &&
Epoch {epoch.toString()}
} +
+
+); + +function formatSettlementAmount(state: SettlementAmountState, rail: Rail): string { + switch (state.status) { + case "loading": + return "Loading..."; + case "error": + return "Unavailable"; + case "ready": + return formatTokenTruncated(state.amount, rail.token.decimals, rail.token.symbol, 8); + case "unavailable": + return "—"; + } +} + +interface SettlementDetailsProps { + rail: Rail; + currentEpoch: bigint | undefined; + untilEpoch: bigint | undefined; + settledUptoEpoch: bigint; + epochsSinceLastSettlement: bigint; + settlementAmountState: SettlementAmountState; +} + +export const SettlementDetails = ({ + rail, + currentEpoch, + untilEpoch, + settledUptoEpoch, + epochsSinceLastSettlement, + settlementAmountState, +}: SettlementDetailsProps) => { + const epochsToSettleText = currentEpoch === undefined ? "Loading..." : formatEpochDuration(epochsSinceLastSettlement); + + return ( +
+
+ + + +
+ Epochs to Settle: +
+
{epochsToSettleText}
+ {currentEpoch !== undefined && ( +
{epochsSinceLastSettlement.toString()} epochs
+ )} +
+
+ +
+ +
+ Payment Rate: + + {formatToken( + BigInt(rail.paymentRate) * TIME_CONSTANTS.EPOCHS_PER_DAY, + rail.token.decimals, + `${rail.token.symbol}/day`, + 12, + )} + +
+ +
+ Historical Settled: + + {formatToken(rail.totalSettledAmount, rail.token.decimals, rail.token.symbol, 8)} + +
+ +
+ +
+ Settlement Amount: + {formatSettlementAmount(settlementAmountState, rail)} +
+
+
+ ); +}; diff --git a/apps/explorer/src/components/UserConsole/SettleRailDialog/SettlementNotices.tsx b/apps/explorer/src/components/UserConsole/SettleRailDialog/SettlementNotices.tsx new file mode 100644 index 00000000..9e7dba94 --- /dev/null +++ b/apps/explorer/src/components/UserConsole/SettleRailDialog/SettlementNotices.tsx @@ -0,0 +1,36 @@ +import { AlertCircle, Info } from "lucide-react"; +import type { SettlementAmountState } from "./useSettleRailDialog"; + +interface SettlementNoticesProps { + settlementAmountStatus: SettlementAmountState["status"]; + showNoUnsettledWarning: boolean; +} + +export const SettlementNotices = ({ settlementAmountStatus, showNoUnsettledWarning }: SettlementNoticesProps) => ( + <> +
+ +

+ Calculated from the current on-chain state. The amount may change before confirmation. +

+
+ + {settlementAmountStatus === "error" && ( +
+ +

+ Unable to calculate the settlement amount. Close the dialog and try again. +

+
+ )} + + {showNoUnsettledWarning && ( +
+ +

+ The rail is already settled up to the selected epoch. +

+
+ )} + +); diff --git a/apps/explorer/src/components/UserConsole/SettleRailDialog/index.tsx b/apps/explorer/src/components/UserConsole/SettleRailDialog/index.tsx new file mode 100644 index 00000000..6e47fe3a --- /dev/null +++ b/apps/explorer/src/components/UserConsole/SettleRailDialog/index.tsx @@ -0,0 +1,96 @@ +import { Button } from "@filecoin-foundation/ui-filecoin/Button"; +import type { Rail } from "@filecoin-pay/types"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@filecoin-pay/ui/components/dialog"; +import { formatAddress } from "@/utils/formatter"; +import { InlineTextLoader, RailStateBadge, RoleIndicator } from "../../shared"; +import { SettlementDetails } from "./SettlementDetails"; +import { SettlementNotices } from "./SettlementNotices"; +import { type SettleRail, useSettleRailDialog } from "./useSettleRailDialog"; + +interface SettleRailDialogProps { + rail: Rail; + userAddress: string; + currentEpoch?: bigint; + open: boolean; + onOpenChange: (open: boolean) => void; + isSettling?: boolean; + settleRail: SettleRail; +} + +export const SettleRailDialog: React.FC = ({ + rail, + userAddress, + currentEpoch, + open, + onOpenChange, + isSettling = false, + settleRail, +}) => { + const settlement = useSettleRailDialog({ + rail, + userAddress, + currentEpoch, + open, + isSettling, + settleRail, + onSettled: () => onOpenChange(false), + }); + + const confirmButtonContent = isSettling ? : "Confirm"; + + return ( + + + + Settle Rail #{rail.railId.toString()} + Confirm settlement for all pending payments up to the selected epoch. + + +
+
+
+ + +
+
Operator: {formatAddress(rail.operator.address)}
+
+ + + + +
+ + + + + +
+
+ ); +}; diff --git a/apps/explorer/src/components/UserConsole/SettleRailDialog/useSettleRailDialog.ts b/apps/explorer/src/components/UserConsole/SettleRailDialog/useSettleRailDialog.ts new file mode 100644 index 00000000..359a9297 --- /dev/null +++ b/apps/explorer/src/components/UserConsole/SettleRailDialog/useSettleRailDialog.ts @@ -0,0 +1,118 @@ +import type { Rail } from "@filecoin-pay/types"; +import { toast } from "sonner"; +import type { Hex } from "viem"; +import { useRailSettlementAmounts } from "@/hooks/useRailSettlementAmounts"; +import type { SettleRailParams } from "@/hooks/useRailSettlements"; +import { + getRailSettlementEligibility, + getRailSettlementUnavailableReason, + getSettlementUntilEpoch, + getUnsettledEpochs, +} from "@/utils/railSettlement"; + +export type SettlementAmountState = + | { status: "unavailable"; reason: string } + | { status: "loading"; reason: string } + | { status: "error"; reason: string } + | { status: "ready"; amount: bigint; untilEpoch: bigint }; + +export type SettleRail = (params: SettleRailParams) => Promise; + +interface UseSettleRailDialogOptions { + rail: Rail; + userAddress: string; + currentEpoch: bigint | undefined; + open: boolean; + isSettling: boolean; + settleRail: SettleRail; + onSettled: () => void; +} + +export function useSettleRailDialog({ + rail, + userAddress, + currentEpoch, + open, + isSettling, + settleRail, + onSettled, +}: UseSettleRailDialogOptions) { + const railId = BigInt(rail.railId); + const settledUptoEpoch = BigInt(rail.settledUpto); + const untilEpoch = + currentEpoch !== undefined ? getSettlementUntilEpoch(BigInt(rail.endEpoch), currentEpoch) : undefined; + const epochsSinceLastSettlement = getUnsettledEpochs(rail, untilEpoch); + const settlementEligibility = getRailSettlementEligibility(rail, currentEpoch); + const isSettlementAllowed = settlementEligibility.status === "allowed"; + + const { + data: settlementAmounts, + isError, + isFetching, + isPending, + } = useRailSettlementAmounts({ + railId, + untilEpoch, + enabled: open && isSettlementAllowed, + }); + + let settlementAmountState: SettlementAmountState; + if (!isSettlementAllowed) { + settlementAmountState = { + status: "unavailable", + reason: getRailSettlementUnavailableReason(settlementEligibility), + }; + } else if (isSettling) { + settlementAmountState = { status: "unavailable", reason: "A settlement is already in progress." }; + } else if (open && (isPending || isFetching)) { + settlementAmountState = { status: "loading", reason: "The settlement amount is still being calculated." }; + } else if (isError) { + settlementAmountState = { + status: "error", + reason: "Unable to calculate the settlement amount. Close the dialog and try again.", + }; + } else if (settlementAmounts !== undefined) { + settlementAmountState = { + status: "ready", + amount: settlementAmounts.totalSettledAmount, + untilEpoch: settlementEligibility.untilEpoch, + }; + } else { + settlementAmountState = { status: "unavailable", reason: "The settlement amount is unavailable." }; + } + + const canSettle = isSettlementAllowed && !isSettling && settlementAmountState.status === "ready"; + const role: "payer" | "payee" = rail.payer.address.toLowerCase() === userAddress.toLowerCase() ? "payer" : "payee"; + + const handleSettle = async () => { + if (settlementAmountState.status !== "ready") { + toast.error("Unable to settle", { description: settlementAmountState.reason }); + return; + } + + try { + await settleRail({ + railId, + untilEpoch: settlementAmountState.untilEpoch, + settlementAmount: settlementAmountState.amount, + tokenSymbol: rail.token.symbol, + tokenDecimals: Number(rail.token.decimals), + }); + onSettled(); + } catch { + // useRailSettlements reports transaction errors to the user. + } + }; + + return { + role, + currentEpoch, + untilEpoch, + settledUptoEpoch, + epochsSinceLastSettlement, + settlementEligibility, + settlementAmountState, + canSettle, + handleSettle, + }; +} diff --git a/apps/explorer/src/components/UserConsole/States/AccountNotFound.tsx b/apps/explorer/src/components/UserConsole/States/AccountNotFound.tsx index 1c9c9419..48b955b6 100644 --- a/apps/explorer/src/components/UserConsole/States/AccountNotFound.tsx +++ b/apps/explorer/src/components/UserConsole/States/AccountNotFound.tsx @@ -46,7 +46,8 @@ const AccountNotFound = () => {
{/* Deposit Dialog */} - + {/* This account has no indexed tokens by definition, so the picker only offers address entry. */} + {/* Deposit and Approve Dialog */} diff --git a/apps/explorer/src/components/UserConsole/TopUpActivityContext.tsx b/apps/explorer/src/components/UserConsole/TopUpActivityContext.tsx new file mode 100644 index 00000000..763e6eea --- /dev/null +++ b/apps/explorer/src/components/UserConsole/TopUpActivityContext.tsx @@ -0,0 +1,23 @@ +"use client"; + +import { createContext, type ReactNode, useContext, useMemo, useState } from "react"; + +type TopUpActivity = { + isTopUpActive: boolean; + setTopUpActive: (active: boolean) => void; +}; + +const TopUpActivityContext = createContext(null); + +export function TopUpActivityProvider({ children }: { children: ReactNode }) { + const [isTopUpActive, setTopUpActive] = useState(false); + const value = useMemo(() => ({ isTopUpActive, setTopUpActive }), [isTopUpActive]); + + return {children}; +} + +export function useTopUpActivity(): TopUpActivity { + const activity = useContext(TopUpActivityContext); + if (!activity) throw new Error("useTopUpActivity must be used within TopUpActivityProvider"); + return activity; +} diff --git a/apps/explorer/src/components/UserConsole/index.ts b/apps/explorer/src/components/UserConsole/index.ts index 5ff522d3..c1247f31 100644 --- a/apps/explorer/src/components/UserConsole/index.ts +++ b/apps/explorer/src/components/UserConsole/index.ts @@ -1,10 +1,10 @@ export { AccountInfo, AccountInfoSkeleton } from "./AccountInfo"; export { AlertsBanner } from "./AlertsBanner"; -export { AlertsStatus } from "./AlertsStatus"; export { ApproveOperatorDialog } from "./ApproveOperatorDialog"; export { BetaWarning } from "./BetaWarning"; export { DepositDialog } from "./DepositDialog"; export { FundsSection } from "./FundsSection"; +export { TopUpDialogController } from "./FundsSection/TopUpDialogController"; export { IncreaseApprovalDialog } from "./IncreaseApprovalDialog"; export { OperatorApprovalsSection } from "./OperatorApprovalsSection"; export { RailsSearch } from "./RailsSearch"; diff --git a/apps/explorer/src/components/shared/Balance.tsx b/apps/explorer/src/components/shared/Balance.tsx index 4a1896c4..d1836c7f 100644 --- a/apps/explorer/src/components/shared/Balance.tsx +++ b/apps/explorer/src/components/shared/Balance.tsx @@ -67,7 +67,7 @@ const Balance = () => { return ( -