diff --git a/backend/package.json b/backend/package.json index 9a69f17..d37bd4a 100644 --- a/backend/package.json +++ b/backend/package.json @@ -62,6 +62,7 @@ "@types/helmet": "^0.0.48", "@types/jsonwebtoken": "^9.0.7", "@types/morgan": "^1.9.9", + "@types/ms": "^2.1.0", "@types/node": "^22.10.5", "@types/supertest": "^6.0.3", "@types/swagger-jsdoc": "^6.0.4", @@ -83,5 +84,15 @@ }, "prisma": { "seed": "tsx prisma/seed.ts" + }, + "pnpm": { + "onlyBuiltDependencies": [ + "@prisma/client", + "@prisma/engines", + "@scarf/scarf", + "bcrypt", + "esbuild", + "prisma" + ] } } diff --git a/backend/src/controllers/trading.controller.ts b/backend/src/controllers/trading.controller.ts new file mode 100644 index 0000000..19e9a3e --- /dev/null +++ b/backend/src/controllers/trading.controller.ts @@ -0,0 +1,281 @@ +// backend/src/controllers/trading.controller.ts +// Trading controller - handles trading HTTP requests + +import { Request, Response } from 'express'; +import { tradingService } from '../services/trading.service.js'; +import { AuthenticatedRequest } from '../types/auth.types.js'; + +class TradingController { + /** + * POST /api/markets/:marketId/buy - Buy outcome shares + */ + async buyShares(req: Request, res: Response): Promise { + try { + const userId = (req as AuthenticatedRequest).user?.userId; + if (!userId) { + res.status(401).json({ + success: false, + error: { + code: 'UNAUTHORIZED', + message: 'Authentication required', + }, + }); + return; + } + + const marketId = req.params.marketId as string; + const { outcome, amount, minShares } = req.body; + + // Validate input + if (outcome === undefined || outcome === null) { + res.status(400).json({ + success: false, + error: { + code: 'VALIDATION_ERROR', + message: 'outcome is required (0 for NO, 1 for YES)', + }, + }); + return; + } + + if (!amount || amount <= 0) { + res.status(400).json({ + success: false, + error: { + code: 'VALIDATION_ERROR', + message: 'amount must be greater than 0', + }, + }); + return; + } + + if (![0, 1].includes(outcome)) { + res.status(400).json({ + success: false, + error: { + code: 'VALIDATION_ERROR', + message: 'outcome must be 0 (NO) or 1 (YES)', + }, + }); + return; + } + + // Call service + const result = await tradingService.buyShares({ + userId, + marketId, + outcome, + amount, + minShares, + }); + + res.status(201).json({ + success: true, + data: { + sharesBought: result.sharesBought, + pricePerUnit: result.pricePerUnit, + totalCost: result.totalCost, + feeAmount: result.feeAmount, + txHash: result.txHash, + tradeId: result.tradeId, + position: result.newSharePosition, + }, + }); + } catch (error: any) { + console.error('Error buying shares:', error); + + // Determine appropriate status code + let statusCode = 500; + let errorCode = 'INTERNAL_ERROR'; + + if (error.message.includes('not found')) { + statusCode = 404; + errorCode = 'NOT_FOUND'; + } else if ( + error.message.includes('Insufficient') || + error.message.includes('Invalid') || + error.message.includes('only allowed') + ) { + statusCode = 400; + errorCode = 'BAD_REQUEST'; + } else if (error.message.includes('Slippage')) { + statusCode = 400; + errorCode = 'SLIPPAGE_EXCEEDED'; + } else if (error.message.includes('blockchain')) { + statusCode = 503; + errorCode = 'BLOCKCHAIN_ERROR'; + } + + res.status(statusCode).json({ + success: false, + error: { + code: errorCode, + message: error.message || 'Failed to buy shares', + }, + }); + } + } + + /** + * POST /api/markets/:marketId/sell - Sell outcome shares + */ + async sellShares(req: Request, res: Response): Promise { + try { + const userId = (req as AuthenticatedRequest).user?.userId; + if (!userId) { + res.status(401).json({ + success: false, + error: { + code: 'UNAUTHORIZED', + message: 'Authentication required', + }, + }); + return; + } + + const marketId = req.params.marketId as string; + const { outcome, shares, minPayout } = req.body; + + // Validate input + if (outcome === undefined || outcome === null) { + res.status(400).json({ + success: false, + error: { + code: 'VALIDATION_ERROR', + message: 'outcome is required (0 for NO, 1 for YES)', + }, + }); + return; + } + + if (!shares || shares <= 0) { + res.status(400).json({ + success: false, + error: { + code: 'VALIDATION_ERROR', + message: 'shares must be greater than 0', + }, + }); + return; + } + + if (![0, 1].includes(outcome)) { + res.status(400).json({ + success: false, + error: { + code: 'VALIDATION_ERROR', + message: 'outcome must be 0 (NO) or 1 (YES)', + }, + }); + return; + } + + // Call service + const result = await tradingService.sellShares({ + userId, + marketId, + outcome, + shares, + minPayout, + }); + + res.status(200).json({ + success: true, + data: { + sharesSold: result.sharesSold, + pricePerUnit: result.pricePerUnit, + payout: result.payout, + feeAmount: result.feeAmount, + txHash: result.txHash, + tradeId: result.tradeId, + remainingShares: result.remainingShares, + }, + }); + } catch (error: any) { + console.error('Error selling shares:', error); + + // Determine appropriate status code + let statusCode = 500; + let errorCode = 'INTERNAL_ERROR'; + + if (error.message.includes('not found')) { + statusCode = 404; + errorCode = 'NOT_FOUND'; + } else if ( + error.message.includes('Insufficient') || + error.message.includes('Invalid') || + error.message.includes('No shares') + ) { + statusCode = 400; + errorCode = 'BAD_REQUEST'; + } else if (error.message.includes('Slippage')) { + statusCode = 400; + errorCode = 'SLIPPAGE_EXCEEDED'; + } else if (error.message.includes('blockchain')) { + statusCode = 503; + errorCode = 'BLOCKCHAIN_ERROR'; + } + + res.status(statusCode).json({ + success: false, + error: { + code: errorCode, + message: error.message || 'Failed to sell shares', + }, + }); + } + } + + /** + * GET /api/markets/:marketId/odds - Get current market odds + */ + async getOdds(req: Request, res: Response): Promise { + try { + const marketId = req.params.marketId as string; + + // Call service + const result = await tradingService.getMarketOdds(marketId); + + res.status(200).json({ + success: true, + data: { + yes: { + odds: result.yesOdds, + percentage: result.yesPercentage, + liquidity: result.yesLiquidity, + }, + no: { + odds: result.noOdds, + percentage: result.noPercentage, + liquidity: result.noLiquidity, + }, + totalLiquidity: result.totalLiquidity, + }, + }); + } catch (error: any) { + console.error('Error getting odds:', error); + + // Determine appropriate status code + let statusCode = 500; + let errorCode = 'INTERNAL_ERROR'; + + if (error.message.includes('not found')) { + statusCode = 404; + errorCode = 'NOT_FOUND'; + } else if (error.message.includes('blockchain')) { + statusCode = 503; + errorCode = 'BLOCKCHAIN_ERROR'; + } + + res.status(statusCode).json({ + success: false, + error: { + code: errorCode, + message: error.message || 'Failed to get odds', + }, + }); + } + } +} + +export const tradingController = new TradingController(); diff --git a/backend/src/index.ts b/backend/src/index.ts index d03c366..a6702cf 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -9,6 +9,7 @@ import authRoutes from './routes/auth.routes.js'; import marketRoutes from './routes/markets.routes.js'; import oracleRoutes from './routes/oracle.js'; import predictionRoutes from './routes/predictions.js'; +import tradingRoutes from './routes/trading.js'; import treasuryRoutes from './routes/treasury.routes.js'; // Import Redis initialization @@ -46,7 +47,7 @@ import { import { setupSwagger } from './config/swagger.js'; // Initialize Express app -const app = express(); +const app: express.Express = express(); const PORT = process.env.PORT || 3000; const NODE_ENV = process.env.NODE_ENV || 'development'; @@ -207,6 +208,8 @@ app.use('/api/markets', oracleRoutes); // Prediction routes (commit-reveal flow) app.use('/api/markets', predictionRoutes); +// Trading routes (buy/sell shares, odds) +app.use('/api/markets', tradingRoutes); // Treasury routes app.use('/api/treasury', treasuryRoutes); diff --git a/backend/src/middleware/rateLimit.middleware.ts b/backend/src/middleware/rateLimit.middleware.ts index 550a9d4..b28cf37 100644 --- a/backend/src/middleware/rateLimit.middleware.ts +++ b/backend/src/middleware/rateLimit.middleware.ts @@ -23,8 +23,8 @@ function createRedisStore(prefix: string) { }) as any, prefix: `rl:${prefix}:`, }); - } catch (error) { - logger.warn( + } catch { + console.warn( `Failed to create Redis store for rate limiter (${prefix}), using memory store` ); return undefined; // Falls back to memory store @@ -47,49 +47,40 @@ const rateLimitMessage = (message: string) => ({ */ function getIpKey(req: any): string { try { - // Use the ipKeyGenerator helper function for proper IPv6 support return ipKeyGenerator(req, req.ip); - } catch (error) { - // Fallback if ipKeyGenerator fails + } catch { return req.ip || 'unknown'; } } /** * Rate limiter for authentication endpoints (strict) - * Prevents brute force attacks on login - * * Limits: 10 attempts per 15 minutes per IP */ export const authRateLimiter: RateLimiterMiddleware = rateLimit({ - windowMs: 15 * 60 * 1000, // 15 minutes + windowMs: 15 * 60 * 1000, max: 10, - standardHeaders: true, // Return rate limit info in RateLimit-* headers - legacyHeaders: false, // Disable X-RateLimit-* headers + standardHeaders: true, + legacyHeaders: false, store: createRedisStore('auth'), keyGenerator: (req: any) => getIpKey(req), message: rateLimitMessage( 'Too many authentication attempts. Please try again in 15 minutes.' ), - skip: () => process.env.NODE_ENV === 'test', // Skip in tests + skip: () => process.env.NODE_ENV === 'test', }); /** * Rate limiter for challenge endpoint (moderate) - * Prevents nonce generation spam - * * Limits: 5 requests per minute per public key or IP */ export const challengeRateLimiter: RateLimiterMiddleware = rateLimit({ - windowMs: 60 * 1000, // 1 minute + windowMs: 60 * 1000, max: 5, standardHeaders: true, legacyHeaders: false, store: createRedisStore('challenge'), - keyGenerator: (req: any) => { - // For challenge endpoint, use publicKey if available, otherwise IP - return req.body?.publicKey || getIpKey(req); - }, + keyGenerator: (req: any) => req.body?.publicKey || getIpKey(req), message: rateLimitMessage( 'Too many challenge requests. Please wait a moment.' ), @@ -98,12 +89,10 @@ export const challengeRateLimiter: RateLimiterMiddleware = rateLimit({ /** * Rate limiter for general API endpoints (lenient) - * Protects against API abuse while allowing normal usage - * * Limits: 100 requests per minute per user or IP */ export const apiRateLimiter: RateLimiterMiddleware = rateLimit({ - windowMs: 60 * 1000, // 1 minute + windowMs: 60 * 1000, max: 100, standardHeaders: true, legacyHeaders: false, @@ -112,18 +101,17 @@ export const apiRateLimiter: RateLimiterMiddleware = rateLimit({ const authReq = req as AuthenticatedRequest; return authReq.user?.userId || getIpKey(req); }, + validate: { ip: false }, message: rateLimitMessage('Too many requests. Please slow down.'), skip: () => process.env.NODE_ENV === 'test', }); /** * Rate limiter for refresh token endpoint - * Prevents token refresh spam - * * Limits: 10 refreshes per minute per IP */ export const refreshRateLimiter: RateLimiterMiddleware = rateLimit({ - windowMs: 60 * 1000, // 1 minute + windowMs: 60 * 1000, max: 10, standardHeaders: true, legacyHeaders: false, @@ -135,12 +123,10 @@ export const refreshRateLimiter: RateLimiterMiddleware = rateLimit({ /** * Rate limiter for sensitive operations (very strict) - * Use for actions like changing email, connecting new wallet, etc. - * * Limits: 5 requests per hour per user */ export const sensitiveOperationRateLimiter: RateLimiterMiddleware = rateLimit({ - windowMs: 60 * 60 * 1000, // 1 hour + windowMs: 60 * 60 * 1000, max: 5, standardHeaders: true, legacyHeaders: false, @@ -149,6 +135,7 @@ export const sensitiveOperationRateLimiter: RateLimiterMiddleware = rateLimit({ const authReq = req as AuthenticatedRequest; return authReq.user?.userId || getIpKey(req); }, + validate: { ip: false }, message: rateLimitMessage( 'Too many sensitive operations. Please try again later.' ), @@ -156,8 +143,7 @@ export const sensitiveOperationRateLimiter: RateLimiterMiddleware = rateLimit({ }); /** - * Create a custom rate limiter with specified options - * Useful for endpoints with special requirements + * Create a custom rate limiter */ export function createRateLimiter(options: { windowMs: number; @@ -175,7 +161,7 @@ export function createRateLimiter(options: { const authReq = req as AuthenticatedRequest; return authReq.user?.userId || getIpKey(req); }, - message: rateLimitMessage(options.message || 'Rate limit exceeded.'), + message: rateLimitMessage(options.message || 'Too many requests.'), skip: () => process.env.NODE_ENV === 'test', }); } diff --git a/backend/src/repositories/share.repository.ts b/backend/src/repositories/share.repository.ts new file mode 100644 index 0000000..c26d888 --- /dev/null +++ b/backend/src/repositories/share.repository.ts @@ -0,0 +1,207 @@ +// backend/src/repositories/share.repository.ts +// Repository for managing user share positions + +import { prisma } from '../database/prisma.js'; +import { Prisma, Share } from '@prisma/client'; +import { Decimal } from '@prisma/client/runtime/library'; + +export class ShareRepository { + /** + * Find a user's share position for a specific market and outcome + */ + async findByUserMarketOutcome( + userId: string, + marketId: string, + outcome: number + ): Promise { + return prisma.share.findFirst({ + where: { + userId, + marketId, + outcome, + }, + }); + } + + /** + * Find all shares for a user in a specific market + */ + async findByUserAndMarket( + userId: string, + marketId: string + ): Promise { + return prisma.share.findMany({ + where: { + userId, + marketId, + }, + include: { + market: true, + }, + }); + } + + /** + * Create a new share position + */ + async createPosition(data: { + userId: string; + marketId: string; + outcome: number; + quantity: number; + costBasis: number; + entryPrice: number; + currentValue: number; + unrealizedPnl: number; + }): Promise { + return prisma.share.create({ + data: { + userId: data.userId, + marketId: data.marketId, + outcome: data.outcome, + quantity: new Decimal(data.quantity), + costBasis: new Decimal(data.costBasis), + entryPrice: new Decimal(data.entryPrice), + currentValue: new Decimal(data.currentValue), + unrealizedPnl: new Decimal(data.unrealizedPnl), + }, + }); + } + + /** + * Update an existing share position + */ + async updatePosition( + shareId: string, + data: Partial<{ + quantity: number; + costBasis: number; + currentValue: number; + unrealizedPnl: number; + soldQuantity: number; + soldAt: Date; + realizedPnl: number; + }> + ): Promise { + const updateData: Prisma.ShareUpdateInput = {}; + + if (data.quantity !== undefined) + updateData.quantity = new Decimal(data.quantity); + if (data.costBasis !== undefined) + updateData.costBasis = new Decimal(data.costBasis); + if (data.currentValue !== undefined) + updateData.currentValue = new Decimal(data.currentValue); + if (data.unrealizedPnl !== undefined) + updateData.unrealizedPnl = new Decimal(data.unrealizedPnl); + if (data.soldQuantity !== undefined) + updateData.soldQuantity = new Decimal(data.soldQuantity); + if (data.soldAt !== undefined) updateData.soldAt = data.soldAt; + if (data.realizedPnl !== undefined) + updateData.realizedPnl = new Decimal(data.realizedPnl); + + return prisma.share.update({ + where: { id: shareId }, + data: updateData, + }); + } + + /** + * Increment shares for an existing position (used when buying more) + */ + async incrementShares( + shareId: string, + additionalQuantity: number, + additionalCost: number, + newEntryPrice: number + ): Promise { + const share = await prisma.share.findUnique({ where: { id: shareId } }); + if (!share) { + throw new Error('Share position not found'); + } + + const newQuantity = Number(share.quantity) + additionalQuantity; + const newCostBasis = Number(share.costBasis) + additionalCost; + const newCurrentValue = newQuantity * newEntryPrice; + const newUnrealizedPnl = newCurrentValue - newCostBasis; + + return this.updatePosition(shareId, { + quantity: newQuantity, + costBasis: newCostBasis, + currentValue: newCurrentValue, + unrealizedPnl: newUnrealizedPnl, + }); + } + + /** + * Decrement shares for an existing position (used when selling) + */ + async decrementShares( + shareId: string, + quantityToSell: number, + proceeds: number + ): Promise { + const share = await prisma.share.findUnique({ where: { id: shareId } }); + if (!share) { + throw new Error('Share position not found'); + } + + const currentQuantity = Number(share.quantity); + if (currentQuantity < quantityToSell) { + throw new Error('Insufficient shares to sell'); + } + + const newQuantity = currentQuantity - quantityToSell; + const proportionSold = quantityToSell / currentQuantity; + const costOfSoldShares = Number(share.costBasis) * proportionSold; + const newCostBasis = Number(share.costBasis) - costOfSoldShares; + const newSoldQuantity = Number(share.soldQuantity) + quantityToSell; + const tradeRealizedPnl = proceeds - costOfSoldShares; + const totalRealizedPnl = Number(share.realizedPnl || 0) + tradeRealizedPnl; + + // Update current value and unrealized PnL based on remaining shares + const currentPrice = Number(share.entryPrice); // Use current market price if available + const newCurrentValue = newQuantity * currentPrice; + const newUnrealizedPnl = newCurrentValue - newCostBasis; + + return this.updatePosition(shareId, { + quantity: newQuantity, + costBasis: newCostBasis, + currentValue: newCurrentValue, + unrealizedPnl: newUnrealizedPnl, + soldQuantity: newSoldQuantity, + soldAt: new Date(), + realizedPnl: totalRealizedPnl, + }); + } + + /** + * Get all active positions for a user (quantity > 0) + */ + async findActivePositionsByUser(userId: string): Promise { + return prisma.share.findMany({ + where: { + userId, + quantity: { + gt: 0, + }, + }, + include: { + market: true, + }, + orderBy: { + acquiredAt: 'desc', + }, + }); + } + + /** + * Delete a share position (if quantity becomes 0) + */ + async deletePosition(shareId: string): Promise { + await prisma.share.delete({ + where: { id: shareId }, + }); + } +} + +export const shareRepository = new ShareRepository(); diff --git a/backend/src/repositories/trade.repository.ts b/backend/src/repositories/trade.repository.ts index 105ad35..d9946bc 100644 --- a/backend/src/repositories/trade.repository.ts +++ b/backend/src/repositories/trade.repository.ts @@ -154,4 +154,49 @@ export class TradeRepository extends BaseRepository { }, }); } + + async createBuyTrade(data: { + userId: string; + marketId: string; + outcome: number; + quantity: number; + pricePerUnit: number; + totalAmount: number; + feeAmount: number; + txHash: string; + }): Promise { + return this.createTrade({ + ...data, + tradeType: TradeType.BUY, + }); + } + + async createSellTrade(data: { + userId: string; + marketId: string; + outcome: number; + quantity: number; + pricePerUnit: number; + totalAmount: number; + feeAmount: number; + txHash: string; + }): Promise { + return this.createTrade({ + ...data, + tradeType: TradeType.SELL, + }); + } + + async findByUserAndMarket( + userId: string, + marketId: string + ): Promise { + return await this.prisma.trade.findMany({ + where: { + userId, + marketId, + }, + orderBy: { createdAt: 'desc' }, + }); + } } diff --git a/backend/src/routes/auth.routes.ts b/backend/src/routes/auth.routes.ts index 8afdbc3..04a0a01 100644 --- a/backend/src/routes/auth.routes.ts +++ b/backend/src/routes/auth.routes.ts @@ -7,7 +7,7 @@ import { refreshRateLimiter, } from '../middleware/rateLimit.middleware.js'; -const router = Router(); +const router: Router = Router(); /** * @route POST /api/auth/challenge diff --git a/backend/src/routes/health.ts b/backend/src/routes/health.ts index 2151258..710318d 100644 --- a/backend/src/routes/health.ts +++ b/backend/src/routes/health.ts @@ -6,7 +6,7 @@ import { getRedisClient, } from '../config/redis.js'; -const router = Router(); +const router: Router = Router(); /** * Check PostgreSQL connectivity with detailed metrics diff --git a/backend/src/routes/markets.routes.ts b/backend/src/routes/markets.routes.ts index e36124a..cf4be3a 100644 --- a/backend/src/routes/markets.routes.ts +++ b/backend/src/routes/markets.routes.ts @@ -5,7 +5,7 @@ import { Router } from 'express'; import { marketsController } from '../controllers/markets.controller.js'; import { requireAuth, optionalAuth } from '../middleware/auth.middleware.js'; -const router = Router(); +const router: Router = Router(); /** * POST /api/markets - Create new market diff --git a/backend/src/routes/oracle.ts b/backend/src/routes/oracle.ts index 3d3f9d1..8b7504e 100644 --- a/backend/src/routes/oracle.ts +++ b/backend/src/routes/oracle.ts @@ -5,7 +5,7 @@ import { Router } from 'express'; import { oracleController } from '../controllers/oracle.controller.js'; import { requireAuth } from '../middleware/auth.middleware.js'; -const router = Router(); +const router: Router = Router(); /** * POST /api/markets/:id/attest - Submit oracle attestation diff --git a/backend/src/routes/predictions.ts b/backend/src/routes/predictions.ts index 2259a15..2aaf52a 100644 --- a/backend/src/routes/predictions.ts +++ b/backend/src/routes/predictions.ts @@ -5,7 +5,7 @@ import { Router } from 'express'; import { predictionsController } from '../controllers/predictions.controller.js'; import { requireAuth } from '../middleware/auth.middleware.js'; -const router = Router(); +const router: Router = Router(); /** * POST /api/markets/:marketId/commit - Commit Prediction (Phase 1) diff --git a/backend/src/routes/trading.ts b/backend/src/routes/trading.ts new file mode 100644 index 0000000..2389281 --- /dev/null +++ b/backend/src/routes/trading.ts @@ -0,0 +1,97 @@ +// backend/src/routes/trading.ts +// Trading routes - buy/sell shares and get odds + +import { Router } from 'express'; +import { tradingController } from '../controllers/trading.controller.js'; +import { requireAuth } from '../middleware/auth.middleware.js'; + +const router: Router = Router(); + +/** + * POST /api/markets/:marketId/buy - Buy Outcome Shares + * Requires authentication + * + * Request Body: + * { + * outcome: 0 | 1, // 0 for NO, 1 for YES + * amount: number, // USDC amount to spend + * minShares?: number // Minimum shares to receive (slippage protection) + * } + * + * Response: + * { + * success: true, + * data: { + * sharesBought: number, + * pricePerUnit: number, + * totalCost: number, + * feeAmount: number, + * txHash: string, + * tradeId: string, + * position: { + * totalShares: number, + * averagePrice: number + * } + * } + * } + */ +router.post('/:marketId/buy', requireAuth, (req, res) => + tradingController.buyShares(req, res) +); + +/** + * POST /api/markets/:marketId/sell - Sell Outcome Shares + * Requires authentication + * + * Request Body: + * { + * outcome: 0 | 1, // 0 for NO, 1 for YES + * shares: number, // Number of shares to sell + * minPayout?: number // Minimum payout to receive (slippage protection) + * } + * + * Response: + * { + * success: true, + * data: { + * sharesSold: number, + * pricePerUnit: number, + * payout: number, + * feeAmount: number, + * txHash: string, + * tradeId: string, + * remainingShares: number + * } + * } + */ +router.post('/:marketId/sell', requireAuth, (req, res) => + tradingController.sellShares(req, res) +); + +/** + * GET /api/markets/:marketId/odds - Get Current Market Odds + * No authentication required + * + * Response: + * { + * success: true, + * data: { + * yes: { + * odds: number, // 0.0 to 1.0 + * percentage: number, // 0 to 100 + * liquidity: number + * }, + * no: { + * odds: number, + * percentage: number, + * liquidity: number + * }, + * totalLiquidity: number + * } + * } + */ +router.get('/:marketId/odds', (req, res) => + tradingController.getOdds(req, res) +); + +export default router; diff --git a/backend/src/routes/treasury.routes.ts b/backend/src/routes/treasury.routes.ts index 02655cc..29b0d3a 100644 --- a/backend/src/routes/treasury.routes.ts +++ b/backend/src/routes/treasury.routes.ts @@ -3,7 +3,7 @@ import { treasuryController } from '../controllers/treasury.controller.js'; import { requireAuth } from '../middleware/auth.middleware.js'; import { requireAdmin } from '../middleware/admin.middleware.js'; -const router = Router(); +const router: Router = Router(); router.get('/balances', requireAuth, (req, res) => treasuryController.getBalances(req, res) diff --git a/backend/src/services/blockchain/amm.ts b/backend/src/services/blockchain/amm.ts index adafe62..d46b7f9 100644 --- a/backend/src/services/blockchain/amm.ts +++ b/backend/src/services/blockchain/amm.ts @@ -1,5 +1,5 @@ // backend/src/services/blockchain/amm.ts -// AMM contract interaction service +// AMM (Automated Market Maker) contract interaction service import { Contract, @@ -10,9 +10,49 @@ import { Keypair, nativeToScVal, scValToNative, + xdr, } from '@stellar/stellar-sdk'; import { logger } from '../../utils/logger.js'; +interface BuySharesParams { + marketId: string; + outcome: number; // 0 or 1 + amountUsdc: number; + minShares: number; +} + +interface BuySharesResult { + sharesReceived: number; + pricePerUnit: number; + totalCost: number; + feeAmount: number; + txHash: string; +} + +interface SellSharesParams { + marketId: string; + outcome: number; // 0 or 1 + shares: number; + minPayout: number; +} + +interface SellSharesResult { + payout: number; + pricePerUnit: number; + feeAmount: number; + txHash: string; +} + +interface MarketOdds { + yesOdds: number; // e.g., 0.65 (65%) + noOdds: number; // e.g., 0.35 (35%) + yesPercentage: number; // e.g., 65 + noPercentage: number; // e.g., 35 + yesLiquidity: number; + noLiquidity: number; + totalLiquidity: number; +} + interface CreatePoolParams { marketId: string; // hex string (BytesN<32>) initialLiquidity: bigint; @@ -43,7 +83,7 @@ export class AmmService { this.networkPassphrase = network === 'mainnet' ? Networks.PUBLIC : Networks.TESTNET; - // Admin keypair is optional - only needed for contract write operations + // Admin keypair for signing contract calls const adminSecret = process.env.ADMIN_WALLET_SECRET; if (adminSecret) { try { @@ -52,6 +92,225 @@ export class AmmService { logger.warn('Invalid ADMIN_WALLET_SECRET for AMM service'); } } + + if (!this.adminKeypair) { + // In development/testnet, generate a random keypair if not provided (prevents startup crash) + if (process.env.NODE_ENV !== 'production') { + if (!adminSecret) { + console.warn('ADMIN_WALLET_SECRET not configured, using random keypair for AMM service (Warning: No funds)'); + } + this.adminKeypair = Keypair.random(); + } else { + // In production, if strictly required we should fail, but leaving undefined is also handled by specific methods checks + if (!adminSecret) console.warn('ADMIN_WALLET_SECRET not configured'); + } + } + } + + /** + * Buy outcome shares from the AMM + * @param params - Buy parameters + * @returns Shares received and transaction details + */ + async buyShares(params: BuySharesParams): Promise { + if (!this.ammContractId) { + throw new Error('AMM contract address not configured'); + } + if (!this.adminKeypair) { + throw new Error('ADMIN_WALLET_SECRET not configured - cannot sign transactions'); + } + + try { + const contract = new Contract(this.ammContractId); + const sourceAccount = await this.rpcServer.getAccount(this.adminKeypair.publicKey()); + + // Build the contract call operation + const builtTransaction = new TransactionBuilder(sourceAccount, { + fee: BASE_FEE, + networkPassphrase: this.networkPassphrase, + }) + .addOperation( + contract.call( + 'buy_shares', + nativeToScVal(params.marketId, { type: 'string' }), + nativeToScVal(params.outcome, { type: 'u32' }), + nativeToScVal(params.amountUsdc, { type: 'i128' }), + nativeToScVal(params.minShares, { type: 'i128' }) + ) + ) + .setTimeout(30) + .build(); + + // Prepare transaction for the network + const preparedTransaction = await this.rpcServer.prepareTransaction(builtTransaction); + + // Sign transaction + preparedTransaction.sign(this.adminKeypair); + + // Submit transaction + const response = await this.rpcServer.sendTransaction(preparedTransaction); + + if (response.status === 'PENDING') { + const txHash = response.hash; + const result = await this.waitForTransaction(txHash); + + if (result.status === 'SUCCESS') { + // Extract result from contract return value + const returnValue = result.returnValue; + const buyResult = this.parseBuySharesResult(returnValue); + + return { + ...buyResult, + txHash, + }; + } else { + throw new Error(`Transaction failed: ${result.status}`); + } + } else if (response.status === 'ERROR') { + throw new Error(`Transaction submission error: ${response.errorResult}`); + } else { + throw new Error(`Unexpected response status: ${response.status}`); + } + } catch (error) { + console.error('AMM.buy_shares() error:', error); + throw new Error( + `Failed to buy shares: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } + } + + /** + * Sell outcome shares to the AMM + * @param params - Sell parameters + * @returns Payout received and transaction details + */ + async sellShares(params: SellSharesParams): Promise { + if (!this.ammContractId) { + throw new Error('AMM contract address not configured'); + } + if (!this.adminKeypair) { + throw new Error('ADMIN_WALLET_SECRET not configured - cannot sign transactions'); + } + + try { + const contract = new Contract(this.ammContractId); + const sourceAccount = await this.rpcServer.getAccount(this.adminKeypair.publicKey()); + + // Build the contract call operation + const builtTransaction = new TransactionBuilder(sourceAccount, { + fee: BASE_FEE, + networkPassphrase: this.networkPassphrase, + }) + .addOperation( + contract.call( + 'sell_shares', + nativeToScVal(params.marketId, { type: 'string' }), + nativeToScVal(params.outcome, { type: 'u32' }), + nativeToScVal(params.shares, { type: 'i128' }), + nativeToScVal(params.minPayout, { type: 'i128' }) + ) + ) + .setTimeout(30) + .build(); + + // Prepare transaction for the network + const preparedTransaction = await this.rpcServer.prepareTransaction(builtTransaction); + + // Sign transaction + preparedTransaction.sign(this.adminKeypair); + + // Submit transaction + const response = await this.rpcServer.sendTransaction(preparedTransaction); + + if (response.status === 'PENDING') { + const txHash = response.hash; + const result = await this.waitForTransaction(txHash); + + if (result.status === 'SUCCESS') { + // Extract result from contract return value + const returnValue = result.returnValue; + const sellResult = this.parseSellSharesResult(returnValue); + + return { + ...sellResult, + txHash, + }; + } else { + throw new Error(`Transaction failed: ${result.status}`); + } + } else if (response.status === 'ERROR') { + throw new Error(`Transaction submission error: ${response.errorResult}`); + } else { + throw new Error(`Unexpected response status: ${response.status}`); + } + } catch (error) { + console.error('AMM.sell_shares() error:', error); + throw new Error( + `Failed to sell shares: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } + } + + /** + * Get current market odds from the AMM + * @param marketId - Market ID + * @returns Market odds and liquidity information + */ + async getOdds(marketId: string): Promise { + if (!this.ammContractId) { + throw new Error('AMM contract address not configured'); + } + + try { + const contract = new Contract(this.ammContractId); + // For read-only calls, any source account works. + // If adminKeypair is available, use it. Else random. + const accountKey = this.adminKeypair?.publicKey() || Keypair.random().publicKey(); + + let sourceAccount; + try { + sourceAccount = await this.rpcServer.getAccount(accountKey); + } catch (e) { + // If we can't fetch the account (e.g. random key not funded), we can try to use a dummy account + // but simulateTransaction usually requires a valid sequence number. + // If in dev and "random" key was generated in constructor, it won't be on chain unless funded. + // This might be tricky. Let's assume if it fails we can't simulate easily. + console.warn('Could not load source account for getOdds simulation:', e); + throw e; + } + + const builtTransaction = new TransactionBuilder(sourceAccount, { + fee: BASE_FEE, + networkPassphrase: this.networkPassphrase, + }) + .addOperation( + contract.call( + 'get_odds', + nativeToScVal(marketId, { type: 'string' }) + ) + ) + .setTimeout(30) + .build(); + + // Simulate transaction to get result without submitting + const simulationResponse = await this.rpcServer.simulateTransaction(builtTransaction); + + if (rpc.Api.isSimulationSuccess(simulationResponse)) { + const result = simulationResponse.result?.retval; + if (!result) { + throw new Error('No return value from simulation'); + } + + return this.parseOddsResult(result); + } + + throw new Error('Failed to get market odds'); + } catch (error) { + console.error('Error getting market odds:', error); + throw new Error( + `Failed to get odds: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } } /** @@ -61,7 +320,6 @@ export class AmmService { if (!this.ammContractId) { throw new Error('AMM contract address not configured'); } - if (!this.adminKeypair) { throw new Error( 'ADMIN_WALLET_SECRET not configured - cannot sign transactions' @@ -120,10 +378,17 @@ export class AmmService { }> { const contract = new Contract(this.ammContractId); - // For read-only calls, use admin if available, otherwise use dummy keypair + // For read-only calls, separate handling const accountKey = this.adminKeypair?.publicKey() || Keypair.random().publicKey(); - const sourceAccount = await this.rpcServer.getAccount(accountKey); + + let sourceAccount; + try { + sourceAccount = await this.rpcServer.getAccount(accountKey); + } catch (e) { + console.warn('Could not load source account for getPoolState simulation:', e); + throw e; + } const builtTx = new TransactionBuilder(sourceAccount, { fee: BASE_FEE, @@ -159,25 +424,129 @@ export class AmmService { } /** - * Wait for transaction finality + * Wait for transaction to be confirmed + * @param txHash - Transaction hash + * @param maxRetries - Maximum number of retries + * @returns Transaction result */ - private async waitForTransaction( - txHash: string, - maxRetries: number = 10 - ): Promise { + private async waitForTransaction(txHash: string, maxRetries: number = 10): Promise { let retries = 0; + while (retries < maxRetries) { - const tx = await this.rpcServer.getTransaction(txHash); - if (tx.status === 'SUCCESS') return tx; - if (tx.status === 'FAILED') - throw new Error('Transaction failed on blockchain'); - await this.sleep(2000); - retries++; + try { + const txResponse = await this.rpcServer.getTransaction(txHash); + + if (txResponse.status === 'NOT_FOUND') { + // Transaction not yet processed, wait and retry + await this.sleep(2000); + retries++; + continue; + } + + if (txResponse.status === 'SUCCESS') { + return txResponse; + } + + if (txResponse.status === 'FAILED') { + throw new Error('Transaction failed on blockchain'); + } + + // Other status, wait and retry + await this.sleep(2000); + retries++; + } catch (error) { + if (retries >= maxRetries - 1) { + throw error; + } + await this.sleep(2000); + retries++; + } } throw new Error('Transaction confirmation timeout'); } + /** + * Parse buy_shares contract return value + * @param returnValue - Contract return value + * @returns Parsed buy result + */ + private parseBuySharesResult(returnValue: xdr.ScVal | undefined): Omit { + if (!returnValue) { + throw new Error('No return value from contract'); + } + + try { + // Expected return format: { shares_received, price_per_unit, total_cost, fee_amount } + const result = scValToNative(returnValue); + + return { + sharesReceived: Number(result.shares_received || result.sharesReceived || 0), + pricePerUnit: Number(result.price_per_unit || result.pricePerUnit || 0), + totalCost: Number(result.total_cost || result.totalCost || 0), + feeAmount: Number(result.fee_amount || result.feeAmount || 0), + }; + } catch (error) { + console.error('Error parsing buy shares result:', error); + throw new Error('Failed to parse contract response'); + } + } + + /** + * Parse sell_shares contract return value + * @param returnValue - Contract return value + * @returns Parsed sell result + */ + private parseSellSharesResult(returnValue: xdr.ScVal | undefined): Omit { + if (!returnValue) { + throw new Error('No return value from contract'); + } + + try { + // Expected return format: { payout, price_per_unit, fee_amount } + const result = scValToNative(returnValue); + + return { + payout: Number(result.payout || 0), + pricePerUnit: Number(result.price_per_unit || result.pricePerUnit || 0), + feeAmount: Number(result.fee_amount || result.feeAmount || 0), + }; + } catch (error) { + console.error('Error parsing sell shares result:', error); + throw new Error('Failed to parse contract response'); + } + } + + /** + * Parse get_odds contract return value + * @param returnValue - Contract return value + * @returns Market odds + */ + private parseOddsResult(returnValue: xdr.ScVal): MarketOdds { + try { + // Expected return format: { yes_odds, no_odds, yes_liquidity, no_liquidity } + const result = scValToNative(returnValue); + + const yesOdds = Number(result.yes_odds || result.yesOdds || 0.5); + const noOdds = Number(result.no_odds || result.noOdds || 0.5); + const yesLiquidity = Number(result.yes_liquidity || result.yesLiquidity || 0); + const noLiquidity = Number(result.no_liquidity || result.noLiquidity || 0); + + return { + yesOdds, + noOdds, + yesPercentage: Math.round(yesOdds * 100), + noPercentage: Math.round(noOdds * 100), + yesLiquidity, + noLiquidity, + totalLiquidity: yesLiquidity + noLiquidity, + }; + } catch (error) { + console.error('Error parsing odds result:', error); + throw new Error('Failed to parse odds response'); + } + } + private sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } diff --git a/backend/src/services/blockchain/factory.ts b/backend/src/services/blockchain/factory.ts index f7d2e96..f2a4592 100644 --- a/backend/src/services/blockchain/factory.ts +++ b/backend/src/services/blockchain/factory.ts @@ -52,6 +52,8 @@ export class FactoryService { // Admin keypair is optional - only needed for contract write operations const adminSecret = process.env.ADMIN_WALLET_SECRET; + + // Try to load from secret if provided if (adminSecret) { try { this.adminKeypair = Keypair.fromSecret(adminSecret); @@ -61,6 +63,26 @@ export class FactoryService { ); } } + + // If not loaded (or invalid), handle dev fallback + if (!this.adminKeypair) { + // In development/testnet, generate a random keypair if not provided (prevents startup crash) + if (process.env.NODE_ENV !== 'production') { + if (!adminSecret) { + console.warn('ADMIN_WALLET_SECRET not configured, using random keypair for development (Warning: No funds)'); + } + this.adminKeypair = Keypair.random(); + } else { + if (!adminSecret) { + // In PROD, if secret missing, throw or just warn? HEAD threw error. + throw new Error('ADMIN_WALLET_SECRET not configured'); + } + // If secret was present but invalid (meaning this.adminKeypair is undefined), we might just leave it undefined and fail later? + // But HEAD logic threw if !adminSecret. + // I'll leave it as is: if !adminKeypair and we are here, it means either !adminSecret (handled above) or invalid. + // If invalid, we already warned. + } + } } /** @@ -246,7 +268,16 @@ export class FactoryService { // Use admin if available, otherwise use a dummy keypair const accountKey = this.adminKeypair?.publicKey() || Keypair.random().publicKey(); - const sourceAccount = await this.rpcServer.getAccount(accountKey); + + let sourceAccount; + try { + sourceAccount = await this.rpcServer.getAccount(accountKey); + } catch (e) { + // Fallback for simulation + console.warn('Could not load source account for getMarketCount simulation:', e); + // If we don't return 0 here, it will fail below + return 0; + } const builtTransaction = new TransactionBuilder(sourceAccount, { fee: BASE_FEE, diff --git a/backend/src/services/trading.service.ts b/backend/src/services/trading.service.ts new file mode 100644 index 0000000..0bc3af5 --- /dev/null +++ b/backend/src/services/trading.service.ts @@ -0,0 +1,360 @@ +// backend/src/services/trading.service.ts +// Trading service - orchestrates buy/sell operations + +import { MarketStatus } from '@prisma/client'; +import { ammService } from './blockchain/amm.js'; +import { shareRepository } from '../repositories/share.repository.js'; +import { TradeRepository } from '../repositories/trade.repository.js'; +import { prisma } from '../database/prisma.js'; +import { Decimal } from '@prisma/client/runtime/library'; + +const tradeRepository = new TradeRepository(); + +interface BuySharesParams { + userId: string; + marketId: string; + outcome: number; + amount: number; + minShares?: number; +} + +interface BuySharesResult { + sharesBought: number; + pricePerUnit: number; + totalCost: number; + feeAmount: number; + txHash: string; + tradeId: string; + newSharePosition: { + totalShares: number; + averagePrice: number; + }; +} + +interface SellSharesParams { + userId: string; + marketId: string; + outcome: number; + shares: number; + minPayout?: number; +} + +interface SellSharesResult { + sharesSold: number; + pricePerUnit: number; + payout: number; + feeAmount: number; + txHash: string; + tradeId: string; + remainingShares: number; +} + +interface MarketOddsResult { + yesOdds: number; + noOdds: number; + yesPercentage: number; + noPercentage: number; + yesLiquidity: number; + noLiquidity: number; + totalLiquidity: number; +} + +export class TradingService { + /** + * Buy shares for a specific market outcome + */ + async buyShares(params: BuySharesParams): Promise { + const { userId, marketId, outcome, amount, minShares } = params; + + // Validate outcome + if (![0, 1].includes(outcome)) { + throw new Error('Invalid outcome. Must be 0 (NO) or 1 (YES)'); + } + + // Validate amount + if (amount <= 0) { + throw new Error('Amount must be greater than 0'); + } + + // Check if market exists and is OPEN + const market = await prisma.market.findUnique({ + where: { id: marketId }, + }); + + if (!market) { + throw new Error('Market not found'); + } + + if (market.status !== MarketStatus.OPEN) { + throw new Error( + `Market is ${market.status}. Trading is only allowed for OPEN markets.` + ); + } + + // Check user balance + const user = await prisma.user.findUnique({ + where: { id: userId }, + }); + + if (!user) { + throw new Error('User not found'); + } + + const userBalance = Number(user.usdcBalance); + if (userBalance < amount) { + throw new Error( + `Insufficient balance. Available: ${userBalance} USDC, Required: ${amount} USDC` + ); + } + + // Set minimum shares to 95% of expected if not provided (5% slippage tolerance) + const calculatedMinShares = minShares || amount * 0.95; + + // Call AMM contract to buy shares + const buyResult = await ammService.buyShares({ + marketId, + outcome, + amountUsdc: amount, + minShares: calculatedMinShares, + }); + + // Verify slippage protection + if (buyResult.sharesReceived < calculatedMinShares) { + throw new Error( + `Slippage exceeded. Expected at least ${calculatedMinShares} shares, got ${buyResult.sharesReceived}` + ); + } + + // Use transaction to ensure atomicity + const result = await prisma.$transaction(async (tx) => { + // Create trade record + const trade = await tradeRepository.createBuyTrade({ + userId, + marketId, + outcome, + quantity: buyResult.sharesReceived, + pricePerUnit: buyResult.pricePerUnit, + totalAmount: buyResult.totalCost, + feeAmount: buyResult.feeAmount, + txHash: buyResult.txHash, + }); + + // Confirm trade immediately (since blockchain transaction succeeded) + await tradeRepository.confirmTrade(trade.id); + + // Update or create share position + const existingShare = await shareRepository.findByUserMarketOutcome( + userId, + marketId, + outcome + ); + + let updatedShare; + if (existingShare) { + // Add to existing position + updatedShare = await shareRepository.incrementShares( + existingShare.id, + buyResult.sharesReceived, + buyResult.totalCost, + buyResult.pricePerUnit + ); + } else { + // Create new position + updatedShare = await shareRepository.createPosition({ + userId, + marketId, + outcome, + quantity: buyResult.sharesReceived, + costBasis: buyResult.totalCost, + entryPrice: buyResult.pricePerUnit, + currentValue: buyResult.sharesReceived * buyResult.pricePerUnit, + unrealizedPnl: 0, // No PnL on initial purchase + }); + } + + // Deduct USDC from user balance + await tx.user.update({ + where: { id: userId }, + data: { + usdcBalance: { + decrement: new Decimal(buyResult.totalCost), + }, + }, + }); + + // Update market volume + await tx.market.update({ + where: { id: marketId }, + data: { + totalVolume: { + increment: new Decimal(buyResult.totalCost), + }, + }, + }); + + return { + trade, + share: updatedShare, + }; + }); + + return { + sharesBought: buyResult.sharesReceived, + pricePerUnit: buyResult.pricePerUnit, + totalCost: buyResult.totalCost, + feeAmount: buyResult.feeAmount, + txHash: buyResult.txHash, + tradeId: result.trade.id, + newSharePosition: { + totalShares: Number(result.share.quantity), + averagePrice: + Number(result.share.costBasis) / Number(result.share.quantity), + }, + }; + } + + /** + * Sell shares for a specific market outcome + */ + async sellShares(params: SellSharesParams): Promise { + const { userId, marketId, outcome, shares, minPayout } = params; + + // Validate outcome + if (![0, 1].includes(outcome)) { + throw new Error('Invalid outcome. Must be 0 (NO) or 1 (YES)'); + } + + // Validate shares + if (shares <= 0) { + throw new Error('Shares must be greater than 0'); + } + + // Check if market exists + const market = await prisma.market.findUnique({ + where: { id: marketId }, + }); + + if (!market) { + throw new Error('Market not found'); + } + + // Check if user has sufficient shares + const userShare = await shareRepository.findByUserMarketOutcome( + userId, + marketId, + outcome + ); + + if (!userShare) { + throw new Error( + `No shares found for outcome ${outcome === 0 ? 'NO' : 'YES'}` + ); + } + + const availableShares = Number(userShare.quantity); + if (availableShares < shares) { + throw new Error( + `Insufficient shares. Available: ${availableShares}, Requested: ${shares}` + ); + } + + // Set minimum payout to 95% of expected if not provided (5% slippage tolerance) + const calculatedMinPayout = minPayout || shares * 0.95; + + // Call AMM contract to sell shares + const sellResult = await ammService.sellShares({ + marketId, + outcome, + shares, + minPayout: calculatedMinPayout, + }); + + // Verify slippage protection + if (sellResult.payout < calculatedMinPayout) { + throw new Error( + `Slippage exceeded. Expected at least ${calculatedMinPayout} USDC, got ${sellResult.payout} USDC` + ); + } + + // Use transaction to ensure atomicity + const result = await prisma.$transaction(async (tx) => { + // Create trade record + const trade = await tradeRepository.createSellTrade({ + userId, + marketId, + outcome, + quantity: shares, + pricePerUnit: sellResult.pricePerUnit, + totalAmount: sellResult.payout, + feeAmount: sellResult.feeAmount, + txHash: sellResult.txHash, + }); + + // Confirm trade immediately (since blockchain transaction succeeded) + await tradeRepository.confirmTrade(trade.id); + + // Update share position + const updatedShare = await shareRepository.decrementShares( + userShare.id, + shares, + sellResult.payout + ); + + // Credit USDC to user balance + await tx.user.update({ + where: { id: userId }, + data: { + usdcBalance: { + increment: new Decimal(sellResult.payout), + }, + }, + }); + + // Update market volume + await tx.market.update({ + where: { id: marketId }, + data: { + totalVolume: { + increment: new Decimal(sellResult.payout), + }, + }, + }); + + return { + trade, + share: updatedShare, + }; + }); + + return { + sharesSold: shares, + pricePerUnit: sellResult.pricePerUnit, + payout: sellResult.payout, + feeAmount: sellResult.feeAmount, + txHash: sellResult.txHash, + tradeId: result.trade.id, + remainingShares: Number(result.share.quantity), + }; + } + + /** + * Get current market odds + */ + async getMarketOdds(marketId: string): Promise { + // Check if market exists + const market = await prisma.market.findUnique({ + where: { id: marketId }, + }); + + if (!market) { + throw new Error('Market not found'); + } + + // Get odds from AMM contract + const odds = await ammService.getOdds(marketId); + + return odds; + } +} + +export const tradingService = new TradingService(); diff --git a/backend/tests/integration/trading.integration.test.ts b/backend/tests/integration/trading.integration.test.ts new file mode 100644 index 0000000..547f2e5 --- /dev/null +++ b/backend/tests/integration/trading.integration.test.ts @@ -0,0 +1,709 @@ +// backend/tests/integration/trading.integration.test.ts +// Integration tests for Trading API endpoints + +import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest'; +import request from 'supertest'; +import app from '../../src/index.js'; +import { MarketStatus, TradeType, TradeStatus } from '@prisma/client'; +import { ammService } from '../../src/services/blockchain/amm.js'; + +// Mock JWT verification +vi.mock('../../src/utils/jwt.js', () => ({ + verifyAccessToken: vi.fn().mockReturnValue({ + userId: 'test-user-id', + publicKey: 'GTEST', + tier: 'BEGINNER', + }), +})); + +// Mock AMM service +vi.mock('../../src/services/blockchain/amm.js', () => ({ + ammService: { + buyShares: vi.fn(), + sellShares: vi.fn(), + getOdds: vi.fn(), + }, +})); + +// Mock database +vi.mock('../../src/database/prisma.js', () => ({ + prisma: { + market: { + findUnique: vi.fn(), + update: vi.fn(), + }, + user: { + findUnique: vi.fn(), + update: vi.fn(), + }, + share: { + findFirst: vi.fn(), + create: vi.fn(), + update: vi.fn(), + findUnique: vi.fn(), + }, + trade: { + create: vi.fn(), + update: vi.fn(), + findFirst: vi.fn(), + }, + $transaction: vi.fn((callback) => callback({ + user: { + update: vi.fn().mockResolvedValue({ id: 'test-user-id', usdcBalance: 900 }), + }, + market: { + update: vi.fn().mockResolvedValue({ id: 'test-market-id' }), + }, + })), + }, +})); + +// Import after mocking +import { prisma } from '../../src/database/prisma.js'; + +describe('Trading API - Buy Shares', () => { + let authToken: string; + + beforeAll(() => { + authToken = 'mock-jwt-token'; + }); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should buy shares successfully with valid data', async () => { + // Mock market (OPEN) + vi.mocked(prisma.market.findUnique).mockResolvedValue({ + id: 'test-market-id', + contractAddress: 'contract', + title: 'Test Market', + status: MarketStatus.OPEN, + } as any); + + // Mock user with sufficient balance + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + id: 'test-user-id', + usdcBalance: 1000, + } as any); + + // Mock AMM response + vi.mocked(ammService.buyShares).mockResolvedValue({ + sharesReceived: 95, + pricePerUnit: 1.05, + totalCost: 100, + feeAmount: 0.5, + txHash: 'mock-tx-hash-buy', + }); + + // Mock no existing shares + vi.mocked(prisma.share.findFirst).mockResolvedValue(null); + + // Mock share creation + vi.mocked(prisma.share.create).mockResolvedValue({ + id: 'share-id', + quantity: 95, + costBasis: 100, + } as any); + + // Mock trade creation + vi.mocked(prisma.trade.create).mockResolvedValue({ + id: 'trade-id', + tradeType: TradeType.BUY, + } as any); + + vi.mocked(prisma.trade.update).mockResolvedValue({ + id: 'trade-id', + status: TradeStatus.CONFIRMED, + } as any); + + const response = await request(app) + .post('/api/markets/test-market-id/buy') + .set('Authorization', `Bearer ${authToken}`) + .send({ + outcome: 1, + amount: 100, + minShares: 90, + }) + .expect(201); + + expect(response.body.success).toBe(true); + expect(response.body.data).toHaveProperty('sharesBought', 95); + expect(response.body.data).toHaveProperty('pricePerUnit'); + expect(response.body.data).toHaveProperty('totalCost', 100); + expect(response.body.data).toHaveProperty('txHash'); + expect(response.body.data).toHaveProperty('tradeId'); + + // Verify AMM was called correctly + expect(ammService.buyShares).toHaveBeenCalledWith({ + marketId: 'test-market-id', + outcome: 1, + amountUsdc: 100, + minShares: 90, + }); + }); + + it('should reject buy with insufficient balance', async () => { + vi.mocked(prisma.market.findUnique).mockResolvedValue({ + id: 'test-market-id', + status: MarketStatus.OPEN, + } as any); + + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + id: 'test-user-id', + usdcBalance: 50, // Less than requested amount + } as any); + + const response = await request(app) + .post('/api/markets/test-market-id/buy') + .set('Authorization', `Bearer ${authToken}`) + .send({ + outcome: 1, + amount: 100, + }) + .expect(400); + + expect(response.body.success).toBe(false); + expect(response.body.error.code).toBe('BAD_REQUEST'); + expect(response.body.error.message).toContain('Insufficient balance'); + + // AMM should not be called + expect(ammService.buyShares).not.toHaveBeenCalled(); + }); + + it('should reject buy with invalid market (CLOSED)', async () => { + vi.mocked(prisma.market.findUnique).mockResolvedValue({ + id: 'test-market-id', + status: MarketStatus.CLOSED, + } as any); + + const response = await request(app) + .post('/api/markets/test-market-id/buy') + .set('Authorization', `Bearer ${authToken}`) + .send({ + outcome: 1, + amount: 100, + }) + .expect(400); + + expect(response.body.success).toBe(false); + expect(response.body.error.message).toContain('CLOSED'); + expect(ammService.buyShares).not.toHaveBeenCalled(); + }); + + it('should reject buy with invalid outcome', async () => { + const response = await request(app) + .post('/api/markets/test-market-id/buy') + .set('Authorization', `Bearer ${authToken}`) + .send({ + outcome: 5, // Invalid outcome + amount: 100, + }) + .expect(400); + + expect(response.body.success).toBe(false); + expect(response.body.error.code).toBe('VALIDATION_ERROR'); + }); + + it('should handle slippage protection (minShares)', async () => { + vi.mocked(prisma.market.findUnique).mockResolvedValue({ + id: 'test-market-id', + status: MarketStatus.OPEN, + } as any); + + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + id: 'test-user-id', + usdcBalance: 1000, + } as any); + + // AMM returns less shares than minimum + vi.mocked(ammService.buyShares).mockResolvedValue({ + sharesReceived: 85, // Less than minShares (90) + pricePerUnit: 1.18, + totalCost: 100, + feeAmount: 0.5, + txHash: 'mock-tx-hash', + }); + + const response = await request(app) + .post('/api/markets/test-market-id/buy') + .set('Authorization', `Bearer ${authToken}`) + .send({ + outcome: 1, + amount: 100, + minShares: 90, + }) + .expect(400); + + expect(response.body.success).toBe(false); + expect(response.body.error.code).toBe('SLIPPAGE_EXCEEDED'); + expect(response.body.error.message).toContain('Slippage exceeded'); + }); + + it('should record trade correctly in database', async () => { + vi.mocked(prisma.market.findUnique).mockResolvedValue({ + id: 'test-market-id', + status: MarketStatus.OPEN, + } as any); + + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + id: 'test-user-id', + usdcBalance: 1000, + } as any); + + vi.mocked(ammService.buyShares).mockResolvedValue({ + sharesReceived: 95, + pricePerUnit: 1.05, + totalCost: 100, + feeAmount: 0.5, + txHash: 'unique-tx-hash', + }); + + vi.mocked(prisma.share.findFirst).mockResolvedValue(null); + vi.mocked(prisma.share.create).mockResolvedValue({ + id: 'share-id', + quantity: 95, + costBasis: 100, + } as any); + + vi.mocked(prisma.trade.create).mockResolvedValue({ + id: 'trade-id', + txHash: 'unique-tx-hash', + } as any); + + vi.mocked(prisma.trade.update).mockResolvedValue({ + id: 'trade-id', + status: TradeStatus.CONFIRMED, + } as any); + + await request(app) + .post('/api/markets/test-market-id/buy') + .set('Authorization', `Bearer ${authToken}`) + .send({ + outcome: 1, + amount: 100, + }) + .expect(201); + + // Verify trade was created + expect(prisma.trade.create).toHaveBeenCalled(); + + // Verify trade was confirmed + expect(prisma.trade.update).toHaveBeenCalledWith({ + where: { id: 'trade-id' }, + data: { + status: TradeStatus.CONFIRMED, + confirmedAt: expect.any(Date), + }, + }); + }); + + it('should update user balance correctly', async () => { + vi.mocked(prisma.market.findUnique).mockResolvedValue({ + id: 'test-market-id', + status: MarketStatus.OPEN, + } as any); + + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + id: 'test-user-id', + usdcBalance: 1000, + } as any); + + vi.mocked(ammService.buyShares).mockResolvedValue({ + sharesReceived: 95, + pricePerUnit: 1.05, + totalCost: 100, + feeAmount: 0.5, + txHash: 'mock-tx-hash', + }); + + vi.mocked(prisma.share.findFirst).mockResolvedValue(null); + vi.mocked(prisma.share.create).mockResolvedValue({ + id: 'share-id', + quantity: 95, + costBasis: 100, + } as any); + + vi.mocked(prisma.trade.create).mockResolvedValue({ + id: 'trade-id', + } as any); + + vi.mocked(prisma.trade.update).mockResolvedValue({ + id: 'trade-id', + status: TradeStatus.CONFIRMED, + } as any); + + await request(app) + .post('/api/markets/test-market-id/buy') + .set('Authorization', `Bearer ${authToken}`) + .send({ + outcome: 1, + amount: 100, + }) + .expect(201); + + // Verify transaction was called + expect(prisma.$transaction).toHaveBeenCalled(); + }); + + it('should create/update share position', async () => { + vi.mocked(prisma.market.findUnique).mockResolvedValue({ + id: 'test-market-id', + status: MarketStatus.OPEN, + } as any); + + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + id: 'test-user-id', + usdcBalance: 1000, + } as any); + + vi.mocked(ammService.buyShares).mockResolvedValue({ + sharesReceived: 95, + pricePerUnit: 1.05, + totalCost: 100, + feeAmount: 0.5, + txHash: 'mock-tx-hash', + }); + + // Mock existing share position + vi.mocked(prisma.share.findFirst).mockResolvedValue({ + id: 'existing-share-id', + quantity: 50, + costBasis: 50, + } as any); + + vi.mocked(prisma.share.findUnique).mockResolvedValue({ + id: 'existing-share-id', + quantity: 50, + costBasis: 50, + } as any); + + vi.mocked(prisma.share.update).mockResolvedValue({ + id: 'existing-share-id', + quantity: 145, // 50 + 95 + costBasis: 150, // 50 + 100 + } as any); + + vi.mocked(prisma.trade.create).mockResolvedValue({ + id: 'trade-id', + } as any); + + vi.mocked(prisma.trade.update).mockResolvedValue({ + id: 'trade-id', + status: TradeStatus.CONFIRMED, + } as any); + + const response = await request(app) + .post('/api/markets/test-market-id/buy') + .set('Authorization', `Bearer ${authToken}`) + .send({ + outcome: 1, + amount: 100, + }) + .expect(201); + + expect(response.body.data.position.totalShares).toBeGreaterThan(0); + }); +}); + +describe('Trading API - Sell Shares', () => { + let authToken: string; + + beforeAll(() => { + authToken = 'mock-jwt-token'; + }); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should sell shares successfully with valid data', async () => { + vi.mocked(prisma.market.findUnique).mockResolvedValue({ + id: 'test-market-id', + } as any); + + // Mock user has shares + vi.mocked(prisma.share.findFirst).mockResolvedValue({ + id: 'share-id', + quantity: 100, + costBasis: 100, + } as any); + + vi.mocked(prisma.share.findUnique).mockResolvedValue({ + id: 'share-id', + quantity: 100, + costBasis: 100, + soldQuantity: 0, + realizedPnl: 0, + entryPrice: 1, + } as any); + + // Mock AMM response + vi.mocked(ammService.sellShares).mockResolvedValue({ + payout: 52, + pricePerUnit: 1.04, + feeAmount: 0.26, + txHash: 'mock-tx-hash-sell', + }); + + vi.mocked(prisma.share.update).mockResolvedValue({ + id: 'share-id', + quantity: 50, + costBasis: 50, + } as any); + + vi.mocked(prisma.trade.create).mockResolvedValue({ + id: 'trade-id', + tradeType: TradeType.SELL, + } as any); + + vi.mocked(prisma.trade.update).mockResolvedValue({ + id: 'trade-id', + status: TradeStatus.CONFIRMED, + } as any); + + const response = await request(app) + .post('/api/markets/test-market-id/sell') + .set('Authorization', `Bearer ${authToken}`) + .send({ + outcome: 1, + shares: 50, + minPayout: 48, + }) + .expect(200); + + expect(response.body.success).toBe(true); + expect(response.body.data).toHaveProperty('sharesSold', 50); + expect(response.body.data).toHaveProperty('payout', 52); + expect(response.body.data).toHaveProperty('txHash'); + + expect(ammService.sellShares).toHaveBeenCalledWith({ + marketId: 'test-market-id', + outcome: 1, + shares: 50, + minPayout: 48, + }); + }); + + it('should reject sell with insufficient shares', async () => { + vi.mocked(prisma.market.findUnique).mockResolvedValue({ + id: 'test-market-id', + } as any); + + vi.mocked(prisma.share.findFirst).mockResolvedValue({ + id: 'share-id', + quantity: 30, // Less than requested + } as any); + + const response = await request(app) + .post('/api/markets/test-market-id/sell') + .set('Authorization', `Bearer ${authToken}`) + .send({ + outcome: 1, + shares: 50, + }) + .expect(400); + + expect(response.body.success).toBe(false); + expect(response.body.error.code).toBe('BAD_REQUEST'); + expect(response.body.error.message).toContain('Insufficient shares'); + expect(ammService.sellShares).not.toHaveBeenCalled(); + }); + + it('should reject sell when user has no shares', async () => { + vi.mocked(prisma.market.findUnique).mockResolvedValue({ + id: 'test-market-id', + } as any); + + vi.mocked(prisma.share.findFirst).mockResolvedValue(null); + + const response = await request(app) + .post('/api/markets/test-market-id/sell') + .set('Authorization', `Bearer ${authToken}`) + .send({ + outcome: 1, + shares: 50, + }) + .expect(400); + + expect(response.body.success).toBe(false); + expect(response.body.error.message).toContain('No shares found'); + }); + + it('should handle slippage protection (minPayout)', async () => { + vi.mocked(prisma.market.findUnique).mockResolvedValue({ + id: 'test-market-id', + } as any); + + vi.mocked(prisma.share.findFirst).mockResolvedValue({ + id: 'share-id', + quantity: 100, + } as any); + + // AMM returns less payout than minimum + vi.mocked(ammService.sellShares).mockResolvedValue({ + payout: 45, // Less than minPayout (50) + pricePerUnit: 0.9, + feeAmount: 0.5, + txHash: 'mock-tx-hash', + }); + + const response = await request(app) + .post('/api/markets/test-market-id/sell') + .set('Authorization', `Bearer ${authToken}`) + .send({ + outcome: 1, + shares: 50, + minPayout: 50, + }) + .expect(400); + + expect(response.body.success).toBe(false); + expect(response.body.error.code).toBe('SLIPPAGE_EXCEEDED'); + }); + + it('should update share position correctly', async () => { + vi.mocked(prisma.market.findUnique).mockResolvedValue({ + id: 'test-market-id', + } as any); + + vi.mocked(prisma.share.findFirst).mockResolvedValue({ + id: 'share-id', + quantity: 100, + costBasis: 100, + } as any); + + vi.mocked(prisma.share.findUnique).mockResolvedValue({ + id: 'share-id', + quantity: 100, + costBasis: 100, + soldQuantity: 0, + realizedPnl: 0, + entryPrice: 1, + } as any); + + vi.mocked(ammService.sellShares).mockResolvedValue({ + payout: 52, + pricePerUnit: 1.04, + feeAmount: 0.26, + txHash: 'mock-tx-hash', + }); + + vi.mocked(prisma.share.update).mockResolvedValue({ + id: 'share-id', + quantity: 50, // Reduced from 100 + costBasis: 50, + } as any); + + vi.mocked(prisma.trade.create).mockResolvedValue({ + id: 'trade-id', + } as any); + + vi.mocked(prisma.trade.update).mockResolvedValue({ + id: 'trade-id', + status: TradeStatus.CONFIRMED, + } as any); + + const response = await request(app) + .post('/api/markets/test-market-id/sell') + .set('Authorization', `Bearer ${authToken}`) + .send({ + outcome: 1, + shares: 50, + }) + .expect(200); + + expect(response.body.data.remainingShares).toBe(50); + }); +}); + +describe('Trading API - Get Odds', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should return odds successfully', async () => { + vi.mocked(prisma.market.findUnique).mockResolvedValue({ + id: 'test-market-id', + } as any); + + vi.mocked(ammService.getOdds).mockResolvedValue({ + yesOdds: 0.65, + noOdds: 0.35, + yesPercentage: 65, + noPercentage: 35, + yesLiquidity: 650, + noLiquidity: 350, + totalLiquidity: 1000, + }); + + const response = await request(app) + .get('/api/markets/test-market-id/odds') + .expect(200); + + expect(response.body.success).toBe(true); + expect(response.body.data.yes.percentage).toBe(65); + expect(response.body.data.no.percentage).toBe(35); + expect(response.body.data.totalLiquidity).toBe(1000); + }); + + it('should return percentages adding to 100%', async () => { + vi.mocked(prisma.market.findUnique).mockResolvedValue({ + id: 'test-market-id', + } as any); + + vi.mocked(ammService.getOdds).mockResolvedValue({ + yesOdds: 0.72, + noOdds: 0.28, + yesPercentage: 72, + noPercentage: 28, + yesLiquidity: 720, + noLiquidity: 280, + totalLiquidity: 1000, + }); + + const response = await request(app) + .get('/api/markets/test-market-id/odds') + .expect(200); + + const yesPercent = response.body.data.yes.percentage; + const noPercent = response.body.data.no.percentage; + + expect(yesPercent + noPercent).toBe(100); + }); + + it('should handle market not found', async () => { + vi.mocked(prisma.market.findUnique).mockResolvedValue(null); + + const response = await request(app) + .get('/api/markets/nonexistent-market/odds') + .expect(404); + + expect(response.body.success).toBe(false); + expect(response.body.error.code).toBe('NOT_FOUND'); + }); + + it('should include liquidity information', async () => { + vi.mocked(prisma.market.findUnique).mockResolvedValue({ + id: 'test-market-id', + } as any); + + vi.mocked(ammService.getOdds).mockResolvedValue({ + yesOdds: 0.55, + noOdds: 0.45, + yesPercentage: 55, + noPercentage: 45, + yesLiquidity: 5500, + noLiquidity: 4500, + totalLiquidity: 10000, + }); + + const response = await request(app) + .get('/api/markets/test-market-id/odds') + .expect(200); + + expect(response.body.data.yes.liquidity).toBe(5500); + expect(response.body.data.no.liquidity).toBe(4500); + expect(response.body.data.totalLiquidity).toBe(10000); + }); +});