From 8d12c679ae37dee2c4a78ba58332f3183dd8fdb0 Mon Sep 17 00:00:00 2001 From: Face <69168154+face-hh@users.noreply.github.com> Date: Sat, 31 May 2025 12:56:38 +0300 Subject: [PATCH] fix: race conditions --- website/src/lib/server/s3.ts | 33 +- .../api/coin/[coinSymbol]/trade/+server.ts | 308 +++++++++--------- website/src/routes/api/coin/create/+server.ts | 82 +++-- .../api/hopium/questions/create/+server.ts | 1 + .../src/routes/api/promo/verify/+server.ts | 5 +- .../src/routes/api/rewards/claim/+server.ts | 1 + .../src/routes/api/trades/recent/+server.ts | 5 +- 7 files changed, 230 insertions(+), 205 deletions(-) diff --git a/website/src/lib/server/s3.ts b/website/src/lib/server/s3.ts index 20123f4..3e96702 100644 --- a/website/src/lib/server/s3.ts +++ b/website/src/lib/server/s3.ts @@ -49,13 +49,20 @@ export async function uploadProfilePicture( contentType: string, contentLength?: number ): Promise { - let fileExtension = contentType.split('/')[1]; - // Ensure a valid image extension or default to jpg - if (!fileExtension || !['jpg', 'jpeg', 'png', 'gif', 'webp'].includes(fileExtension.toLowerCase())) { - fileExtension = 'jpg'; + if (!contentType || !contentType.startsWith('image/')) { + throw new Error('Invalid file type. Only images are allowed.'); } + + const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp']; + if (!allowedTypes.includes(contentType.toLowerCase())) { + throw new Error('Unsupported image format. Only JPEG, PNG, GIF, and WebP are allowed.'); + } + + let fileExtension = contentType.split('/')[1]; + if (fileExtension === 'jpeg') fileExtension = 'jpg'; + const key = `avatars/${identifier}.${fileExtension}`; - + const command = new PutObjectCommand({ Bucket: PUBLIC_B2_BUCKET, Key: key, @@ -74,12 +81,20 @@ export async function uploadCoinIcon( contentType: string, contentLength?: number ): Promise { - let fileExtension = contentType.split('/')[1]; - if (!fileExtension || !['jpg', 'jpeg', 'png', 'gif', 'webp'].includes(fileExtension.toLowerCase())) { - fileExtension = 'png'; + if (!contentType || !contentType.startsWith('image/')) { + throw new Error('Invalid file type. Only images are allowed.'); } + + const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp']; + if (!allowedTypes.includes(contentType.toLowerCase())) { + throw new Error('Unsupported image format. Only JPEG, PNG, GIF, and WebP are allowed.'); + } + + let fileExtension = contentType.split('/')[1]; + if (fileExtension === 'jpeg') fileExtension = 'jpg'; + const key = `coins/${coinSymbol.toLowerCase()}.${fileExtension}`; - + const command = new PutObjectCommand({ Bucket: PUBLIC_B2_BUCKET, Key: key, diff --git a/website/src/routes/api/coin/[coinSymbol]/trade/+server.ts b/website/src/routes/api/coin/[coinSymbol]/trade/+server.ts index bc1e841..0696cea 100644 --- a/website/src/routes/api/coin/[coinSymbol]/trade/+server.ts +++ b/website/src/routes/api/coin/[coinSymbol]/trade/+server.ts @@ -58,66 +58,75 @@ export async function POST({ params, request }) { throw error(400, 'Invalid transaction type'); } - if (!amount || amount <= 0) { - throw error(400, 'Invalid amount'); + if (!amount || typeof amount !== 'number' || !Number.isFinite(amount) || amount <= 0) { + throw error(400, 'Invalid amount - must be a positive finite number'); + } + + if (amount > Number.MAX_SAFE_INTEGER) { + throw error(400, 'Amount too large'); } const userId = Number(session.user.id); const normalizedSymbol = coinSymbol.toUpperCase(); - const [coinData] = await db.select().from(coin).where(eq(coin.symbol, normalizedSymbol)).limit(1); - - if (!coinData) { + const [coinExists] = await db.select({ id: coin.id }).from(coin).where(eq(coin.symbol, normalizedSymbol)).limit(1); + if (!coinExists) { throw error(404, 'Coin not found'); } - if (!coinData.isListed) { - throw error(400, 'This coin is delisted and cannot be traded'); - } + return await db.transaction(async (tx) => { + const [coinData] = await tx.select().from(coin).where(eq(coin.symbol, normalizedSymbol)).for('update').limit(1); - const [userData] = await db.select({ - baseCurrencyBalance: user.baseCurrencyBalance, - username: user.username, - image: user.image - }).from(user).where(eq(user.id, userId)).limit(1); - - if (!userData) { - throw error(404, 'User not found'); - } - - const userBalance = Number(userData.baseCurrencyBalance); - const poolCoinAmount = Number(coinData.poolCoinAmount); - const poolBaseCurrencyAmount = Number(coinData.poolBaseCurrencyAmount); - const currentPrice = Number(coinData.currentPrice); - - let newPrice: number; - let totalCost: number; - let priceImpact: number = 0; - - if (poolCoinAmount <= 0 || poolBaseCurrencyAmount <= 0) { - throw error(400, 'Liquidity pool is not properly initialized or is empty. Trading halted.'); - } - - if (type === 'BUY') { - // AMM BUY: amount = dollars to spend - const k = poolCoinAmount * poolBaseCurrencyAmount; - const newPoolBaseCurrency = poolBaseCurrencyAmount + amount; - const newPoolCoin = k / newPoolBaseCurrency; - const coinsBought = poolCoinAmount - newPoolCoin; - - totalCost = amount; - newPrice = newPoolBaseCurrency / newPoolCoin; - priceImpact = ((newPrice - currentPrice) / currentPrice) * 100; - - if (userBalance < totalCost) { - throw error(400, `Insufficient funds. You need *${totalCost.toFixed(6)} BUSS but only have *${userBalance.toFixed(6)} BUSS`); + if (!coinData) { + throw error(404, 'Coin not found'); } - if (coinsBought <= 0) { - throw error(400, 'Trade amount too small - would result in zero tokens'); + if (!coinData.isListed) { + throw error(400, 'This coin is delisted and cannot be traded'); } - await db.transaction(async (tx) => { + const [userData] = await tx.select({ + baseCurrencyBalance: user.baseCurrencyBalance, + username: user.username, + image: user.image + }).from(user).where(eq(user.id, userId)).for('update').limit(1); + + if (!userData) { + throw error(404, 'User not found'); + } + + const userBalance = Number(userData.baseCurrencyBalance); + const poolCoinAmount = Number(coinData.poolCoinAmount); + const poolBaseCurrencyAmount = Number(coinData.poolBaseCurrencyAmount); + const currentPrice = Number(coinData.currentPrice); + + let newPrice: number; + let totalCost: number; + let priceImpact: number = 0; + + if (poolCoinAmount <= 0 || poolBaseCurrencyAmount <= 0) { + throw error(400, 'Liquidity pool is not properly initialized or is empty. Trading halted.'); + } + + if (type === 'BUY') { + // AMM BUY: amount = dollars to spend + const k = poolCoinAmount * poolBaseCurrencyAmount; + const newPoolBaseCurrency = poolBaseCurrencyAmount + amount; + const newPoolCoin = k / newPoolBaseCurrency; + const coinsBought = poolCoinAmount - newPoolCoin; + + totalCost = amount; + newPrice = newPoolBaseCurrency / newPoolCoin; + priceImpact = ((newPrice - currentPrice) / currentPrice) * 100; + + if (userBalance < totalCost) { + throw error(400, `Insufficient funds. You need *${totalCost.toFixed(6)} BUSS but only have *${userBalance.toFixed(6)} BUSS`); + } + + if (coinsBought <= 0) { + throw error(400, 'Trade amount too small - would result in zero tokens'); + } + await tx.update(user) .set({ baseCurrencyBalance: (userBalance - totalCost).toString(), @@ -181,94 +190,91 @@ export async function POST({ params, request }) { }) .where(eq(coin.id, coinData.id)); - await redis.publish(`prices:${normalizedSymbol}`, JSON.stringify({ + const priceUpdateData = { currentPrice: newPrice, marketCap: Number(coinData.circulatingSupply) * newPrice, change24h: metrics.change24h, volume24h: metrics.volume24h, poolCoinAmount: newPoolCoin, poolBaseCurrencyAmount: newPoolBaseCurrency - })); - }); + }; - // REDIS - const tradeData = { - type: 'BUY', - username: userData.username, - userImage: userData.image || '', - amount: coinsBought, - coinSymbol: normalizedSymbol, - coinName: coinData.name, - coinIcon: coinData.icon || '', - totalValue: totalCost, - price: newPrice, - timestamp: Date.now(), - userId: userId.toString() - }; + const tradeData = { + type: 'BUY', + username: userData.username, + userImage: userData.image || '', + amount: coinsBought, + coinSymbol: normalizedSymbol, + coinName: coinData.name, + coinIcon: coinData.icon || '', + totalValue: totalCost, + price: newPrice, + timestamp: Date.now(), + userId: userId.toString() + }; - await redis.publish('trades:all', JSON.stringify({ - type: 'all-trades', - data: tradeData - })); + await redis.publish(`prices:${normalizedSymbol}`, JSON.stringify(priceUpdateData)); - if (totalCost >= 1000) { - await redis.publish('trades:large', JSON.stringify({ - type: 'live-trade', + await redis.publish('trades:all', JSON.stringify({ + type: 'all-trades', data: tradeData })); - } - // End REDIS - return json({ - success: true, - type: 'BUY', - coinsBought, - totalCost, - newPrice, - priceImpact, - newBalance: userBalance - totalCost - }); + if (totalCost >= 1000) { + await redis.publish('trades:large', JSON.stringify({ + type: 'live-trade', + data: tradeData + })); + } - } else { - // AMM SELL: amount = number of coins to sell - const [userHolding] = await db - .select({ quantity: userPortfolio.quantity }) - .from(userPortfolio) - .where(and( - eq(userPortfolio.userId, userId), - eq(userPortfolio.coinId, coinData.id) - )) - .limit(1); + return json({ + success: true, + type: 'BUY', + coinsBought, + totalCost, + newPrice, + priceImpact, + newBalance: userBalance - totalCost + }); - if (!userHolding || Number(userHolding.quantity) < amount) { - throw error(400, `Insufficient coins. You have ${userHolding ? Number(userHolding.quantity) : 0} but trying to sell ${amount}`); - } + } else { + // AMM SELL: amount = number of coins to sell + const [userHolding] = await tx + .select({ quantity: userPortfolio.quantity }) + .from(userPortfolio) + .where(and( + eq(userPortfolio.userId, userId), + eq(userPortfolio.coinId, coinData.id) + )) + .limit(1); - // Allow more aggressive selling for rug pull simulation - prevent only mathematical breakdown - const maxSellable = Math.floor(poolCoinAmount * 0.995); // 99.5% instead of 99% - if (amount > maxSellable) { - throw error(400, `Cannot sell more than 99.5% of pool tokens. Max sellable: ${maxSellable} tokens`); - } + if (!userHolding || Number(userHolding.quantity) < amount) { + throw error(400, `Insufficient coins. You have ${userHolding ? Number(userHolding.quantity) : 0} but trying to sell ${amount}`); + } - const k = poolCoinAmount * poolBaseCurrencyAmount; - const newPoolCoin = poolCoinAmount + amount; - const newPoolBaseCurrency = k / newPoolCoin; - const baseCurrencyReceived = poolBaseCurrencyAmount - newPoolBaseCurrency; + // Allow more aggressive selling for rug pull simulation - prevent only mathematical breakdown + const maxSellable = Math.floor(poolCoinAmount * 0.995); + if (amount > maxSellable) { + throw error(400, `Cannot sell more than 99.5% of pool tokens. Max sellable: ${maxSellable} tokens`); + } - totalCost = baseCurrencyReceived; - newPrice = newPoolBaseCurrency / newPoolCoin; - priceImpact = ((newPrice - currentPrice) / currentPrice) * 100; + const k = poolCoinAmount * poolBaseCurrencyAmount; + const newPoolCoin = poolCoinAmount + amount; + const newPoolBaseCurrency = k / newPoolCoin; + const baseCurrencyReceived = poolBaseCurrencyAmount - newPoolBaseCurrency; - // Lower minimum liquidity for more dramatic crashes - if (newPoolBaseCurrency < 10) { - throw error(400, `Trade would drain pool below minimum liquidity (*10 BUSS). Try selling fewer tokens.`); - } + totalCost = baseCurrencyReceived; + newPrice = newPoolBaseCurrency / newPoolCoin; + priceImpact = ((newPrice - currentPrice) / currentPrice) * 100; - if (totalCost <= 0) { - throw error(400, 'Trade amount results in zero base currency received'); - } + if (newPoolBaseCurrency < 10) { + throw error(400, `Trade would drain pool below minimum liquidity (*10 BUSS). Try selling fewer tokens.`); + } + + if (totalCost <= 0) { + throw error(400, 'Trade amount results in zero base currency received'); + } - await db.transaction(async (tx) => { await tx.update(user) .set({ baseCurrencyBalance: (userBalance + totalCost).toString(), @@ -323,52 +329,52 @@ export async function POST({ params, request }) { }) .where(eq(coin.id, coinData.id)); - await redis.publish(`prices:${normalizedSymbol}`, JSON.stringify({ + const priceUpdateData = { currentPrice: newPrice, marketCap: Number(coinData.circulatingSupply) * newPrice, change24h: metrics.change24h, volume24h: metrics.volume24h, poolCoinAmount: newPoolCoin, poolBaseCurrencyAmount: newPoolBaseCurrency - })); - }); + }; - // REDIS - const tradeData = { - type: 'SELL', - username: userData.username, - userImage: userData.image || '', - amount: amount, - coinSymbol: normalizedSymbol, - coinName: coinData.name, - coinIcon: coinData.icon || '', - totalValue: totalCost, - price: newPrice, - timestamp: Date.now(), - userId: userId.toString() - }; + const tradeData = { + type: 'SELL', + username: userData.username, + userImage: userData.image || '', + amount: amount, + coinSymbol: normalizedSymbol, + coinName: coinData.name, + coinIcon: coinData.icon || '', + totalValue: totalCost, + price: newPrice, + timestamp: Date.now(), + userId: userId.toString() + }; - await redis.publish('trades:all', JSON.stringify({ - type: 'all-trades', - data: tradeData - })); + await redis.publish(`prices:${normalizedSymbol}`, JSON.stringify(priceUpdateData)); - if (totalCost >= 1000) { - await redis.publish('trades:large', JSON.stringify({ - type: 'live-trade', + await redis.publish('trades:all', JSON.stringify({ + type: 'all-trades', data: tradeData })); - } - // End REDIS - return json({ - success: true, - type: 'SELL', - coinsSold: amount, - totalReceived: totalCost, - newPrice, - priceImpact, - newBalance: userBalance + totalCost - }); - } + if (totalCost >= 1000) { + await redis.publish('trades:large', JSON.stringify({ + type: 'live-trade', + data: tradeData + })); + } + + return json({ + success: true, + type: 'SELL', + coinsSold: amount, + totalReceived: totalCost, + newPrice, + priceImpact, + newBalance: userBalance + totalCost + }); + } + }); } diff --git a/website/src/routes/api/coin/create/+server.ts b/website/src/routes/api/coin/create/+server.ts index 407aa5a..e8ce27c 100644 --- a/website/src/routes/api/coin/create/+server.ts +++ b/website/src/routes/api/coin/create/+server.ts @@ -1,7 +1,7 @@ import { auth } from '$lib/auth'; import { error, json } from '@sveltejs/kit'; import { db } from '$lib/server/db'; -import { coin, userPortfolio, user, priceHistory, transaction } from '$lib/server/db/schema'; +import { coin, user, priceHistory } from '$lib/server/db/schema'; import { eq } from 'drizzle-orm'; import { uploadCoinIcon } from '$lib/server/s3'; import { CREATION_FEE, FIXED_SUPPLY, STARTING_PRICE, INITIAL_LIQUIDITY, TOTAL_COST, MAX_FILE_SIZE } from '$lib/data/constants'; @@ -31,32 +31,6 @@ async function validateInputs(name: string, symbol: string, iconFile: File | nul } } -async function validateUserBalance(userId: number) { - const [userData] = await db - .select({ baseCurrencyBalance: user.baseCurrencyBalance }) - .from(user) - .where(eq(user.id, userId)) - .limit(1); - - if (!userData) { - throw error(404, 'User not found'); - } - - const currentBalance = Number(userData.baseCurrencyBalance); - if (currentBalance < TOTAL_COST) { - throw error(400, `Insufficient funds. You need $${TOTAL_COST.toFixed(2)} but only have $${currentBalance.toFixed(2)}.`); - } - - return currentBalance; -} - -async function validateSymbolUnique(symbol: string) { - const existingCoin = await db.select().from(coin).where(eq(coin.symbol, symbol)).limit(1); - if (existingCoin.length > 0) { - throw error(400, 'A coin with this symbol already exists'); - } -} - async function handleIconUpload(iconFile: File | null, symbol: string): Promise { if (!iconFile || iconFile.size === 0) { return null; @@ -90,20 +64,31 @@ export async function POST({ request }) { await validateInputs(name, normalizedSymbol, iconFile); - const [currentBalance] = await Promise.all([ - validateUserBalance(userId), - validateSymbolUnique(normalizedSymbol) - ]); - - let iconKey: string | null = null; - try { - iconKey = await handleIconUpload(iconFile, normalizedSymbol); - } catch (e) { - console.error('Icon upload failed, continuing without icon:', e); - } - let createdCoin: any; + let iconKey: string | null = null; + await db.transaction(async (tx) => { + const existingCoin = await tx.select().from(coin).where(eq(coin.symbol, normalizedSymbol)).limit(1); + if (existingCoin.length > 0) { + throw error(400, 'A coin with this symbol already exists'); + } + + const [userData] = await tx + .select({ baseCurrencyBalance: user.baseCurrencyBalance }) + .from(user) + .where(eq(user.id, userId)) + .for('update') + .limit(1); + + if (!userData) { + throw error(404, 'User not found'); + } + + const currentBalance = Number(userData.baseCurrencyBalance); + if (currentBalance < TOTAL_COST) { + throw error(400, `Insufficient funds. You need $${TOTAL_COST.toFixed(2)} but only have $${currentBalance.toFixed(2)}.`); + } + await tx.update(user) .set({ baseCurrencyBalance: (currentBalance - TOTAL_COST).toString(), @@ -114,7 +99,7 @@ export async function POST({ request }) { const [newCoin] = await tx.insert(coin).values({ name, symbol: normalizedSymbol, - icon: iconKey, + icon: null, creatorId: userId, initialSupply: FIXED_SUPPLY.toString(), circulatingSupply: FIXED_SUPPLY.toString(), @@ -126,14 +111,27 @@ export async function POST({ request }) { createdCoin = newCoin; - await tx.insert(priceHistory).values({ coinId: newCoin.id, price: STARTING_PRICE.toString() }); - }); + if (iconFile && iconFile.size > 0) { + try { + iconKey = await handleIconUpload(iconFile, normalizedSymbol); + + await db.update(coin) + .set({ icon: iconKey }) + .where(eq(coin.id, createdCoin.id)); + + createdCoin.icon = iconKey; + } catch (e) { + console.error('Icon upload failed after coin creation:', e); + // coin is still created successfully, just without icon + } + } + return json({ success: true, coin: { diff --git a/website/src/routes/api/hopium/questions/create/+server.ts b/website/src/routes/api/hopium/questions/create/+server.ts index 1ec4fd3..66a50f0 100644 --- a/website/src/routes/api/hopium/questions/create/+server.ts +++ b/website/src/routes/api/hopium/questions/create/+server.ts @@ -37,6 +37,7 @@ export const POST: RequestHandler = async ({ request }) => { .select({ baseCurrencyBalance: user.baseCurrencyBalance }) .from(user) .where(eq(user.id, userId)) + .for('update') .limit(1); if (!userData || Number(userData.baseCurrencyBalance) < MIN_BALANCE_REQUIRED) { diff --git a/website/src/routes/api/promo/verify/+server.ts b/website/src/routes/api/promo/verify/+server.ts index f15fb7d..ebe26cb 100644 --- a/website/src/routes/api/promo/verify/+server.ts +++ b/website/src/routes/api/promo/verify/+server.ts @@ -33,6 +33,7 @@ export const POST: RequestHandler = async ({ request }) => { }) .from(promoCode) .where(eq(promoCode.code, normalizedCode)) + .for('update') .limit(1); if (!promoData) { @@ -64,7 +65,8 @@ export const POST: RequestHandler = async ({ request }) => { const [{ totalUses }] = await tx .select({ totalUses: count() }) .from(promoCodeRedemption) - .where(eq(promoCodeRedemption.promoCodeId, promoData.id)); + .where(eq(promoCodeRedemption.promoCodeId, promoData.id)) + .for('update'); if (totalUses >= promoData.maxUses) { return json({ error: 'This promo code has reached its usage limit' }, { status: 400 }); @@ -75,6 +77,7 @@ export const POST: RequestHandler = async ({ request }) => { .select({ baseCurrencyBalance: user.baseCurrencyBalance }) .from(user) .where(eq(user.id, userId)) + .for('update') .limit(1); if (!userData) { diff --git a/website/src/routes/api/rewards/claim/+server.ts b/website/src/routes/api/rewards/claim/+server.ts index bbdc11e..1ae9b38 100644 --- a/website/src/routes/api/rewards/claim/+server.ts +++ b/website/src/routes/api/rewards/claim/+server.ts @@ -57,6 +57,7 @@ export const POST: RequestHandler = async ({ request }) => { }) .from(user) .where(eq(user.id, userId)) + .for('update') .limit(1); if (!userData[0]) { diff --git a/website/src/routes/api/trades/recent/+server.ts b/website/src/routes/api/trades/recent/+server.ts index d7c07b6..7fd00ca 100644 --- a/website/src/routes/api/trades/recent/+server.ts +++ b/website/src/routes/api/trades/recent/+server.ts @@ -6,7 +6,8 @@ import { validateSearchParams } from '$lib/utils/validation'; export async function GET({ url }) { const params = validateSearchParams(url.searchParams); - const limit = params.getPositiveInt('limit', 100); + const requestedLimit = params.getPositiveInt('limit', 100); + const limit = Math.min(requestedLimit, 1000); const minValue = params.getNonNegativeFloat('minValue', 0); try { @@ -46,7 +47,7 @@ export async function GET({ url }) { totalValue: Number(trade.totalValue), price: Number(trade.price), timestamp: trade.timestamp.getTime(), - userId: trade.userId.toString() + userId: trade.userId?.toString() ?? '' })); return json({ trades: formattedTrades });