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
2 changes: 2 additions & 0 deletions backend/docs/events.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ notifications fire. Topic values are the `symbol` in `topics[0]` the parser matc
| `erq_done` / `early_redemption_processed` | `parseEarlyRedemptionProcessedEvent` | Sets `redemption_requests.processed = TRUE` | none |
| `erq_can` / `erq_can2` / `early_redemption_cancelled` | `parseEarlyRedemptionCancelledEvent` | Sets `redemption_requests.processed = TRUE` | none |
| `zkme_upd` | `parseZkmeVerifierUpdatedEvent` | Updates `vaults.zkme_verifier_address` | none |
| `adm_xfr` / `admin_transferred` | `parseAdminTransferredEvent` | Inserts `factory_admin_history` row | none |
| `def_upd` / `defaults_updated` | `parseDefaultsUpdatedEvent` | None (recorded to `indexed_events.parsed_data` only) | none |
| `kyc_set` | `parseKycSetEvent` | `UserService.upsertUser`, inserts an `indexed_events` row directly (bypasses `recordEvent`) | none |
| `paused` / `v_pause` | `parsePausedEvent` | Sets `vaults.paused = TRUE` | none |
| `unpaused` / `v_unpause` | `parseUnpausedEvent` | Sets `vaults.paused = FALSE` | none |
Expand Down
153 changes: 118 additions & 35 deletions backend/src/api/controllers/factory.test.ts
Original file line number Diff line number Diff line change
@@ -1,61 +1,144 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { vi, describe, it, expect, beforeEach } from "vitest";

vi.mock("../../db/index.js", () => ({ query: vi.fn() }));
vi.mock("../../config.js", () => ({
config: { stellar: { vaultFactoryContractId: "CFACTORY000000000000000000000000000000000000000000000" } },
}));

