Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions frontend/app/api/v1/protocol/summary/route.ts
Original file line number Diff line number Diff line change
@@ -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 })
}
}
146 changes: 146 additions & 0 deletions frontend/app/components/Dashboard.tsx
Original file line number Diff line number Diff line change
@@ -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<AnalyticsData | null>(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 (
<div className="p-8 text-slate-400 font-mono animate-pulse">Loading protocol state...</div>
)
if (error || !data)
return (
<div className="p-4 border border-red-500/20 bg-red-500/10 rounded-lg text-red-400 flex items-center gap-2">
<ShieldAlert className="w-5 h-5" />
<span>Failed to fetch real-time analytics from Subgraph node.</span>
</div>
)

const { protocol, pools } = data

return (
<div className="min-h-screen bg-slate-950 text-slate-100 p-8 space-y-8 font-sans">
<header className="flex justify-between items-center border-b border-slate-800 pb-5">
<div>
<h1 className="text-2xl font-bold tracking-tight">Protocol Analytics</h1>
<p className="text-sm text-slate-400">Real-time metrics & liquidity overview</p>
</div>
<span className="px-3 py-1 text-xs rounded-full bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full bg-emerald-400 animate-ping" /> Live Indexer
</span>
</header>

{/* Primary KPI Metrics */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<KpiCard
title="Total Value Locked"
value={`$${Number(protocol.totalValueLockedUSD).toLocaleString(undefined, { maximumFractionDigits: 2 })}`}
icon={<DollarSign className="text-emerald-400" />}
/>
<KpiCard
title="Cumulative Volume"
value={`$${Number(protocol.totalVolumeUSD).toLocaleString(undefined, { maximumFractionDigits: 2 })}`}
icon={<Activity className="text-blue-400" />}
/>
<KpiCard
title="Active Pools"
value={protocol.activePoolsCount}
icon={<Layers className="text-purple-400" />}
/>
<KpiCard
title="Total Members"
value={protocol.totalMembers}
icon={<Users className="text-amber-400" />}
/>
</div>

{/* Pool Breakdown Table */}
<div className="border border-slate-800 rounded-xl bg-slate-900/50 overflow-hidden">
<div className="p-4 border-b border-slate-800 flex justify-between items-center">
<h2 className="font-semibold text-lg">Top Active Pools</h2>
</div>
<div className="overflow-x-auto">
<table className="w-full text-left text-sm text-slate-400">
<thead className="bg-slate-800/50 text-slate-200 text-xs uppercase tracking-wider">
<tr>
<th className="p-4">Pool Address</th>
<th className="p-4">TVL (USD)</th>
<th className="p-4">Total Volume</th>
<th className="p-4">Status</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800">
{pools.map((pool) => (
<tr key={pool.id} className="hover:bg-slate-800/30 transition-colors">
<td className="p-4 font-mono text-slate-300 flex items-center gap-1">
{pool.id.slice(0, 6)}...{pool.id.slice(-4)}
<ArrowUpRight className="w-3.5 h-3.5 text-slate-500" />
</td>
<td className="p-4 text-slate-200 font-medium">
${Number(pool.totalValueLockedUSD).toLocaleString()}
</td>
<td className="p-4">${Number(pool.volumeUSD).toLocaleString()}</td>
<td className="p-4">
<span
className={`px-2 py-0.5 text-xs rounded ${pool.isActive ? "bg-emerald-500/10 text-emerald-400" : "bg-slate-800 text-slate-500"}`}
>
{pool.isActive ? "Active" : "Inactive"}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
)
}

function KpiCard({ title, value, icon }: { title: string; value: string; icon: React.ReactNode }) {
return (
<div className="p-5 border border-slate-800 rounded-xl bg-slate-900/40 space-y-3">
<div className="flex justify-between items-center text-slate-400">
<span className="text-xs uppercase font-medium">{title}</span>
{icon}
</div>
<div className="text-2xl font-bold tracking-tight text-slate-100">{value}</div>
</div>
)
}
Empty file added subgraph/abis/Factory.json
Empty file.
Empty file added subgraph/abis/Pool.json
Empty file.
33 changes: 33 additions & 0 deletions subgraph/schema.graphql
Original file line number Diff line number Diff line change
@@ -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!
}
Empty file added subgraph/src/factory.ts
Empty file.
60 changes: 60 additions & 0 deletions subgraph/src/pool.ts
Original file line number Diff line number Diff line change
@@ -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();
}
}
48 changes: 48 additions & 0 deletions subgraph/subgraph.yaml
Original file line number Diff line number Diff line change
@@ -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
44 changes: 44 additions & 0 deletions subgraph/tests/pool.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading