From 42f80589bc99a2c393ef8eec39ef3b53560cf359 Mon Sep 17 00:00:00 2001 From: hardcordev Date: Sat, 25 Jul 2026 12:15:56 -0700 Subject: [PATCH 1/2] feat: add factory admin history, event log, defaults, and creation rate endpoints Adds four read endpoints under /api/v1/factory: admin-history and events (both requireApiKey-protected), defaults, and vault-creation-rate. admin-history and defaults need data the indexer never captured, so this also wires up the previously-unhandled adm_xfr and def_upd factory events: adm_xfr now writes to a new factory_admin_history table, and def_upd is recorded to indexed_events.parsed_data so /defaults can read it back. Closes #839, closes #840, closes #841, closes #842 --- backend/docs/events.md | 2 + backend/src/api/controllers/factory.test.ts | 144 ++++++++++++++++++ backend/src/api/controllers/factory.ts | 120 +++++++++++++++ backend/src/api/routes/factory.ts | 26 ++++ backend/src/app.ts | 2 + .../migrations/028_factory_admin_history.sql | 11 ++ backend/src/services/indexer.ts | 118 ++++++++++++++ backend/src/services/openapi.ts | 97 ++++++++++++ backend/src/test/fixtures/events.test.ts | 19 +++ backend/src/test/fixtures/events.ts | 32 ++++ 10 files changed, 571 insertions(+) create mode 100644 backend/src/api/controllers/factory.test.ts create mode 100644 backend/src/api/controllers/factory.ts create mode 100644 backend/src/api/routes/factory.ts create mode 100644 backend/src/db/migrations/028_factory_admin_history.sql diff --git a/backend/docs/events.md b/backend/docs/events.md index 4edca994..e629f114 100644 --- a/backend/docs/events.md +++ b/backend/docs/events.md @@ -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 | diff --git a/backend/src/api/controllers/factory.test.ts b/backend/src/api/controllers/factory.test.ts new file mode 100644 index 00000000..750ea5f1 --- /dev/null +++ b/backend/src/api/controllers/factory.test.ts @@ -0,0 +1,144 @@ +import { vi, describe, it, expect, beforeEach } from "vitest"; + +vi.mock("../../db/index.js", () => ({ query: vi.fn() })); +vi.mock("../../config.js", () => ({ + config: { stellar: { vaultFactoryContractId: "CFACTORY000000000000000000000000000000000000000000000" } }, +})); + +import { + getFactoryAdminHistory, + getVaultCreationRate, + getFactoryDefaults, + getFactoryEvents, +} from "./factory.js"; + +function makeRes() { + return { + json: vi.fn().mockReturnThis(), + status: vi.fn().mockReturnThis(), + }; +} + +describe("getFactoryAdminHistory (#839)", () => { + const next = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns admin transfer history ordered reverse-chronologically", async () => { + const { query } = await import("../../db/index.js"); + (query as ReturnType).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 res = makeRes(); + await getFactoryAdminHistory({} as any, res as any, next); + + expect(res.json).toHaveBeenCalledWith([ + { 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("returns an empty array when no transfers have occurred", async () => { + const { query } = await import("../../db/index.js"); + (query as ReturnType).mockResolvedValueOnce([]); + + 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).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).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).mockResolvedValueOnce([]); + + const res = makeRes(); + await getFactoryDefaults({} as any, res as any, next); + + expect(res.json).toHaveBeenCalledWith({ + defaultAsset: null, + defaultZkmeVerifier: null, + defaultCooperator: null, + }); + }); +}); + +describe("getFactoryEvents (#842)", () => { + 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; + 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" }]); + + const req = { query: { page: 1, pageSize: 20 } }; + const res = makeRes(); + await getFactoryEvents(req as any, res as any, next); + + 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, + }); + }); +}); diff --git a/backend/src/api/controllers/factory.ts b/backend/src/api/controllers/factory.ts new file mode 100644 index 00000000..28085f00 --- /dev/null +++ b/backend/src/api/controllers/factory.ts @@ -0,0 +1,120 @@ +import type { Request, Response, NextFunction } from "express"; +import { query } from "../../db/index.js"; +import { config } from "../../config.js"; + +// 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`, + ); + + 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); + } +} + +// 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<{ + 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 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 + LIMIT $2 OFFSET $3`, + [factoryContractId, pageSize, offset], + ); + + 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({ + 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); + } +} diff --git a/backend/src/api/routes/factory.ts b/backend/src/api/routes/factory.ts new file mode 100644 index 00000000..5b936b73 --- /dev/null +++ b/backend/src/api/routes/factory.ts @@ -0,0 +1,26 @@ +import { Router } from "express"; +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(); + +// 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); diff --git a/backend/src/app.ts b/backend/src/app.ts index 50840378..88ed908c 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -11,6 +11,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 { errorHandler } from "./api/middleware/errors.js"; @@ -56,6 +57,7 @@ export function createApp(): Express { app.use("/api/v1/yields", publicLimiter, yieldsRouter); app.use("/api/v1/analytics", publicLimiter, analyticsRouter); 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); app.all( diff --git a/backend/src/db/migrations/028_factory_admin_history.sql b/backend/src/db/migrations/028_factory_admin_history.sql new file mode 100644 index 00000000..2967b68a --- /dev/null +++ b/backend/src/db/migrations/028_factory_admin_history.sql @@ -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); diff --git a/backend/src/services/indexer.ts b/backend/src/services/indexer.ts index 358f243c..d211f324 100644 --- a/backend/src/services/indexer.ts +++ b/backend/src/services/indexer.ts @@ -608,6 +608,26 @@ export class Indexer { return; } + const adminTransferred = parseAdminTransferredEvent(event); + if (adminTransferred) { + await this.handleAdminTransferred(event.ledger ?? 0, adminTransferred); + await this.recordEvent(event, "adm_xfr", { + oldAdmin: adminTransferred.oldAdmin, + newAdmin: adminTransferred.newAdmin, + }); + return; + } + + const defaultsUpdated = parseDefaultsUpdatedEvent(event); + if (defaultsUpdated) { + await this.recordEvent(event, "def_upd", { + asset: defaultsUpdated.asset, + zkmeVerifier: defaultsUpdated.zkmeVerifier, + cooperator: defaultsUpdated.cooperator, + }); + return; + } + const kycSet = parseKycSetEvent(event); if (kycSet) { await this.handleKycSet(event.contractId ?? "", kycSet); @@ -1214,6 +1234,18 @@ export class Indexer { logger.info({ contractId, verifier: ev.newVerifier }, "Processed zkme_upd event"); } + private async handleAdminTransferred( + ledger: number, + ev: { oldAdmin: string; newAdmin: string }, + ): Promise { + await query( + `INSERT INTO factory_admin_history (old_admin, new_admin, ledger, recorded_at) + VALUES ($1, $2, $3, NOW())`, + [ev.oldAdmin, ev.newAdmin, ledger], + ); + logger.info({ oldAdmin: ev.oldAdmin, newAdmin: ev.newAdmin, ledger }, "Processed adm_xfr event"); + } + private async handleKycSet( contractId: string, ev: { user: string; verified: boolean; timestamp: bigint }, @@ -2082,6 +2114,92 @@ export function parseZkmeVerifierUpdatedEvent(rawEvent: unknown): ParsedZkmeVeri } } +// ── Issue #839: parseAdminTransferredEvent ──────────────────────────────────── + +export interface ParsedAdminTransferredEvent { + oldAdmin: string; + newAdmin: string; +} + +export function parseAdminTransferredEvent(rawEvent: unknown): ParsedAdminTransferredEvent | null { + try { + if (!rawEvent || typeof rawEvent !== "object") return null; + const ev = rawEvent as Record; + const topics = (ev["topic"] ?? ev["topics"]) as unknown[] | undefined; + const value = ev["value"] ?? ev["data"]; + + if (!Array.isArray(topics) || topics.length < 1 || value == null) return null; + + const parsedTopics = topics.map((t) => + typeof t === "string" ? xdr.ScVal.fromXDR(t, "base64") : (t as xdr.ScVal), + ); + const parsedValue = typeof value === "string" + ? xdr.ScVal.fromXDR(value, "base64") + : value; + + let eventName: string; + try { + eventName = String(scValToNative(parsedTopics[0]) ?? ""); + } catch { + return null; + } + if (eventName !== "adm_xfr" && eventName !== "admin_transferred") return null; + + const data = scValToNative(parsedValue as xdr.ScVal); + const arr = Array.isArray(data) ? data : Object.values((data as Record) ?? {}); + const oldAdmin = String(arr[0] ?? ""); + const newAdmin = String(arr[1] ?? ""); + + return { oldAdmin, newAdmin }; + } catch { + return null; + } +} + +// ── Issue #841: parseDefaultsUpdatedEvent ───────────────────────────────────── + +export interface ParsedDefaultsUpdatedEvent { + asset: string; + zkmeVerifier: string; + cooperator: string; +} + +export function parseDefaultsUpdatedEvent(rawEvent: unknown): ParsedDefaultsUpdatedEvent | null { + try { + if (!rawEvent || typeof rawEvent !== "object") return null; + const ev = rawEvent as Record; + const topics = (ev["topic"] ?? ev["topics"]) as unknown[] | undefined; + const value = ev["value"] ?? ev["data"]; + + if (!Array.isArray(topics) || topics.length < 1 || value == null) return null; + + const parsedTopics = topics.map((t) => + typeof t === "string" ? xdr.ScVal.fromXDR(t, "base64") : (t as xdr.ScVal), + ); + const parsedValue = typeof value === "string" + ? xdr.ScVal.fromXDR(value, "base64") + : value; + + let eventName: string; + try { + eventName = String(scValToNative(parsedTopics[0]) ?? ""); + } catch { + return null; + } + if (eventName !== "def_upd" && eventName !== "defaults_updated") return null; + + const data = scValToNative(parsedValue as xdr.ScVal); + const arr = Array.isArray(data) ? data : Object.values((data as Record) ?? {}); + const asset = String(arr[0] ?? ""); + const zkmeVerifier = String(arr[1] ?? ""); + const cooperator = String(arr[2] ?? ""); + + return { asset, zkmeVerifier, cooperator }; + } catch { + return null; + } +} + // ── Issue #594: role events ───────────────────────────────────────────────── export interface ParsedRoleGrantedEvent { diff --git a/backend/src/services/openapi.ts b/backend/src/services/openapi.ts index 0263be0b..58311c47 100644 --- a/backend/src/services/openapi.ts +++ b/backend/src/services/openapi.ts @@ -520,6 +520,103 @@ function registerPaths(): void { 404: { description: "Webhook not found", content: { "application/json": { schema: errorResponseSchema } } }, }, }); + + registry.registerPath({ + method: "get", + path: "/api/v1/factory/admin-history", + summary: "Get factory admin transfer history (requires API key)", + tags: ["Factory"], + responses: { + 200: { + description: "Admin transfer history, most recent first", + content: { + "application/json": { + schema: z.array( + z.object({ + oldAdmin: z.string(), + newAdmin: z.string(), + ledger: z.number(), + recordedAt: z.string(), + }), + ), + }, + }, + }, + }, + }); + + registry.registerPath({ + method: "get", + path: "/api/v1/factory/vault-creation-rate", + summary: "Get vault creation rate over rolling windows", + tags: ["Factory"], + responses: { + 200: { + description: "Vault counts created within each rolling window", + content: { + "application/json": { + schema: z.object({ last24h: z.number(), last7d: z.number(), last30d: z.number() }), + }, + }, + }, + }, + }); + + registry.registerPath({ + method: "get", + path: "/api/v1/factory/defaults", + summary: "Get canonical default vault parameters", + tags: ["Factory"], + responses: { + 200: { + description: "Most recently indexed default vault parameters", + content: { + "application/json": { + schema: z.object({ + defaultAsset: z.string().nullable(), + defaultZkmeVerifier: z.string().nullable(), + defaultCooperator: z.string().nullable(), + }), + }, + }, + }, + }, + }); + + registry.registerPath({ + method: "get", + path: "/api/v1/factory/events", + summary: "Get factory event log (requires API key)", + tags: ["Factory"], + request: { + query: z.object({ + page: z.coerce.number().optional(), + pageSize: z.coerce.number().optional(), + }), + }, + responses: { + 200: { + description: "Paginated factory event log, most recent ledger first", + content: { + "application/json": { + schema: z.object({ + data: z.array( + z.object({ + eventType: z.string(), + ledger: z.number(), + txHash: z.string(), + createdAt: z.string(), + }), + ), + total: z.number(), + page: z.number(), + pageSize: z.number(), + }), + }, + }, + }, + }, + }); } registerPaths(); diff --git a/backend/src/test/fixtures/events.test.ts b/backend/src/test/fixtures/events.test.ts index 71efbe91..0095e1a3 100644 --- a/backend/src/test/fixtures/events.test.ts +++ b/backend/src/test/fixtures/events.test.ts @@ -17,6 +17,8 @@ import { parseEarlyRedemptionProcessedEvent, parseEarlyRedemptionCancelledEvent, parseZkmeVerifierUpdatedEvent, + parseAdminTransferredEvent, + parseDefaultsUpdatedEvent, parseKycSetEvent, parsePausedEvent, parseUnpausedEvent, @@ -24,6 +26,7 @@ import { import { VAULT_CONTRACT, USER_ADDRESS, + OTHER_ADDRESS, makeDepositEvent, makeWithdrawEvent, makeYieldDistributedEvent, @@ -41,6 +44,8 @@ import { makeEarlyRedemptionProcessedEvent, makeEarlyRedemptionCancelledEvent, makeZkmeVerifierUpdatedEvent, + makeAdminTransferredEvent, + makeDefaultsUpdatedEvent, makeKycSetEvent, makePausedEvent, makeUnpausedEvent, @@ -138,6 +143,20 @@ describe("event fixture factory (#697)", () => { expect(parsed?.caller).toBe(USER_ADDRESS); }); + it("makeAdminTransferredEvent parses to the expected fields", () => { + const parsed = parseAdminTransferredEvent( + makeAdminTransferredEvent({ oldAdmin: USER_ADDRESS, newAdmin: OTHER_ADDRESS }), + ); + expect(parsed).toEqual({ oldAdmin: USER_ADDRESS, newAdmin: OTHER_ADDRESS }); + }); + + it("makeDefaultsUpdatedEvent parses to the expected fields", () => { + const parsed = parseDefaultsUpdatedEvent( + makeDefaultsUpdatedEvent({ asset: "XLM", zkmeVerifier: USER_ADDRESS, cooperator: OTHER_ADDRESS }), + ); + expect(parsed).toEqual({ asset: "XLM", zkmeVerifier: USER_ADDRESS, cooperator: OTHER_ADDRESS }); + }); + it("makeKycSetEvent parses to the expected fields", () => { const parsed = parseKycSetEvent(makeKycSetEvent({ verified: false })); expect(parsed).toEqual({ user: USER_ADDRESS, verified: false, timestamp: 1_700_000_000n }); diff --git a/backend/src/test/fixtures/events.ts b/backend/src/test/fixtures/events.ts index c2d5938a..74ce2378 100644 --- a/backend/src/test/fixtures/events.ts +++ b/backend/src/test/fixtures/events.ts @@ -337,6 +337,38 @@ export function makeZkmeVerifierUpdatedEvent( }; } +export function makeAdminTransferredEvent( + overrides: Partial = {}, +) { + const oldAdmin = overrides.oldAdmin ?? USER_ADDRESS; + const newAdmin = overrides.newAdmin ?? OTHER_ADDRESS; + return { + ...baseEvent(overrides), + topic: [nativeToScVal("adm_xfr")], + value: nativeToScVal([oldAdmin, newAdmin]), + }; +} + +export function makeDefaultsUpdatedEvent( + overrides: Partial = {}, +) { + const asset = overrides.asset ?? "XLM"; + const zkmeVerifier = overrides.zkmeVerifier ?? USER_ADDRESS; + const cooperator = overrides.cooperator ?? OTHER_ADDRESS; + return { + ...baseEvent(overrides), + topic: [nativeToScVal("def_upd")], + value: nativeToScVal([asset, zkmeVerifier, cooperator]), + }; +} + export function makeKycSetEvent( overrides: Partial Date: Sat, 25 Jul 2026 12:40:46 -0700 Subject: [PATCH 2/2] fix: add stable secondary sort to factory admin-history and events queries Ties on recorded_at/ledger are possible when multiple factory events land in the same ledger, which would make pagination order nondeterministic. --- backend/src/api/controllers/factory.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/src/api/controllers/factory.ts b/backend/src/api/controllers/factory.ts index 28085f00..eb89655a 100644 --- a/backend/src/api/controllers/factory.ts +++ b/backend/src/api/controllers/factory.ts @@ -13,7 +13,7 @@ export async function getFactoryAdminHistory(_req: Request, res: Response, next: }>( `SELECT old_admin, new_admin, ledger, recorded_at FROM factory_admin_history - ORDER BY recorded_at DESC`, + ORDER BY recorded_at DESC, id DESC`, ); res.json( @@ -92,7 +92,7 @@ export async function getFactoryEvents(req: Request, res: Response, next: NextFu `SELECT event_type, ledger, tx_hash, created_at FROM indexed_events WHERE contract_id = $1 - ORDER BY ledger DESC + ORDER BY ledger DESC, id DESC LIMIT $2 OFFSET $3`, [factoryContractId, pageSize, offset], );