Skip to content
Closed
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
6 changes: 6 additions & 0 deletions prisma/migrations/20260924_add_graduated_curve/migration.sql
Original file line number Diff line number Diff line change
@@ -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;
Comment on lines +1 to +6
1 change: 1 addition & 0 deletions prisma/schema/activity.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ enum ActivityType {
GOVERNANCE_PROPOSAL_CREATED
TIMELOCK_LOCKUP_PROPOSED
CIRCUIT_BREAKER_THRESHOLD_UPDATED
GRADUATED_CURVE_CONFIGURED
}

model Activity {
Expand Down
4 changes: 4 additions & 0 deletions prisma/schema/creator.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
242 changes: 242 additions & 0 deletions src/modules/creator/__tests__/creator-curve.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
27 changes: 27 additions & 0 deletions src/modules/creator/creator-curve.service.ts
Original file line number Diff line number Diff line change
@@ -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 };
},
};
Loading
Loading