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
57 changes: 57 additions & 0 deletions .github/workflows/deploy-docs.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
name: Deploy Docs

on:
pull_request:
paths:
- "docs-site/**"
- ".github/workflows/deploy-docs.yml"
push:
branches: [main]
paths:
- "docs-site/**"
- ".github/workflows/deploy-docs.yml"

defaults:
run:
working-directory: docs-site

jobs:
build-docs:
name: Build developer portal
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: 20

# No lockfile is committed for the portal yet; once one lands, add
# `cache: npm` + `cache-dependency-path: docs-site/package-lock.json`
# and switch this to `npm ci` for reproducible builds.
- name: Install dependencies
run: npm install --no-audit --no-fund

- name: Build site (fails on broken links / missing assets)
run: npm run build

- name: Upload pages artifact
if: github.ref == 'refs/heads/main'
uses: actions/upload-pages-artifact@v3
with:
path: docs-site/build

deploy-docs:
name: Deploy to GitHub Pages
if: github.ref == 'refs/heads/main'
needs: build-docs
runs-on: ubuntu-latest
permissions:
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- id: deployment
uses: actions/deploy-pages@v4
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
-- TimescaleDB analytics backbone (#1480).
--
-- Protocol TVL, streaming velocity and withdrawn totals change with every
-- ledger second, so they are snapshotted into a hypertable and pre-aggregated
-- with continuous aggregates instead of being recomputed from the raw Stream
-- and StreamEvent tables on every request.
--
-- Requires the timescaledb extension to be available on the PostgreSQL
-- instance (see docker-compose.yml). If the extension is not installed the
-- statements below fail; the analytics API falls back to live Prisma
-- aggregation in that case, so existing deployments keep working.

CREATE EXTENSION IF NOT EXISTS timescaledb CASCADE;

-- Hypertable for continuous stream flow snapshots. One row per
-- (token_address, snapshot tick) produced by the indexer's aggregation pass.
CREATE TABLE IF NOT EXISTS stream_flow_snapshots (
time TIMESTAMPTZ NOT NULL,
token_address TEXT NOT NULL,
active_stream_count INT NOT NULL,
total_locked_amount NUMERIC(38, 0) NOT NULL,
cumulative_streamed_amount NUMERIC(38, 0) NOT NULL,
cumulative_withdrawn_amount NUMERIC(38, 0) NOT NULL,
flow_velocity_per_second NUMERIC(38, 0) NOT NULL
);

SELECT create_hypertable('stream_flow_snapshots', 'time', if_not_exists => TRUE);

CREATE INDEX IF NOT EXISTS idx_stream_flow_snapshots_token_time
ON stream_flow_snapshots (token_address, time DESC);

-- Hourly continuous aggregate: average TVL, summed velocity and peak stream
-- count per token, refreshable incrementally by TimescaleDB policies.
CREATE MATERIALIZED VIEW IF NOT EXISTS hourly_protocol_metrics
WITH (timescaledb.continuous) AS
SELECT time_bucket('1 hour', time) AS bucket,
token_address,
AVG(total_locked_amount) AS avg_tvl,
SUM(flow_velocity_per_second) AS aggregate_velocity,
MAX(active_stream_count) AS peak_streams
FROM stream_flow_snapshots
GROUP BY bucket, token_address
WITH NO DATA;

-- Daily continuous aggregate for 30d/90d/1y historical charts.
CREATE MATERIALIZED VIEW IF NOT EXISTS daily_protocol_metrics
WITH (timescaledb.continuous) AS
SELECT time_bucket('1 day', time) AS bucket,
token_address,
AVG(total_locked_amount) AS avg_tvl,
SUM(flow_velocity_per_second) AS aggregate_velocity,
MAX(active_stream_count) AS peak_streams,
MAX(cumulative_withdrawn_amount) AS cumulative_withdrawn
FROM stream_flow_snapshots
GROUP BY bucket, token_address
WITH NO DATA;

-- Refresh policies: hourly aggregate refreshes shortly after each hour ends,
-- daily aggregate in the small hours. Both retain their full window.
SELECT add_continuous_aggregate_policy('hourly_protocol_metrics',
start_offset => INTERVAL '3 days',
end_offset => INTERVAL '1 hour',
schedule_interval => INTERVAL '1 hour');

SELECT add_continuous_aggregate_policy('daily_protocol_metrics',
start_offset => INTERVAL '40 days',
end_offset => INTERVAL '1 day',
schedule_interval => INTERVAL '1 day');

-- Compress raw snapshots older than 14 days to keep the hypertable small
-- while retaining every data point for the daily rollups.
ALTER TABLE stream_flow_snapshots SET (
timescaledb.compress,
timescaledb.compress_segmentby = 'token_address'
);

SELECT add_compression_policy('stream_flow_snapshots', INTERVAL '14 days');
97 changes: 97 additions & 0 deletions backend/src/controllers/analytics.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
/**
* Analytics controller — HTTP surface for the #1480 analytics stack.
*
* Read-only endpoints; no auth beyond the API's global middleware because
* every payload here is public protocol statistics (the same numbers a
* block explorer would show).
*/

import type { Request, Response } from "express";
import {
getDefiLlamaAdapter,
getHistoricalAnalytics,
getProtocolTvl,
type AnalyticsInterval,
type AnalyticsPeriod,
} from "../services/analytics.service.js";
import { sendApiError } from "../types/api-error.js";
import logger from "../logger.js";

const VALID_PERIODS: AnalyticsPeriod[] = ["7d", "30d", "90d", "1y"];
const VALID_INTERVALS: AnalyticsInterval[] = ["1h", "1d"];

/**
* GET /api/v1/analytics/tvl
*
* Current protocol TVL partitioned by token asset.
*/
export async function getTvlHandler(_req: Request, res: Response) {
try {
const snapshot = await getProtocolTvl();
res.json({ success: true, data: snapshot });
} catch (error) {
logger.error({ err: error }, "analytics TVL request failed");
sendApiError(res, 500, "ANALYTICS_UNAVAILABLE", "TVL snapshot unavailable");
}
}

/**
* GET /api/v1/analytics/historical?period=30d&interval=1d
*
* Pre-aggregated time-series points for frontend charts.
*/
export async function getHistoricalHandler(req: Request, res: Response) {
const period = (req.query.period ?? "30d") as AnalyticsPeriod;
const interval = (req.query.interval ?? "1d") as AnalyticsInterval;

if (!VALID_PERIODS.includes(period)) {
return sendApiError(
res,
400,
"INVALID_PERIOD",
`period must be one of: ${VALID_PERIODS.join(", ")}`
);
}
if (!VALID_INTERVALS.includes(interval)) {
return sendApiError(
res,
400,
"INVALID_INTERVAL",
`interval must be one of: ${VALID_INTERVALS.join(", ")}`
);
}

try {
const series = await getHistoricalAnalytics(period, interval);
res.json({ success: true, data: series });
} catch (error) {
logger.error({ err: error }, "analytics historical request failed");
sendApiError(
res,
500,
"ANALYTICS_UNAVAILABLE",
"Historical analytics unavailable"
);
}
}

/**
* GET /api/v1/analytics/defillama
*
* Standardized DefiLlama protocol-TVL adapter response.
*/
export async function getDefiLlamaHandler(_req: Request, res: Response) {
try {
const adapter = await getDefiLlamaAdapter();
// DefiLlama consumes the raw object; no success envelope here.
res.json(adapter);
} catch (error) {
logger.error({ err: error }, "DefiLlama adapter request failed");
sendApiError(
res,
500,
"ANALYTICS_UNAVAILABLE",
"DefiLlama adapter unavailable"
);
}
}
22 changes: 22 additions & 0 deletions backend/src/routes/v1/analytics.routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/**
* Analytics routes (#1480) — public protocol statistics.
*
* - GET /tvl — current TVL partitioned by token
* - GET /historical — pre-aggregated chart series (`?period=&interval=`)
* - GET /defillama — DefiLlama adapter payload
*/

import { Router } from "express";
import {
getDefiLlamaHandler,
getHistoricalHandler,
getTvlHandler,
} from "../../controllers/analytics.controller.js";

const router = Router();

router.get("/tvl", getTvlHandler);
router.get("/historical", getHistoricalHandler);
router.get("/defillama", getDefiLlamaHandler);

export default router;
2 changes: 2 additions & 0 deletions backend/src/routes/v1/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import userRoutes from "./user.routes.js";
import authRoutes from "./auth.routes.js";
import adminRoutes from "./admin.routes.js";
import webhookRoutes from "./webhook.routes.js";
import analyticsRoutes from "./analytics.routes.js";

const router = Router();

Expand All @@ -14,6 +15,7 @@ router.use("/events", eventsRoutes);
router.use("/users", userRoutes);
router.use("/auth", authRoutes);
router.use("/webhooks", webhookRoutes);
router.use("/analytics", analyticsRoutes);

// Admin routes
router.use("/admin", adminRoutes);
Expand Down
Loading
Loading