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,13 +49,20 @@ export async function uploadProfilePicture(
contentType: string, contentType: string,
contentLength?: number contentLength?: number
): Promise<string> { ): Promise<string> {
let fileExtension = contentType.split('/')[1]; if (!contentType || !contentType.startsWith('image/')) {
// Ensure a valid image extension or default to jpg throw new Error('Invalid file type. Only images are allowed.');
if (!fileExtension || !['jpg', 'jpeg', 'png', 'gif', 'webp'].includes(fileExtension.toLowerCase())) {
fileExtension = 'jpg';
} }
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 key = `avatars/${identifier}.${fileExtension}`;
const command = new PutObjectCommand({ const command = new PutObjectCommand({
Bucket: PUBLIC_B2_BUCKET, Bucket: PUBLIC_B2_BUCKET,
Key: key, Key: key,
@ -74,12 +81,20 @@ export async function uploadCoinIcon(
contentType: string, contentType: string,
contentLength?: number contentLength?: number
): Promise<string> { ): Promise<string> {
let fileExtension = contentType.split('/')[1]; if (!contentType || !contentType.startsWith('image/')) {
if (!fileExtension || !['jpg', 'jpeg', 'png', 'gif', 'webp'].includes(fileExtension.toLowerCase())) { throw new Error('Invalid file type. Only images are allowed.');
fileExtension = 'png';
} }
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 key = `coins/${coinSymbol.toLowerCase()}.${fileExtension}`;
const command = new PutObjectCommand({ const command = new PutObjectCommand({
Bucket: PUBLIC_B2_BUCKET, Bucket: PUBLIC_B2_BUCKET,
Key: key, Key: key,

View file

@ -58,66 +58,75 @@ export async function POST({ params, request }) {
throw error(400, 'Invalid transaction type'); throw error(400, 'Invalid transaction type');
} }
if (!amount || amount <= 0) { if (!amount || typeof amount !== 'number' || !Number.isFinite(amount) || amount <= 0) {
throw error(400, 'Invalid amount'); 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 userId = Number(session.user.id);
const normalizedSymbol = coinSymbol.toUpperCase(); 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) {
if (!coinData) {
throw error(404, 'Coin not found'); throw error(404, 'Coin not found');
} }
if (!coinData.isListed) { return await db.transaction(async (tx) => {
throw error(400, 'This coin is delisted and cannot be traded'); const [coinData] = await tx.select().from(coin).where(eq(coin.symbol, normalizedSymbol)).for('update').limit(1);
}
const [userData] = await db.select({ if (!coinData) {
baseCurrencyBalance: user.baseCurrencyBalance, throw error(404, 'Coin not found');
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 (coinsBought <= 0) { if (!coinData.isListed) {
throw error(400, 'Trade amount too small - would result in zero tokens'); 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) await tx.update(user)
.set({ .set({
baseCurrencyBalance: (userBalance - totalCost).toString(), baseCurrencyBalance: (userBalance - totalCost).toString(),
@ -181,94 +190,91 @@ export async function POST({ params, request }) {
}) })
.where(eq(coin.id, coinData.id)); .where(eq(coin.id, coinData.id));
await redis.publish(`prices:${normalizedSymbol}`, JSON.stringify({ const priceUpdateData = {
currentPrice: newPrice, currentPrice: newPrice,
marketCap: Number(coinData.circulatingSupply) * newPrice, marketCap: Number(coinData.circulatingSupply) * newPrice,
change24h: metrics.change24h, change24h: metrics.change24h,
volume24h: metrics.volume24h, volume24h: metrics.volume24h,
poolCoinAmount: newPoolCoin, poolCoinAmount: newPoolCoin,
poolBaseCurrencyAmount: newPoolBaseCurrency poolBaseCurrencyAmount: newPoolBaseCurrency
})); };
});
// REDIS const tradeData = {
const tradeData = { type: 'BUY',
type: 'BUY', username: userData.username,
username: userData.username, userImage: userData.image || '',
userImage: userData.image || '', amount: coinsBought,
amount: coinsBought, coinSymbol: normalizedSymbol,
coinSymbol: normalizedSymbol, coinName: coinData.name,
coinName: coinData.name, coinIcon: coinData.icon || '',
coinIcon: coinData.icon || '', totalValue: totalCost,
totalValue: totalCost, price: newPrice,
price: newPrice, timestamp: Date.now(),
timestamp: Date.now(), userId: userId.toString()
userId: userId.toString() };
};
await redis.publish('trades:all', JSON.stringify({ await redis.publish(`prices:${normalizedSymbol}`, JSON.stringify(priceUpdateData));
type: 'all-trades',
data: tradeData
}));
if (totalCost >= 1000) { await redis.publish('trades:all', JSON.stringify({
await redis.publish('trades:large', JSON.stringify({ type: 'all-trades',
type: 'live-trade',
data: tradeData data: tradeData
})); }));
}
// End REDIS
return json({ if (totalCost >= 1000) {
success: true, await redis.publish('trades:large', JSON.stringify({
type: 'BUY', type: 'live-trade',
coinsBought, data: tradeData
totalCost, }));
newPrice, }
priceImpact,
newBalance: userBalance - totalCost
});
} else { return json({
// AMM SELL: amount = number of coins to sell success: true,
const [userHolding] = await db type: 'BUY',
.select({ quantity: userPortfolio.quantity }) coinsBought,
.from(userPortfolio) totalCost,
.where(and( newPrice,
eq(userPortfolio.userId, userId), priceImpact,
eq(userPortfolio.coinId, coinData.id) newBalance: userBalance - totalCost
)) });
.limit(1);
if (!userHolding || Number(userHolding.quantity) < amount) { } else {
throw error(400, `Insufficient coins. You have ${userHolding ? Number(userHolding.quantity) : 0} but trying to sell ${amount}`); // 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 if (!userHolding || Number(userHolding.quantity) < amount) {
const maxSellable = Math.floor(poolCoinAmount * 0.995); // 99.5% instead of 99% throw error(400, `Insufficient coins. You have ${userHolding ? Number(userHolding.quantity) : 0} but trying to sell ${amount}`);
if (amount > maxSellable) { }
throw error(400, `Cannot sell more than 99.5% of pool tokens. Max sellable: ${maxSellable} tokens`);
}
const k = poolCoinAmount * poolBaseCurrencyAmount; // Allow more aggressive selling for rug pull simulation - prevent only mathematical breakdown
const newPoolCoin = poolCoinAmount + amount; const maxSellable = Math.floor(poolCoinAmount * 0.995);
const newPoolBaseCurrency = k / newPoolCoin; if (amount > maxSellable) {
const baseCurrencyReceived = poolBaseCurrencyAmount - newPoolBaseCurrency; throw error(400, `Cannot sell more than 99.5% of pool tokens. Max sellable: ${maxSellable} tokens`);
}
totalCost = baseCurrencyReceived; const k = poolCoinAmount * poolBaseCurrencyAmount;
newPrice = newPoolBaseCurrency / newPoolCoin; const newPoolCoin = poolCoinAmount + amount;
priceImpact = ((newPrice - currentPrice) / currentPrice) * 100; const newPoolBaseCurrency = k / newPoolCoin;
const baseCurrencyReceived = poolBaseCurrencyAmount - newPoolBaseCurrency;
// Lower minimum liquidity for more dramatic crashes totalCost = baseCurrencyReceived;
if (newPoolBaseCurrency < 10) { newPrice = newPoolBaseCurrency / newPoolCoin;
throw error(400, `Trade would drain pool below minimum liquidity (*10 BUSS). Try selling fewer tokens.`); priceImpact = ((newPrice - currentPrice) / currentPrice) * 100;
}
if (totalCost <= 0) { if (newPoolBaseCurrency < 10) {
throw error(400, 'Trade amount results in zero base currency received'); 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) await tx.update(user)
.set({ .set({
baseCurrencyBalance: (userBalance + totalCost).toString(), baseCurrencyBalance: (userBalance + totalCost).toString(),
@ -323,52 +329,52 @@ export async function POST({ params, request }) {
}) })
.where(eq(coin.id, coinData.id)); .where(eq(coin.id, coinData.id));
await redis.publish(`prices:${normalizedSymbol}`, JSON.stringify({ const priceUpdateData = {
currentPrice: newPrice, currentPrice: newPrice,
marketCap: Number(coinData.circulatingSupply) * newPrice, marketCap: Number(coinData.circulatingSupply) * newPrice,
change24h: metrics.change24h, change24h: metrics.change24h,
volume24h: metrics.volume24h, volume24h: metrics.volume24h,
poolCoinAmount: newPoolCoin, poolCoinAmount: newPoolCoin,
poolBaseCurrencyAmount: newPoolBaseCurrency poolBaseCurrencyAmount: newPoolBaseCurrency
})); };
});
// REDIS const tradeData = {
const tradeData = { type: 'SELL',
type: 'SELL', username: userData.username,
username: userData.username, userImage: userData.image || '',
userImage: userData.image || '', amount: amount,
amount: amount, coinSymbol: normalizedSymbol,
coinSymbol: normalizedSymbol, coinName: coinData.name,
coinName: coinData.name, coinIcon: coinData.icon || '',
coinIcon: coinData.icon || '', totalValue: totalCost,
totalValue: totalCost, price: newPrice,
price: newPrice, timestamp: Date.now(),
timestamp: Date.now(), userId: userId.toString()
userId: userId.toString() };
};
await redis.publish('trades:all', JSON.stringify({ await redis.publish(`prices:${normalizedSymbol}`, JSON.stringify(priceUpdateData));
type: 'all-trades',
data: tradeData
}));
if (totalCost >= 1000) { await redis.publish('trades:all', JSON.stringify({
await redis.publish('trades:large', JSON.stringify({ type: 'all-trades',
type: 'live-trade',
data: tradeData data: tradeData
})); }));
}
// End REDIS
return json({ if (totalCost >= 1000) {
success: true, await redis.publish('trades:large', JSON.stringify({
type: 'SELL', type: 'live-trade',
coinsSold: amount, data: tradeData
totalReceived: totalCost, }));
newPrice, }
priceImpact,
newBalance: userBalance + totalCost return json({
}); success: true,
} type: 'SELL',
coinsSold: amount,
totalReceived: totalCost,
newPrice,
priceImpact,
newBalance: userBalance + totalCost
});
}
});
} }

View file

@ -1,7 +1,7 @@
import { auth } from '$lib/auth'; import { auth } from '$lib/auth';
import { error, json } from '@sveltejs/kit'; import { error, json } from '@sveltejs/kit';
import { db } from '$lib/server/db'; 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 { eq } from 'drizzle-orm';
import { uploadCoinIcon } from '$lib/server/s3'; import { uploadCoinIcon } from '$lib/server/s3';
import { CREATION_FEE, FIXED_SUPPLY, STARTING_PRICE, INITIAL_LIQUIDITY, TOTAL_COST, MAX_FILE_SIZE } from '$lib/data/constants'; 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> { async function handleIconUpload(iconFile: File | null, symbol: string): Promise<string | null> {
if (!iconFile || iconFile.size === 0) { if (!iconFile || iconFile.size === 0) {
return null; return null;
@ -90,20 +64,31 @@ export async function POST({ request }) {
await validateInputs(name, normalizedSymbol, iconFile); 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 createdCoin: any;
let iconKey: string | null = null;
await db.transaction(async (tx) => { 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) await tx.update(user)
.set({ .set({
baseCurrencyBalance: (currentBalance - TOTAL_COST).toString(), baseCurrencyBalance: (currentBalance - TOTAL_COST).toString(),
@ -114,7 +99,7 @@ export async function POST({ request }) {
const [newCoin] = await tx.insert(coin).values({ const [newCoin] = await tx.insert(coin).values({
name, name,
symbol: normalizedSymbol, symbol: normalizedSymbol,
icon: iconKey, icon: null,
creatorId: userId, creatorId: userId,
initialSupply: FIXED_SUPPLY.toString(), initialSupply: FIXED_SUPPLY.toString(),
circulatingSupply: FIXED_SUPPLY.toString(), circulatingSupply: FIXED_SUPPLY.toString(),
@ -126,14 +111,27 @@ export async function POST({ request }) {
createdCoin = newCoin; createdCoin = newCoin;
await tx.insert(priceHistory).values({ await tx.insert(priceHistory).values({
coinId: newCoin.id, coinId: newCoin.id,
price: STARTING_PRICE.toString() 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({ return json({
success: true, success: true,
coin: { coin: {

View file

@ -37,6 +37,7 @@ export const POST: RequestHandler = async ({ request }) => {
.select({ baseCurrencyBalance: user.baseCurrencyBalance }) .select({ baseCurrencyBalance: user.baseCurrencyBalance })
.from(user) .from(user)
.where(eq(user.id, userId)) .where(eq(user.id, userId))
.for('update')
.limit(1); .limit(1);
if (!userData || Number(userData.baseCurrencyBalance) < MIN_BALANCE_REQUIRED) { if (!userData || Number(userData.baseCurrencyBalance) < MIN_BALANCE_REQUIRED) {

View file

@ -33,6 +33,7 @@ export const POST: RequestHandler = async ({ request }) => {
}) })
.from(promoCode) .from(promoCode)
.where(eq(promoCode.code, normalizedCode)) .where(eq(promoCode.code, normalizedCode))
.for('update')
.limit(1); .limit(1);
if (!promoData) { if (!promoData) {
@ -64,7 +65,8 @@ export const POST: RequestHandler = async ({ request }) => {
const [{ totalUses }] = await tx const [{ totalUses }] = await tx
.select({ totalUses: count() }) .select({ totalUses: count() })
.from(promoCodeRedemption) .from(promoCodeRedemption)
.where(eq(promoCodeRedemption.promoCodeId, promoData.id)); .where(eq(promoCodeRedemption.promoCodeId, promoData.id))
.for('update');
if (totalUses >= promoData.maxUses) { if (totalUses >= promoData.maxUses) {
return json({ error: 'This promo code has reached its usage limit' }, { status: 400 }); 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 }) .select({ baseCurrencyBalance: user.baseCurrencyBalance })
.from(user) .from(user)
.where(eq(user.id, userId)) .where(eq(user.id, userId))
.for('update')
.limit(1); .limit(1);
if (!userData) { if (!userData) {

View file

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

View file

@ -6,7 +6,8 @@ import { validateSearchParams } from '$lib/utils/validation';
export async function GET({ url }) { export async function GET({ url }) {
const params = validateSearchParams(url.searchParams); 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); const minValue = params.getNonNegativeFloat('minValue', 0);
try { try {
@ -46,7 +47,7 @@ export async function GET({ url }) {
totalValue: Number(trade.totalValue), totalValue: Number(trade.totalValue),
price: Number(trade.price), price: Number(trade.price),
timestamp: trade.timestamp.getTime(), timestamp: trade.timestamp.getTime(),
userId: trade.userId.toString() userId: trade.userId?.toString() ?? ''
})); }));
return json({ trades: formattedTrades }); return json({ trades: formattedTrades });