Skip to content
Merged
17 changes: 14 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -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**

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down
10 changes: 7 additions & 3 deletions apps/fe/README.md
Original file line number Diff line number Diff line change
@@ -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.

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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.
Expand Down Expand Up @@ -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 |
Expand All @@ -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
Expand Down
22 changes: 22 additions & 0 deletions apps/fe/_config/viem/ws-clients.ts
Original file line number Diff line number Diff line change
@@ -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),
})
64 changes: 38 additions & 26 deletions apps/fe/_config/wagmi/wagmi-config.ts
Original file line number Diff line number Diff line change
@@ -1,58 +1,70 @@
/**
* @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,
}),
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
2 changes: 1 addition & 1 deletion apps/fe/_hooks/cqrs/commands/use-claim.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
8 changes: 5 additions & 3 deletions apps/fe/_hooks/cqrs/queries/use-publications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})
Expand Down
4 changes: 4 additions & 0 deletions apps/fe/_hooks/cqrs/query-keys/publications-keys.ts
Original file line number Diff line number Diff line change
@@ -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,
}
125 changes: 125 additions & 0 deletions apps/fe/_hooks/websockets/use-watch-new-publication-status-event.ts
Original file line number Diff line number Diff line change
@@ -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<NodeJS.Timeout | null>(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])
}
Loading
Loading