diff --git a/frontend/__tests__/pool.test.ts b/frontend/__tests__/pool.test.ts new file mode 100644 index 0000000..c7aeb21 --- /dev/null +++ b/frontend/__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 + let 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 + let mockEvent = newMockEvent(); + let memberAddress = Address.fromString("0x0000000000000000000000000000000000000001"); + + let memberParam = new ethereum.EventParam("member", ethereum.Value.fromAddress(memberAddress)); + mockEvent.parameters = [memberParam]; + + let 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 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 0000000..4451041 --- /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: any) { + return NextResponse.json( + { success: false, error: error.message || "Failed to fetch protocol analytics" }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/frontend/app/components/Dashboard.tsx b/frontend/app/components/Dashboard.tsx new file mode 100644 index 0000000..95c2e14 --- /dev/null +++ b/frontend/app/components/Dashboard.tsx @@ -0,0 +1,122 @@ +'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}
+
+ ); +} \ No newline at end of file diff --git a/subgraph/abis/Factory.json b/subgraph/abis/Factory.json new file mode 100644 index 0000000..e69de29 diff --git a/subgraph/abis/Pool.json b/subgraph/abis/Pool.json new file mode 100644 index 0000000..e69de29 diff --git a/subgraph/schema.graphql b/subgraph/schema.graphql new file mode 100644 index 0000000..30e9e72 --- /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 0000000..e69de29 diff --git a/subgraph/src/pool.ts b/subgraph/src/pool.ts new file mode 100644 index 0000000..f9ca6a5 --- /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 0000000..79387e8 --- /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