diff --git a/frontend/app/api/v1/protocol/summary/route.ts b/frontend/app/api/v1/protocol/summary/route.ts new file mode 100644 index 00000000..2383c4b4 --- /dev/null +++ b/frontend/app/api/v1/protocol/summary/route.ts @@ -0,0 +1,46 @@ +import { NextResponse } from "next/server" + +const SUBGRAPH_URL = + process.env.NEXT_PUBLIC_SUBGRAPH_URL || + "https://api.thegraph.com/subgraphs/name/protocol/analytics" + +const QUERY = ` + query GetProtocolSummary { + protocols(first: 1) { + totalValueLockedUSD + totalVolumeUSD + activePoolsCount + totalMembers + } + protocolDayDatas(first: 30, orderBy: date, orderDirection: desc) { + date + tvlUSD + dailyVolumeUSD + } + pools(first: 10, orderBy: totalValueLockedUSD, orderDirection: desc) { + id + totalValueLockedUSD + volumeUSD + isActive + } + } +` + +export async function GET() { + try { + const res = await fetch(SUBGRAPH_URL, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ query: QUERY }), + next: { revalidate: 60 }, // Cache payload for 60 seconds + }) + + const { data, errors } = await res.json() + if (errors) throw new Error(JSON.stringify(errors)) + + return NextResponse.json({ success: true, data }, { status: 200 }) + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to fetch protocol analytics" + return NextResponse.json({ success: false, error: message }, { status: 500 }) + } +} diff --git a/frontend/app/components/Dashboard.tsx b/frontend/app/components/Dashboard.tsx new file mode 100644 index 00000000..a5bc9fde --- /dev/null +++ b/frontend/app/components/Dashboard.tsx @@ -0,0 +1,146 @@ +"use client" + +import React, { useEffect, useState } from "react" +import { Activity, DollarSign, Layers, Users, ArrowUpRight, ShieldAlert } from "lucide-react" + +interface AnalyticsData { + protocol: { + totalValueLockedUSD: string + totalVolumeUSD: string + activePoolsCount: string + totalMembers: string + } + pools: Array<{ id: string; totalValueLockedUSD: string; volumeUSD: string; isActive: boolean }> +} + +export default function AnalyticsDashboard() { + const [data, setData] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(false) + + useEffect(() => { + async function fetchData() { + try { + const res = await fetch("/api/v1/protocol/summary") + const json = await res.json() + if (json.success && json.data.protocols[0]) { + setData({ + protocol: json.data.protocols[0], + pools: json.data.pools, + }) + } else { + setError(true) + } + } catch { + setError(true) + } finally { + setLoading(false) + } + } + fetchData() + }, []) + + if (loading) + return ( +
Loading protocol state...
+ ) + if (error || !data) + return ( +
+ + Failed to fetch real-time analytics from Subgraph node. +
+ ) + + const { protocol, pools } = data + + return ( +
+
+
+

Protocol Analytics

+

Real-time metrics & liquidity overview

+
+ + Live Indexer + +
+ + {/* Primary KPI Metrics */} +
+ } + /> + } + /> + } + /> + } + /> +
+ + {/* Pool Breakdown Table */} +
+
+

Top Active Pools

