From 84e26478c86e59bb289b004663eb378149dd4c1b Mon Sep 17 00:00:00 2001 From: TS-mfon Date: Thu, 24 Sep 2026 17:58:43 +0100 Subject: [PATCH] feat(creator): add key deprecation and buyback endpoint with holder notifications Closes #872 - Add POST /api/v1/creator/:keyId/deprecate with creator authentication - Validate buybackPricePerKey is a positive integer > 0 - Verify creator XLM balance covers circulatingSupply * buybackPricePerKey - Submit deprecate_key contract call via deprecateGateway - Update CreatorProfile.status to Deprecated and set tradingPaused to true - Log KEY_DEPRECATED activity record - Enqueue holder notifications to Redis queue:notifications via getRedisClient - Add database migration for status column and KEY_DEPRECATED enum --- .../migration.sql | 5 + prisma/schema/activity.prisma | 1 + prisma/schema/creator.prisma | 2 + .../__tests__/creator-deprecate.test.ts | 243 ++++++++++++++++++ .../creator/creator-deprecate.service.ts | 24 ++ src/modules/creator/creator.routes.ts | 155 +++++++++++ 6 files changed, 430 insertions(+) create mode 100644 prisma/migrations/20260924_add_key_deprecation/migration.sql create mode 100644 src/modules/creator/__tests__/creator-deprecate.test.ts create mode 100644 src/modules/creator/creator-deprecate.service.ts diff --git a/prisma/migrations/20260924_add_key_deprecation/migration.sql b/prisma/migrations/20260924_add_key_deprecation/migration.sql new file mode 100644 index 00000000..407b0e1a --- /dev/null +++ b/prisma/migrations/20260924_add_key_deprecation/migration.sql @@ -0,0 +1,5 @@ +-- AlterEnum +ALTER TYPE "ActivityType" ADD VALUE IF NOT EXISTS 'KEY_DEPRECATED'; + +-- AlterTable +ALTER TABLE "CreatorProfile" ADD COLUMN IF NOT EXISTS "status" TEXT NOT NULL DEFAULT 'Active'; diff --git a/prisma/schema/activity.prisma b/prisma/schema/activity.prisma index aaa9a6b4..c7e24189 100644 --- a/prisma/schema/activity.prisma +++ b/prisma/schema/activity.prisma @@ -17,6 +17,7 @@ enum ActivityType { GOVERNANCE_PROPOSAL_CREATED TIMELOCK_LOCKUP_PROPOSED CIRCUIT_BREAKER_THRESHOLD_UPDATED + KEY_DEPRECATED } model Activity { diff --git a/prisma/schema/creator.prisma b/prisma/schema/creator.prisma index 201ff542..a24afe07 100644 --- a/prisma/schema/creator.prisma +++ b/prisma/schema/creator.prisma @@ -25,6 +25,8 @@ model CreatorProfile { circuitBreakerThreshold Int @default(3000) @map("circuit_breaker_threshold") perks Json? followersCount Int @default(0) + /// Current lifecycle status of the creator key (e.g. Active, Deprecated). + status String @default("Active") createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/src/modules/creator/__tests__/creator-deprecate.test.ts b/src/modules/creator/__tests__/creator-deprecate.test.ts new file mode 100644 index 00000000..40719bef --- /dev/null +++ b/src/modules/creator/__tests__/creator-deprecate.test.ts @@ -0,0 +1,243 @@ +const mockRedis = { + lpush: jest.fn().mockResolvedValue(1), +}; + +jest.mock('../../../utils/redis.utils', () => ({ + getRedisClient: jest.fn(() => mockRedis), + getRedis: jest.fn(() => mockRedis), +})); + +jest.mock('../buy.service', () => ({ + buyGateway: { + getXlmBalance: jest.fn(), + }, +})); + +jest.mock('../creator-deprecate.service', () => ({ + deprecateGateway: { + deprecateKey: jest.fn().mockResolvedValue({ transactionHash: 'mock-tx-hash' }), + }, +})); + +jest.mock('../../../utils/prisma.utils', () => ({ + prisma: { + creatorProfile: { + findFirst: jest.fn(), + update: jest.fn(), + }, + stellarWallet: { + findUnique: jest.fn(), + }, + keyOwnership: { + findMany: jest.fn(), + }, + activity: { + create: jest.fn(), + }, + }, +})); + +jest.mock('../../../utils/logger.utils', () => ({ + logger: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + }, +})); + +import request from 'supertest'; +import express from 'express'; +import creatorRouter from '../creator.routes'; +import { prisma } from '../../../utils/prisma.utils'; +import { signWalletAccessToken } from '../../../utils/jwt.utils'; +import { buyGateway } from '../buy.service'; +import { deprecateGateway } from '../creator-deprecate.service'; + +const app = express(); +app.use(express.json()); +app.use('/api/v1/creator', creatorRouter); + +describe('Key Deprecation Endpoint (#872)', () => { + const creatorWallet = 'GCREATORWALLETAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; + const otherWallet = 'GOTHERWALLETAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; + const creatorUserId = 'user-creator-1'; + const otherUserId = 'user-other-2'; + const keyId = 'creator-1'; + + let creatorToken: string; + let otherToken: string; + + beforeAll(() => { + creatorToken = signWalletAccessToken(creatorWallet); + otherToken = signWalletAccessToken(otherWallet); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns 401 when no token is provided', async () => { + const res = await request(app) + .post(`/api/v1/creator/${keyId}/deprecate`) + .send({ buybackPricePerKey: 5 }); + + expect(res.status).toBe(401); + }); + + it('returns 403 when caller is not the creator of the key', async () => { + (prisma.creatorProfile.findFirst as jest.Mock).mockResolvedValue({ + id: keyId, + handle: keyId, + userId: creatorUserId, + }); + (prisma.stellarWallet.findUnique as jest.Mock).mockResolvedValue({ + userId: otherUserId, + }); + + const res = await request(app) + .post(`/api/v1/creator/${keyId}/deprecate`) + .set('Authorization', `Bearer ${otherToken}`) + .send({ buybackPricePerKey: 5 }); + + expect(res.status).toBe(403); + }); + + it('returns 422 when buybackPricePerKey is 0', async () => { + (prisma.creatorProfile.findFirst as jest.Mock).mockResolvedValue({ + id: keyId, + handle: keyId, + userId: creatorUserId, + }); + (prisma.stellarWallet.findUnique as jest.Mock).mockResolvedValue({ + userId: creatorUserId, + }); + + const res = await request(app) + .post(`/api/v1/creator/${keyId}/deprecate`) + .set('Authorization', `Bearer ${creatorToken}`) + .send({ buybackPricePerKey: 0 }); + + expect(res.status).toBe(422); + expect(res.body.error.message).toContain('greater than zero'); + }); + + it('returns 422 when buybackPricePerKey is negative or non-integer', async () => { + (prisma.creatorProfile.findFirst as jest.Mock).mockResolvedValue({ + id: keyId, + handle: keyId, + userId: creatorUserId, + }); + (prisma.stellarWallet.findUnique as jest.Mock).mockResolvedValue({ + userId: creatorUserId, + }); + + const resNegative = await request(app) + .post(`/api/v1/creator/${keyId}/deprecate`) + .set('Authorization', `Bearer ${creatorToken}`) + .send({ buybackPricePerKey: -5 }); + expect(resNegative.status).toBe(422); + + const resFloat = await request(app) + .post(`/api/v1/creator/${keyId}/deprecate`) + .set('Authorization', `Bearer ${creatorToken}`) + .send({ buybackPricePerKey: 2.5 }); + expect(resFloat.status).toBe(422); + + const resString = await request(app) + .post(`/api/v1/creator/${keyId}/deprecate`) + .set('Authorization', `Bearer ${creatorToken}`) + .send({ buybackPricePerKey: 'invalid' }); + expect(resString.status).toBe(422); + }); + + it('returns 400 when creator has insufficient XLM balance', async () => { + (prisma.creatorProfile.findFirst as jest.Mock).mockResolvedValue({ + id: keyId, + handle: keyId, + userId: creatorUserId, + circulatingSupply: 100, + }); + (prisma.stellarWallet.findUnique as jest.Mock).mockResolvedValue({ + userId: creatorUserId, + }); + // 100 * 5 = 500 XLM required, but creator only has 200 XLM + (buyGateway.getXlmBalance as jest.Mock).mockResolvedValue(200); + + const res = await request(app) + .post(`/api/v1/creator/${keyId}/deprecate`) + .set('Authorization', `Bearer ${creatorToken}`) + .send({ buybackPricePerKey: 5 }); + + expect(res.status).toBe(400); + expect(res.body.error.message).toContain('Insufficient creator XLM balance'); + }); + + it('submits contract call, updates status to Deprecated, pauses trading, and enqueues holder notifications', async () => { + (prisma.creatorProfile.findFirst as jest.Mock).mockResolvedValue({ + id: keyId, + handle: keyId, + userId: creatorUserId, + circulatingSupply: 50, + }); + (prisma.stellarWallet.findUnique as jest.Mock).mockResolvedValue({ + userId: creatorUserId, + }); + // 50 * 2 = 100 XLM required, creator has 150 XLM + (buyGateway.getXlmBalance as jest.Mock).mockResolvedValue(150); + + (prisma.creatorProfile.update as jest.Mock).mockResolvedValue({ + id: keyId, + status: 'Deprecated', + tradingPaused: true, + }); + + const holders = [ + { ownerAddress: 'GHOLDER1AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', balance: 30 }, + { ownerAddress: 'GHOLDER2AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', balance: 20 }, + ]; + (prisma.keyOwnership.findMany as jest.Mock).mockResolvedValue(holders); + + const res = await request(app) + .post(`/api/v1/creator/${keyId}/deprecate`) + .set('Authorization', `Bearer ${creatorToken}`) + .send({ buybackPricePerKey: 2 }); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.data.keyId).toBe(keyId); + expect(res.body.data.status).toBe('Deprecated'); + expect(res.body.data.buybackPricePerKey).toBe(2); + expect(res.body.data.circulatingSupply).toBe(50); + expect(res.body.data.notifiedHoldersCount).toBe(2); + + expect(deprecateGateway.deprecateKey).toHaveBeenCalledWith({ + creatorId: keyId, + buybackPricePerKey: 2, + circulatingSupply: 50, + }); + + expect(prisma.creatorProfile.update).toHaveBeenCalledWith({ + where: { id: keyId }, + data: { + status: 'Deprecated', + tradingPaused: true, + }, + }); + + expect(prisma.activity.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + type: 'KEY_DEPRECATED', + creatorId: keyId, + }), + }) + ); + + expect(mockRedis.lpush).toHaveBeenCalledTimes(2); + expect(mockRedis.lpush).toHaveBeenCalledWith( + 'queue:notifications', + expect.stringContaining('key_deprecated') + ); + }); +}); diff --git a/src/modules/creator/creator-deprecate.service.ts b/src/modules/creator/creator-deprecate.service.ts new file mode 100644 index 00000000..73dff2da --- /dev/null +++ b/src/modules/creator/creator-deprecate.service.ts @@ -0,0 +1,24 @@ +import { logger } from '../../utils/logger.utils'; + +export interface DeprecateGateway { + deprecateKey(input: { + creatorId: string; + buybackPricePerKey: number; + circulatingSupply: number; + }): Promise<{ transactionHash?: string }>; +} + +export const deprecateGateway: DeprecateGateway = { + async deprecateKey(input) { + logger.info( + { + operation: 'deprecate_key_contract_call', + keyId: input.creatorId, + buybackPricePerKey: input.buybackPricePerKey, + circulatingSupply: input.circulatingSupply, + }, + 'Submitting deprecate_key contract call' + ); + return { transactionHash: undefined }; + }, +}; diff --git a/src/modules/creator/creator.routes.ts b/src/modules/creator/creator.routes.ts index 70a2a93f..18ec1c93 100644 --- a/src/modules/creator/creator.routes.ts +++ b/src/modules/creator/creator.routes.ts @@ -9,6 +9,10 @@ import { requireKeyCreator, AuthenticatedRequest } from '../../middlewares/jwt-a import { sendError, sendSuccess } from '../../utils/api-response.utils'; import { ErrorCode } from '../../constants/error.constants'; import { prisma } from '../../utils/prisma.utils'; +import { buyGateway } from './buy.service'; +import { horizonRequest } from '../../utils/horizon-api.utils'; +import { getRedisClient } from '../../utils/redis.utils'; +import { deprecateGateway } from './creator-deprecate.service'; const creatorsRouter = Router(); @@ -101,6 +105,157 @@ creatorsRouter.post( next(error); } } + ); + +/** + * POST /api/v1/creator/:keyId/deprecate + * Initiate a buyback wind-down and deprecate a creator key (#872). + */ +creatorsRouter.post( + '/:keyId/deprecate', + requireKeyCreator('keyId'), + async (req: AuthenticatedRequest, res, next) => { + const keyId = Array.isArray(req.params.keyId) + ? req.params.keyId[0] + : req.params.keyId; + + const { buybackPricePerKey } = req.body || {}; + + if (buybackPricePerKey === 0) { + sendError( + res, + 422, + ErrorCode.UNPROCESSABLE_ENTITY, + 'buybackPricePerKey must be greater than zero' + ); + return; + } + + if ( + typeof buybackPricePerKey !== 'number' || + isNaN(buybackPricePerKey) || + !Number.isInteger(buybackPricePerKey) || + buybackPricePerKey < 1 + ) { + sendError( + res, + 422, + ErrorCode.UNPROCESSABLE_ENTITY, + 'buybackPricePerKey must be a positive integer' + ); + return; + } + + try { + const creatorProfile = await prisma.creatorProfile.findFirst({ + where: { OR: [{ id: keyId }, { handle: keyId }] }, + }); + if (!creatorProfile) { + sendError(res, 404, ErrorCode.NOT_FOUND, 'Key not found'); + return; + } + + const circulatingSupply = Number(creatorProfile.circulatingSupply); + const requiredXlm = circulatingSupply * buybackPricePerKey; + + // Check creator wallet balance + const walletAddress = req.user!.wallet; + let creatorBalance = 0; + try { + creatorBalance = await buyGateway.getXlmBalance(walletAddress); + } catch { + try { + const resHorizon = await horizonRequest(`/accounts/${walletAddress}`); + if (resHorizon.ok) { + const data = (await resHorizon.json()) as { + balances?: Array<{ asset_type: string; balance: string }>; + }; + const native = data.balances?.find(b => b.asset_type === 'native'); + creatorBalance = native ? parseFloat(native.balance) : 0; + } + } catch { + creatorBalance = 0; + } + } + + if (creatorBalance < requiredXlm) { + sendError( + res, + 400, + ErrorCode.INSUFFICIENT_BALANCE, + 'Insufficient creator XLM balance to cover key buyback' + ); + return; + } + + // Submit deprecate_key contract call + await deprecateGateway.deprecateKey({ + creatorId: creatorProfile.id, + buybackPricePerKey, + circulatingSupply, + }); + + // Update key status to Deprecated in database + const updated = await prisma.creatorProfile.update({ + where: { id: creatorProfile.id }, + data: { + status: 'Deprecated', + tradingPaused: true, + }, + }); + + // Record activity + await prisma.activity.create({ + data: { + type: 'KEY_DEPRECATED', + actor: walletAddress, + creatorId: creatorProfile.id, + payload: { + keyId: creatorProfile.id, + buybackPricePerKey, + circulatingSupply, + notification: 'key_deprecated', + }, + }, + }); + + // Notify all current holders via the notification queue + const holders = await prisma.keyOwnership.findMany({ + where: { + creatorId: creatorProfile.id, + balance: { gt: 0 }, + }, + select: { ownerAddress: true, balance: true }, + }); + + const redis = getRedisClient(); + for (const holder of holders) { + if (redis) { + await redis.lpush( + 'queue:notifications', + JSON.stringify({ + type: 'key_deprecated', + keyId: creatorProfile.id, + walletAddress: holder.ownerAddress, + buybackPricePerKey, + balance: holder.balance.toString(), + timestamp: new Date().toISOString(), + }) + ); + } + } + + sendSuccess(res, { + keyId: creatorProfile.id, + status: updated.status, + buybackPricePerKey, + circulatingSupply, + notifiedHoldersCount: holders.length, + }); + } catch (error) { + next(error); + } + } ); export default creatorsRouter; \ No newline at end of file