Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
0f46e33
feat: conditional streams, analytics stack, developer portal, copy fe…
sojetunde8 Sep 28, 2026
222a28a
ci(docs): drop npm cache requirement until a lockfile lands
sojetunde8 Sep 28, 2026
66a7fed
fix(docs): use the official mermaid theme; drop nonexistent remark-me…
sojetunde8 Sep 28, 2026
07d8138
fix(docs): site-absolute doc links, MDX-safe API pages, official merm…
sojetunde8 Sep 28, 2026
7d702f6
fix(docs): declare homePageId and point the navbar playground at its …
sojetunde8 Sep 28, 2026
4170814
fix(docs): serve the overview doc at '/' via slug front matter
sojetunde8 Sep 28, 2026
a305fc8
fix: regenerate package-lock.json to match package.json on main
sojetunde8 Sep 28, 2026
86277ab
Merge branch 'main' into feature/1480-1483-platform-suite
sojetunde8 Oct 5, 2026
99fc3e7
fix(contracts): silence dead_code on PriceOracle consumed by contract…
sojetunde8 Oct 5, 2026
d177501
style(contracts): apply rustfmt to stream_contract
sojetunde8 Oct 5, 2026
5aa35ac
fix(backend): aggregate analytics in JS over i128 strings and fix log…
sojetunde8 Oct 5, 2026
f090e22
fix(frontend): drop unused imports failing production type check
sojetunde8 Oct 5, 2026
d3ca263
test(contracts): cover dispute, allowance, rate and conditional miles…
sojetunde8 Oct 5, 2026
5113f57
Merge branch 'main' into feature/1480-1483-platform-suite
sojetunde8 Oct 5, 2026
9cfca4f
Merge branch 'main' into feature/1480-1483-platform-suite
sojetunde8 Oct 6, 2026
235f96c
fix(deps): bump proxy-addr and source-map-js past critical and high a…
sojetunde8 Oct 6, 2026
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');
99 changes: 99 additions & 0 deletions backend/src/controllers/analytics.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
/**
* 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("analytics TVL request failed:", error);
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)) {
sendApiError(
res,
400,
"INVALID_PERIOD",
`period must be one of: ${VALID_PERIODS.join(", ")}`
);
return;
}
if (!VALID_INTERVALS.includes(interval)) {
sendApiError(
res,
400,
"INVALID_INTERVAL",
`interval must be one of: ${VALID_INTERVALS.join(", ")}`
);
return;
}

try {
const series = await getHistoricalAnalytics(period, interval);
res.json({ success: true, data: series });
} catch (error) {
logger.error("analytics historical request failed:", error);
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("DefiLlama adapter request failed:", error);
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";
import tokenRoutes from "./token.routes.js";

const router = Router();
Expand All @@ -15,6 +16,7 @@ router.use("/events", eventsRoutes);
router.use("/users", userRoutes);
router.use("/auth", authRoutes);
router.use("/webhooks", webhookRoutes);
router.use("/analytics", analyticsRoutes);
router.use("/tokens", tokenRoutes);

// Admin routes
Expand Down
Loading
Loading