async function getTestContext() {
const { query } = await import("../../db/index.js");
const { getVaultTypeDistribution } = await import("./factory.js");
return { query: query as ReturnType<typeof vi.fn>, getVaultTypeDistribution };
import {
getFactoryAdminHistory,
getVaultCreationRate,
getFactoryDefaults,
getFactoryEvents,
} from "./factory.js";

function makeRes() {
return {
json: vi.fn().mockReturnThis(),
status: vi.fn().mockReturnThis(),
};
}

describe("getVaultTypeDistribution", () => {
describe("getFactoryAdminHistory (#839)", () => {
const next = vi.fn();

beforeEach(() => {
vi.clearAllMocks();
});

it("returns vault type counts with percentages summing to 100", async () => {
const { query, getVaultTypeDistribution } = await getTestContext();
query.mockResolvedValueOnce([
{ vault_type: "real-estate", count: "3" },
{ vault_type: "treasury", count: "1" },
it("returns admin transfer history ordered reverse-chronologically", async () => {
const { query } = await import("../../db/index.js");
(query as ReturnType<typeof vi.fn>).mockResolvedValueOnce([
{ old_admin: "GOLD", new_admin: "GNEW", ledger: 200, recorded_at: new Date("2026-01-02") },
{ old_admin: "GOLDER", new_admin: "GOLD", ledger: 100, recorded_at: new Date("2026-01-01") },
]);

const req = {} as any;
const res = { json: vi.fn() } as any;
const next = vi.fn();

await getVaultTypeDistribution(req, res, next);
const res = makeRes();
await getFactoryAdminHistory({} as any, res as any, next);

expect(res.json).toHaveBeenCalledWith([
{ vaultType: "real-estate", count: 3, percentage: 75 },
{ vaultType: "treasury", count: 1, percentage: 25 },
{ oldAdmin: "GOLD", newAdmin: "GNEW", ledger: 200, recordedAt: new Date("2026-01-02") },
{ oldAdmin: "GOLDER", newAdmin: "GOLD", ledger: 100, recordedAt: new Date("2026-01-01") },
]);
});

it("omits categories with no vaults and returns an empty array when there are none", async () => {
const { query, getVaultTypeDistribution } = await getTestContext();
query.mockResolvedValueOnce([]);
it("returns an empty array when no transfers have occurred", async () => {
const { query } = await import("../../db/index.js");
(query as ReturnType<typeof vi.fn>).mockResolvedValueOnce([]);

const req = {} as any;
const res = { json: vi.fn() } as any;
const next = vi.fn();

await getVaultTypeDistribution(req, res, next);
const res = makeRes();
await getFactoryAdminHistory({} as any, res as any, next);

expect(res.json).toHaveBeenCalledWith([]);
});
});

describe("getVaultCreationRate (#840)", () => {
const next = vi.fn();

beforeEach(() => {
vi.clearAllMocks();
});

it("returns counts for each rolling window", async () => {
const { query } = await import("../../db/index.js");
(query as ReturnType<typeof vi.fn>).mockResolvedValueOnce([
{ last24h: "2", last7d: "5", last30d: "12" },
]);

const res = makeRes();
await getVaultCreationRate({} as any, res as any, next);

expect(res.json).toHaveBeenCalledWith({ last24h: 2, last7d: 5, last30d: 12 });
});
});

describe("getFactoryDefaults (#841)", () => {
const next = vi.fn();

beforeEach(() => {
vi.clearAllMocks();
});

it("returns the fields from the most recent def_upd event", async () => {
const { query } = await import("../../db/index.js");
(query as ReturnType<typeof vi.fn>).mockResolvedValueOnce([
{ parsed_data: { asset: "XLM", zkmeVerifier: "GZKME", cooperator: "GCOOP" } },
]);

const res = makeRes();
await getFactoryDefaults({} as any, res as any, next);

expect(res.json).toHaveBeenCalledWith({
defaultAsset: "XLM",
defaultZkmeVerifier: "GZKME",
defaultCooperator: "GCOOP",
});
});

it("returns nulls when no def_upd event has been indexed", async () => {
const { query } = await import("../../db/index.js");
(query as ReturnType<typeof vi.fn>).mockResolvedValueOnce([]);

const res = makeRes();
await getFactoryDefaults({} as any, res as any, next);

expect(res.json).toHaveBeenCalledWith({
defaultAsset: null,
defaultZkmeVerifier: null,
defaultCooperator: null,
});
});
});

it("forwards errors to next", async () => {
const { query, getVaultTypeDistribution } = await getTestContext();
const err = new Error("db down");
query.mockRejectedValueOnce(err);
describe("getFactoryEvents (#842)", () => {
const next = vi.fn();

const req = {} as any;
const res = { json: vi.fn() } as any;
const next = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});

it("returns paginated factory events in reverse ledger order", async () => {
const { query } = await import("../../db/index.js");
const mockQuery = query as ReturnType<typeof vi.fn>;
mockQuery.mockResolvedValueOnce([
{ event_type: "v_create", ledger: 300, tx_hash: "tx2", created_at: new Date("2026-01-02") },
{ event_type: "adm_xfr", ledger: 200, tx_hash: "tx1", created_at: new Date("2026-01-01") },
]);
mockQuery.mockResolvedValueOnce([{ count: "2" }]);

await getVaultTypeDistribution(req, res, next);
const req = { query: { page: 1, pageSize: 20 } };
const res = makeRes();
await getFactoryEvents(req as any, res as any, next);

expect(next).toHaveBeenCalledWith(err);
expect(res.json).toHaveBeenCalledWith({
data: [
{ eventType: "v_create", ledger: 300, txHash: "tx2", createdAt: new Date("2026-01-02") },
{ eventType: "adm_xfr", ledger: 200, txHash: "tx1", createdAt: new Date("2026-01-01") },
],
total: 2,
page: 1,
pageSize: 20,
});
});
});
134 changes: 106 additions & 28 deletions backend/src/api/controllers/factory.ts
Original file line number Diff line number Diff line change
@@ -1,41 +1,119 @@
import type { Request, Response, NextFunction } from "express";
import { query } from "../../db/index.js";
import { config } from "../../config.js";

interface VaultTypeDistributionEntry {
vaultType: string;
count: number;
percentage: number;
// GET /api/v1/factory/admin-history — audit log of factory admin transfers (#839)
export async function getFactoryAdminHistory(_req: Request, res: Response, next: NextFunction) {
try {
const rows = await query<{
old_admin: string;
new_admin: string;
ledger: number;
recorded_at: Date;
}>(
`SELECT old_admin, new_admin, ledger, recorded_at
FROM factory_admin_history
ORDER BY recorded_at DESC, id DESC`,
);

res.json(
rows.map((r) => ({
oldAdmin: r.old_admin,
newAdmin: r.new_admin,
ledger: r.ledger,
recordedAt: r.recorded_at,
})),
);
} catch (err) {
next(err);
}
}

// GET /api/v1/factory/vault-creation-rate — vault deployment rate over rolling windows (#840)
export async function getVaultCreationRate(_req: Request, res: Response, next: NextFunction) {
try {
const rows = await query<{ last24h: string; last7d: string; last30d: string }>(
`SELECT
COUNT(*) FILTER (WHERE created_at >= NOW() - INTERVAL '24 hours')::text AS last24h,
COUNT(*) FILTER (WHERE created_at >= NOW() - INTERVAL '7 days')::text AS last7d,
COUNT(*) FILTER (WHERE created_at >= NOW() - INTERVAL '30 days')::text AS last30d
FROM vaults`,
);

const row = rows[0];
res.json({
last24h: parseInt(row?.last24h ?? "0", 10),
last7d: parseInt(row?.last7d ?? "0", 10),
last30d: parseInt(row?.last30d ?? "0", 10),
});
} catch (err) {
next(err);
}
}

/**
* Distribution of deployed vaults by type (rwa_category), so clients can
* show what proportion of the factory's vaults fall into each category (#843).
* Categories with no vaults are omitted rather than shown at 0%.
*/
export async function getVaultTypeDistribution(_req: Request, res: Response, next: NextFunction) {
// GET /api/v1/factory/defaults — canonical default vault parameters (#841)
export async function getFactoryDefaults(_req: Request, res: Response, next: NextFunction) {
try {
const rows = await query<{ vault_type: string; count: string }>(
`SELECT rwa_category AS vault_type, COUNT(*)::text AS count
FROM vaults
WHERE archived = FALSE AND rwa_category IS NOT NULL
GROUP BY rwa_category
ORDER BY count DESC`,
const rows = await query<{
parsed_data: { asset?: string; zkmeVerifier?: string; cooperator?: string } | null;
}>(
`SELECT parsed_data
FROM indexed_events
WHERE contract_id = $1 AND event_type = 'def_upd'
ORDER BY ledger DESC
LIMIT 1`,
[config.stellar.vaultFactoryContractId],
);

const total = rows.reduce((sum, row) => sum + parseInt(row.count, 10), 0);
const defaults = rows[0]?.parsed_data ?? null;
res.json({
defaultAsset: defaults?.asset ?? null,
defaultZkmeVerifier: defaults?.zkmeVerifier ?? null,
defaultCooperator: defaults?.cooperator ?? null,
});
} catch (err) {
next(err);
}
}

// GET /api/v1/factory/events — chronological factory-level event log (#842)
export async function getFactoryEvents(req: Request, res: Response, next: NextFunction) {
try {
const { page, pageSize } = req.query as unknown as { page: number; pageSize: number };
const offset = (page - 1) * pageSize;
const factoryContractId = config.stellar.vaultFactoryContractId;

const rows = await query<{
event_type: string;
ledger: number;
tx_hash: string;
created_at: Date;
}>(
`SELECT event_type, ledger, tx_hash, created_at
FROM indexed_events
WHERE contract_id = $1
ORDER BY ledger DESC, id DESC
LIMIT $2 OFFSET $3`,
[factoryContractId, pageSize, offset],
);

const distribution: VaultTypeDistributionEntry[] = total === 0
? []
: rows.map((row) => {
const count = parseInt(row.count, 10);
return {
vaultType: row.vault_type,
count,
percentage: (count / total) * 100,
};
});
const countRows = await query<{ count: string }>(
"SELECT COUNT(*)::text as count FROM indexed_events WHERE contract_id = $1",
[factoryContractId],
);
const total = parseInt(countRows[0]?.count ?? "0", 10);

res.json(distribution);
res.json({
data: rows.map((r) => ({
eventType: r.event_type,
ledger: r.ledger,
txHash: r.tx_hash,
createdAt: r.created_at,
})),
total,
page,
pageSize,
});
} catch (err) {
next(err);
}
Expand Down
24 changes: 22 additions & 2 deletions backend/src/api/routes/factory.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,26 @@
import { Router } from "express";
import { getVaultTypeDistribution } from "../controllers/factory.js";
import { z } from "zod";
import {
getFactoryAdminHistory,
getVaultCreationRate,
getFactoryDefaults,
getFactoryEvents,
} from "../controllers/factory.js";
import { validateQuery } from "../middleware/validate.js";
import { requireApiKey } from "../middleware/auth.js";

const factoryEventsQuerySchema = z.object({
page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).default(20).transform((value) => Math.min(value, 100)),
});

export const factoryRouter = Router();

factoryRouter.get("/vault-type-distribution", getVaultTypeDistribution);
// Reverse-chronological admin transfer log for audits (#839)
factoryRouter.get("/admin-history", requireApiKey(), getFactoryAdminHistory);
// Vault deployment rate over rolling 24h/7d/30d windows (#840)
factoryRouter.get("/vault-creation-rate", getVaultCreationRate);
// Canonical default vault parameters sourced from the latest def_upd event (#841)
factoryRouter.get("/defaults", getFactoryDefaults);
// Paginated, reverse-ledger-order factory event log (#842)
factoryRouter.get("/events", requireApiKey(), validateQuery(factoryEventsQuerySchema), getFactoryEvents);
2 changes: 2 additions & 0 deletions backend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { vaultsRouter } from "./api/routes/vaults.js";
import { usersRouter } from "./api/routes/users.js";
import { yieldsRouter } from "./api/routes/yields.js";
import { adminRouter } from "./api/routes/admin.js";
import { factoryRouter } from "./api/routes/factory.js";
import { webhooksRouter } from "./api/routes/webhooks.js";
import { analyticsRouter } from "./api/routes/analytics.js";
import { factoryRouter } from "./api/routes/factory.js";
Expand Down Expand Up @@ -56,6 +57,7 @@ export function createApp(): Express {
app.use("/api/v1/analytics", publicLimiter, analyticsRouter);
app.use("/api/v1/factory", publicLimiter, factoryRouter);
app.use("/api/v1/admin", authLimiter, adminRouter);
app.use("/api/v1/factory", publicLimiter, factoryRouter);
app.use("/api/v1/webhooks", authLimiter, webhooksRouter);
app.use("/internal", authLimiter, internalAuth, internalRouter);
// SDL export for client codegen tools (e.g. graphql-codegen); cached since the
Expand Down
11 changes: 11 additions & 0 deletions backend/src/db/migrations/028_factory_admin_history.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
-- Track factory admin transfers for audit purposes (#839)

CREATE TABLE IF NOT EXISTS factory_admin_history (
id SERIAL PRIMARY KEY,
old_admin TEXT NOT NULL,
new_admin TEXT NOT NULL,
ledger INT NOT NULL,
recorded_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX IF NOT EXISTS idx_factory_admin_history_recorded_at ON factory_admin_history (recorded_at DESC);
Loading
Loading