From f9ed64b45bbaf9da72c6e4abd920863790f4a67d Mon Sep 17 00:00:00 2001 From: mijinummi Date: Thu, 20 Aug 2026 13:48:24 +0100 Subject: [PATCH 1/2] feat(analytics): build protocol-wide analytics dashboard --- frontend/__tests__/pool.test.ts | 44 +++++++ frontend/app/api/v1/protocol/summary/route.ts | 46 +++++++ frontend/app/components/Dashboard.tsx | 122 ++++++++++++++++++ subgraph/abis/Factory.json | 0 subgraph/abis/Pool.json | 0 subgraph/schema.graphql | 33 +++++ subgraph/src/factory.ts | 0 subgraph/src/pool.ts | 60 +++++++++ subgraph/subgraph.yaml | 48 +++++++ 9 files changed, 353 insertions(+) create mode 100644 frontend/__tests__/pool.test.ts create mode 100644 frontend/app/api/v1/protocol/summary/route.ts create mode 100644 frontend/app/components/Dashboard.tsx create mode 100644 subgraph/abis/Factory.json create mode 100644 subgraph/abis/Pool.json create mode 100644 subgraph/schema.graphql create mode 100644 subgraph/src/factory.ts create mode 100644 subgraph/src/pool.ts create mode 100644 subgraph/subgraph.yaml diff --git a/frontend/__tests__/pool.test.ts b/frontend/__tests__/pool.test.ts new file mode 100644 index 00000000..c7aeb210 --- /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 00000000..4451041c --- /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 00000000..95c2e14d --- /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 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 From cb527977810aa93d06b1095526d12815ba680468 Mon Sep 17 00:00:00 2001 From: Sendi0011 Date: Tue, 15 Sep 2026 16:32:12 +0100 Subject: [PATCH 2/2] feat(analytics): adopt protocol analytics dashboard (#241) and fix lint + test placement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adopts mijinummi's protocol analytics dashboard (API route + Dashboard component) and the subgraph scaffolding (The Graph protocol indexer). Fixes two CI failures: 1. frontend/__tests__/pool.test.ts imported matchstick-as (a subgraph test framework not in frontend's deps) — moved to subgraph/tests/pool.test.ts where Graph projects expect matchstick tests to live. This also removes it from vitest's test roots (__tests__/). 2. prefer-const (5) and no-explicit-any (1) lint errors in pool.test.ts and route.ts fixed; route.ts error: any replaced with instanceof Error check. 3. Prettier run on route.ts and Dashboard.tsx. --- frontend/app/api/v1/protocol/summary/route.ts | 32 +++--- frontend/app/components/Dashboard.tsx | 102 +++++++++++------- .../__tests__ => subgraph/tests}/pool.test.ts | 10 +- 3 files changed, 84 insertions(+), 60 deletions(-) rename {frontend/__tests__ => subgraph/tests}/pool.test.ts (80%) diff --git a/frontend/app/api/v1/protocol/summary/route.ts b/frontend/app/api/v1/protocol/summary/route.ts index 4451041c..2383c4b4 100644 --- a/frontend/app/api/v1/protocol/summary/route.ts +++ b/frontend/app/api/v1/protocol/summary/route.ts @@ -1,6 +1,8 @@ -import { NextResponse } from 'next/server'; +import { NextResponse } from "next/server" -const SUBGRAPH_URL = process.env.NEXT_PUBLIC_SUBGRAPH_URL || "https://api.thegraph.com/subgraphs/name/protocol/analytics"; +const SUBGRAPH_URL = + process.env.NEXT_PUBLIC_SUBGRAPH_URL || + "https://api.thegraph.com/subgraphs/name/protocol/analytics" const QUERY = ` query GetProtocolSummary { @@ -22,25 +24,23 @@ const QUERY = ` isActive } } -`; +` export async function GET() { try { const res = await fetch(SUBGRAPH_URL, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, + method: "POST", + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ query: QUERY }), - next: { revalidate: 60 } // Cache payload for 60 seconds - }); + next: { revalidate: 60 }, // Cache payload for 60 seconds + }) - const { data, errors } = await res.json(); - if (errors) throw new Error(JSON.stringify(errors)); + 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 } - ); + 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 }) } -} \ No newline at end of file +} diff --git a/frontend/app/components/Dashboard.tsx b/frontend/app/components/Dashboard.tsx index 95c2e14d..a5bc9fde 100644 --- a/frontend/app/components/Dashboard.tsx +++ b/frontend/app/components/Dashboard.tsx @@ -1,54 +1,58 @@ -'use client'; +"use client" -import React, { useEffect, useState } from 'react'; -import { Activity, DollarSign, Layers, Users, ArrowUpRight, ShieldAlert } from 'lucide-react'; +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 }>; + 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); + 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(); + 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 - }); + pools: json.data.pools, + }) } else { - setError(true); + setError(true) } } catch { - setError(true); + setError(true) } finally { - setLoading(false); + setLoading(false) } } - fetchData(); - }, []); + fetchData() + }, []) - if (loading) return
Loading protocol state...
; - if (error || !data) return ( -
- - Failed to fetch real-time analytics from Subgraph node. -
- ); + if (loading) + return ( +
Loading protocol state...
+ ) + if (error || !data) + return ( +
+ + Failed to fetch real-time analytics from Subgraph node. +
+ ) - const { protocol, pools } = data; + const { protocol, pools } = data return (
@@ -64,10 +68,26 @@ export default function AnalyticsDashboard() { {/* Primary KPI Metrics */}
- } /> - } /> - } /> - } /> + } + /> + } + /> + } + /> + } + />
{/* Pool Breakdown Table */} @@ -92,11 +112,15 @@ export default function AnalyticsDashboard() { {pool.id.slice(0, 6)}...{pool.id.slice(-4)} - ${Number(pool.totalValueLockedUSD).toLocaleString()} + + ${Number(pool.totalValueLockedUSD).toLocaleString()} + ${Number(pool.volumeUSD).toLocaleString()} - - {pool.isActive ? 'Active' : 'Inactive'} + + {pool.isActive ? "Active" : "Inactive"} @@ -106,7 +130,7 @@ export default function AnalyticsDashboard() {
- ); + ) } function KpiCard({ title, value, icon }: { title: string; value: string; icon: React.ReactNode }) { @@ -118,5 +142,5 @@ function KpiCard({ title, value, icon }: { title: string; value: string; icon: R
{value}
- ); -} \ No newline at end of file + ) +} diff --git a/frontend/__tests__/pool.test.ts b/subgraph/tests/pool.test.ts similarity index 80% rename from frontend/__tests__/pool.test.ts rename to subgraph/tests/pool.test.ts index c7aeb210..c956140d 100644 --- a/frontend/__tests__/pool.test.ts +++ b/subgraph/tests/pool.test.ts @@ -7,7 +7,7 @@ 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"); + const protocol = new Protocol("1"); protocol.totalValueLockedUSD = BigInt.fromI32(0).toBigDecimal(); protocol.totalVolumeUSD = BigInt.fromI32(0).toBigDecimal(); protocol.activePoolsCount = BigInt.fromI32(0); @@ -15,13 +15,13 @@ describe("Pool Unit Tests", () => { protocol.save(); // Create mock MemberJoined event - let mockEvent = newMockEvent(); - let memberAddress = Address.fromString("0x0000000000000000000000000000000000000001"); + const mockEvent = newMockEvent(); + const memberAddress = Address.fromString("0x0000000000000000000000000000000000000001"); - let memberParam = new ethereum.EventParam("member", ethereum.Value.fromAddress(memberAddress)); + const memberParam = new ethereum.EventParam("member", ethereum.Value.fromAddress(memberAddress)); mockEvent.parameters = [memberParam]; - let memberJoinedEvent = new MemberJoined( + const memberJoinedEvent = new MemberJoined( mockEvent.address, mockEvent.logIndex, mockEvent.transactionLogIndex,