From 42993701c3b0237bc5ac1d443e57d81d1ac8d617 Mon Sep 17 00:00:00 2001 From: siegfriedbz Date: Sat, 25 Apr 2026 13:14:18 +0200 Subject: [PATCH 1/9] refactor(wagmi): document config --- apps/fe/_config/wagmi/wagmi-config.ts | 53 +++++++++++++++++++-------- 1 file changed, 37 insertions(+), 16 deletions(-) diff --git a/apps/fe/_config/wagmi/wagmi-config.ts b/apps/fe/_config/wagmi/wagmi-config.ts index a25c71a..cf1b9e3 100644 --- a/apps/fe/_config/wagmi/wagmi-config.ts +++ b/apps/fe/_config/wagmi/wagmi-config.ts @@ -1,38 +1,61 @@ +/** + * @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. Multi-Transport Failover: WSS-first with HTTPS fallback. + * 4. 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" -// 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: + * - We use a static-priority `fallback` strategy. + * - WebSocket (Priority 1): Provides instant "push" notifications for contract events. + * - HTTP (Priority 2): Acts as a reliable failover for standard read/write requests. + */ export const wagmiAdapter = new WagmiAdapter({ storage: createStorage({ storage: cookieStorage, @@ -40,17 +63,15 @@ 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 + webSocket(ALCHEMY_ETH_SEPOLIA_WSS), + http(ALCHEMY_ETH_SEPOLIA_RPC_URL), ]), }, }) From 65bc274a7e3416f7a1f4ea51747886a91dc23730 Mon Sep 17 00:00:00 2001 From: siegfriedbz Date: Sat, 25 Apr 2026 13:15:24 +0200 Subject: [PATCH 2/9] feat(queries): scope publication cache keys by query params --- apps/fe/_hooks/cqrs/queries/use-publications.ts | 4 ++-- apps/fe/_hooks/cqrs/query-keys/publications-keys.ts | 3 +++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/fe/_hooks/cqrs/queries/use-publications.ts b/apps/fe/_hooks/cqrs/queries/use-publications.ts index c68ebb4..9dea832 100644 --- a/apps/fe/_hooks/cqrs/queries/use-publications.ts +++ b/apps/fe/_hooks/cqrs/queries/use-publications.ts @@ -14,11 +14,11 @@ type Props = { } export const usePublications = (props: Props) => { - const { initialData = { items: [], totalCount: 0 }, searchQueryParams } = + 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..027647d 100644 --- a/apps/fe/_hooks/cqrs/query-keys/publications-keys.ts +++ b/apps/fe/_hooks/cqrs/query-keys/publications-keys.ts @@ -1,4 +1,7 @@ +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, } From fcc546ce74926988341e8518860969227f98b3b2 Mon Sep 17 00:00:00 2001 From: siegfriedbz Date: Sat, 25 Apr 2026 13:17:11 +0200 Subject: [PATCH 3/9] feat(ws): invalidate publications cache on NewPublicationStatus event --- .../use-watch-new-publication-status-event.ts | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 apps/fe/_hooks/websockets/use-watch-new-publication-status-event.ts 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..f511a4f --- /dev/null +++ b/apps/fe/_hooks/websockets/use-watch-new-publication-status-event.ts @@ -0,0 +1,84 @@ +"use client" + +import { NetworkSchema } from "@packages/schema" +import { BioVerifyContractConfig, NetworkToChainId } from "@packages/utils" +import { useQueryClient } from "@tanstack/react-query" +import { useCallback, useEffect, useRef } from "react" +import type { Log } from "viem" +import { useWatchContractEvent } from "wagmi" +import { publicationsKeys } from "../cqrs/query-keys/publications-keys" + +type NewPublicationStatusArgs = { + pubId?: bigint + newStatus?: number +} + +const INVALIDATION_DELAY_MS = 3_000 + +export const useWatchNewPublicationStatusEvent = () => { + const queryClient = useQueryClient() + + const debounceTimerRef = useRef(null) + + useEffect(() => { + return () => { + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current) + } + } + }, []) + + 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(", ")}`, + ) + } + + // Coalesce rapid-fire events into a single trailing invalidation + // to avoid redundant refetches when multiple status changes arrive together + 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], + ) + + useWatchContractEvent({ + ...BioVerifyContractConfig[NetworkSchema.enum.base_sepolia], + chainId: NetworkToChainId[NetworkSchema.enum.base_sepolia], + eventName: "NewPublicationStatus", + onLogs: handleLogs, + }) + + useWatchContractEvent({ + ...BioVerifyContractConfig[NetworkSchema.enum.eth_sepolia], + chainId: NetworkToChainId[NetworkSchema.enum.eth_sepolia], + eventName: "NewPublicationStatus", + onLogs: handleLogs, + }) +} From 173e8efc59842d6704f78cd551f77d2bfc50abc3 Mon Sep 17 00:00:00 2001 From: siegfriedbz Date: Sat, 25 Apr 2026 13:24:21 +0200 Subject: [PATCH 4/9] chore(docs): update READMEs --- README.md | 17 ++++++++++++++--- apps/fe/README.md | 8 ++++++-- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index e254233..1f28933 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 directly via Alchemy WebSocket transports, 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..49838a1 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 @@ -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 Alchemy WebSocket transports. 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 Alchemy WSS on both chains; 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 From c4e2658fe9e9cc844ab7858e93bbada965e04219 Mon Sep 17 00:00:00 2001 From: siegfriedbz Date: Sat, 25 Apr 2026 13:38:22 +0200 Subject: [PATCH 5/9] chore: fix linters --- apps/fe/_config/wagmi/wagmi-config.ts | 13 +- apps/fe/_hooks/cqrs/commands/use-claim.ts | 2 +- .../_hooks/cqrs/queries/use-publications.ts | 6 +- .../cqrs/query-keys/publications-keys.ts | 3 +- .../use-watch-new-publication-status-event.ts | 132 +++++++++--------- 5 files changed, 79 insertions(+), 77 deletions(-) diff --git a/apps/fe/_config/wagmi/wagmi-config.ts b/apps/fe/_config/wagmi/wagmi-config.ts index cf1b9e3..d1b1401 100644 --- a/apps/fe/_config/wagmi/wagmi-config.ts +++ b/apps/fe/_config/wagmi/wagmi-config.ts @@ -1,6 +1,6 @@ /** * @title Wagmi & Reown Configuration - * @notice This file configures the Web3 provider layer, handling multi-chain + * @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. @@ -16,7 +16,8 @@ import { cookieStorage, createStorage } from "@wagmi/core" import { fallback, http, webSocket } from "wagmi" // --- Alchemy Configuration --- -const ALCHEMY_BASE_SEPOLIA_RPC_URL = env.NEXT_PUBLIC_ALCHEMY_BASE_SEPOLIA_RPC_URL +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 const ALCHEMY_BASE_SEPOLIA_WSS = env.NEXT_PUBLIC_ALCHEMY_BASE_SEPOLIA_WSS const ALCHEMY_ETH_SEPOLIA_WSS = env.NEXT_PUBLIC_ALCHEMY_ETH_SEPOLIA_WSS @@ -27,10 +28,10 @@ const networks = [sepolia, baseSepolia] /** * @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$ + * * 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 + * * Mapping these here ensures the Reown/AppKit Modal uses our dedicated Alchemy * nodes for balance checks instead of slower public RPCs. */ export const customRpcUrls = { @@ -49,7 +50,7 @@ export const customRpcUrls = { * @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 + * - `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: * - We use a static-priority `fallback` strategy. 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 9dea832..281575a 100644 --- a/apps/fe/_hooks/cqrs/queries/use-publications.ts +++ b/apps/fe/_hooks/cqrs/queries/use-publications.ts @@ -14,8 +14,10 @@ type Props = { } export const usePublications = (props: Props) => { - const { initialData = { items: [], totalCount: 0 }, searchQueryParams = { limit: 10, offset: 0 } } = - props + const { + initialData = { items: [], totalCount: 0 }, + searchQueryParams = { limit: 10, offset: 0 }, + } = props const { data, isFetching, isError, refetch } = useQuery({ queryKey: publicationsKeys.byQueryParams(searchQueryParams), diff --git a/apps/fe/_hooks/cqrs/query-keys/publications-keys.ts b/apps/fe/_hooks/cqrs/query-keys/publications-keys.ts index 027647d..3f54410 100644 --- a/apps/fe/_hooks/cqrs/query-keys/publications-keys.ts +++ b/apps/fe/_hooks/cqrs/query-keys/publications-keys.ts @@ -2,6 +2,7 @@ import { PublicationsQueryParams } from "@packages/cqrs" export const publicationsKeys = { all: ["publications"] as const, - byQueryParams: (query: PublicationsQueryParams) => ["publications", query] 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 index f511a4f..54e17c7 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 @@ -9,76 +9,74 @@ import { useWatchContractEvent } from "wagmi" import { publicationsKeys } from "../cqrs/query-keys/publications-keys" type NewPublicationStatusArgs = { - pubId?: bigint - newStatus?: number + pubId?: bigint + newStatus?: number } const INVALIDATION_DELAY_MS = 3_000 export const useWatchNewPublicationStatusEvent = () => { - const queryClient = useQueryClient() - - const debounceTimerRef = useRef(null) - - useEffect(() => { - return () => { - if (debounceTimerRef.current) { - clearTimeout(debounceTimerRef.current) - } - } - }, []) - - 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(", ")}`, - ) - } - - // Coalesce rapid-fire events into a single trailing invalidation - // to avoid redundant refetches when multiple status changes arrive together - 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], - ) - - useWatchContractEvent({ - ...BioVerifyContractConfig[NetworkSchema.enum.base_sepolia], - chainId: NetworkToChainId[NetworkSchema.enum.base_sepolia], - eventName: "NewPublicationStatus", - onLogs: handleLogs, - }) - - useWatchContractEvent({ - ...BioVerifyContractConfig[NetworkSchema.enum.eth_sepolia], - chainId: NetworkToChainId[NetworkSchema.enum.eth_sepolia], - eventName: "NewPublicationStatus", - onLogs: handleLogs, - }) + const queryClient = useQueryClient() + + const debounceTimerRef = useRef(null) + + useEffect(() => { + return () => { + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current) + } + } + }, []) + + 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(", ")}`, + ) + } + + // Coalesce rapid-fire events into a single trailing invalidation + // to avoid redundant refetches when multiple status changes arrive together + 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], + ) + + useWatchContractEvent({ + ...BioVerifyContractConfig[NetworkSchema.enum.base_sepolia], + chainId: NetworkToChainId[NetworkSchema.enum.base_sepolia], + eventName: "NewPublicationStatus", + onLogs: handleLogs, + }) + + useWatchContractEvent({ + ...BioVerifyContractConfig[NetworkSchema.enum.eth_sepolia], + chainId: NetworkToChainId[NetworkSchema.enum.eth_sepolia], + eventName: "NewPublicationStatus", + onLogs: handleLogs, + }) } From 27eb23f809bcf94b6f95cfa84fc6f9783d10ef82 Mon Sep 17 00:00:00 2001 From: siegfriedbz Date: Sat, 25 Apr 2026 16:12:07 +0200 Subject: [PATCH 6/9] feat(fe): add standalone viem WebSocket clients for chain event subscriptions --- apps/fe/_config/viem/ws-clients.ts | 22 ++++++++++++++++ apps/fe/_config/wagmi/wagmi-config.ts | 38 ++++++++++----------------- 2 files changed, 36 insertions(+), 24 deletions(-) create mode 100644 apps/fe/_config/viem/ws-clients.ts 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 d1b1401..4e117fb 100644 --- a/apps/fe/_config/wagmi/wagmi-config.ts +++ b/apps/fe/_config/wagmi/wagmi-config.ts @@ -1,26 +1,22 @@ /** * @title Wagmi & Reown Configuration - * @notice This file configures the Web3 provider layer, handling multi-chain + * @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. Multi-Transport Failover: WSS-first with HTTPS fallback. - * 4. SSR Optimization: Cookie-based hydration to prevent UI "flicker." + * 3. SSR Optimization: Cookie-based hydration to prevent UI "flicker." */ import { env } from "@packages/env" 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 Configuration --- -const ALCHEMY_BASE_SEPOLIA_RPC_URL = - env.NEXT_PUBLIC_ALCHEMY_BASE_SEPOLIA_RPC_URL +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 -const ALCHEMY_BASE_SEPOLIA_WSS = env.NEXT_PUBLIC_ALCHEMY_BASE_SEPOLIA_WSS -const ALCHEMY_ETH_SEPOLIA_WSS = env.NEXT_PUBLIC_ALCHEMY_ETH_SEPOLIA_WSS const RAINBOWKIT_PROJECT_ID = env.NEXT_PUBLIC_RAINBOWKIT_PROJECT_ID || "" const networks = [sepolia, baseSepolia] @@ -28,10 +24,10 @@ const networks = [sepolia, baseSepolia] /** * @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$ + * * 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 + * * Mapping these here ensures the Reown/AppKit Modal uses our dedicated Alchemy * nodes for balance checks instead of slower public RPCs. */ export const customRpcUrls = { @@ -50,12 +46,12 @@ export const customRpcUrls = { * @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 + * - `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: - * - We use a static-priority `fallback` strategy. - * - WebSocket (Priority 1): Provides instant "push" notifications for contract events. - * - HTTP (Priority 2): Acts as a reliable failover for standard read/write requests. + * - 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({ @@ -66,15 +62,9 @@ export const wagmiAdapter = new WagmiAdapter({ networks, customRpcUrls, transports: { - [baseSepolia.id]: fallback([ - webSocket(ALCHEMY_BASE_SEPOLIA_WSS), - http(ALCHEMY_BASE_SEPOLIA_RPC_URL), - ]), - [sepolia.id]: fallback([ - webSocket(ALCHEMY_ETH_SEPOLIA_WSS), - http(ALCHEMY_ETH_SEPOLIA_RPC_URL), - ]), + [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 From db9ed6e6a1fe7a54c94709c6d1944f3c73ab2440 Mon Sep 17 00:00:00 2001 From: siegfriedbz Date: Sat, 25 Apr 2026 16:15:35 +0200 Subject: [PATCH 7/9] chore(docs): update READMEs --- README.md | 2 +- apps/fe/README.md | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 1f28933..8b733ee 100644 --- a/README.md +++ b/README.md @@ -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 directly via Alchemy WebSocket transports, 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 table stays in sync without polling. ```mermaid graph LR diff --git a/apps/fe/README.md b/apps/fe/README.md index 49838a1..75dc949 100644 --- a/apps/fe/README.md +++ b/apps/fe/README.md @@ -62,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 @@ -87,7 +87,7 @@ 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 Alchemy WebSocket transports. 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**: 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 @@ -146,7 +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 Alchemy WSS on both chains; rapid-fire events are debounced into a single TanStack Query cache invalidation (3-second delay for CQRS eventual consistency) +- **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 From 0a2808cf417555a89e94fc6400185ff9618c0f58 Mon Sep 17 00:00:00 2001 From: siegfriedbz Date: Sat, 25 Apr 2026 16:16:09 +0200 Subject: [PATCH 8/9] refactor(fe): rewrite publication status watcher to use viem directly --- .../use-watch-new-publication-status-event.ts | 99 +++++++++++++------ .../table/publications-table-container.tsx | 9 +- 2 files changed, 78 insertions(+), 30 deletions(-) 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 54e17c7..88cbfdc 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 @@ -1,11 +1,34 @@ "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, NetworkToChainId } from "@packages/utils" +import { BioVerifyContractConfig } from "@packages/utils" import { useQueryClient } from "@tanstack/react-query" import { useCallback, useEffect, useRef } from "react" -import type { Log } from "viem" -import { useWatchContractEvent } from "wagmi" +import type { Log, WatchContractEventReturnType } from "viem" import { publicationsKeys } from "../cqrs/query-keys/publications-keys" type NewPublicationStatusArgs = { @@ -13,26 +36,26 @@ type NewPublicationStatusArgs = { 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) - useEffect(() => { - return () => { - if (debounceTimerRef.current) { - clearTimeout(debounceTimerRef.current) - } - } - }, []) - const handleLogs = useCallback( (logs: Log[]) => { if (logs.length === 0) return - const typedLogs = logs as (Log & { args?: NewPublicationStatusArgs })[] + const typedLogs = logs as (Log & { + args?: NewPublicationStatusArgs + })[] const pubIds = typedLogs .map((log) => log.args?.pubId?.toString()) .filter(Boolean) @@ -45,8 +68,6 @@ export const useWatchNewPublicationStatusEvent = () => { ) } - // Coalesce rapid-fire events into a single trailing invalidation - // to avoid redundant refetches when multiple status changes arrive together if (debounceTimerRef.current) { clearTimeout(debounceTimerRef.current) } @@ -59,24 +80,46 @@ export const useWatchNewPublicationStatusEvent = () => { }) if (process.env.NODE_ENV === "development") { - console.log(`[WS] Cache invalidated for pubIds: ${pubIds.join(", ")}`) + console.log( + `[WS] Cache invalidated for pubIds: ${pubIds.join(", ")}`, + ) } }, INVALIDATION_DELAY_MS) }, [queryClient], ) - useWatchContractEvent({ - ...BioVerifyContractConfig[NetworkSchema.enum.base_sepolia], - chainId: NetworkToChainId[NetworkSchema.enum.base_sepolia], - eventName: "NewPublicationStatus", - onLogs: handleLogs, - }) - - useWatchContractEvent({ - ...BioVerifyContractConfig[NetworkSchema.enum.eth_sepolia], - chainId: NetworkToChainId[NetworkSchema.enum.eth_sepolia], - eventName: "NewPublicationStatus", - onLogs: handleLogs, - }) + 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/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, From e9aaabc57c686462a2901c55489a1edf45a3feba Mon Sep 17 00:00:00 2001 From: siegfriedbz Date: Sat, 25 Apr 2026 16:24:18 +0200 Subject: [PATCH 9/9] fix(fe): guard against null addresses in publications table --- .../_components/table/columns.tsx | 20 ++++++++++++++----- apps/fe/app/_components/address-display.tsx | 6 ++++-- 2 files changed, 19 insertions(+), 7 deletions(-) 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/_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(