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
11 changes: 11 additions & 0 deletions prisma/schema/whitelist.prisma
Original file line number Diff line number Diff line change
@@ -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])
}
8 changes: 8 additions & 0 deletions src/config.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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').
Expand Down
60 changes: 60 additions & 0 deletions src/jobs/governance-sync.job.ts
Original file line number Diff line number Diff line change
@@ -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<typeof setInterval> | 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');
}
2 changes: 2 additions & 0 deletions src/modules/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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;
24 changes: 24 additions & 0 deletions src/modules/investor/dividend.service.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
131 changes: 131 additions & 0 deletions src/modules/investor/investor.routes.ts
Original file line number Diff line number Diff line change
@@ -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;
33 changes: 33 additions & 0 deletions src/modules/investor/whitelist.service.ts
Original file line number Diff line number Diff line change
@@ -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);
}
49 changes: 49 additions & 0 deletions src/modules/keys/key-transfer.service.ts
Original file line number Diff line number Diff line change
@@ -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) };
});
}
6 changes: 6 additions & 0 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -67,6 +71,7 @@ async function startServer() {
checkOptionalDependencies();

startDetectPriceMovementsJob();
startGovernanceSyncJob();

const server = app.listen(envConfig.PORT, () => {
logger.info(`Server running on port ${envConfig.PORT}`);
Expand Down Expand Up @@ -101,6 +106,7 @@ function createGracefulShutdownHandler(server: ReturnType<typeof app.listen>) {

stopOwnershipSnapshotCleanupJob();
stopDetectPriceMovementsJob();
stopGovernanceSyncJob();
await prisma.$disconnect();
logger.info('Database connection closed');

Expand Down
Loading