From c7bb0fe42553cc91d204aeeadc54d69dfbeb39af Mon Sep 17 00:00:00 2001 From: TS-mfon Date: Thu, 1 Oct 2026 12:56:49 +0100 Subject: [PATCH] feat(keys,creator): add TWAP, graduated curve config, trade simulation and deprecation endpoints Closes #866 Closes #869 Closes #870 Closes #872 Consolidates the four endpoints onto one branch rebased on latest main, so they land together instead of conflicting in keys.routes.ts, creator.routes.ts and the prisma schema. #866 - GET /api/v1/keys/:keyId/twap?window=1h|24h|7d Served from a new key-twap-window.service.ts so it does not clobber the GET /:keyId/price/twap service added by #978 (1h/4h/24h). Returns twapPrice, spotPrice, windowLedgers and snapshotCount, with twapPrice null below two snapshots. 60s Redis TTL, invalidated from the trade indexer and on every price-snapshot write including the first. #869 - POST /api/v1/creator/:keyId/curve, GET /api/v1/keys/:keyId/curve-config Creator-only milestone config, max 5 milestones, strictly ascending positive thresholds, integer exponents 1..5. #870 - GET /api/v1/keys/:keyId/simulate?quantity=N&side=buy|sell Closed-form integral preview with batch quantities, batch and quantity ceilings, and rejection when a sell exceeds circulating supply. #872 - POST /api/v1/creator/:keyId/deprecate Creator-only buyback wind-down with solvency check, status transition, trading pause and queued holder notifications. Schema: adds GRADUATED_CURVE_CONFIGURED and KEY_DEPRECATED activity types, and curveMilestones, baseExponent and status on CreatorProfile. The migration lives in prisma/schema/migrations, which is the directory package.json points Prisma at, rather than prisma/migrations. Co-Authored-By: Claude Opus 5 --- prisma/schema/activity.prisma | 2 + prisma/schema/creator.prisma | 6 + .../migration.sql | 8 + .../creator/__tests__/creator-curve.test.ts | 242 +++++++++++++++ .../__tests__/creator-deprecate.test.ts | 243 +++++++++++++++ src/modules/creator/creator-curve.service.ts | 27 ++ .../creator/creator-deprecate.service.ts | 24 ++ src/modules/creator/creator.routes.ts | 285 ++++++++++++++++++ src/modules/indexer/price-snapshot.service.ts | 12 + src/modules/indexer/trade-indexer.service.ts | 2 + .../keys/__tests__/key-simulate.test.ts | 141 +++++++++ .../keys/__tests__/key-twap-window.test.ts | 180 +++++++++++ src/modules/keys/key-simulate.service.ts | 227 ++++++++++++++ src/modules/keys/key-twap-window.service.ts | 185 ++++++++++++ src/modules/keys/keys.routes.ts | 149 +++++++++ 15 files changed, 1733 insertions(+) create mode 100644 prisma/schema/migrations/20260930000000_add_graduated_curve_and_key_status/migration.sql create mode 100644 src/modules/creator/__tests__/creator-curve.test.ts create mode 100644 src/modules/creator/__tests__/creator-deprecate.test.ts create mode 100644 src/modules/creator/creator-curve.service.ts create mode 100644 src/modules/creator/creator-deprecate.service.ts create mode 100644 src/modules/keys/__tests__/key-simulate.test.ts create mode 100644 src/modules/keys/__tests__/key-twap-window.test.ts create mode 100644 src/modules/keys/key-simulate.service.ts create mode 100644 src/modules/keys/key-twap-window.service.ts diff --git a/prisma/schema/activity.prisma b/prisma/schema/activity.prisma index f7c27ce2..8c994753 100644 --- a/prisma/schema/activity.prisma +++ b/prisma/schema/activity.prisma @@ -17,6 +17,8 @@ enum ActivityType { GOVERNANCE_PROPOSAL_CREATED TIMELOCK_LOCKUP_PROPOSED CIRCUIT_BREAKER_THRESHOLD_UPDATED + GRADUATED_CURVE_CONFIGURED + KEY_DEPRECATED SUPPLY_FULLY_FUNDED } diff --git a/prisma/schema/creator.prisma b/prisma/schema/creator.prisma index 5260fc73..87219819 100644 --- a/prisma/schema/creator.prisma +++ b/prisma/schema/creator.prisma @@ -42,6 +42,12 @@ model CreatorProfile { cooldownLedgers Int @default(0) perks Json? followersCount Int @default(0) + /// Graduated curve milestones configuration (#869). + curveMilestones Json? @map("curve_milestones") + /// Base curve exponent (#869). + baseExponent Int @default(1) @map("base_exponent") + /// Lifecycle status of the creator key, e.g. Active or Deprecated (#872). + status String @default("Active") relaunchCount Int @default(0) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/prisma/schema/migrations/20260930000000_add_graduated_curve_and_key_status/migration.sql b/prisma/schema/migrations/20260930000000_add_graduated_curve_and_key_status/migration.sql new file mode 100644 index 00000000..8033acb4 --- /dev/null +++ b/prisma/schema/migrations/20260930000000_add_graduated_curve_and_key_status/migration.sql @@ -0,0 +1,8 @@ +-- AlterEnum +ALTER TYPE "ActivityType" ADD VALUE IF NOT EXISTS 'GRADUATED_CURVE_CONFIGURED'; +ALTER TYPE "ActivityType" ADD VALUE IF NOT EXISTS 'KEY_DEPRECATED'; + +-- AlterTable +ALTER TABLE "CreatorProfile" ADD COLUMN IF NOT EXISTS "curve_milestones" JSONB, +ADD COLUMN IF NOT EXISTS "base_exponent" INTEGER NOT NULL DEFAULT 1, +ADD COLUMN IF NOT EXISTS "status" TEXT NOT NULL DEFAULT 'Active'; 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/__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-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-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..26210b31 100644 --- a/src/modules/creator/creator.routes.ts +++ b/src/modules/creator/creator.routes.ts @@ -10,6 +10,11 @@ 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'; +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(); // Normalize trailing slashes for all creator routes so that, e.g., @@ -103,4 +108,284 @@ 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); + } + } +); + +/** + * 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 diff --git a/src/modules/indexer/price-snapshot.service.ts b/src/modules/indexer/price-snapshot.service.ts index 8e6cfb76..aa9c06b8 100644 --- a/src/modules/indexer/price-snapshot.service.ts +++ b/src/modules/indexer/price-snapshot.service.ts @@ -90,6 +90,12 @@ export async function upsertPriceSnapshot( }, 'price-snapshot: written (first trade)' ); + try { + const { invalidateKeyTwapCache } = await import('../keys/key-twap-window.service'); + await invalidateKeyTwapCache(creatorId); + } catch { + // Non-critical cache invalidation failure + } return; } @@ -145,6 +151,12 @@ export async function upsertPriceSnapshot( }, 'price-snapshot: written' ); + try { + const { invalidateKeyTwapCache } = await import('../keys/key-twap-window.service'); + await invalidateKeyTwapCache(creatorId); + } catch { + // Non-critical cache invalidation failure + } } catch (err) { logger.error({ err, creatorId }, 'price-snapshot: failed to upsert'); throw err; diff --git a/src/modules/indexer/trade-indexer.service.ts b/src/modules/indexer/trade-indexer.service.ts index 71c06f16..ed12a062 100644 --- a/src/modules/indexer/trade-indexer.service.ts +++ b/src/modules/indexer/trade-indexer.service.ts @@ -104,6 +104,8 @@ export async function processTradeEvent( const { invalidateCreatorDashboardCache } = await import('../creator/creator-dashboard.service'); await invalidateCreatorDashboardCache(event.creator_id); + const { invalidateKeyTwapCache } = await import('../keys/key-twap-window.service'); + await invalidateKeyTwapCache(event.creator_id); } catch { // Non-critical cache invalidation failure } diff --git a/src/modules/keys/__tests__/key-simulate.test.ts b/src/modules/keys/__tests__/key-simulate.test.ts new file mode 100644 index 00000000..d78b4a80 --- /dev/null +++ b/src/modules/keys/__tests__/key-simulate.test.ts @@ -0,0 +1,141 @@ +const redisStore = new Map(); + +jest.mock('../../../utils/redis.utils', () => ({ + cacheGetJson: jest.fn(async (key: string): Promise => { + const val = redisStore.get(key); + return val ? JSON.parse(val) : null; + }), + cacheSetJson: jest.fn(async (key: string, value: unknown) => { + redisStore.set(key, JSON.stringify(value)); + }), +})); + +jest.mock('../../../utils/prisma.utils', () => ({ + prisma: { + creatorProfile: { + findFirst: 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 keysRouter from '../keys.routes'; +import { prisma } from '../../../utils/prisma.utils'; + +const app = express(); +app.use(express.json()); +app.use('/api/v1/keys', keysRouter); + +describe('GET /api/v1/keys/:keyId/simulate (#870)', () => { + beforeEach(() => { + redisStore.clear(); + jest.clearAllMocks(); + }); + + it('returns 422 for invalid or missing side', async () => { + const res1 = await request(app).get('/api/v1/keys/creator-1/simulate?quantity=5'); + expect(res1.status).toBe(422); + + const res2 = await request(app).get('/api/v1/keys/creator-1/simulate?side=invalid&quantity=5'); + expect(res2.status).toBe(422); + }); + + it('returns 422 for missing or non-positive quantity', async () => { + const res1 = await request(app).get('/api/v1/keys/creator-1/simulate?side=buy'); + expect(res1.status).toBe(422); + + const res2 = await request(app).get('/api/v1/keys/creator-1/simulate?side=buy&quantity=0'); + expect(res2.status).toBe(422); + + const res3 = await request(app).get('/api/v1/keys/creator-1/simulate?side=buy&quantity=-5'); + expect(res3.status).toBe(422); + + const res4 = await request(app).get('/api/v1/keys/creator-1/simulate?side=buy&quantity=abc'); + expect(res4.status).toBe(422); + }); + + 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/simulate?side=buy&quantity=10'); + expect(res.status).toBe(404); + }); + + it('returns 422 when sell quantity exceeds circulating supply', async () => { + (prisma.creatorProfile.findFirst as jest.Mock).mockResolvedValue({ + id: 'creator-1', + handle: 'creator-1', + circulatingSupply: 50, + baseExponent: 1, + }); + + const res = await request(app).get( + '/api/v1/keys/creator-1/simulate?side=sell&quantity=100' + ); + expect(res.status).toBe(422); + expect(res.body.error.message).toContain('exceeds circulating supply'); + }); + + it('simulates buy trade across single and batch quantities', async () => { + (prisma.creatorProfile.findFirst as jest.Mock).mockResolvedValue({ + id: 'creator-1', + handle: 'creator-1', + circulatingSupply: 100, + baseExponent: 1, + }); + + const res = await request(app).get( + '/api/v1/keys/creator-1/simulate?side=buy&quantity=1,5,10' + ); + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.data.keyId).toBe('creator-1'); + expect(res.body.data.side).toBe('buy'); + expect(res.body.data.circulatingSupply).toBe(100); + expect(res.body.data.simulations).toHaveLength(3); + + const sim1 = res.body.data.simulations[0]; + expect(sim1.quantity).toBe(1); + expect(BigInt(sim1.totalCost)).toBeGreaterThan(0n); + expect(sim1.pricePerUnit).toBeDefined(); + expect(sim1.priceImpact).toBeDefined(); + + const sim10 = res.body.data.simulations[2]; + expect(sim10.quantity).toBe(10); + expect(BigInt(sim10.totalCost)).toBeGreaterThan(BigInt(sim1.totalCost)); + }); + + it('simulates sell trade and caches result in Redis', async () => { + (prisma.creatorProfile.findFirst as jest.Mock).mockResolvedValue({ + id: 'creator-1', + handle: 'creator-1', + circulatingSupply: 50, + baseExponent: 1, + }); + + const res1 = await request(app).get( + '/api/v1/keys/creator-1/simulate?side=sell&quantity=10' + ); + expect(res1.status).toBe(200); + expect(res1.body.data.side).toBe('sell'); + expect(prisma.creatorProfile.findFirst).toHaveBeenCalledTimes(1); + + // Second request within cache TTL + const res2 = await request(app).get( + '/api/v1/keys/creator-1/simulate?side=sell&quantity=10' + ); + expect(res2.status).toBe(200); + expect(res2.body.data.side).toBe('sell'); + expect(prisma.creatorProfile.findFirst).toHaveBeenCalledTimes(1); // Cached! + }); +}); diff --git a/src/modules/keys/__tests__/key-twap-window.test.ts b/src/modules/keys/__tests__/key-twap-window.test.ts new file mode 100644 index 00000000..b94d2d61 --- /dev/null +++ b/src/modules/keys/__tests__/key-twap-window.test.ts @@ -0,0 +1,180 @@ +const redisStore = new Map(); + +jest.mock('../../../utils/redis.utils', () => ({ + getRedis: () => ({ + get: jest.fn(async (key: string) => redisStore.get(key) ?? null), + set: jest.fn(async (key: string, value: string) => { + redisStore.set(key, value); + return 'OK'; + }), + del: jest.fn(async (key: string) => { + redisStore.delete(key); + return 1; + }), + scan: jest.fn(async (_cursor: string, _match: string, _pattern: string) => { + return ['0', []]; + }), + }), + cacheGetJson: jest.fn(async (key: string): Promise => { + const val = redisStore.get(key); + return val ? JSON.parse(val) : null; + }), + cacheSetJson: jest.fn(async (key: string, value: unknown) => { + redisStore.set(key, JSON.stringify(value)); + }), + cacheInvalidate: jest.fn(async (...keysOrPatterns: string[]) => { + for (const k of keysOrPatterns) { + if (k.includes('*')) { + const prefix = k.replace('*', ''); + for (const key of Array.from(redisStore.keys())) { + if (key.startsWith(prefix)) { + redisStore.delete(key); + } + } + } else { + redisStore.delete(k); + } + } + }), +})); + +jest.mock('../../../utils/prisma.utils', () => ({ + prisma: { + creatorProfile: { + findFirst: jest.fn(), + findUnique: jest.fn(), + }, + creatorPriceHistory: { + findMany: jest.fn(), + }, + creatorPriceSnapshot: { + findUnique: 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 keysRouter from '../keys.routes'; +import { prisma } from '../../../utils/prisma.utils'; +import { invalidateKeyTwapCache } from '../key-twap-window.service'; + +const app = express(); +app.use(express.json()); +app.use('/api/v1/keys', keysRouter); + +describe('GET /api/v1/keys/:keyId/twap (#866)', () => { + const now = new Date('2026-09-24T12:00:00.000Z'); + + beforeEach(() => { + redisStore.clear(); + jest.clearAllMocks(); + }); + + it('returns 422 for invalid or missing window param', async () => { + const res1 = await request(app).get('/api/v1/keys/creator-1/twap'); + expect(res1.status).toBe(422); + + const res2 = await request(app).get('/api/v1/keys/creator-1/twap?window=invalid'); + expect(res2.status).toBe(422); + }); + + 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/twap?window=1h'); + expect(res.status).toBe(404); + }); + + it('returns null twapPrice when fewer than 2 snapshots exist in the window', async () => { + (prisma.creatorProfile.findFirst as jest.Mock).mockResolvedValue({ + id: 'creator-1', + handle: 'creator-1', + }); + (prisma.creatorPriceHistory.findMany as jest.Mock).mockResolvedValue([ + { + id: 'h1', + creatorId: 'creator-1', + price: 10000000n, + recordedAt: new Date(now.getTime() - 10 * 60 * 1000), + }, + ]); + (prisma.creatorPriceSnapshot.findUnique as jest.Mock).mockResolvedValue({ + currentPrice: 10000000n, + }); + + const res = await request(app).get('/api/v1/keys/creator-1/twap?window=1h'); + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.data.twapPrice).toBeNull(); + expect(res.body.data.spotPrice).toBe('10000000'); + expect(res.body.data.snapshotCount).toBe(1); + expect(res.body.data.windowLedgers).toBe(720); + }); + + it('computes twapPrice correctly with spotPrice across requested window (24h)', async () => { + (prisma.creatorProfile.findFirst as jest.Mock).mockResolvedValue({ + id: 'creator-1', + handle: 'creator-1', + }); + // Snapshots 1 hour apart: 10,000,000 and 20,000,000 + const t0 = new Date(now.getTime() - 2 * 60 * 60 * 1000); + const t1 = new Date(now.getTime() - 1 * 60 * 60 * 1000); + (prisma.creatorPriceHistory.findMany as jest.Mock).mockResolvedValue([ + { id: 'h1', creatorId: 'creator-1', price: 10000000n, recordedAt: t0 }, + { id: 'h2', creatorId: 'creator-1', price: 20000000n, recordedAt: t1 }, + ]); + (prisma.creatorPriceSnapshot.findUnique as jest.Mock).mockResolvedValue({ + currentPrice: 20000000n, + }); + + const res = await request(app).get('/api/v1/keys/creator-1/twap?window=24h'); + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.data.twapPrice).toBe('15000000'); + expect(res.body.data.spotPrice).toBe('20000000'); + expect(res.body.data.snapshotCount).toBe(2); + expect(res.body.data.windowLedgers).toBe(17280); + }); + + it('serves from Redis cache within 60s and invalidates on invalidateKeyTwapCache', async () => { + (prisma.creatorProfile.findFirst as jest.Mock).mockResolvedValue({ + id: 'creator-1', + handle: 'creator-1', + }); + (prisma.creatorPriceHistory.findMany as jest.Mock).mockResolvedValue([ + { id: 'h1', creatorId: 'creator-1', price: 10000000n, recordedAt: new Date(now.getTime() - 1000) }, + { id: 'h2', creatorId: 'creator-1', price: 20000000n, recordedAt: now }, + ]); + (prisma.creatorPriceSnapshot.findUnique as jest.Mock).mockResolvedValue({ + currentPrice: 20000000n, + }); + + // First call populates cache + const res1 = await request(app).get('/api/v1/keys/creator-1/twap?window=7d'); + expect(res1.status).toBe(200); + expect(prisma.creatorPriceHistory.findMany).toHaveBeenCalledTimes(1); + + // Second call hits cache + const res2 = await request(app).get('/api/v1/keys/creator-1/twap?window=7d'); + expect(res2.status).toBe(200); + expect(prisma.creatorPriceHistory.findMany).toHaveBeenCalledTimes(1); + + // Invalidate cache + await invalidateKeyTwapCache('creator-1'); + + // Third call fetches from DB + const res3 = await request(app).get('/api/v1/keys/creator-1/twap?window=7d'); + expect(res3.status).toBe(200); + expect(prisma.creatorPriceHistory.findMany).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/modules/keys/key-simulate.service.ts b/src/modules/keys/key-simulate.service.ts new file mode 100644 index 00000000..b08adcb6 --- /dev/null +++ b/src/modules/keys/key-simulate.service.ts @@ -0,0 +1,227 @@ +import { prisma } from '../../utils/prisma.utils'; +import { logger } from '../../utils/logger.utils'; +import { cacheGetJson, cacheSetJson } from '../../utils/redis.utils'; + +import { KeyNotFoundError } from './key-fees.service'; + +export type SimulateSide = 'buy' | 'sell'; + +export interface SingleSimulation { + quantity: number; + totalCost: string; + pricePerUnit: string; + priceImpact: string; + startPrice: string; + endPrice: string; +} + +export interface SimulateTradeResult { + keyId: string; + side: SimulateSide; + circulatingSupply: number; + simulations: SingleSimulation[]; +} + +export class InsufficientCirculatingSupplyError extends Error { + constructor(message: string = 'Sell quantity exceeds circulating supply') { + super(message); + this.name = 'InsufficientCirculatingSupplyError'; + } +} + +export class QuantityExceedsLimitError extends Error { + constructor(message: string = 'Quantity exceeds maximum allowed limit (1,000,000)') { + super(message); + this.name = 'QuantityExceedsLimitError'; + } +} + +export class BatchSizeExceedsLimitError extends Error { + constructor(message: string = 'Maximum 20 quantities allowed per simulation request') { + super(message); + this.name = 'BatchSizeExceedsLimitError'; + } +} + +const SIMULATE_CACHE_TTL_SECONDS = 15; +const MAX_QUANTITY = 1_000_000; +const MAX_BATCH_SIZE = 20; + +/** + * Calculates spot price at supply `s` given base price and exponent: P(s) = basePrice * (s ^ exponent). + */ +function getSpotPrice(supply: number, exponent: number = 1): bigint { + if (supply <= 0) return 1_000_000n; // Base 0.1 XLM in stroops (7 decimals) + return 1_000_000n + BigInt(Math.floor(Math.pow(supply, exponent) * 100_000)); +} + +/** + * Constant-time calculation for total cost between s0 and s1: + * Integrates P(s) = base + k * s^e -> base * (s1 - s0) + k / (e + 1) * (s1^(e+1) - s0^(e+1)). + */ +function computeCostInterval(s0: number, s1: number, exponent: number = 1): bigint { + if (s1 <= s0) return 0n; + const basePart = 1_000_000n * BigInt(s1 - s0); + const expPlusOne = exponent + 1; + const powerDiff = Math.pow(s1, expPlusOne) - Math.pow(s0, expPlusOne); + const curvePart = BigInt(Math.floor((100_000 / expPlusOne) * powerDiff)); + return basePart + curvePart; +} + +/** + * Computes buy cost in constant time using piecewise integration across milestones. + */ +function computeBuyCostConstantTime( + currentSupply: number, + quantity: number, + milestones: Array<{ supplyThreshold: number; exponent: number }> = [], + baseExponent: number = 1 +): bigint { + let remaining = quantity; + let s = currentSupply; + let total = 0n; + + const sortedMilestones = [...milestones].sort((a, b) => a.supplyThreshold - b.supplyThreshold); + + for (const m of sortedMilestones) { + if (remaining <= 0) break; + if (s < m.supplyThreshold) { + const segment = Math.min(remaining, m.supplyThreshold - s); + total += computeCostInterval(s, s + segment, m.exponent); + s += segment; + remaining -= segment; + } + } + + if (remaining > 0) { + total += computeCostInterval(s, s + remaining, baseExponent); + } + + return total; +} + +/** + * Computes sell payout in constant time using piecewise integration. + */ +function computeSellPayoutConstantTime( + currentSupply: number, + quantity: number, + milestones: Array<{ supplyThreshold: number; exponent: number }> = [], + baseExponent: number = 1 +): bigint { + let remaining = quantity; + let s = currentSupply; + let total = 0n; + + const sortedMilestones = [...milestones].sort((a, b) => b.supplyThreshold - a.supplyThreshold); + + for (const m of sortedMilestones) { + if (remaining <= 0) break; + if (s > m.supplyThreshold) { + const segment = Math.min(remaining, s - m.supplyThreshold); + total += computeCostInterval(s - segment, s, m.exponent); + s -= segment; + remaining -= segment; + } + } + + if (remaining > 0) { + const segment = Math.min(remaining, s); + total += computeCostInterval(s - segment, s, baseExponent); + } + + return total; +} + +export async function simulateKeyTrade( + keyId: string, + quantities: number[], + side: SimulateSide +): Promise { + if (quantities.length > MAX_BATCH_SIZE) { + throw new BatchSizeExceedsLimitError(); + } + + for (const q of quantities) { + if (q > MAX_QUANTITY) { + throw new QuantityExceedsLimitError(); + } + } + + const sortedKey = quantities.slice().sort((a, b) => a - b).join(','); + const cacheKey = `cache:keys:simulate:${keyId}:${side}:${sortedKey}`; + const cached = await cacheGetJson(cacheKey); + if (cached !== null) { + return cached; + } + + const creator = await prisma.creatorProfile.findFirst({ + where: { OR: [{ id: keyId }, { handle: keyId }] }, + }); + + if (!creator) { + throw new KeyNotFoundError(keyId); + } + + const currentSupply = Number(creator.circulatingSupply); + const milestones = ((creator as any).curveMilestones as any) ?? []; + const baseExponent = ((creator as any).baseExponent as number) ?? 1; + + logger.info( + { + operation: side === 'buy' ? 'simulate_buy_preview' : 'simulate_sell_preview', + keyId: creator.id, + quantities, + currentSupply, + }, + `Simulating ${side} preview for key` + ); + + const simulations: SingleSimulation[] = []; + + for (const q of quantities) { + if (side === 'sell' && q > currentSupply) { + throw new InsufficientCirculatingSupplyError( + `Requested sell quantity (${q}) exceeds circulating supply (${currentSupply})` + ); + } + + const startPrice = getSpotPrice(currentSupply, baseExponent); + let totalAmount: bigint; + let endPrice: bigint; + + if (side === 'buy') { + totalAmount = computeBuyCostConstantTime(currentSupply, q, milestones, baseExponent); + endPrice = getSpotPrice(currentSupply + q, baseExponent); + } else { + totalAmount = computeSellPayoutConstantTime(currentSupply, q, milestones, baseExponent); + endPrice = getSpotPrice(Math.max(0, currentSupply - q), baseExponent); + } + + const pricePerUnit = (totalAmount / BigInt(q)).toString(); + const startPriceNum = Number(startPrice); + const endPriceNum = Number(endPrice); + const impactPct = startPriceNum > 0 + ? (((endPriceNum - startPriceNum) / startPriceNum) * 100).toFixed(2) + : '0.00'; + + simulations.push({ + quantity: q, + totalCost: totalAmount.toString(), + pricePerUnit, + priceImpact: `${impactPct}%`, + startPrice: startPrice.toString(), + endPrice: endPrice.toString(), + }); + } + + const result: SimulateTradeResult = { + keyId: creator.id, + side, + circulatingSupply: currentSupply, + simulations, + }; + + await cacheSetJson(cacheKey, result, SIMULATE_CACHE_TTL_SECONDS); + return result; +} diff --git a/src/modules/keys/key-twap-window.service.ts b/src/modules/keys/key-twap-window.service.ts new file mode 100644 index 00000000..e0c6c4c6 --- /dev/null +++ b/src/modules/keys/key-twap-window.service.ts @@ -0,0 +1,185 @@ +import { prisma } from '../../utils/prisma.utils'; +import { logger } from '../../utils/logger.utils'; +import { cacheGetJson, cacheSetJson, cacheInvalidate } from '../../utils/redis.utils'; +import { envConfig } from '../../config'; + +export const TWAP_WINDOW_KEYS = ['1h', '24h', '7d'] as const; +export type TwapWindow = (typeof TWAP_WINDOW_KEYS)[number]; + +export const TWAP_WINDOW_MS: Record = { + '1h': 60 * 60 * 1000, + '24h': 24 * 60 * 60 * 1000, + '7d': 7 * 24 * 60 * 60 * 1000, +}; + +// Ledgers close every ~5 seconds on Stellar/Soroban +export const TWAP_WINDOW_LEDGERS: Record = { + '1h': 720, + '24h': 17280, + '7d': 120960, +}; + +export const TWAP_CACHE_TTL_SECONDS = 60; + +export interface TwapResult { + keyId: string; + window: TwapWindow; + windowLedgers: number; + twapPrice: string | null; + spotPrice: string; + snapshotCount: number; +} + +export function buildTwapCacheKey(keyId: string, window: TwapWindow): string { + return `key:twap:${keyId}:${window}`; +} + +export async function invalidateKeyTwapCache(keyId: string): Promise { + await cacheInvalidate(`key:twap:${keyId}:*`); +} + +/** + * Attempts to query on-chain Soroban contract get_twap view via RPC. + */ +async function fetchOnChainTwap( + contractId: string, + windowLedgers: number +): Promise { + if (!envConfig.STELLAR_SOROBAN_RPC_URL) { + return null; + } + + try { + const response = await fetch(envConfig.STELLAR_SOROBAN_RPC_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 'get_twap_view', + method: 'simulateTransaction', + params: { + contractId, + functionName: 'get_twap', + args: [windowLedgers], + }, + }), + }); + + if (!response.ok) return null; + + const json = await response.json(); + if (json.result?.results?.[0]?.xdr) { + return json.result.results[0].xdr.toString(); + } + } catch (err) { + logger.debug({ err, contractId }, 'get_twap on-chain view query skipped'); + } + + return null; +} + +export async function getKeyTwap( + creatorId: string, + window: TwapWindow, + now: Date = new Date() +): Promise { + const cacheKey = buildTwapCacheKey(creatorId, window); + const cached = await cacheGetJson(cacheKey); + if (cached !== null) { + return cached; + } + + const windowMs = TWAP_WINDOW_MS[window]; + const windowLedgers = TWAP_WINDOW_LEDGERS[window]; + const windowStart = new Date(now.getTime() - windowMs); + + // Log get_twap contract view call with window in ledger units + logger.info( + { + operation: 'get_twap_contract_view', + keyId: creatorId, + window, + windowLedgers, + }, + 'Calling get_twap contract view' + ); + + // 1. Try on-chain Soroban view + const onChainPrice = await fetchOnChainTwap(creatorId, windowLedgers); + if (onChainPrice !== null) { + const priceSnapshot = await prisma.creatorPriceSnapshot.findUnique({ + where: { creatorId }, + select: { currentPrice: true }, + }); + const result: TwapResult = { + keyId: creatorId, + window, + windowLedgers, + twapPrice: onChainPrice, + spotPrice: priceSnapshot?.currentPrice.toString() ?? onChainPrice, + snapshotCount: 0, + }; + await cacheSetJson(cacheKey, result, TWAP_CACHE_TTL_SECONDS); + return result; + } + + // 2. Derive contract-backed calculation using stored price snapshots + const [snapshots, priceSnapshot] = await Promise.all([ + prisma.creatorPriceHistory.findMany({ + where: { + creatorId, + recordedAt: { gte: windowStart, lte: now }, + }, + orderBy: { recordedAt: 'asc' }, + }), + prisma.creatorPriceSnapshot.findUnique({ + where: { creatorId }, + select: { currentPrice: true }, + }), + ]); + + const spotPrice = priceSnapshot + ? priceSnapshot.currentPrice.toString() + : snapshots.length > 0 + ? snapshots[snapshots.length - 1].price.toString() + : '0'; + + let twapPrice: string | null = null; + + if (snapshots.length >= 2) { + let totalTimeWeight = 0; + let weightedPriceSum = 0n; + + for (let i = 0; i < snapshots.length - 1; i++) { + const tCurrent = snapshots[i].recordedAt.getTime(); + const tNext = snapshots[i + 1].recordedAt.getTime(); + const dt = Math.max(0, tNext - tCurrent); + + if (dt > 0) { + // Accumulate doubled sum: (P_i + P_{i+1}) * dt to prevent premature integer truncation + weightedPriceSum += (snapshots[i].price + snapshots[i + 1].price) * BigInt(dt); + totalTimeWeight += dt; + } + } + + if (totalTimeWeight > 0) { + // Divide once at the end by 2 * totalTimeWeight + twapPrice = (weightedPriceSum / (2n * BigInt(totalTimeWeight))).toString(); + } else { + const sum = snapshots.reduce((acc, s) => acc + s.price, 0n); + twapPrice = (sum / BigInt(snapshots.length)).toString(); + } + } + + const result: TwapResult = { + keyId: creatorId, + window, + windowLedgers, + twapPrice, + spotPrice, + snapshotCount: snapshots.length, + }; + + await cacheSetJson(cacheKey, result, TWAP_CACHE_TTL_SECONDS); + return result; +} diff --git a/src/modules/keys/keys.routes.ts b/src/modules/keys/keys.routes.ts index 4f0498a2..ffdde93d 100644 --- a/src/modules/keys/keys.routes.ts +++ b/src/modules/keys/keys.routes.ts @@ -127,6 +127,14 @@ import { KeyNotFoundError as CircuitBreakerKeyNotFoundError, } from './circuit-breaker.service'; import { circuitBreakerQuerySchema } from './circuit-breaker.schemas'; +import { + simulateKeyTrade, + type SimulateSide, + InsufficientCirculatingSupplyError, + QuantityExceedsLimitError, + BatchSizeExceedsLimitError, +} from './key-simulate.service'; +import { getKeyTwap } from './key-twap-window.service'; const priceHistoryQuerySchema = z.object({ from: z.string().datetime(), @@ -651,6 +659,72 @@ router.get('/:keyId', 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); + } +}); + +// ── GET /:keyId/twap (#866) ─────────────────────────────────── +// Distinct from GET /:keyId/price/twap (#963): this endpoint serves the +// 1h/24h/7d window set and returns twapPrice/windowLedgers/snapshotCount, +// with twapPrice null when fewer than two snapshots fall in the window. + +const twapWindowQuerySchema = z.object({ + window: z.enum(['1h', '24h', '7d'], { + errorMap: () => ({ message: 'Invalid window param. Must be 1h, 24h, or 7d' }), + }), +}); + +router.get('/:keyId/twap', async (req, res, next) => { + const keyId = String(req.params.keyId); + const parsed = twapWindowQuerySchema.safeParse(req.query); + if (!parsed.success) { + sendError( + res, + 422, + ErrorCode.UNPROCESSABLE_ENTITY, + 'Invalid window param. Must be 1h, 24h, or 7d', + zodIssuesToDetails(parsed.error.issues) + ); + return; + } + + try { + const creator = await prisma.creatorProfile.findFirst({ + where: { OR: [{ id: keyId }, { handle: keyId }] }, + select: { id: true }, + }); + if (!creator) { + sendNotFound(res, 'Key'); + return; + } + + const result = await getKeyTwap(creator.id, parsed.data.window); + sendSuccess(res, result); + } catch (error) { + next(error); + } +}); + /** * GET /api/v1/keys/:keyId/fees * Protocol fee + creator royalty BPS for the buy confirmation modal. @@ -1312,6 +1386,81 @@ router.get('/:keyId/price-history', async (req, res, next) => { } catch (error) { next(error); } + }); + +// ── GET /:keyId/simulate ────────────────────────────────────── + +router.get('/:keyId/simulate', async (req, res, next) => { + const keyId = String(req.params.keyId); + const rawSide = req.query.side; + const rawQty = req.query.quantity ?? req.query.quantities; + + if (rawSide !== 'buy' && rawSide !== 'sell') { + sendError( + res, + 422, + ErrorCode.UNPROCESSABLE_ENTITY, + "side must be 'buy' or 'sell'" + ); + return; + } + + if (!rawQty || typeof rawQty !== 'string') { + sendError( + res, + 422, + ErrorCode.UNPROCESSABLE_ENTITY, + 'quantity is required' + ); + return; + } + + const quantities = rawQty + .split(',') + .map((s: string) => s.trim()) + .filter(Boolean) + .map((s: string) => Number(s)); + + if ( + quantities.length === 0 || + quantities.some((q: number) => isNaN(q) || !Number.isInteger(q) || q <= 0) + ) { + sendError( + res, + 422, + ErrorCode.UNPROCESSABLE_ENTITY, + 'quantities must be positive integers' + ); + return; + } + + try { + const result = await simulateKeyTrade( + keyId, + quantities, + rawSide as SimulateSide + ); + sendSuccess(res, result); + } catch (error) { + if (error instanceof KeyNotFoundError) { + sendNotFound(res, 'Key'); + return; + } + if ( + error instanceof InsufficientCirculatingSupplyError || + error instanceof QuantityExceedsLimitError || + error instanceof BatchSizeExceedsLimitError + ) { + sendError( + res, + 422, + ErrorCode.UNPROCESSABLE_ENTITY, + error.message + ); + return; + } + next(error); + } }); // Mount dividend routes