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
30 changes: 30 additions & 0 deletions prisma/schema/freeze.prisma
Original file line number Diff line number Diff line change
@@ -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])
}
42 changes: 42 additions & 0 deletions src/modules/freeze/freeze.controllers.ts
Original file line number Diff line number Diff line change
@@ -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); }
};
9 changes: 9 additions & 0 deletions src/modules/freeze/freeze.routes.ts
Original file line number Diff line number Diff line change
@@ -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;
6 changes: 6 additions & 0 deletions src/modules/freeze/freeze.schemas.ts
Original file line number Diff line number Diff line change
@@ -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<typeof FreezeBodySchema>;
export type UnfreezeBody = z.infer<typeof UnfreezeBodySchema>;
158 changes: 158 additions & 0 deletions src/modules/freeze/freeze.service.ts
Original file line number Diff line number Diff line change
@@ -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<FreezeStatus> {
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<FreezeStatus> {
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<FreezeStatus> {
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<string, unknown>;
}): Promise<void> {
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);
}
13 changes: 11 additions & 2 deletions src/modules/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -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'),
Expand All @@ -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;
13 changes: 13 additions & 0 deletions src/utils/freeze-cache.utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
type Entry = { value: any; expiresAt: number };
const cache = new Map<string, Entry>();
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(); }
Loading