diff --git a/README.md b/README.md index e254233..8b733ee 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,17 @@ ![CI-Foundry](https://github.com/SiegfriedBz/BioVerify_Agentic_DApp/actions/workflows/foundry-tests.yml/badge.svg) -# BioVerify +# ๐Ÿงฌ BioVerify + +## ๐Ÿงช Quick Start + +1. **Try the Demo:** [๐ŸŒ Live Demo](https://bio-verify-ai-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) +5. **Submit a publication** and/or **register as a reviewer** and let the agents do the rest! + +--- **Durable AI Agent Protocol for Scientific Integrity** @@ -46,6 +56,7 @@ graph TD BC -- "emits events" --> Alchemy Alchemy -- "POST webhook" --> FE FE -- "processContractEvent" --> CQRS + BC -- "WSS (NewPublicationStatus)" --> FE CQRS -- "upserts / queries" --> DB CQRS -- "viem contract calls" --> BC FE -- "serves Inngest functions" --> Inngest @@ -60,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. +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. ```mermaid graph LR @@ -141,7 +152,7 @@ pnpm workspaces with two apps and seven packages. ``` apps/ contracts/ BioVerifyV3 Solidity contract (Foundry) โ€” staking, VRF, settlement - fe/ Next.js 16 frontend โ€” DApp UI, webhook API, Inngest serving + fe/ Next.js 16 frontend โ€” DApp UI, webhook API, Inngest serving, WSS event subscriptions packages/ agents/ LangGraph AI agents (submission + review) diff --git a/apps/fe/README.md b/apps/fe/README.md index 6f374d6..75dc949 100644 --- a/apps/fe/README.md +++ b/apps/fe/README.md @@ -1,4 +1,4 @@ -# BioVerify Frontend +# ๐Ÿงฌ BioVerify Frontend Next.js 16 App Router DApp for decentralized scientific peer review. Scientists submit publications on-chain, AI agents validate originality, Chainlink VRF selects peer reviewers, and human verdicts settle stakes -- all orchestrated through this interface. @@ -34,6 +34,7 @@ graph TD Chain -- "emits events" --> Alchemy Alchemy -- "POST webhook" --> FE FE -- "processContractEvent" --> CQRS + Chain -- "WSS (NewPublicationStatus)" --> FE CQRS -- "upserts / queries" --> NeonDB CQRS -- "viem contract calls" --> Chain FE -- "serves Inngest functions" --> Inngest @@ -61,7 +62,7 @@ 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 -`CustomWagmiProvider` initializes Reown AppKit with `baseSepolia` and `sepolia` networks, configures Alchemy RPC transports with WebSocket-first fallback, and hydrates wallet state from cookies for SSR. +`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. ### Data Flow @@ -86,6 +87,8 @@ sequenceDiagram **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. +**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. + ### CQRS Bridge The `_api/queries/index.ts` file is a `"use server"` re-export of all `@packages/cqrs` query functions. This makes server-only Drizzle queries callable from client-side TanStack Query hooks through Next.js Server Actions, keeping database access strictly server-side while the client gets reactive data. @@ -127,7 +130,7 @@ 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. | +| `/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/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]/review` | Reviewer form with EIP-712 signing and agent handoff | @@ -143,6 +146,7 @@ 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) - **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/_config/viem/ws-clients.ts b/apps/fe/_config/viem/ws-clients.ts new file mode 100644 index 0000000..a02b4f3 --- /dev/null +++ b/apps/fe/_config/viem/ws-clients.ts @@ -0,0 +1,22 @@ +import { env } from "@packages/env" +import { createPublicClient, webSocket } from "viem" +import { baseSepolia, sepolia } from "viem/chains" + +/** + * @title Standalone viem WebSocket Client Factories + * @notice Creates wallet-independent public clients for real-time event subscriptions. + * @dev Called inside useEffect to guarantee browser-only execution (no SSR). + * Each factory returns a fresh client; callers should cache via ref if needed. + */ + +export const createBaseSepoliaWsClient = () => + createPublicClient({ + chain: baseSepolia, + transport: webSocket(env.NEXT_PUBLIC_ALCHEMY_BASE_SEPOLIA_WSS), + }) + +export const createEthSepoliaWsClient = () => + createPublicClient({ + chain: sepolia, + transport: webSocket(env.NEXT_PUBLIC_ALCHEMY_ETH_SEPOLIA_WSS), + }) diff --git a/apps/fe/_config/wagmi/wagmi-config.ts b/apps/fe/_config/wagmi/wagmi-config.ts index a25c71a..4e117fb 100644 --- a/apps/fe/_config/wagmi/wagmi-config.ts +++ b/apps/fe/_config/wagmi/wagmi-config.ts @@ -1,38 +1,58 @@ +/** + * @title Wagmi & Reown Configuration + * @notice This file configures the Web3 provider layer, handling multi-chain + * connectivity, replay protection, and server-side state hydration. + * * High-Reliability Features: + * 1. EIP-155 Replay Protection: Ensures transactions are chain-specific. + * 2. CAIP-2 Compliance: Universal chain identification for Reown/AppKit. + * 3. SSR Optimization: Cookie-based hydration to prevent UI "flicker." + */ + import { env } from "@packages/env" -import { baseSepolia, sepolia } from "@reown/appkit/networks" import { WagmiAdapter } from "@reown/appkit-adapter-wagmi" +import { baseSepolia, sepolia } from "@reown/appkit/networks" import { cookieStorage, createStorage } from "@wagmi/core" -import { fallback, http, webSocket } from "wagmi" +import { http } from "wagmi" -// Alchemy Https -const ALCHEMY_BASE_SEPOLIA_RPC_URL = - env.NEXT_PUBLIC_ALCHEMY_BASE_SEPOLIA_RPC_URL +// --- Alchemy Configuration --- +const ALCHEMY_BASE_SEPOLIA_RPC_URL = env.NEXT_PUBLIC_ALCHEMY_BASE_SEPOLIA_RPC_URL const ALCHEMY_ETH_SEPOLIA_RPC_URL = env.NEXT_PUBLIC_ALCHEMY_ETH_SEPOLIA_RPC_URL -// Alchemy Websockets -const ALCHEMY_BASE_SEPOLIA_WSS = env.NEXT_PUBLIC_ALCHEMY_BASE_SEPOLIA_WSS -const ALCHEMY_ETH_SEPOLIA_WSS = env.NEXT_PUBLIC_ALCHEMY_ETH_SEPOLIA_WSS - -// Rainbow kit const RAINBOWKIT_PROJECT_ID = env.NEXT_PUBLIC_RAINBOWKIT_PROJECT_ID || "" - const networks = [sepolia, baseSepolia] -// 1. Define the Custom RPC Map using CAIP-2 compliant IDs +/** + * @notice Custom RPC Map using CAIP-2 (Chain Agnostic Improvement Proposals) + * @dev CAIP-2 uses a `namespace:reference` format (e.g., "eip155:84532"). + * * Namespace (eip155): Identifies the EVM ecosystem based on EIP-155 standards, + * which prevent replay attacks by incorporating the Chain ID into the $v$ + * value of the ECDSA signature. + * * Mapping these here ensures the Reown/AppKit Modal uses our dedicated Alchemy + * nodes for balance checks instead of slower public RPCs. + */ export const customRpcUrls = { "eip155:84532": [ - // Base Sepolia { url: ALCHEMY_BASE_SEPOLIA_RPC_URL || baseSepolia.rpcUrls.default.http[0], }, ], "eip155:11155111": [ - // Eth Sepolia { url: ALCHEMY_ETH_SEPOLIA_RPC_URL || sepolia.rpcUrls.default.http[0] }, ], } -//Set up the Wagmi Adapter (Config) +/** + * @notice Wagmi Adapter Instance + * @dev This adapter bridges Wagmi hooks with the Reown (formerly WalletConnect) AppKit. + * * SSR & Storage: + * - `ssr: true`: Enables hydration-safe rendering. + * - `cookieStorage`: Persists session state in HTTP headers, allowing the server + * to recognize the user's wallet before the JS bundle loads, preventing UI flicker. + * * Transports: + * - HTTP-only via Alchemy. Real-time event subscriptions (WebSocket) are handled + * separately by standalone viem clients in `@/_config/viem/ws-clients.ts`, + * decoupled from wallet state so they work for all visitors. + */ export const wagmiAdapter = new WagmiAdapter({ storage: createStorage({ storage: cookieStorage, @@ -40,19 +60,11 @@ export const wagmiAdapter = new WagmiAdapter({ ssr: true, projectId: RAINBOWKIT_PROJECT_ID, networks, - // customRpcUrls passed for the Reown Modal customRpcUrls, - // Transports handle the logic for Wagmi Hooks (useReadContract, useWatchContractEvent) transports: { - [baseSepolia.id]: fallback([ - webSocket(ALCHEMY_BASE_SEPOLIA_WSS), - http(ALCHEMY_BASE_SEPOLIA_RPC_URL), - ]), - [sepolia.id]: fallback([ - webSocket(ALCHEMY_ETH_SEPOLIA_WSS), // Priority 1: Instant Event Pushes - http(ALCHEMY_ETH_SEPOLIA_RPC_URL), // Priority 2: Standard Request Backup - ]), + [baseSepolia.id]: http(ALCHEMY_BASE_SEPOLIA_RPC_URL), + [sepolia.id]: http(ALCHEMY_ETH_SEPOLIA_RPC_URL), }, }) -export const reownConfig = wagmiAdapter.wagmiConfig +export const reownConfig = wagmiAdapter.wagmiConfig \ No newline at end of file diff --git a/apps/fe/_hooks/cqrs/commands/use-claim.ts b/apps/fe/_hooks/cqrs/commands/use-claim.ts index ab55f08..3f7d634 100644 --- a/apps/fe/_hooks/cqrs/commands/use-claim.ts +++ b/apps/fe/_hooks/cqrs/commands/use-claim.ts @@ -1,11 +1,11 @@ "use client" +import { reownConfig } from "@/_config/wagmi/wagmi-config" import type { Member } from "@packages/schema" import { useMutation, useQueryClient } from "@tanstack/react-query" import { waitForTransactionReceipt, writeContract } from "@wagmi/core" import { toast } from "sonner" import { formatEther, parseEther } from "viem" -import { reownConfig } from "@/_config/wagmi/wagmi-config" import { useContractConfig } from "../../use-contract-config" import { membersKeys } from "../query-keys/members-keys" import { statsKeys } from "../query-keys/stats-keys" diff --git a/apps/fe/_hooks/cqrs/queries/use-publications.ts b/apps/fe/_hooks/cqrs/queries/use-publications.ts index c68ebb4..281575a 100644 --- a/apps/fe/_hooks/cqrs/queries/use-publications.ts +++ b/apps/fe/_hooks/cqrs/queries/use-publications.ts @@ -14,11 +14,13 @@ type Props = { } export const usePublications = (props: Props) => { - const { initialData = { items: [], totalCount: 0 }, searchQueryParams } = - props + const { + initialData = { items: [], totalCount: 0 }, + searchQueryParams = { limit: 10, offset: 0 }, + } = props const { data, isFetching, isError, refetch } = useQuery({ - queryKey: publicationsKeys.all, + queryKey: publicationsKeys.byQueryParams(searchQueryParams), queryFn: () => getPublications(searchQueryParams), initialData, }) diff --git a/apps/fe/_hooks/cqrs/query-keys/publications-keys.ts b/apps/fe/_hooks/cqrs/query-keys/publications-keys.ts index 132517c..3f54410 100644 --- a/apps/fe/_hooks/cqrs/query-keys/publications-keys.ts +++ b/apps/fe/_hooks/cqrs/query-keys/publications-keys.ts @@ -1,4 +1,8 @@ +import { PublicationsQueryParams } from "@packages/cqrs" + export const publicationsKeys = { all: ["publications"] as const, + byQueryParams: (query: PublicationsQueryParams) => + ["publications", query] as const, detail: (id: string) => [...publicationsKeys.all, "detail", id] as const, } 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 new file mode 100644 index 0000000..88cbfdc --- /dev/null +++ b/apps/fe/_hooks/websockets/use-watch-new-publication-status-event.ts @@ -0,0 +1,125 @@ +"use client" + +/** + * @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. + * + * @dev Uses standalone viem WebSocket clients (not wagmi) so that subscriptions + * remain active regardless of the user's wallet connection state. This is + * critical for the "spectator" use-case where User B sees real-time updates + * triggered by User A's submission without needing a connected wallet. + * + * Clients are created inside useEffect to guarantee browser-only execution + * (WebSocket is not available during SSR) and cached in a ref for the + * lifetime of the component. + * + * Rapid-fire events are coalesced via a trailing debounce to avoid redundant + * refetches when multiple status transitions arrive in quick succession + * (e.g. Submitted โ†’ InReview within the same block). + */ + +import { + createBaseSepoliaWsClient, + createEthSepoliaWsClient, +} from "@/_config/viem/ws-clients" +import { NetworkSchema } from "@packages/schema" +import { BioVerifyContractConfig } from "@packages/utils" +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" + +type NewPublicationStatusArgs = { + pubId?: bigint + newStatus?: number +} + +/** Trailing debounce window for coalescing rapid-fire events. */ +const INVALIDATION_DELAY_MS = 3_000 + +const baseSepoliaConfig = + BioVerifyContractConfig[NetworkSchema.enum.base_sepolia] +const ethSepoliaConfig = + BioVerifyContractConfig[NetworkSchema.enum.eth_sepolia] + +export const useWatchNewPublicationStatusEvent = () => { + const queryClient = useQueryClient() + + const debounceTimerRef = useRef(null) + + const handleLogs = useCallback( + (logs: Log[]) => { + if (logs.length === 0) return + + const typedLogs = logs as (Log & { + args?: NewPublicationStatusArgs + })[] + const pubIds = typedLogs + .map((log) => log.args?.pubId?.toString()) + .filter(Boolean) + + if (pubIds.length === 0) return + + if (process.env.NODE_ENV === "development") { + console.log( + `[WS] NewPublicationStatus events for pubIds: ${pubIds.join(", ")}`, + ) + } + + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current) + } + + debounceTimerRef.current = setTimeout(() => { + debounceTimerRef.current = null + + queryClient.invalidateQueries({ + queryKey: publicationsKeys.all, + }) + + if (process.env.NODE_ENV === "development") { + console.log( + `[WS] Cache invalidated for pubIds: ${pubIds.join(", ")}`, + ) + } + }, INVALIDATION_DELAY_MS) + }, + [queryClient], + ) + + useEffect(() => { + const baseSepoliaWsClient = createBaseSepoliaWsClient() + const ethSepoliaWsClient = createEthSepoliaWsClient() + + const unwatchFns: WatchContractEventReturnType[] = [] + + unwatchFns.push( + baseSepoliaWsClient.watchContractEvent({ + address: baseSepoliaConfig.address, + abi: baseSepoliaConfig.abi, + eventName: "NewPublicationStatus", + onLogs: handleLogs, + }), + ) + + unwatchFns.push( + ethSepoliaWsClient.watchContractEvent({ + address: ethSepoliaConfig.address, + abi: ethSepoliaConfig.abi, + eventName: "NewPublicationStatus", + onLogs: handleLogs, + }), + ) + + return () => { + for (const unwatch of unwatchFns) { + unwatch() + } + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current) + } + } + }, [handleLogs]) +} diff --git a/apps/fe/app/(routes)/publications/_components/table/columns.tsx b/apps/fe/app/(routes)/publications/_components/table/columns.tsx index ee229f2..44bb007 100644 --- a/apps/fe/app/(routes)/publications/_components/table/columns.tsx +++ b/apps/fe/app/(routes)/publications/_components/table/columns.tsx @@ -1,9 +1,5 @@ "use client" -import { type Publication, PublicationStatusSchema } from "@packages/schema" -import { ChainIdToNetwork } from "@packages/utils" -import type { ColumnDef } from "@tanstack/react-table" -import { CircleOffIcon, CircleSlash2Icon, DicesIcon } from "lucide-react" import { AddressDisplay } from "@/app/_components/address-display" import { NetworkBadge, networkOptions } from "@/app/_components/network-badge" import { @@ -12,6 +8,10 @@ import { } from "@/app/_components/publication-status-badge" import { TypographySmall } from "@/app/_components/typography" import { Badge } from "@/components/ui/badge" +import { type Publication, PublicationStatusSchema } from "@packages/schema" +import { ChainIdToNetwork } from "@packages/utils" +import type { ColumnDef } from "@tanstack/react-table" +import { CircleOffIcon, CircleSlash2Icon, DicesIcon } from "lucide-react" export const columns: ColumnDef[] = [ { @@ -87,7 +87,17 @@ export const columns: ColumnDef[] = [ Publisher ), - cell: ({ row }) => , + cell: ({ row }) => { + const publisher = row.getValue("publisher") + if (!publisher) { + return ( + + โ€” + + ) + } + return + }, }, { accessorKey: "reviewers", 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 2eb42df..c806744 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,9 +1,10 @@ "use client" -import type { PublicationsQueryParams } from "@packages/cqrs" -import type { PublicationsResponse } from "@packages/schema" 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" import { PublicationsTableSkeleton } from "../publications-table-skeleton" import { PublicationsTable } from "./publications-table" @@ -15,6 +16,10 @@ 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 const { data: publicationsResponse, isFetching, diff --git a/apps/fe/app/_components/address-display.tsx b/apps/fe/app/_components/address-display.tsx index f94b072..d41661d 100644 --- a/apps/fe/app/_components/address-display.tsx +++ b/apps/fe/app/_components/address-display.tsx @@ -1,9 +1,9 @@ "use client" -import { CheckIcon, CopyIcon } from "lucide-react" -import { type FC, useCallback, useState } from "react" import { Button } from "@/components/ui/button" import { cn } from "@/lib/utils" +import { CheckIcon, CopyIcon } from "lucide-react" +import { type FC, useCallback, useState } from "react" import { TypographySmall } from "./typography" const DELAY = 2_000 @@ -16,6 +16,8 @@ type Props = { export const AddressDisplay: FC = ({ address, className }) => { const [copied, setCopied] = useState(false) + if (!address) return null + const displayAddress = `${address.slice(0, 6)}...${address.slice(-4)}` const copyToClipboard = useCallback(