diff --git a/prisma/schema/whitelist.prisma b/prisma/schema/whitelist.prisma new file mode 100644 index 0000000..f462221 --- /dev/null +++ b/prisma/schema/whitelist.prisma @@ -0,0 +1,11 @@ +// prisma/schema/whitelist.prisma + +model Whitelist { + id String @id @default(cuid()) + address String + creatorId String + createdAt DateTime @default(now()) + + @@unique([address, creatorId]) + @@index([creatorId]) +} diff --git a/src/config.schema.ts b/src/config.schema.ts index 96f6dd9..c732de8 100644 --- a/src/config.schema.ts +++ b/src/config.schema.ts @@ -183,6 +183,14 @@ export const envSchema = z .positive() .default(5), + // Governance proposal sync job + GOVERNANCE_SYNC_ENABLED: booleanCoerce.default(false), + GOVERNANCE_SYNC_INTERVAL_MINUTES: z.coerce + .number() + .int() + .positive() + .default(5), + // Request body size limits (see docs/body-size-limits.md). // Accepts any size string understood by the `bytes` package used // internally by body-parser (e.g. '100kb', '1mb', '10mb'). diff --git a/src/jobs/governance-sync.job.ts b/src/jobs/governance-sync.job.ts new file mode 100644 index 0000000..f8bcc8f --- /dev/null +++ b/src/jobs/governance-sync.job.ts @@ -0,0 +1,60 @@ +// src/jobs/governance-sync.job.ts +import { envConfig } from '../config'; +import { logger } from '../utils/logger.utils'; +import { prisma } from '../utils/prisma.utils'; + +export async function syncGovernanceProposals() { + const activeProposals = await prisma.governanceProposal.findMany({ + where: { status: 'active' }, + select: { id: true, keyId: true, proposalId: true, expiresAt: true }, + }); + + let closed = 0; + for (const proposal of activeProposals) { + if (new Date() > proposal.expiresAt) { + await prisma.governanceProposal.update({ + where: { id: proposal.id }, + data: { status: 'closed', closedAt: new Date() }, + }); + closed++; + } + } + + logger.info({ scanned: activeProposals.length, closed }, 'governanceSync completed'); + return { scanned: activeProposals.length, closed }; +} + +let governanceTimer: ReturnType | null = null; + +export function startGovernanceSyncJob(): void { + if (!envConfig.GOVERNANCE_SYNC_ENABLED) { + logger.info('governanceSync job is disabled'); + return; + } + + const intervalMs = (envConfig.GOVERNANCE_SYNC_INTERVAL_MINUTES ?? 5) * 60 * 1000; + + const run = async () => { + try { + await syncGovernanceProposals(); + } catch (error) { + logger.error({ err: error }, 'governanceSync failed'); + } + }; + + void run(); + governanceTimer = setInterval(() => { void run(); }, intervalMs); + + if (typeof (governanceTimer as any).unref === 'function') { + governanceTimer.unref(); + } + + logger.info({ intervalMinutes: envConfig.GOVERNANCE_SYNC_INTERVAL_MINUTES ?? 5 }, 'governanceSync job started'); +} + +export function stopGovernanceSyncJob(): void { + if (!governanceTimer) return; + clearInterval(governanceTimer); + governanceTimer = null; + logger.info('governanceSync job stopped'); +} diff --git a/src/modules/index.ts b/src/modules/index.ts index b63eae0..8462189 100644 --- a/src/modules/index.ts +++ b/src/modules/index.ts @@ -19,6 +19,7 @@ import keysRouter from './keys/keys.routes'; import notificationsRouter from './notifications/notification.routes'; import horizonWebhookRouter from './webhooks/horizon-webhook.routes'; import vestingRouter from './vesting/vesting.routes'; +import investorRouter from './investor/investor.routes'; import { BASE as CREATORS_BASE } from '../constants/creator.constants'; const router = Router(); @@ -47,5 +48,6 @@ router.use('/keys', routeBodySizeLimit('default'), keysRouter); router.use('/notifications', routeBodySizeLimit('default'), notificationsRouter); router.use('/webhooks', routeBodySizeLimit('default'), horizonWebhookRouter); router.use('/vesting', routeBodySizeLimit('default'), vestingRouter); +router.use('/investor', routeBodySizeLimit('default'), investorRouter); export default router; diff --git a/src/modules/investor/dividend.service.ts b/src/modules/investor/dividend.service.ts new file mode 100644 index 0000000..913919a --- /dev/null +++ b/src/modules/investor/dividend.service.ts @@ -0,0 +1,24 @@ +// src/modules/investor/dividend.service.ts +import { prisma } from '../../utils/prisma.utils'; + +export class DividendNotFoundError extends Error {} + +export async function getInvestorDividends( + wallet: string, + cursor?: string, + limit = 20 +) { + const where: any = { investorAddress: wallet }; + if (cursor) { + where.id = { gt: cursor }; + } + const items = await prisma.dividend.findMany({ + where, + orderBy: { distributedAt: 'desc' }, + take: limit + 1, + }); + const hasMore = items.length > limit; + const data = hasMore ? items.slice(0, limit) : items; + const nextCursor = hasMore ? data[data.length - 1].id : null; + return { data, nextCursor, hasMore }; +} diff --git a/src/modules/investor/investor.routes.ts b/src/modules/investor/investor.routes.ts new file mode 100644 index 0000000..13a83b3 --- /dev/null +++ b/src/modules/investor/investor.routes.ts @@ -0,0 +1,131 @@ +// src/modules/investor/investor.routes.ts +import { Router } from 'express'; +import { z } from 'zod'; +import { + sendSuccess, + sendNotFound, + sendValidationError, + zodIssuesToDetails, +} from '../../utils/api-response.utils'; +import { + getInvestorDividends, +} from './dividend.service'; +import { + addToWhitelist, + removeFromWhitelist, + getWhitelistAddresses, + WhitelistNotFoundError, +} from './whitelist.service'; + +const router = Router(); + +const dividendsParamsSchema = z.object({ + wallet: z.string().min(1), +}); + +const dividendsQuerySchema = z.object({ + cursor: z.string().optional(), + limit: z.coerce.number().int().min(1).max(100).optional(), +}); + +/** + * GET /api/v1/investor/:wallet/dividends + * List dividend payouts for an investor wallet with cursor pagination. + */ +router.get('/:wallet/dividends', async (req, res, next) => { + const params = dividendsParamsSchema.safeParse(req.params); + if (!params.success) { + sendValidationError(res, 'Invalid wallet', zodIssuesToDetails(params.error.issues)); + return; + } + const query = dividendsQuerySchema.safeParse(req.query); + if (!query.success) { + sendValidationError(res, 'Invalid query', zodIssuesToDetails(query.error.issues)); + return; + } + try { + const result = await getInvestorDividends( + params.data.wallet, + query.data.cursor, + query.data.limit + ); + sendSuccess(res, result); + } catch (error) { + next(error); + } +}); + +const whitelistParamsSchema = z.object({ + keyId: z.string().min(1), +}); + +const whitelistBodySchema = z.object({ + address: z.string().min(1), +}); + +/** + * POST /api/v1/investor/:keyId/whitelist/add + */ +router.post('/:keyId/whitelist/add', async (req, res, next) => { + const params = whitelistParamsSchema.safeParse(req.params); + if (!params.success) { + sendValidationError(res, 'Invalid keyId', zodIssuesToDetails(params.error.issues)); + return; + } + const body = whitelistBodySchema.safeParse(req.body); + if (!body.success) { + sendValidationError(res, 'Invalid body', zodIssuesToDetails(body.error.issues)); + return; + } + try { + const entry = await addToWhitelist(params.data.keyId, body.data.address); + sendSuccess(res, entry, 201); + } catch (error) { + next(error); + } +}); + +/** + * POST /api/v1/investor/:keyId/whitelist/remove + */ +router.post('/:keyId/whitelist/remove', async (req, res, next) => { + const params = whitelistParamsSchema.safeParse(req.params); + if (!params.success) { + sendValidationError(res, 'Invalid keyId', zodIssuesToDetails(params.error.issues)); + return; + } + const body = whitelistBodySchema.safeParse(req.body); + if (!body.success) { + sendValidationError(res, 'Invalid body', zodIssuesToDetails(body.error.issues)); + return; + } + try { + await removeFromWhitelist(params.data.keyId, body.data.address); + sendSuccess(res, { removed: true }); + } catch (error) { + if (error instanceof WhitelistNotFoundError) { + sendNotFound(res, 'Whitelist entry'); + return; + } + next(error); + } +}); + +/** + * GET /api/v1/investor/:keyId/whitelist/addresses + */ +router.get('/:keyId/whitelist/addresses', async (req, res, next) => { + const params = whitelistParamsSchema.safeParse(req.params); + if (!params.success) { + sendValidationError(res, 'Invalid keyId', zodIssuesToDetails(params.error.issues)); + return; + } + try { + const addresses = await getWhitelistAddresses(params.data.keyId); + sendSuccess(res, addresses); + } catch (error) { + next(error); + } +}); + +export default router; diff --git a/src/modules/investor/whitelist.service.ts b/src/modules/investor/whitelist.service.ts new file mode 100644 index 0000000..ea13ddf --- /dev/null +++ b/src/modules/investor/whitelist.service.ts @@ -0,0 +1,33 @@ +// src/modules/investor/whitelist.service.ts +import { prisma } from '../../utils/prisma.utils'; + +export class WhitelistNotFoundError extends Error {} + +export async function addToWhitelist(creatorId: string, address: string) { + return prisma.whitelist.upsert({ + where: { address_creatorId: { address, creatorId } }, + update: {}, + create: { address, creatorId }, + }); +} + +export async function removeFromWhitelist(creatorId: string, address: string) { + const existing = await prisma.whitelist.findUnique({ + where: { address_creatorId: { address, creatorId } }, + }); + if (!existing) { + throw new WhitelistNotFoundError(); + } + await prisma.whitelist.delete({ + where: { address_creatorId: { address, creatorId } }, + }); +} + +export async function getWhitelistAddresses(creatorId: string) { + const entries = await prisma.whitelist.findMany({ + where: { creatorId }, + select: { address: true }, + orderBy: { createdAt: 'asc' }, + }); + return entries.map((e: { address: string }) => e.address); +} diff --git a/src/modules/keys/key-transfer.service.ts b/src/modules/keys/key-transfer.service.ts new file mode 100644 index 0000000..f5682d2 --- /dev/null +++ b/src/modules/keys/key-transfer.service.ts @@ -0,0 +1,49 @@ +// src/modules/keys/key-transfer.service.ts +import { prisma } from '../../utils/prisma.utils'; +import { KeyNotFoundError } from './key-fees.service'; + +export async function transferKeys( + keyId: string, + fromAddress: string, + toAddress: string, + quantity: number +) { + if (quantity <= 0) { + throw new Error('Quantity must be positive'); + } + if (fromAddress === toAddress) { + throw new Error('Cannot transfer to the same address'); + } + + const creator = await prisma.creatorProfile.findUnique({ + where: { id: keyId }, + select: { id: true }, + }); + if (!creator) { + throw new KeyNotFoundError(keyId); + } + + return prisma.$transaction(async (tx: any) => { + const sender = await tx.keyOwnership.findUnique({ + where: { ownerAddress_creatorId: { ownerAddress: fromAddress, creatorId: keyId } }, + select: { balance: true }, + }); + const senderBalance = Number(sender?.balance ?? 0); + if (senderBalance < quantity) { + throw new Error('Insufficient balance'); + } + + const result = await tx.keyOwnership.upsert({ + where: { ownerAddress_creatorId: { ownerAddress: toAddress, creatorId: keyId } }, + update: { balance: { increment: quantity } }, + create: { ownerAddress: toAddress, creatorId: keyId, balance: quantity }, + }); + + await tx.keyOwnership.update({ + where: { ownerAddress_creatorId: { ownerAddress: fromAddress, creatorId: keyId } }, + data: { balance: { decrement: quantity } }, + }); + + return { from: fromAddress, to: toAddress, quantity, newBalance: Number(result.balance) }; + }); +} diff --git a/src/server.ts b/src/server.ts index 0bd6a3f..f0a68d8 100644 --- a/src/server.ts +++ b/src/server.ts @@ -16,6 +16,10 @@ import { startDetectPriceMovementsJob, stopDetectPriceMovementsJob, } from './jobs/detect-price-movements.job'; +import { + startGovernanceSyncJob, + stopGovernanceSyncJob, +} from './jobs/governance-sync.job'; import { connectRedis, disconnectRedis } from './utils/redis.utils'; import { broadcastServerClosing, closeAllConnections } from './utils/sse-fanout.utils'; import { buildStartupConfigSummary } from './utils/config-summary.utils'; @@ -67,6 +71,7 @@ async function startServer() { checkOptionalDependencies(); startDetectPriceMovementsJob(); + startGovernanceSyncJob(); const server = app.listen(envConfig.PORT, () => { logger.info(`Server running on port ${envConfig.PORT}`); @@ -101,6 +106,7 @@ function createGracefulShutdownHandler(server: ReturnType) { stopOwnershipSnapshotCleanupJob(); stopDetectPriceMovementsJob(); + stopGovernanceSyncJob(); await prisma.$disconnect(); logger.info('Database connection closed');