fix: race conditions
This commit is contained in:
parent
5eda7b0953
commit
8d12c679ae
7 changed files with 230 additions and 205 deletions
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ export const POST: RequestHandler = async ({ request }) => {
|
|||
})
|
||||
.from(user)
|
||||
.where(eq(user.id, userId))
|
||||
.for('update')
|
||||
.limit(1);
|
||||
|
||||
if (!userData[0]) {
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
Reference in a new issue