fix: race conditions

This commit is contained in:
Face 2025-05-31 12:56:38 +03:00
parent 5eda7b0953
commit 8d12c679ae
7 changed files with 230 additions and 205 deletions

View file

@ -49,11 +49,18 @@ export async function uploadProfilePicture(
contentType: string,
contentLength?: number
): Promise<string> {
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({
@ -74,10 +81,18 @@ export async function uploadCoinIcon(
contentType: string,
contentLength?: number
): Promise<string> {
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({

View file

@ -58,14 +58,24 @@ 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);
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');
}
return await db.transaction(async (tx) => {
const [coinData] = await tx.select().from(coin).where(eq(coin.symbol, normalizedSymbol)).for('update').limit(1);
if (!coinData) {
throw error(404, 'Coin not found');
@ -75,11 +85,11 @@ export async function POST({ params, request }) {
throw error(400, 'This coin is delisted and cannot be traded');
}
const [userData] = await db.select({
const [userData] = await tx.select({
baseCurrencyBalance: user.baseCurrencyBalance,
username: user.username,
image: user.image
}).from(user).where(eq(user.id, userId)).limit(1);
}).from(user).where(eq(user.id, userId)).for('update').limit(1);
if (!userData) {
throw error(404, 'User not found');
@ -117,7 +127,6 @@ export async function POST({ params, request }) {
throw error(400, 'Trade amount too small - would result in zero tokens');
}
await db.transaction(async (tx) => {
await tx.update(user)
.set({
baseCurrencyBalance: (userBalance - totalCost).toString(),
@ -181,17 +190,15 @@ 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,
@ -206,6 +213,8 @@ export async function POST({ params, request }) {
userId: userId.toString()
};
await redis.publish(`prices:${normalizedSymbol}`, JSON.stringify(priceUpdateData));
await redis.publish('trades:all', JSON.stringify({
type: 'all-trades',
data: tradeData
@ -217,7 +226,6 @@ export async function POST({ params, request }) {
data: tradeData
}));
}
// End REDIS
return json({
success: true,
@ -231,7 +239,7 @@ export async function POST({ params, request }) {
} else {
// AMM SELL: amount = number of coins to sell
const [userHolding] = await db
const [userHolding] = await tx
.select({ quantity: userPortfolio.quantity })
.from(userPortfolio)
.where(and(
@ -245,7 +253,7 @@ export async function POST({ params, request }) {
}
// Allow more aggressive selling for rug pull simulation - prevent only mathematical breakdown
const maxSellable = Math.floor(poolCoinAmount * 0.995); // 99.5% instead of 99%
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`);
}
@ -259,7 +267,6 @@ export async function POST({ params, request }) {
newPrice = newPoolBaseCurrency / newPoolCoin;
priceImpact = ((newPrice - currentPrice) / currentPrice) * 100;
// 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.`);
}
@ -268,7 +275,6 @@ export async function POST({ params, request }) {
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,17 +329,15 @@ 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,
@ -348,6 +352,8 @@ export async function POST({ params, request }) {
userId: userId.toString()
};
await redis.publish(`prices:${normalizedSymbol}`, JSON.stringify(priceUpdateData));
await redis.publish('trades:all', JSON.stringify({
type: 'all-trades',
data: tradeData
@ -359,7 +365,6 @@ export async function POST({ params, request }) {
data: tradeData
}));
}
// End REDIS
return json({
success: true,
@ -371,4 +376,5 @@ export async function POST({ params, request }) {
newBalance: userBalance + totalCost
});
}
});
}

View file

@ -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<string | null> {
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 createdCoin: any;
let iconKey: string | null = null;
try {
iconKey = await handleIconUpload(iconFile, normalizedSymbol);
} catch (e) {
console.error('Icon upload failed, continuing without icon:', e);
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)}.`);
}
let createdCoin: any;
await db.transaction(async (tx) => {
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: {

View file

@ -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) {

View file

@ -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) {

View file

@ -57,6 +57,7 @@ export const POST: RequestHandler = async ({ request }) => {
})
.from(user)
.where(eq(user.id, userId))
.for('update')
.limit(1);
if (!userData[0]) {

View file

@ -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 });