Skip to content
Open
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
44 changes: 44 additions & 0 deletions frontend/__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
let protocol = new Protocol("1");

Check failure on line 10 in frontend/__tests__/pool.test.ts

View workflow job for this annotation

GitHub Actions / Frontend – Lint & Format Check

'protocol' is never reassigned. Use 'const' instead
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();

Check failure on line 18 in frontend/__tests__/pool.test.ts

View workflow job for this annotation

GitHub Actions / Frontend – Lint & Format Check

'mockEvent' is never reassigned. Use 'const' instead
let memberAddress = Address.fromString("0x0000000000000000000000000000000000000001");

Check failure on line 19 in frontend/__tests__/pool.test.ts

View workflow job for this annotation

GitHub Actions / Frontend – Lint & Format Check

'memberAddress' is never reassigned. Use 'const' instead

let memberParam = new ethereum.EventParam("member", ethereum.Value.fromAddress(memberAddress));

Check failure on line 21 in frontend/__tests__/pool.test.ts

View workflow job for this annotation

GitHub Actions / Frontend – Lint & Format Check

'memberParam' is never reassigned. Use 'const' instead
mockEvent.parameters = [memberParam];

let memberJoinedEvent = new MemberJoined(

Check failure on line 24 in frontend/__tests__/pool.test.ts

View workflow job for this annotation

GitHub Actions / Frontend – Lint & Format Check

'memberJoinedEvent' is never reassigned. Use 'const' instead
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();
});
});
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: any) {

Check failure on line 40 in frontend/app/api/v1/protocol/summary/route.ts

View workflow job for this annotation

GitHub Actions / Frontend – Lint & Format Check

Unexpected any. Specify a different type
return NextResponse.json(
{ success: false, error: error.message || "Failed to fetch protocol analytics" },
{ status: 500 }
);
}
}
122 changes: 122 additions & 0 deletions frontend/app/components/Dashboard.tsx
Original file line number Diff line number Diff line change
@@ -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<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
Loading