+
+
+ + + + + + + + + + + {pools.map((pool) => ( + + + + + + + ))} + +
Pool AddressTVL (USD)Total VolumeStatus
+ {pool.id.slice(0, 6)}...{pool.id.slice(-4)} + + + ${Number(pool.totalValueLockedUSD).toLocaleString()} + ${Number(pool.volumeUSD).toLocaleString()} + + {pool.isActive ? "Active" : "Inactive"} + +
+
+
+
+ ) +} + +function KpiCard({ title, value, icon }: { title: string; value: string; icon: React.ReactNode }) { + return ( +
+
+ {title} + {icon} +
+
{value}
+
+ ) +} diff --git a/subgraph/abis/Factory.json b/subgraph/abis/Factory.json new file mode 100644 index 00000000..e69de29b diff --git a/subgraph/abis/Pool.json b/subgraph/abis/Pool.json new file mode 100644 index 00000000..e69de29b diff --git a/subgraph/schema.graphql b/subgraph/schema.graphql new file mode 100644 index 00000000..30e9e723 --- /dev/null +++ b/subgraph/schema.graphql @@ -0,0 +1,33 @@ +type Protocol @entity { + id: ID! # Single instance "1" + totalValueLockedUSD: BigDecimal! + totalVolumeUSD: BigDecimal! + activePoolsCount: BigInt! + totalMembers: BigInt! +} + +type ProtocolDayData @entity { + id: ID! # Timestamp / 86400 + date: Int! + tvlUSD: BigDecimal! + dailyVolumeUSD: BigDecimal! + totalMembers: BigInt! +} + +type Pool @entity { + id: ID! # Pool contract address + token0: Bytes! + token1: Bytes! + reserve0: BigInt! + reserve1: BigInt! + totalValueLockedUSD: BigDecimal! + volumeUSD: BigDecimal! + isActive: Boolean! + createdAtTimestamp: BigInt! +} + +type Member @entity { + id: ID! # Wallet address + joinedTimestamp: BigInt! + txCount: BigInt! +} \ No newline at end of file diff --git a/subgraph/src/factory.ts b/subgraph/src/factory.ts new file mode 100644 index 00000000..e69de29b diff --git a/subgraph/src/pool.ts b/subgraph/src/pool.ts new file mode 100644 index 00000000..f9ca6a53 --- /dev/null +++ b/subgraph/src/pool.ts @@ -0,0 +1,60 @@ +import { BigInt, BigDecimal } from "@graphprotocol/graph-ts"; +import { Swap, MemberJoined } from "../generated/templates/Pool/Pool"; +import { Protocol, ProtocolDayData, Pool, Member } from "../generated/schema"; + +const FACTORY_ADDRESS = "1"; + +export function handleSwap(event: Swap): void { + let pool = Pool.load(event.address.toHexString()); + if (!pool) return; + + // Mock USD pricing calculation for volume (e.g., token0 amount * price) + let volumeUSD = event.params.amount0In.toBigDecimal().div(BigDecimal.fromString("1e18")); + + // Update Pool stats + pool.volumeUSD = pool.volumeUSD.plus(volumeUSD); + pool.save(); + + // Update Protocol global stats + let protocol = Protocol.load(FACTORY_ADDRESS); + if (protocol) { + protocol.totalVolumeUSD = protocol.totalVolumeUSD.plus(volumeUSD); + protocol.save(); + } + + // Update Daily Aggregates + let dayID = event.block.timestamp.toI32() / 86400; + let dayData = ProtocolDayData.load(dayID.toString()); + if (!dayData) { + dayData = new ProtocolDayData(dayID.toString()); + dayData.date = dayID * 86400; + dayData.tvlUSD = protocol ? protocol.totalValueLockedUSD : BigDecimal.fromString("0"); + dayData.dailyVolumeUSD = BigDecimal.fromString("0"); + dayData.totalMembers = protocol ? protocol.totalMembers : BigInt.fromI32(0); + } + + dayData.dailyVolumeUSD = dayData.dailyVolumeUSD.plus(volumeUSD); + dayData.tvlUSD = protocol ? protocol.totalValueLockedUSD : BigDecimal.fromString("0"); + dayData.save(); +} + +export function handleMemberJoined(event: MemberJoined): void { + let memberId = event.params.member.toHexString(); + let member = Member.load(memberId); + + if (!member) { + member = new Member(memberId); + member.joinedTimestamp = event.block.timestamp; + member.txCount = BigInt.fromI32(1); + member.save(); + + let protocol = Protocol.load(FACTORY_ADDRESS); + if (protocol) { + protocol.totalMembers = protocol.totalMembers.plus(BigInt.fromI32(1)); + protocol.save(); + } + } else { + member.txCount = member.txCount.plus(BigInt.fromI32(1)); + member.save(); + } +} \ No newline at end of file diff --git a/subgraph/subgraph.yaml b/subgraph/subgraph.yaml new file mode 100644 index 00000000..79387e88 --- /dev/null +++ b/subgraph/subgraph.yaml @@ -0,0 +1,48 @@ +specVersion: 0.0.5 +schema: + file: ./schema.graphql +dataSources: + - kind: ethereum + name: Factory + network: mainnet + source: + address: "0x1111111111111111111111111111111111111111" + abi: Factory + startBlock: 17000000 + mapping: + kind: ethereum/events + apiVersion: 0.0.7 + language: wasm/assemblyscript + entities: + - Protocol + - Pool + abis: + - name: Factory + file: ./abis/Factory.json + eventHandlers: + - event: PoolCreated(indexed address,indexed address,address,uint256) + handler: handlePoolCreated + file: ./src/factory.ts +templates: + - kind: ethereum + name: Pool + network: mainnet + source: + abi: Pool + mapping: + kind: ethereum/events + apiVersion: 0.0.7 + language: wasm/assemblyscript + entities: + - Pool + - ProtocolDayData + - Member + abis: + - name: Pool + file: ./abis/Pool.json + eventHandlers: + - event: Swap(indexed address,uint256,uint256,uint256,uint256,indexed address) + handler: handleSwap + - event: MemberJoined(indexed address) + handler: handleMemberJoined + file: ./src/pool.ts \ No newline at end of file diff --git a/subgraph/tests/pool.test.ts b/subgraph/tests/pool.test.ts new file mode 100644 index 00000000..c956140d --- /dev/null +++ b/subgraph/tests/pool.test.ts @@ -0,0 +1,44 @@ +import { assert, describe, test, newMockEvent, clearStore } from "matchstick-as/assembly/index"; +import { Address, BigInt, ethereum } from "@graphprotocol/graph-ts"; +import { MemberJoined } from "../generated/templates/Pool/Pool"; +import { handleMemberJoined } from "../src/pool"; +import { Protocol } from "../generated/schema"; + +describe("Pool Unit Tests", () => { + test("Should increment unique total members on MemberJoined event", () => { + // Setup initial protocol state + const protocol = new Protocol("1"); + protocol.totalValueLockedUSD = BigInt.fromI32(0).toBigDecimal(); + protocol.totalVolumeUSD = BigInt.fromI32(0).toBigDecimal(); + protocol.activePoolsCount = BigInt.fromI32(0); + protocol.totalMembers = BigInt.fromI32(0); + protocol.save(); + + // Create mock MemberJoined event + const mockEvent = newMockEvent(); + const memberAddress = Address.fromString("0x0000000000000000000000000000000000000001"); + + const memberParam = new ethereum.EventParam("member", ethereum.Value.fromAddress(memberAddress)); + mockEvent.parameters = [memberParam]; + + const memberJoinedEvent = new MemberJoined( + mockEvent.address, + mockEvent.logIndex, + mockEvent.transactionLogIndex, + mockEvent.logType, + mockEvent.block, + mockEvent.transaction, + mockEvent.parameters, + mockEvent.receipt + ); + + // Run Handler + handleMemberJoined(memberJoinedEvent); + + // Assertions + assert.fieldEquals("Protocol", "1", "totalMembers", "1"); + assert.fieldEquals("Member", memberAddress.toHexString(), "txCount", "1"); + + clearStore(); + }); +}); \ No newline at end of file