diff --git a/README.md b/README.md index b43fbca..2d820e1 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ ## ๐Ÿงช Quick Start -1. **Try the Demo:** [๐ŸŒ Live Demo](https://bio-verify-ai-dapp.vercel.app/) +1. **Try the Demo:** [๐ŸŒ Live Demo](https://bio-verify-agentic-dapp.vercel.app) 2. **Get Testnet Sepolia ETH:** [Sepolia Faucet](https://sepolia-faucet.pk910.de/) 3. **Swap for Base Sepolia ETH:** [Superbridge](https://superbridge.app/base-sepolia) (only if you want to use Base) 4. **Connect your wallet** to the DApp (Base Sepolia or Ethereum Sepolia) @@ -71,7 +71,7 @@ graph TD ### Event-Driven Data Flow -The contract uses a getter-less design: all state mutations emit events. These are projected off-chain into a Postgres read model, which powers all frontend queries. No on-chain reads required. In parallel, the frontend subscribes to `NewPublicationStatus` events via standalone viem WebSocket clients (Alchemy WSS), independent of wallet connection state, debouncing cache invalidations so the publications table stays in sync without polling. +The contract uses a getter-less design: all state mutations emit events. These are projected off-chain into a Postgres read model, which powers all frontend queries. No on-chain reads required. In parallel, the frontend subscribes to `NewPublicationStatus` events via standalone viem WebSocket clients (Alchemy WSS), independent of wallet connection state, debouncing cache invalidations so the publications list, global stats strip, and related TanStack Query caches stay in sync without polling the table. ```mermaid graph LR @@ -244,8 +244,8 @@ pnpm lint:format # Biome format | Network | Contract Address | |:--------|:-----------------| -| **[Base Sepolia](https://sepolia.basescan.org/address/0xf569d7b5016de6ef0f16fcae82d85d61249df31d)** | `0xf569D7b5016DE6eF0F16FCAe82d85d61249Df31d` | -| **[Ethereum Sepolia](https://sepolia.etherscan.io/address/0xfce6990d551a60f8640a498b6bc34a15822ba3e3)** | `0xfce6990D551a60F8640a498B6bC34A15822BA3e3` | +| **[Base Sepolia](https://sepolia.basescan.org/address/0x76654c2cdadcf869e78928f0785797b6be20f11b)** | `0x76654c2cdadcf869e78928f0785797b6be20f11b` | +| **[Ethereum Sepolia](https://sepolia.etherscan.io/address/0x7d52170db31be4ab3d0166fbba937a031dc6e1ff)** | `0x7d52170db31be4ab3d0166fbba937a031dc6e1ff` | ## Design Decisions & Roadmap diff --git a/apps/fe/README.md b/apps/fe/README.md index 75dc949..c0b086c 100644 --- a/apps/fe/README.md +++ b/apps/fe/README.md @@ -60,7 +60,7 @@ graph TD Two provider trees serve different route groups: - **`RootProviders`** (home): ThemeProvider -> CustomWagmiProvider (Reown AppKit + TanStack QueryClientProvider + NuqsAdapter) -> TooltipProvider -- **`SideProvider`** (publications): Same stack plus `AppSideBarProvider` wrapping a collapsible sidebar layout +- **`SideProvider`** (publications): Same stack plus `AppSideBarProvider` wrapping a collapsible sidebar layout, and **`PublicationsRealtimeProvider`** (under `app/(routes)/publications/layout.tsx`) which mounts `useWatchNewPublicationStatusEvent` so Alchemy WSS subscriptions stay active on every `/publications/*` route (list, new submission, detail, assignments) `CustomWagmiProvider` initializes Reown AppKit with `baseSepolia` and `sepolia` networks, configures Alchemy HTTP transports for wallet-connected operations, and hydrates wallet state from cookies for SSR. @@ -85,9 +85,9 @@ sequenceDiagram TQ-->>Client: live data with smart polling ``` -**Smart polling**: Query hooks like `usePublicationDetail` use a dynamic `refetchInterval` that polls every 5 seconds while a publication is in a pending state, and automatically stops polling once it reaches a terminal status (`PUBLISHED`, `SLASHED`, or `EARLY_SLASHED`). The `PublicationDetailsProvider` context wraps this pattern, exposing live publication data and a syncing indicator to all child components. +**Smart polling**: Query hooks like `usePublicationDetail` use a dynamic `refetchInterval` that polls every **10 seconds** while a publication is in a pending state, and automatically stops polling once it reaches a terminal status (`PUBLISHED`, `SLASHED`, or `EARLY_SLASHED`). This complements WebSocket-driven status updates: reviewer assignment (`Agent_PickReviewers`) and per-review progress (`Agent_RecordReview`) do not emit `NewPublicationStatus`, so polling still refreshes detail data between status transitions. The `PublicationDetailsProvider` context wraps this pattern, exposing live publication data and a syncing indicator to all child components. -**WebSocket cache invalidation**: The `/publications` table uses `useWatchNewPublicationStatusEvent` to subscribe to `NewPublicationStatus` on-chain events on both chains via standalone viem WebSocket clients (`eth_subscribe` over Alchemy WSS), independent of wagmi's wallet connection state. This means all visitors see real-time updates โ€” even without a connected wallet. When events arrive, a debounced invalidation (3-second delay for CQRS eventual consistency) triggers a TanStack Query refetch, keeping the table in sync without polling or manual refresh. +**WebSocket cache invalidation**: `PublicationsRealtimeProvider` calls `useWatchNewPublicationStatusEvent` to subscribe to `NewPublicationStatus` on both chains via standalone viem WebSocket clients (`eth_subscribe` over Alchemy WSS), independent of wagmi's wallet connection state. When events arrive, a debounced invalidation (3-second delay for CQRS eventual consistency) triggers TanStack Query refetches for **`publicationsKeys.all`** (list + detail queries under that prefix) and **`statsKeys.all`** (global stats strip on `/publications`), so the table and metric cards stay in sync without manual refresh. The publications list query also uses `staleTime: 0` so navigating back to `/publications` always background-refetches. ### CQRS Bridge @@ -130,9 +130,9 @@ The server action verifies the EIP-712 signature, then resumes the LangGraph rev | Route | Description | |-------|-------------| | `/` | Landing page with protocol mechanism walkthrough and CTAs | -| `/publications` | Server-side paginated and filtered publications table. nuqs syncs filters (chain, status, page) to URL search params; server component passes them to the CQRS query. TanStack Table in manual mode. Real-time updates via WebSocket subscription to `NewPublicationStatus` on-chain events. | +| `/publications` | Server-side paginated and filtered publications table plus global stats cards. nuqs syncs filters (chain, status, page) to URL search params; server component passes them to the CQRS query. TanStack Table in manual mode. Real-time updates: layout-mounted WebSocket on `NewPublicationStatus` invalidates publications + global stats (debounced). | | `/publications/new` | Submit publication form (react-hook-form + zod, IPFS manifest pinning via Pinata, on-chain `submitPublication`) | -| `/publications/[chainId]/[pubId]` | Publication detail with live smart-polling, verdict timeline, economics sidebar, participants list | +| `/publications/[chainId]/[pubId]` | Publication detail with live smart-polling (10s while non-terminal), verdict timeline, economics sidebar, participants list | | `/publications/[chainId]/[pubId]/review` | Reviewer form with EIP-712 signing and agent handoff | | `/publications/assignments` | Reviewer dashboard: stake management, assigned publications table, review status tracking | @@ -145,8 +145,8 @@ The server action verifies the EIP-712 signature, then resumes the LangGraph rev ## Key Patterns -- **Smart polling** -- `refetchInterval` dynamically stops when a publication reaches a terminal status, eliminating unnecessary network requests -- **WebSocket real-time invalidation** -- `useWatchNewPublicationStatusEvent` subscribes to `NewPublicationStatus` on-chain events via standalone viem WebSocket clients (Alchemy WSS) on both chains, independent of wallet state; rapid-fire events are debounced into a single TanStack Query cache invalidation (3-second delay for CQRS eventual consistency) +- **Smart polling** -- `refetchInterval` (10s while non-terminal) dynamically stops when a publication reaches a terminal status, covering DB fields not tied to `NewPublicationStatus` +- **WebSocket real-time invalidation** -- `useWatchNewPublicationStatusEvent` (via `PublicationsRealtimeProvider` in the publications layout) subscribes to `NewPublicationStatus` on-chain events via standalone viem WebSocket clients (Alchemy WSS) on both chains, independent of wallet state; rapid-fire events are debounced into TanStack Query invalidations for publications and global stats (3-second delay for CQRS eventual consistency) - **Optimistic updates + delayed invalidation** -- immediate UI feedback on transactions, with a 3-second delayed cache invalidation to account for Alchemy webhook -> CQRS projection latency - **Server-first data loading** -- RSC fetches from Neon via `@packages/cqrs`, hydrates client hooks via `initialData` for zero-loading-state initial renders - **`"use server"` CQRS bridge** -- all Drizzle DB queries stay server-only, exposed to client hooks through Next.js Server Actions diff --git a/apps/fe/_hooks/cqrs/queries/use-publication-detail.ts b/apps/fe/_hooks/cqrs/queries/use-publication-detail.ts index b36a4a9..f26aae4 100644 --- a/apps/fe/_hooks/cqrs/queries/use-publication-detail.ts +++ b/apps/fe/_hooks/cqrs/queries/use-publication-detail.ts @@ -19,7 +19,8 @@ export const usePublicationDetail = (params: Params) => { initialData, enabled: !!id, - // Smart Polling: Only poll every 5s if the publication is NOT finalized + // Smart Polling: Only poll every 10s if the publication is NOT finalized + // (WebSocket handles NewPublicationStatus; polling covers Agent_PickReviewers) refetchInterval: (query) => { const status = query.state.data?.status const isFinalized = @@ -27,7 +28,7 @@ export const usePublicationDetail = (params: Params) => { status === PublicationStatusSchema.enum.SLASHED || status === PublicationStatusSchema.enum.EARLY_SLASHED - return isFinalized ? false : 5_000 // Poll every 5s if NOT finalized + return isFinalized ? false : 10_000 }, }) diff --git a/apps/fe/_hooks/cqrs/queries/use-publications.ts b/apps/fe/_hooks/cqrs/queries/use-publications.ts index 281575a..a7c7bd9 100644 --- a/apps/fe/_hooks/cqrs/queries/use-publications.ts +++ b/apps/fe/_hooks/cqrs/queries/use-publications.ts @@ -23,6 +23,7 @@ export const usePublications = (props: Props) => { queryKey: publicationsKeys.byQueryParams(searchQueryParams), queryFn: () => getPublications(searchQueryParams), initialData, + staleTime: 0, }) return { data, isFetching, isError, refetch } diff --git a/apps/fe/_hooks/websockets/use-watch-new-publication-status-event.ts b/apps/fe/_hooks/websockets/use-watch-new-publication-status-event.ts index 5978bab..5088b97 100644 --- a/apps/fe/_hooks/websockets/use-watch-new-publication-status-event.ts +++ b/apps/fe/_hooks/websockets/use-watch-new-publication-status-event.ts @@ -4,7 +4,7 @@ * @title Real-Time Publication Status Watcher * @notice Subscribes to on-chain `NewPublicationStatus` events via WebSocket * (eth_subscribe) on all supported chains, then invalidates the TanStack Query - * publications cache so the UI refreshes automatically. + * publications and stats caches so the list and metric cards refresh together. * * @dev Uses standalone viem WebSocket clients (not wagmi) so that subscriptions * remain active regardless of the user's wallet connection state. This is @@ -30,6 +30,7 @@ import { useQueryClient } from "@tanstack/react-query" import { useCallback, useEffect, useRef } from "react" import type { Log, WatchContractEventReturnType } from "viem" import { publicationsKeys } from "../cqrs/query-keys/publications-keys" +import { statsKeys } from "../cqrs/query-keys/stats-keys" type NewPublicationStatusArgs = { pubId?: bigint @@ -77,9 +78,14 @@ export const useWatchNewPublicationStatusEvent = () => { queryClient.invalidateQueries({ queryKey: publicationsKeys.all, }) + queryClient.invalidateQueries({ + queryKey: statsKeys.all, + }) if (process.env.NODE_ENV === "development") { - console.log(`[WS] Cache invalidated for pubIds: ${pubIds.join(", ")}`) + console.log( + `[WS] Cache invalidated (publications + stats) for pubIds: ${pubIds.join(", ")}`, + ) } }, INVALIDATION_DELAY_MS) }, diff --git a/apps/fe/app/(routes)/publications/_components/publications-realtime-provider.tsx b/apps/fe/app/(routes)/publications/_components/publications-realtime-provider.tsx new file mode 100644 index 0000000..ee38f3c --- /dev/null +++ b/apps/fe/app/(routes)/publications/_components/publications-realtime-provider.tsx @@ -0,0 +1,12 @@ +"use client" + +import { useWatchNewPublicationStatusEvent } from "@/_hooks/websockets/use-watch-new-publication-status-event" +import type { FC, PropsWithChildren } from "react" + +export const PublicationsRealtimeProvider: FC = ({ + children, +}) => { + useWatchNewPublicationStatusEvent() + + return <>{children} +} diff --git a/apps/fe/app/(routes)/publications/_components/table/publications-table-container.tsx b/apps/fe/app/(routes)/publications/_components/table/publications-table-container.tsx index c806744..d1cc8cb 100644 --- a/apps/fe/app/(routes)/publications/_components/table/publications-table-container.tsx +++ b/apps/fe/app/(routes)/publications/_components/table/publications-table-container.tsx @@ -1,7 +1,6 @@ "use client" import { usePublications } from "@/_hooks/cqrs/queries/use-publications" -import { useWatchNewPublicationStatusEvent } from "@/_hooks/websockets/use-watch-new-publication-status-event" import { FetchError } from "@/app/_components/fetch-error" import type { PublicationsQueryParams } from "@packages/cqrs" import type { PublicationsResponse } from "@packages/schema" @@ -16,10 +15,7 @@ type Props = { export const PublicationsTableContainer = (props: Props) => { const { initialData, searchQueryParams } = props - // Alchemy Websockets (invalidate tanstack query publications keys on NewPublicationStatus on-chain event) - useWatchNewPublicationStatusEvent() - - // Fetch publications data + // Fetch publications data (WebSocket invalidation lives in PublicationsRealtimeProvider in layout) const { data: publicationsResponse, isFetching, diff --git a/apps/fe/app/(routes)/publications/layout.tsx b/apps/fe/app/(routes)/publications/layout.tsx index 0229141..e742e15 100644 --- a/apps/fe/app/(routes)/publications/layout.tsx +++ b/apps/fe/app/(routes)/publications/layout.tsx @@ -1,6 +1,7 @@ +import { SideProvider } from "@/_context/side-provider" import { headers } from "next/headers" import { Toaster } from "sonner" -import { SideProvider } from "@/_context/side-provider" +import { PublicationsRealtimeProvider } from "./_components/publications-realtime-provider" export default async function Layout({ children, @@ -14,11 +15,13 @@ export default async function Layout({ return ( - {breadcrumbs} -
- {children} - -
+ + {breadcrumbs} +
+ {children} + +
+
) }