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/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/keys.routes.ts b/src/modules/keys/keys.routes.ts index fbab0a60..72432f99 100644 --- a/src/modules/keys/keys.routes.ts +++ b/src/modules/keys/keys.routes.ts @@ -60,6 +60,13 @@ import { PositionNotFoundError, unfreezePosition, } from './key-freeze.service'; +import { + simulateKeyTrade, + type SimulateSide, + InsufficientCirculatingSupplyError, + QuantityExceedsLimitError, + BatchSizeExceedsLimitError, +} from './key-simulate.service'; const priceHistoryQuerySchema = z.object({ from: z.string().datetime(), @@ -364,6 +371,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