diff --git a/prisma/migrations/20260924_add_graduated_curve/migration.sql b/prisma/migrations/20260924_add_graduated_curve/migration.sql new file mode 100644 index 00000000..5fa5fd3b --- /dev/null +++ b/prisma/migrations/20260924_add_graduated_curve/migration.sql @@ -0,0 +1,6 @@ +-- AlterEnum +ALTER TYPE "ActivityType" ADD VALUE IF NOT EXISTS 'GRADUATED_CURVE_CONFIGURED'; + +-- AlterTable +ALTER TABLE "CreatorProfile" ADD COLUMN IF NOT EXISTS "curve_milestones" JSONB, +ADD COLUMN IF NOT EXISTS "base_exponent" INTEGER NOT NULL DEFAULT 1; diff --git a/prisma/schema/activity.prisma b/prisma/schema/activity.prisma index aaa9a6b4..3ce1ec39 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 + GRADUATED_CURVE_CONFIGURED } model Activity { diff --git a/prisma/schema/creator.prisma b/prisma/schema/creator.prisma index 201ff542..f53b5216 100644 --- a/prisma/schema/creator.prisma +++ b/prisma/schema/creator.prisma @@ -25,6 +25,10 @@ model CreatorProfile { circuitBreakerThreshold Int @default(3000) @map("circuit_breaker_threshold") perks Json? followersCount Int @default(0) + /// Graduated curve milestones configuration. + curveMilestones Json? @map("curve_milestones") + /// Base curve exponent (default 1). + baseExponent Int @default(1) @map("base_exponent") createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/src/modules/creator/__tests__/creator-curve.test.ts b/src/modules/creator/__tests__/creator-curve.test.ts new file mode 100644 index 00000000..9a476ef1 --- /dev/null +++ b/src/modules/creator/__tests__/creator-curve.test.ts @@ -0,0 +1,242 @@ +jest.mock('../../../utils/prisma.utils', () => ({ + prisma: { + creatorProfile: { + findFirst: jest.fn(), + update: jest.fn(), + }, + stellarWallet: { + findUnique: jest.fn(), + }, + activity: { + create: jest.fn(), + }, + }, +})); + +jest.mock('../../../utils/logger.utils', () => ({ + logger: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + }, +})); + +jest.mock('../creator-curve.service', () => ({ + curveGateway: { + configureGraduatedCurve: jest.fn().mockResolvedValue({ transactionHash: 'mock-tx-hash' }), + }, +})); + +import request from 'supertest'; +import express from 'express'; +import creatorRouter from '../creator.routes'; +import keysRouter from '../../keys/keys.routes'; +import { prisma } from '../../../utils/prisma.utils'; +import { signWalletAccessToken } from '../../../utils/jwt.utils'; +import { curveGateway } from '../creator-curve.service'; + +const app = express(); +app.use(express.json()); +app.use('/api/v1/creator', creatorRouter); +app.use('/api/v1/keys', keysRouter); + +describe('Graduated Curve Configuration (#869)', () => { + 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(); + }); + + describe('POST /api/v1/creator/:keyId/curve', () => { + it('returns 401 when no token is provided', async () => { + const res = await request(app) + .post(`/api/v1/creator/${keyId}/curve`) + .send({ milestones: [{ supplyThreshold: 10, exponent: 2 }] }); + + 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}/curve`) + .set('Authorization', `Bearer ${otherToken}`) + .send({ milestones: [{ supplyThreshold: 10, exponent: 2 }] }); + + expect(res.status).toBe(403); + }); + + it('returns 422 when more than 5 milestones are provided', 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}/curve`) + .set('Authorization', `Bearer ${creatorToken}`) + .send({ + milestones: [ + { supplyThreshold: 10, exponent: 2 }, + { supplyThreshold: 20, exponent: 2 }, + { supplyThreshold: 30, exponent: 3 }, + { supplyThreshold: 40, exponent: 3 }, + { supplyThreshold: 50, exponent: 4 }, + { supplyThreshold: 60, exponent: 5 }, + ], + }); + + expect(res.status).toBe(422); + }); + + it('returns 422 when thresholds are out of order', 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}/curve`) + .set('Authorization', `Bearer ${creatorToken}`) + .send({ + milestones: [ + { supplyThreshold: 20, exponent: 2 }, + { supplyThreshold: 10, exponent: 3 }, + ], + }); + + expect(res.status).toBe(422); + }); + + it('returns 422 when exponent is outside 1–5 or fractional', async () => { + (prisma.creatorProfile.findFirst as jest.Mock).mockResolvedValue({ + id: keyId, + handle: keyId, + userId: creatorUserId, + }); + (prisma.stellarWallet.findUnique as jest.Mock).mockResolvedValue({ + userId: creatorUserId, + }); + + const res0 = await request(app) + .post(`/api/v1/creator/${keyId}/curve`) + .set('Authorization', `Bearer ${creatorToken}`) + .send({ milestones: [{ supplyThreshold: 10, exponent: 0 }] }); + expect(res0.status).toBe(422); + + const res6 = await request(app) + .post(`/api/v1/creator/${keyId}/curve`) + .set('Authorization', `Bearer ${creatorToken}`) + .send({ milestones: [{ supplyThreshold: 10, exponent: 6 }] }); + expect(res6.status).toBe(422); + + const resFractional = await request(app) + .post(`/api/v1/creator/${keyId}/curve`) + .set('Authorization', `Bearer ${creatorToken}`) + .send({ milestones: [{ supplyThreshold: 10, exponent: 1.5 }] }); + expect(resFractional.status).toBe(422); + }); + + it('submits contract call, stores milestones, records activity, and returns 200 for valid input', async () => { + const validMilestones = [ + { supplyThreshold: 10, exponent: 2 }, + { supplyThreshold: 50, exponent: 3 }, + { supplyThreshold: 100, exponent: 4 }, + ]; + + (prisma.creatorProfile.findFirst as jest.Mock).mockResolvedValue({ + id: keyId, + handle: keyId, + userId: creatorUserId, + baseExponent: 1, + }); + (prisma.stellarWallet.findUnique as jest.Mock).mockResolvedValue({ + userId: creatorUserId, + }); + (prisma.creatorProfile.update as jest.Mock).mockResolvedValue({ + id: keyId, + curveMilestones: validMilestones, + baseExponent: 1, + }); + + const res = await request(app) + .post(`/api/v1/creator/${keyId}/curve`) + .set('Authorization', `Bearer ${creatorToken}`) + .send({ milestones: validMilestones }); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.data.keyId).toBe(keyId); + expect(res.body.data.milestones).toEqual(validMilestones); + expect(res.body.data.baseExponent).toBe(1); + + expect(curveGateway.configureGraduatedCurve).toHaveBeenCalledWith({ + creatorId: keyId, + milestones: validMilestones, + }); + expect(prisma.creatorProfile.update).toHaveBeenCalledWith({ + where: { id: keyId }, + data: { curveMilestones: validMilestones }, + }); + expect(prisma.activity.create).toHaveBeenCalled(); + }); + }); + + describe('GET /api/v1/keys/:keyId/curve-config', () => { + it('returns 404 when key is not found', async () => { + (prisma.creatorProfile.findFirst as jest.Mock).mockResolvedValue(null); + + const res = await request(app).get(`/api/v1/keys/nonexistent/curve-config`); + expect(res.status).toBe(404); + }); + + it('returns milestones and base exponent without authentication', async () => { + const storedMilestones = [ + { supplyThreshold: 25, exponent: 2 }, + { supplyThreshold: 75, exponent: 4 }, + ]; + + (prisma.creatorProfile.findFirst as jest.Mock).mockResolvedValue({ + id: keyId, + curveMilestones: storedMilestones, + baseExponent: 1, + }); + + const res = await request(app).get(`/api/v1/keys/${keyId}/curve-config`); + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.data.keyId).toBe(keyId); + expect(res.body.data.milestones).toEqual(storedMilestones); + expect(res.body.data.baseExponent).toBe(1); + }); + }); +}); diff --git a/src/modules/creator/creator-curve.service.ts b/src/modules/creator/creator-curve.service.ts new file mode 100644 index 00000000..d977e833 --- /dev/null +++ b/src/modules/creator/creator-curve.service.ts @@ -0,0 +1,27 @@ +import { logger } from '../../utils/logger.utils'; + +export interface GraduatedCurveMilestone { + supplyThreshold: number; + exponent: number; +} + +export interface CurveGateway { + configureGraduatedCurve(input: { + creatorId: string; + milestones: GraduatedCurveMilestone[]; + }): Promise<{ transactionHash?: string }>; +} + +export const curveGateway: CurveGateway = { + async configureGraduatedCurve(input) { + logger.info( + { + operation: 'configure_graduated_curve_contract_call', + keyId: input.creatorId, + milestones: input.milestones, + }, + 'Submitting configure_graduated_curve contract call' + ); + return { transactionHash: undefined }; + }, +}; diff --git a/src/modules/creator/creator.routes.ts b/src/modules/creator/creator.routes.ts index 70a2a93f..0898b753 100644 --- a/src/modules/creator/creator.routes.ts +++ b/src/modules/creator/creator.routes.ts @@ -9,6 +9,7 @@ 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 { curveGateway } from './creator-curve.service'; const creatorsRouter = Router(); @@ -103,4 +104,134 @@ creatorsRouter.post( } ); +/** + * POST /api/v1/creator/:keyId/curve + * Configure graduated bonding curve milestones for a creator key (#869). + */ +creatorsRouter.post( + '/:keyId/curve', + requireKeyCreator('keyId'), + async (req: AuthenticatedRequest, res, next) => { + const keyId = Array.isArray(req.params.keyId) + ? req.params.keyId[0] + : req.params.keyId; + + const { milestones } = req.body || {}; + + if (!Array.isArray(milestones) || milestones.length === 0) { + sendError( + res, + 422, + ErrorCode.UNPROCESSABLE_ENTITY, + 'milestones must be a non-empty array' + ); + return; + } + + if (milestones.length > 5) { + sendError( + res, + 422, + ErrorCode.UNPROCESSABLE_ENTITY, + 'Maximum of 5 milestones allowed' + ); + return; + } + + for (let i = 0; i < milestones.length; i++) { + const m = milestones[i]; + if ( + !m || + typeof m.supplyThreshold !== 'number' || + typeof m.exponent !== 'number' || + isNaN(m.supplyThreshold) || + isNaN(m.exponent) + ) { + sendError( + res, + 422, + ErrorCode.UNPROCESSABLE_ENTITY, + 'Each milestone must have supplyThreshold and exponent' + ); + return; + } + + if (m.supplyThreshold <= 0) { + sendError( + res, + 422, + ErrorCode.UNPROCESSABLE_ENTITY, + 'supplyThreshold must be positive' + ); + return; + } + + if ( + m.exponent < 1 || + m.exponent > 5 || + !Number.isInteger(m.exponent) + ) { + sendError( + res, + 422, + ErrorCode.UNPROCESSABLE_ENTITY, + 'exponent must be an integer between 1 and 5' + ); + return; + } + + if (i > 0 && m.supplyThreshold <= milestones[i - 1].supplyThreshold) { + sendError( + res, + 422, + ErrorCode.UNPROCESSABLE_ENTITY, + 'supplyThreshold values must be in strictly ascending order' + ); + 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; + } + + // Submit configure_graduated_curve contract call + await curveGateway.configureGraduatedCurve({ + creatorId: creatorProfile.id, + milestones, + }); + + const updated = await prisma.creatorProfile.update({ + where: { id: creatorProfile.id }, + data: { curveMilestones: milestones }, + }); + + await prisma.activity.create({ + data: { + type: 'GRADUATED_CURVE_CONFIGURED', + actor: req.user!.wallet, + creatorId: creatorProfile.id, + payload: { + keyId: creatorProfile.id, + milestones, + }, + }, + }); + + sendSuccess(res, { + keyId: creatorProfile.id, + milestones: (updated.curveMilestones as any) ?? milestones, + baseExponent: updated.baseExponent ?? 1, + }); + } catch (error) { + next(error); + } + } +); + export default creatorsRouter; \ No newline at end of file diff --git a/src/modules/keys/keys.routes.ts b/src/modules/keys/keys.routes.ts index 35fd5433..a3e5f257 100644 --- a/src/modules/keys/keys.routes.ts +++ b/src/modules/keys/keys.routes.ts @@ -345,6 +345,30 @@ router.get('/:keyId/price-history', async (req, res, next) => { } }); +// ── GET /:keyId/curve-config ────────────────────────────────── + +router.get('/:keyId/curve-config', async (req, res, next) => { + const keyId = String(req.params.keyId); + try { + const creator = await prisma.creatorProfile.findFirst({ + where: { OR: [{ id: keyId }, { handle: keyId }] }, + select: { id: true, curveMilestones: true, baseExponent: true }, + }); + if (!creator) { + sendNotFound(res, 'Key'); + return; + } + + sendSuccess(res, { + keyId: creator.id, + milestones: (creator.curveMilestones as any) ?? [], + baseExponent: creator.baseExponent ?? 1, + }); + } catch (error) { + next(error); + } +}); + // Mount dividend routes router.use('/', dividendRouter);