diff --git a/prisma/schema/freeze.prisma b/prisma/schema/freeze.prisma new file mode 100644 index 00000000..c09b90dd --- /dev/null +++ b/prisma/schema/freeze.prisma @@ -0,0 +1,30 @@ +// prisma/schema/freeze.prisma +model KeyFreeze { + id String @id @default(cuid()) + keyId String @unique // matches KeyOwnership.creatorId (the "key" identifier in this repo) + isFrozen Boolean @default(false) + reason String? + frozenAt DateTime? + frozenBy String? + unfrozenAt DateTime? + proposalId String? + updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) + + @@index([isFrozen]) +} + +model FreezeEvent { + id String @id @default(cuid()) + keyId String + eventType String // FREEZE | UNFREEZE | PROPOSAL_CREATED + ledger BigInt + txHash String + eventIndex Int + payload Json + createdAt DateTime @default(now()) + + @@unique([txHash, eventIndex]) + @@index([keyId, ledger]) + @@index([ledger]) +} diff --git a/src/modules/freeze/freeze.controllers.ts b/src/modules/freeze/freeze.controllers.ts new file mode 100644 index 00000000..1beb780a --- /dev/null +++ b/src/modules/freeze/freeze.controllers.ts @@ -0,0 +1,42 @@ +import { AsyncController } from '../../types/auth.types'; +import { sendSuccess, sendValidationError, sendNotFound } from '../../utils/api-response.utils'; +import { attachTimestampHeader } from '../../utils/timestamp-headers.utils'; +import { KeyIdParamSchema, FreezeBodySchema, UnfreezeBodySchema } from './freeze.schemas'; +import { getFreezeStatus, emergencyFreeze, initiateUnfreeze } from './freeze.service'; +import { prisma } from '../../utils/prisma.utils'; + +export const httpGetFreezeStatus: AsyncController = async (req, res, next) => { + try { + const parsed = KeyIdParamSchema.safeParse(req.params); + if (!parsed.success) return sendValidationError(res, 'Invalid keyId', parsed.error.issues.map(i => ({ field: i.path.join('.'), message: i.message }))); + const exists = await prisma.keyOwnership.findFirst({ where: { creatorId: parsed.data.keyId } }); + if (!exists) return sendNotFound(res, 'Key'); + const status = await getFreezeStatus(parsed.data.keyId); + attachTimestampHeader(res); + sendSuccess(res, status); + } catch (e) { next(e); } +}; + +export const httpEmergencyFreeze: AsyncController = async (req: any, res, next) => { + try { + const params = KeyIdParamSchema.safeParse(req.params); + if (!params.success) return sendValidationError(res, 'Invalid keyId', params.error.issues.map(i => ({ field: i.path.join('.'), message: i.message }))); + const body = FreezeBodySchema.safeParse(req.body); + if (!body.success) return sendValidationError(res, 'Invalid body', body.error.issues.map(i => ({ field: i.path.join('.'), message: i.message }))); + if (!req.adminId) return sendValidationError(res, 'Admin id missing'); + const status = await emergencyFreeze(params.data.keyId, body.data.reason, req.adminId); + sendSuccess(res, status, 201, 'Key frozen'); + } catch (e) { next(e); } +}; + +export const httpInitiateUnfreeze: AsyncController = async (req: any, res, next) => { + try { + const params = KeyIdParamSchema.safeParse(req.params); + if (!params.success) return sendValidationError(res, 'Invalid keyId', params.error.issues.map(i => ({ field: i.path.join('.'), message: i.message }))); + const body = UnfreezeBodySchema.safeParse(req.body ?? {}); + if (!body.success) return sendValidationError(res, 'Invalid body', body.error.issues.map(i => ({ field: i.path.join('.'), message: i.message }))); + if (!req.adminId) return sendValidationError(res, 'Admin id missing'); + const status = await initiateUnfreeze(params.data.keyId, req.adminId, body.data.reason); + sendSuccess(res, status, 202, 'Unfreeze proposal created'); + } catch (e) { next(e); } +}; diff --git a/src/modules/freeze/freeze.routes.ts b/src/modules/freeze/freeze.routes.ts new file mode 100644 index 00000000..dc211e99 --- /dev/null +++ b/src/modules/freeze/freeze.routes.ts @@ -0,0 +1,9 @@ +import { Router } from 'express'; +import { adminGuard } from '../../middlewares/admin-guard.middleware'; +import { httpGetFreezeStatus, httpEmergencyFreeze, httpInitiateUnfreeze } from './freeze.controllers'; + +const router = Router(); +router.get('/:keyId/freeze', httpGetFreezeStatus); +router.post('/:keyId/freeze', adminGuard, httpEmergencyFreeze); +router.post('/:keyId/unfreeze', adminGuard, httpInitiateUnfreeze); +export default router; diff --git a/src/modules/freeze/freeze.schemas.ts b/src/modules/freeze/freeze.schemas.ts new file mode 100644 index 00000000..b8702ce5 --- /dev/null +++ b/src/modules/freeze/freeze.schemas.ts @@ -0,0 +1,6 @@ +import { z } from 'zod'; +export const KeyIdParamSchema = z.object({ keyId: z.string().min(1).max(128) }).strict(); +export const FreezeBodySchema = z.object({ reason: z.string().min(3).max(500) }).strict(); +export const UnfreezeBodySchema = z.object({ proposalId: z.string().min(1).max(128).optional(), reason: z.string().min(3).max(500).optional() }).strict(); +export type FreezeBody = z.infer; +export type UnfreezeBody = z.infer; diff --git a/src/modules/freeze/freeze.service.ts b/src/modules/freeze/freeze.service.ts new file mode 100644 index 00000000..be024708 --- /dev/null +++ b/src/modules/freeze/freeze.service.ts @@ -0,0 +1,158 @@ +// src/modules/freeze/freeze.service.ts +import { prisma } from '../../utils/prisma.utils'; +import { logger } from '../../utils/logger.utils'; +import { emitAuditEvent } from '../../utils/audit.utils'; +import { + invalidateFreezeCache, + getCachedFreezeStatus, + setCachedFreezeStatus, +} from '../../utils/freeze-cache.utils'; + +export interface FreezeStatus { + keyId: string; + isFrozen: boolean; + reason: string | null; + frozenAt: string | null; + frozenBy: string | null; + proposalId: string | null; +} + +export async function getFreezeStatus(keyId: string): Promise { + const cached = getCachedFreezeStatus(keyId); + if (cached) return cached; + + const row = await prisma.keyFreeze.findUnique({ where: { keyId } }); + const status: FreezeStatus = row + ? { + keyId: row.keyId, + isFrozen: row.isFrozen, + reason: row.reason, + frozenAt: row.frozenAt ? row.frozenAt.toISOString() : null, + frozenBy: row.frozenBy, + proposalId: row.proposalId, + } + : { keyId, isFrozen: false, reason: null, frozenAt: null, frozenBy: null, proposalId: null }; + + setCachedFreezeStatus(keyId, status); + return status; +} + +export async function emergencyFreeze( + keyId: string, + reason: string, + adminId: string +): Promise { + const now = new Date(); + + await prisma.keyFreeze.upsert({ + where: { keyId }, + create: { keyId, isFrozen: true, reason, frozenAt: now, frozenBy: adminId }, + update: { isFrozen: true, reason, frozenAt: now, frozenBy: adminId, unfrozenAt: null }, + }); + + await prisma.freezeEvent.create({ + data: { + keyId, + eventType: 'FREEZE', + ledger: BigInt(0), + txHash: `admin:${adminId}:${now.getTime()}`, + eventIndex: 0, + payload: { reason, adminId }, + }, + }); + + await emitAuditEvent({ + actor: adminId, + action: 'emergency_freeze_key', + target: 'KeyFreeze', + targetId: keyId, + metadata: { reason }, + }); + + invalidateFreezeCache(keyId); + logger.info({ keyId, adminId, reason }, 'freeze: emergency freeze executed'); + return getFreezeStatus(keyId); +} + +export async function initiateUnfreeze( + keyId: string, + adminId: string, + reason?: string +): Promise { + const now = new Date(); + const proposalId = `prop_${keyId}_${now.getTime()}`; + + await prisma.keyFreeze.upsert({ + where: { keyId }, + create: { keyId, isFrozen: false, reason: reason ?? null, proposalId, unfrozenAt: now, frozenBy: adminId }, + update: { proposalId, reason: reason ?? null, unfrozenAt: now }, + }); + + await prisma.freezeEvent.create({ + data: { + keyId, + eventType: 'PROPOSAL_CREATED', + ledger: BigInt(0), + txHash: `proposal:${proposalId}`, + eventIndex: 0, + payload: { adminId, reason, proposalId }, + }, + }); + + await emitAuditEvent({ + actor: adminId, + action: 'initiate_unfreeze_proposal', + target: 'KeyFreeze', + targetId: keyId, + metadata: { proposalId, reason: reason ?? null }, + }); + + invalidateFreezeCache(keyId); + logger.info({ keyId, adminId, proposalId }, 'freeze: unfreeze proposal initiated'); + return getFreezeStatus(keyId); +} + +export async function indexFreezeEvent(input: { + keyId: string; + eventType: 'FREEZE' | 'UNFREEZE' | 'PROPOSAL_CREATED'; + ledger: number; + txHash: string; + eventIndex: number; + payload: Record; +}): Promise { + await prisma.freezeEvent.create({ + data: { + keyId: input.keyId, + eventType: input.eventType, + ledger: BigInt(input.ledger), + txHash: input.txHash, + eventIndex: input.eventIndex, + payload: input.payload as any, + }, + }); + + if (input.eventType === 'FREEZE') { + await prisma.keyFreeze.upsert({ + where: { keyId: input.keyId }, + create: { + keyId: input.keyId, + isFrozen: true, + reason: String(input.payload.reason ?? ''), + frozenAt: new Date(), + }, + update: { + isFrozen: true, + reason: String(input.payload.reason ?? ''), + frozenAt: new Date(), + }, + }); + } else if (input.eventType === 'UNFREEZE') { + await prisma.keyFreeze.upsert({ + where: { keyId: input.keyId }, + create: { keyId: input.keyId, isFrozen: false, unfrozenAt: new Date() }, + update: { isFrozen: false, unfrozenAt: new Date() }, + }); + } + + invalidateFreezeCache(input.keyId); +} diff --git a/src/modules/index.ts b/src/modules/index.ts index cb1bda93..47e9bd1e 100644 --- a/src/modules/index.ts +++ b/src/modules/index.ts @@ -1,6 +1,7 @@ +import { Router } from 'express'; import { routeBodySizeLimit } from '../middlewares/body-size-limit.middleware'; import { queryCostGovernor } from '../middlewares/query-cost-governor.middleware'; -import { Router } from 'express'; + import authRouter from './auth/auth.routes'; import healthRouter from './health/health.routes'; import configRouter from './config/config.routes'; @@ -15,6 +16,8 @@ import subscriptionRouter from './subscriptions/subscription.routes'; import webhookRouter from './webhooks/webhook.router'; import walletsRouter from './wallets/wallets.routes'; import alertsRouter from './alerts/alert.router'; +import freezeRouter from './freeze/freeze.routes'; + import tradingRouter from './trading/multi-buy.routes'; import sequencerRouter from './admin/sequencer.routes'; import keysRouter from './keys/keys.routes'; @@ -66,7 +69,13 @@ router.use('/wallets', routeBodySizeLimit('default'), walletsRouter); router.use('/alerts', routeBodySizeLimit('default'), alertsRouter); router.use('/trading', routeBodySizeLimit('default'), tradingRouter); router.use('/internal', routeBodySizeLimit('default'), sequencerRouter); + +// Keys: existing keysRouter first, then freezeRouter so /:keyId/freeze and +// /:keyId/unfreeze fall through to freeze handlers if keysRouter doesn't own +// them. Both share the same body-size group. router.use('/keys', routeBodySizeLimit('default'), keysRouter); +router.use('/keys', routeBodySizeLimit('default'), freezeRouter); + router.use( '/notifications', routeBodySizeLimit('default'), @@ -87,4 +96,4 @@ router.use('/investor/watchlist', routeBodySizeLimit('default'), watchlistRouter router.use('/staking', routeBodySizeLimit('default'), stakingRouter); router.use('/sellers', routeBodySizeLimit('default'), sellersRouter); -export default router; +export default router; \ No newline at end of file diff --git a/src/utils/freeze-cache.utils.ts b/src/utils/freeze-cache.utils.ts new file mode 100644 index 00000000..05a0d3d2 --- /dev/null +++ b/src/utils/freeze-cache.utils.ts @@ -0,0 +1,13 @@ +type Entry = { value: any; expiresAt: number }; +const cache = new Map(); +const TTL_MS = 30_000; +const k = (x: string) => `freeze:${x}`; +export function getCachedFreezeStatus(key: string) { + const e = cache.get(k(key)); + if (!e) return null; + if (e.expiresAt <= Date.now()) { cache.delete(k(key)); return null; } + return e.value; +} +export function setCachedFreezeStatus(key: string, v: any) { cache.set(k(key), { value: v, expiresAt: Date.now() + TTL_MS }); } +export function invalidateFreezeCache(key: string) { cache.delete(k(key)); } +export function resetFreezeCache() { cache.clear(); }