Skip to content

Commit aea8bb6

Browse files
authored
Merge pull request #1007 from TS-mfon/feat/issues-866-869-870-872-consolidated
feat(keys,creator): add TWAP, graduated curve config, trade simulation and deprecation endpoints
2 parents 413471a + c7bb0fe commit aea8bb6

15 files changed

Lines changed: 1733 additions & 0 deletions

‎prisma/schema/activity.prisma‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ enum ActivityType {
1717
GOVERNANCE_PROPOSAL_CREATED
1818
TIMELOCK_LOCKUP_PROPOSED
1919
CIRCUIT_BREAKER_THRESHOLD_UPDATED
20+
GRADUATED_CURVE_CONFIGURED
21+
KEY_DEPRECATED
2022
SUPPLY_FULLY_FUNDED
2123
}
2224

‎prisma/schema/creator.prisma‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,12 @@ model CreatorProfile {
4242
cooldownLedgers Int @default(0)
4343
perks Json?
4444
followersCount Int @default(0)
45+
/// Graduated curve milestones configuration (#869).
46+
curveMilestones Json? @map("curve_milestones")
47+
/// Base curve exponent (#869).
48+
baseExponent Int @default(1) @map("base_exponent")
49+
/// Lifecycle status of the creator key, e.g. Active or Deprecated (#872).
50+
status String @default("Active")
4551
relaunchCount Int @default(0)
4652
createdAt DateTime @default(now())
4753
updatedAt DateTime @updatedAt
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
-- AlterEnum
2+
ALTER TYPE "ActivityType" ADD VALUE IF NOT EXISTS 'GRADUATED_CURVE_CONFIGURED';
3+
ALTER TYPE "ActivityType" ADD VALUE IF NOT EXISTS 'KEY_DEPRECATED';
4+
5+
-- AlterTable
6+
ALTER TABLE "CreatorProfile" ADD COLUMN IF NOT EXISTS "curve_milestones" JSONB,
7+
ADD COLUMN IF NOT EXISTS "base_exponent" INTEGER NOT NULL DEFAULT 1,
8+
ADD COLUMN IF NOT EXISTS "status" TEXT NOT NULL DEFAULT 'Active';
Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
jest.mock('../../../utils/prisma.utils', () => ({
2+
prisma: {
3+
creatorProfile: {
4+
findFirst: jest.fn(),
5+
update: jest.fn(),
6+
},
7+
stellarWallet: {
8+
findUnique: jest.fn(),
9+
},
10+
activity: {
11+
create: jest.fn(),
12+
},
13+
},
14+
}));
15+
16+
jest.mock('../../../utils/logger.utils', () => ({
17+
logger: {
18+
info: jest.fn(),
19+
warn: jest.fn(),
20+
error: jest.fn(),
21+
debug: jest.fn(),
22+
},
23+
}));
24+
25+
jest.mock('../creator-curve.service', () => ({
26+
curveGateway: {
27+
configureGraduatedCurve: jest.fn().mockResolvedValue({ transactionHash: 'mock-tx-hash' }),
28+
},
29+
}));
30+
31+
import request from 'supertest';
32+
import express from 'express';
33+
import creatorRouter from '../creator.routes';
34+
import keysRouter from '../../keys/keys.routes';
35+
import { prisma } from '../../../utils/prisma.utils';
36+
import { signWalletAccessToken } from '../../../utils/jwt.utils';
37+
import { curveGateway } from '../creator-curve.service';
38+
39+
const app = express();
40+
app.use(express.json());
41+
app.use('/api/v1/creator', creatorRouter);
42+
app.use('/api/v1/keys', keysRouter);
43+
44+
describe('Graduated Curve Configuration (#869)', () => {
45+
const creatorWallet = 'GCREATORWALLETAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';
46+
const otherWallet = 'GOTHERWALLETAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';
47+
const creatorUserId = 'user-creator-1';
48+
const otherUserId = 'user-other-2';
49+
const keyId = 'creator-1';
50+
51+
let creatorToken: string;
52+
let otherToken: string;
53+
54+
beforeAll(() => {
55+
creatorToken = signWalletAccessToken(creatorWallet);
56+
otherToken = signWalletAccessToken(otherWallet);
57+
});
58+
59+
beforeEach(() => {
60+
jest.clearAllMocks();
61+
});
62+
63+
describe('POST /api/v1/creator/:keyId/curve', () => {
64+
it('returns 401 when no token is provided', async () => {
65+
const res = await request(app)
66+
.post(`/api/v1/creator/${keyId}/curve`)
67+
.send({ milestones: [{ supplyThreshold: 10, exponent: 2 }] });
68+
69+
expect(res.status).toBe(401);
70+
});
71+
72+
it('returns 403 when caller is not the creator of the key', async () => {
73+
(prisma.creatorProfile.findFirst as jest.Mock).mockResolvedValue({
74+
id: keyId,
75+
handle: keyId,
76+
userId: creatorUserId,
77+
});
78+
(prisma.stellarWallet.findUnique as jest.Mock).mockResolvedValue({
79+
userId: otherUserId,
80+
});
81+
82+
const res = await request(app)
83+
.post(`/api/v1/creator/${keyId}/curve`)
84+
.set('Authorization', `Bearer ${otherToken}`)
85+
.send({ milestones: [{ supplyThreshold: 10, exponent: 2 }] });
86+
87+
expect(res.status).toBe(403);
88+
});
89+
90+
it('returns 422 when more than 5 milestones are provided', async () => {
91+
(prisma.creatorProfile.findFirst as jest.Mock).mockResolvedValue({
92+
id: keyId,
93+
handle: keyId,
94+
userId: creatorUserId,
95+
});
96+
(prisma.stellarWallet.findUnique as jest.Mock).mockResolvedValue({
97+
userId: creatorUserId,
98+
});
99+
100+
const res = await request(app)
101+
.post(`/api/v1/creator/${keyId}/curve`)
102+
.set('Authorization', `Bearer ${creatorToken}`)
103+
.send({
104+
milestones: [
105+
{ supplyThreshold: 10, exponent: 2 },
106+
{ supplyThreshold: 20, exponent: 2 },
107+
{ supplyThreshold: 30, exponent: 3 },
108+
{ supplyThreshold: 40, exponent: 3 },
109+
{ supplyThreshold: 50, exponent: 4 },
110+
{ supplyThreshold: 60, exponent: 5 },
111+
],
112+
});
113+
114+
expect(res.status).toBe(422);
115+
});
116+
117+
it('returns 422 when thresholds are out of order', async () => {
118+
(prisma.creatorProfile.findFirst as jest.Mock).mockResolvedValue({
119+
id: keyId,
120+
handle: keyId,
121+
userId: creatorUserId,
122+
});
123+
(prisma.stellarWallet.findUnique as jest.Mock).mockResolvedValue({
124+
userId: creatorUserId,
125+
});
126+
127+
const res = await request(app)
128+
.post(`/api/v1/creator/${keyId}/curve`)
129+
.set('Authorization', `Bearer ${creatorToken}`)
130+
.send({
131+
milestones: [
132+
{ supplyThreshold: 20, exponent: 2 },
133+
{ supplyThreshold: 10, exponent: 3 },
134+
],
135+
});
136+
137+
expect(res.status).toBe(422);
138+
});
139+
140+
it('returns 422 when exponent is outside 1–5 or fractional', async () => {
141+
(prisma.creatorProfile.findFirst as jest.Mock).mockResolvedValue({
142+
id: keyId,
143+
handle: keyId,
144+
userId: creatorUserId,
145+
});
146+
(prisma.stellarWallet.findUnique as jest.Mock).mockResolvedValue({
147+
userId: creatorUserId,
148+
});
149+
150+
const res0 = await request(app)
151+
.post(`/api/v1/creator/${keyId}/curve`)
152+
.set('Authorization', `Bearer ${creatorToken}`)
153+
.send({ milestones: [{ supplyThreshold: 10, exponent: 0 }] });
154+
expect(res0.status).toBe(422);
155+
156+
const res6 = await request(app)
157+
.post(`/api/v1/creator/${keyId}/curve`)
158+
.set('Authorization', `Bearer ${creatorToken}`)
159+
.send({ milestones: [{ supplyThreshold: 10, exponent: 6 }] });
160+
expect(res6.status).toBe(422);
161+
162+
const resFractional = await request(app)
163+
.post(`/api/v1/creator/${keyId}/curve`)
164+
.set('Authorization', `Bearer ${creatorToken}`)
165+
.send({ milestones: [{ supplyThreshold: 10, exponent: 1.5 }] });
166+
expect(resFractional.status).toBe(422);
167+
});
168+
169+
it('submits contract call, stores milestones, records activity, and returns 200 for valid input', async () => {
170+
const validMilestones = [
171+
{ supplyThreshold: 10, exponent: 2 },
172+
{ supplyThreshold: 50, exponent: 3 },
173+
{ supplyThreshold: 100, exponent: 4 },
174+
];
175+
176+
(prisma.creatorProfile.findFirst as jest.Mock).mockResolvedValue({
177+
id: keyId,
178+
handle: keyId,
179+
userId: creatorUserId,
180+
baseExponent: 1,
181+
});
182+
(prisma.stellarWallet.findUnique as jest.Mock).mockResolvedValue({
183+
userId: creatorUserId,
184+
});
185+
(prisma.creatorProfile.update as jest.Mock).mockResolvedValue({
186+
id: keyId,
187+
curveMilestones: validMilestones,
188+
baseExponent: 1,
189+
});
190+
191+
const res = await request(app)
192+
.post(`/api/v1/creator/${keyId}/curve`)
193+
.set('Authorization', `Bearer ${creatorToken}`)
194+
.send({ milestones: validMilestones });
195+
196+
expect(res.status).toBe(200);
197+
expect(res.body.success).toBe(true);
198+
expect(res.body.data.keyId).toBe(keyId);
199+
expect(res.body.data.milestones).toEqual(validMilestones);
200+
expect(res.body.data.baseExponent).toBe(1);
201+
202+
expect(curveGateway.configureGraduatedCurve).toHaveBeenCalledWith({
203+
creatorId: keyId,
204+
milestones: validMilestones,
205+
});
206+
expect(prisma.creatorProfile.update).toHaveBeenCalledWith({
207+
where: { id: keyId },
208+
data: { curveMilestones: validMilestones },
209+
});
210+
expect(prisma.activity.create).toHaveBeenCalled();
211+
});
212+
});
213+
214+
describe('GET /api/v1/keys/:keyId/curve-config', () => {
215+
it('returns 404 when key is not found', async () => {
216+
(prisma.creatorProfile.findFirst as jest.Mock).mockResolvedValue(null);
217+
218+
const res = await request(app).get(`/api/v1/keys/nonexistent/curve-config`);
219+
expect(res.status).toBe(404);
220+
});
221+
222+
it('returns milestones and base exponent without authentication', async () => {
223+
const storedMilestones = [
224+
{ supplyThreshold: 25, exponent: 2 },
225+
{ supplyThreshold: 75, exponent: 4 },
226+
];
227+
228+
(prisma.creatorProfile.findFirst as jest.Mock).mockResolvedValue({
229+
id: keyId,
230+
curveMilestones: storedMilestones,
231+
baseExponent: 1,
232+
});
233+
234+
const res = await request(app).get(`/api/v1/keys/${keyId}/curve-config`);
235+
expect(res.status).toBe(200);
236+
expect(res.body.success).toBe(true);
237+
expect(res.body.data.keyId).toBe(keyId);
238+
expect(res.body.data.milestones).toEqual(storedMilestones);
239+
expect(res.body.data.baseExponent).toBe(1);
240+
});
241+
});
242+
});

0 commit comments

Comments
 (0)