From 42cf0900b34791eb72c88b289913d7d3cf116316 Mon Sep 17 00:00:00 2001 From: caxtonacollins Date: Sun, 25 Jan 2026 02:24:06 +0100 Subject: [PATCH 1/5] bug: server bug fix --- backend/src/middleware/rateLimit.middleware.ts | 8 ++++++-- backend/src/routes/predictions.ts | 2 +- backend/src/services/blockchain/factory.ts | 16 +++++++++++++--- 3 files changed, 20 insertions(+), 6 deletions(-) diff --git a/backend/src/middleware/rateLimit.middleware.ts b/backend/src/middleware/rateLimit.middleware.ts index ed59871..37c968b 100644 --- a/backend/src/middleware/rateLimit.middleware.ts +++ b/backend/src/middleware/rateLimit.middleware.ts @@ -50,7 +50,7 @@ export const authRateLimiter: RateLimiterMiddleware = rateLimit({ standardHeaders: true, // Return rate limit info in RateLimit-* headers legacyHeaders: false, // Disable X-RateLimit-* headers store: createRedisStore('auth'), - keyGenerator: (req: any) => req.ip || 'unknown', + keyGenerator: (req: any) => req.ip, message: rateLimitMessage('Too many authentication attempts. Please try again in 15 minutes.'), skip: () => process.env.NODE_ENV === 'test', // Skip in tests }); @@ -68,6 +68,7 @@ export const challengeRateLimiter: RateLimiterMiddleware = rateLimit({ legacyHeaders: false, store: createRedisStore('challenge'), keyGenerator: (req: any) => req.body?.publicKey || req.ip || 'unknown', + validate: { ip: false }, message: rateLimitMessage('Too many challenge requests. Please wait a moment.'), skip: () => process.env.NODE_ENV === 'test', }); @@ -88,6 +89,7 @@ export const apiRateLimiter: RateLimiterMiddleware = rateLimit({ const authReq = req as AuthenticatedRequest; return authReq.user?.userId || req.ip || 'unknown'; }, + validate: { ip: false }, message: rateLimitMessage('Too many requests. Please slow down.'), skip: () => process.env.NODE_ENV === 'test', }); @@ -104,7 +106,7 @@ export const refreshRateLimiter: RateLimiterMiddleware = rateLimit({ standardHeaders: true, legacyHeaders: false, store: createRedisStore('refresh'), - keyGenerator: (req: any) => req.ip || 'unknown', + keyGenerator: (req: any) => req.ip, message: rateLimitMessage('Too many refresh attempts.'), skip: () => process.env.NODE_ENV === 'test', }); @@ -125,6 +127,7 @@ export const sensitiveOperationRateLimiter: RateLimiterMiddleware = rateLimit({ const authReq = req as AuthenticatedRequest; return authReq.user?.userId || req.ip || 'unknown'; }, + validate: { ip: false }, message: rateLimitMessage('Too many sensitive operations. Please try again later.'), skip: () => process.env.NODE_ENV === 'test', }); @@ -149,6 +152,7 @@ export function createRateLimiter(options: { const authReq = req as AuthenticatedRequest; return authReq.user?.userId || req.ip || 'unknown'; }, + validate: { ip: false }, message: rateLimitMessage(options.message || 'Rate limit exceeded.'), skip: () => process.env.NODE_ENV === 'test', }); diff --git a/backend/src/routes/predictions.ts b/backend/src/routes/predictions.ts index 3da0c13..c2269a3 100644 --- a/backend/src/routes/predictions.ts +++ b/backend/src/routes/predictions.ts @@ -167,4 +167,4 @@ TODO: WebSocket Events - Real-Time Prediction Updates - Update every second or on significant change */ -export default {}; +// export default {}; diff --git a/backend/src/services/blockchain/factory.ts b/backend/src/services/blockchain/factory.ts index 96c5883..f168eef 100644 --- a/backend/src/services/blockchain/factory.ts +++ b/backend/src/services/blockchain/factory.ts @@ -3,7 +3,7 @@ import { Contract, - SorobanRpc, + rpc as SorobanRpc, TransactionBuilder, Networks, BASE_FEE, @@ -47,9 +47,16 @@ export class FactoryService { // Admin keypair for signing contract calls const adminSecret = process.env.ADMIN_WALLET_SECRET; if (!adminSecret) { - throw new Error('ADMIN_WALLET_SECRET not configured'); + // In development/testnet, generate a random keypair if not provided (prevents startup crash) + if (process.env.NODE_ENV !== 'production') { + console.warn('ADMIN_WALLET_SECRET not configured, using random keypair for development (Warning: No funds)'); + this.adminKeypair = Keypair.random(); + } else { + throw new Error('ADMIN_WALLET_SECRET not configured'); + } + } else { + this.adminKeypair = Keypair.fromSecret(adminSecret); } - this.adminKeypair = Keypair.fromSecret(adminSecret); } /** @@ -230,6 +237,9 @@ export class FactoryService { if (SorobanRpc.Api.isSimulationSuccess(simulationResponse)) { const result = simulationResponse.result?.retval; + if (!result) { + throw new Error('No return value from simulation'); + } return scValToNative(result) as number; } From bc67426db2e86215132db38fde8f6b3a193c2c88 Mon Sep 17 00:00:00 2001 From: caxtonacollins Date: Sun, 25 Jan 2026 02:24:34 +0100 Subject: [PATCH 2/5] feat: Impl Trading API --- backend/src/controllers/trading.controller.ts | 281 +++++++ backend/src/index.ts | 4 + backend/src/repositories/share.repository.ts | 198 +++++ backend/src/repositories/trade.repository.ts | 42 ++ backend/src/routes/trading.ts | 102 +++ backend/src/services/blockchain/amm.ts | 404 ++++++++++ backend/src/services/trading.service.ts | 345 +++++++++ .../integration/trading.integration.test.ts | 709 ++++++++++++++++++ 8 files changed, 2085 insertions(+) create mode 100644 backend/src/controllers/trading.controller.ts create mode 100644 backend/src/repositories/share.repository.ts create mode 100644 backend/src/routes/trading.ts create mode 100644 backend/src/services/blockchain/amm.ts create mode 100644 backend/src/services/trading.service.ts create mode 100644 backend/tests/integration/trading.integration.test.ts diff --git a/backend/src/controllers/trading.controller.ts b/backend/src/controllers/trading.controller.ts new file mode 100644 index 0000000..46d99f3 --- /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; + 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; + 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; + + // 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 f2bb649..da96651 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -13,6 +13,7 @@ config(); import authRoutes from './routes/auth.routes.js'; import marketRoutes from './routes/markets.routes.js'; import predictionRoutes from './routes/predictions.js'; +import tradingRoutes from './routes/trading.js'; // Import Redis initialization import { initializeRedis, closeRedisConnection, getRedisStatus } from './config/redis.js'; @@ -97,6 +98,9 @@ app.use('/api/markets', marketRoutes); // Prediction routes (commit-reveal flow) app.use('/api/markets', predictionRoutes); +// Trading routes (buy/sell shares, odds) +app.use('/api/markets', tradingRoutes); + // TODO: Add other routes as they are implemented // app.use('/api/users', userRoutes); // app.use('/api/leaderboard', leaderboardRoutes); diff --git a/backend/src/repositories/share.repository.ts b/backend/src/repositories/share.repository.ts new file mode 100644 index 0000000..bcdb9d1 --- /dev/null +++ b/backend/src/repositories/share.repository.ts @@ -0,0 +1,198 @@ +// 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..40a091a 100644 --- a/backend/src/repositories/trade.repository.ts +++ b/backend/src/repositories/trade.repository.ts @@ -154,4 +154,46 @@ 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/trading.ts b/backend/src/routes/trading.ts new file mode 100644 index 0000000..5081854 --- /dev/null +++ b/backend/src/routes/trading.ts @@ -0,0 +1,102 @@ +// 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(); + +/** + * 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/services/blockchain/amm.ts b/backend/src/services/blockchain/amm.ts new file mode 100644 index 0000000..8780128 --- /dev/null +++ b/backend/src/services/blockchain/amm.ts @@ -0,0 +1,404 @@ +// backend/src/services/blockchain/amm.ts +// AMM (Automated Market Maker) contract interaction service + +import { + Contract, + rpc as SorobanRpc, + TransactionBuilder, + Networks, + BASE_FEE, + Keypair, + nativeToScVal, + scValToNative, + xdr, +} from '@stellar/stellar-sdk'; + +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; +} + +export class AMMService { + private rpcServer: SorobanRpc.Server; + private ammContractId: string; + private networkPassphrase: string; + private adminKeypair: Keypair; + + constructor() { + const rpcUrl = process.env.STELLAR_SOROBAN_RPC_URL || 'https://soroban-testnet.stellar.org'; + const network = process.env.STELLAR_NETWORK || 'testnet'; + + this.rpcServer = new SorobanRpc.Server(rpcUrl, { allowHttp: rpcUrl.includes('localhost') }); + this.ammContractId = process.env.AMM_CONTRACT_ADDRESS || ''; + this.networkPassphrase = network === 'mainnet' ? Networks.PUBLIC : Networks.TESTNET; + + // Admin keypair for signing contract calls + const adminSecret = process.env.ADMIN_WALLET_SECRET; + if (!adminSecret) { + // In development/testnet, generate a random keypair if not provided (prevents startup crash) + if (process.env.NODE_ENV !== 'production') { + console.warn('ADMIN_WALLET_SECRET not configured, using random keypair for AMM service (Warning: No funds)'); + this.adminKeypair = Keypair.random(); + } else { + throw new Error('ADMIN_WALLET_SECRET not configured'); + } + } else { + this.adminKeypair = Keypair.fromSecret(adminSecret); + } + } + + /** + * 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'); + } + + 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'); + } + + 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); + const sourceAccount = await this.rpcServer.getAccount(this.adminKeypair.publicKey()); + + 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 (SorobanRpc.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'}` + ); + } + } + + /** + * 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 { + let retries = 0; + + while (retries < maxRetries) { + 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'); + } + } + + /** + * Sleep utility + * @param ms - Milliseconds to sleep + */ + private sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } +} + +// Singleton instance +export const ammService = new AMMService(); diff --git a/backend/src/services/trading.service.ts b/backend/src/services/trading.service.ts new file mode 100644 index 0000000..9657545 --- /dev/null +++ b/backend/src/services/trading.service.ts @@ -0,0 +1,345 @@ +// 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); + }); +}); From c3b82c3cf82f64ea26bbc34a35336f4470abffcf Mon Sep 17 00:00:00 2001 From: caxtonacollins Date: Sat, 31 Jan 2026 18:48:03 +0100 Subject: [PATCH 3/5] Fix TypeScript errors in AMM service --- backend/src/services/blockchain/amm.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/backend/src/services/blockchain/amm.ts b/backend/src/services/blockchain/amm.ts index 104a9ab..9949f7b 100644 --- a/backend/src/services/blockchain/amm.ts +++ b/backend/src/services/blockchain/amm.ts @@ -11,7 +11,6 @@ import { nativeToScVal, scValToNative, xdr, - SorobanRpc, } from '@stellar/stellar-sdk'; interface BuySharesParams { @@ -295,7 +294,7 @@ export class AmmService { // Simulate transaction to get result without submitting const simulationResponse = await this.rpcServer.simulateTransaction(builtTransaction); - if (SorobanRpc.Api.isSimulationSuccess(simulationResponse)) { + if (rpc.Api.isSimulationSuccess(simulationResponse)) { const result = simulationResponse.result?.retval; if (!result) { throw new Error('No return value from simulation'); From 5883da2b40536e2b9f16ac40ee5b71d04b82e87e Mon Sep 17 00:00:00 2001 From: caxtonacollins Date: Sat, 31 Jan 2026 19:02:58 +0100 Subject: [PATCH 4/5] workflow fixed --- backend/package.json | 13 ++++++++- backend/src/controllers/trading.controller.ts | 6 ++-- backend/src/index.ts | 2 +- .../src/middleware/rateLimit.middleware.ts | 7 ++--- backend/src/repositories/share.repository.ts | 23 ++++++++++----- backend/src/repositories/trade.repository.ts | 5 +++- backend/src/routes/auth.routes.ts | 2 +- backend/src/routes/health.ts | 2 +- backend/src/routes/markets.routes.ts | 2 +- backend/src/routes/oracle.ts | 2 +- backend/src/routes/predictions.ts | 2 +- backend/src/routes/trading.ts | 29 ++++++++----------- backend/src/routes/treasury.routes.ts | 2 +- backend/src/services/trading.service.ts | 27 +++++++++++++---- 14 files changed, 77 insertions(+), 47 deletions(-) diff --git a/backend/package.json b/backend/package.json index 546fa58..2411042 100644 --- a/backend/package.json +++ b/backend/package.json @@ -57,6 +57,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", @@ -73,10 +74,20 @@ "prisma": "^5.22.0", "supertest": "^7.2.2", "tsx": "^4.19.2", - "typescript": "^5.9.3", + "typescript": "5.5.4", "vitest": "^2.1.8" }, "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 index 46d99f3..19e9a3e 100644 --- a/backend/src/controllers/trading.controller.ts +++ b/backend/src/controllers/trading.controller.ts @@ -23,7 +23,7 @@ class TradingController { return; } - const { marketId } = req.params; + const marketId = req.params.marketId as string; const { outcome, amount, minShares } = req.body; // Validate input @@ -133,7 +133,7 @@ class TradingController { return; } - const { marketId } = req.params; + const marketId = req.params.marketId as string; const { outcome, shares, minPayout } = req.body; // Validate input @@ -231,7 +231,7 @@ class TradingController { */ async getOdds(req: Request, res: Response): Promise { try { - const { marketId } = req.params; + const marketId = req.params.marketId as string; // Call service const result = await tradingService.getMarketOdds(marketId); diff --git a/backend/src/index.ts b/backend/src/index.ts index 857d9aa..c2413b9 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -45,7 +45,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'; diff --git a/backend/src/middleware/rateLimit.middleware.ts b/backend/src/middleware/rateLimit.middleware.ts index 59bf5c7..57e00d6 100644 --- a/backend/src/middleware/rateLimit.middleware.ts +++ b/backend/src/middleware/rateLimit.middleware.ts @@ -79,8 +79,7 @@ export const challengeRateLimiter: RateLimiterMiddleware = rateLimit({ standardHeaders: true, legacyHeaders: false, store: createRedisStore('challenge'), - keyGenerator: (req: any) => - req.body?.publicKey || getIpKey(req), + keyGenerator: (req: any) => req.body?.publicKey || getIpKey(req), message: rateLimitMessage( 'Too many challenge requests. Please wait a moment.' ), @@ -161,9 +160,7 @@ export function createRateLimiter(options: { const authReq = req as AuthenticatedRequest; return authReq.user?.userId || getIpKey(req); }, - message: rateLimitMessage( - options.message || 'Too many requests.' - ), + 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 index bcdb9d1..c26d888 100644 --- a/backend/src/repositories/share.repository.ts +++ b/backend/src/repositories/share.repository.ts @@ -26,7 +26,10 @@ export class ShareRepository { /** * Find all shares for a user in a specific market */ - async findByUserAndMarket(userId: string, marketId: string): Promise { + async findByUserAndMarket( + userId: string, + marketId: string + ): Promise { return prisma.share.findMany({ where: { userId, @@ -82,13 +85,19 @@ export class ShareRepository { ): 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.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); + if (data.realizedPnl !== undefined) + updateData.realizedPnl = new Decimal(data.realizedPnl); return prisma.share.update({ where: { id: shareId }, diff --git a/backend/src/repositories/trade.repository.ts b/backend/src/repositories/trade.repository.ts index 40a091a..d9946bc 100644 --- a/backend/src/repositories/trade.repository.ts +++ b/backend/src/repositories/trade.repository.ts @@ -187,7 +187,10 @@ export class TradeRepository extends BaseRepository { }); } - async findByUserAndMarket(userId: string, marketId: string): Promise { + async findByUserAndMarket( + userId: string, + marketId: string + ): Promise { return await this.prisma.trade.findMany({ where: { userId, 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 f6990e6..7cd6230 100644 --- a/backend/src/routes/health.ts +++ b/backend/src/routes/health.ts @@ -2,7 +2,7 @@ import { Router, Request, Response } from 'express'; import { checkDatabaseConnection } from '../database/prisma.js'; import { getRedisStatus, isRedisHealthy } from '../config/redis.js'; -const router = Router(); +const router: Router = Router(); /** * Basic health check - Liveness probe 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 index 5081854..2389281 100644 --- a/backend/src/routes/trading.ts +++ b/backend/src/routes/trading.ts @@ -5,19 +5,19 @@ import { Router } from 'express'; import { tradingController } from '../controllers/trading.controller.js'; import { requireAuth } from '../middleware/auth.middleware.js'; -const router = Router(); +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, @@ -35,23 +35,21 @@ const router = Router(); * } * } */ -router.post( - '/:marketId/buy', - requireAuth, - (req, res) => tradingController.buyShares(req, res) +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, @@ -66,16 +64,14 @@ router.post( * } * } */ -router.post( - '/:marketId/sell', - requireAuth, - (req, res) => tradingController.sellShares(req, res) +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, @@ -94,9 +90,8 @@ router.post( * } * } */ -router.get( - '/:marketId/odds', - (req, res) => tradingController.getOdds(req, res) +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/trading.service.ts b/backend/src/services/trading.service.ts index 9657545..0bc3af5 100644 --- a/backend/src/services/trading.service.ts +++ b/backend/src/services/trading.service.ts @@ -86,7 +86,9 @@ export class TradingService { } if (market.status !== MarketStatus.OPEN) { - throw new Error(`Market is ${market.status}. Trading is only allowed for OPEN markets.`); + throw new Error( + `Market is ${market.status}. Trading is only allowed for OPEN markets.` + ); } // Check user balance @@ -100,7 +102,9 @@ export class TradingService { const userBalance = Number(user.usdcBalance); if (userBalance < amount) { - throw new Error(`Insufficient balance. Available: ${userBalance} USDC, Required: ${amount} USDC`); + throw new Error( + `Insufficient balance. Available: ${userBalance} USDC, Required: ${amount} USDC` + ); } // Set minimum shares to 95% of expected if not provided (5% slippage tolerance) @@ -139,7 +143,11 @@ export class TradingService { await tradeRepository.confirmTrade(trade.id); // Update or create share position - const existingShare = await shareRepository.findByUserMarketOutcome(userId, marketId, outcome); + const existingShare = await shareRepository.findByUserMarketOutcome( + userId, + marketId, + outcome + ); let updatedShare; if (existingShare) { @@ -199,7 +207,8 @@ export class TradingService { tradeId: result.trade.id, newSharePosition: { totalShares: Number(result.share.quantity), - averagePrice: Number(result.share.costBasis) / Number(result.share.quantity), + averagePrice: + Number(result.share.costBasis) / Number(result.share.quantity), }, }; } @@ -230,10 +239,16 @@ export class TradingService { } // Check if user has sufficient shares - const userShare = await shareRepository.findByUserMarketOutcome(userId, marketId, outcome); + const userShare = await shareRepository.findByUserMarketOutcome( + userId, + marketId, + outcome + ); if (!userShare) { - throw new Error(`No shares found for outcome ${outcome === 0 ? 'NO' : 'YES'}`); + throw new Error( + `No shares found for outcome ${outcome === 0 ? 'NO' : 'YES'}` + ); } const availableShares = Number(userShare.quantity); From 99ad569b0844959f840dfbc063c0400e3dedfe9f Mon Sep 17 00:00:00 2001 From: caxtonacollins Date: Fri, 20 Feb 2026 21:10:10 +0100 Subject: [PATCH 5/5] chore: fixed pipeline checks --- backend/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/package.json b/backend/package.json index 2411042..9b3e04e 100644 --- a/backend/package.json +++ b/backend/package.json @@ -74,7 +74,7 @@ "prisma": "^5.22.0", "supertest": "^7.2.2", "tsx": "^4.19.2", - "typescript": "5.5.4", + "typescript": "^5.9.3", "vitest": "^2.1.8" }, "prisma": {