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
141 changes: 141 additions & 0 deletions src/modules/keys/__tests__/key-simulate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
const redisStore = new Map<string, string>();

jest.mock('../../../utils/redis.utils', () => ({
cacheGetJson: jest.fn(async <T>(key: string): Promise<T | null> => {
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!
});
});
227 changes: 227 additions & 0 deletions src/modules/keys/key-simulate.service.ts
Original file line number Diff line number Diff line change
@@ -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<SimulateTradeResult> {
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<SimulateTradeResult>(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;
}
Loading
Loading