fix: race conditions
This commit is contained in:
parent
5eda7b0953
commit
8d12c679ae
7 changed files with 230 additions and 205 deletions
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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: {
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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]) {
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
|
|
|
||||||
Reference in a new issue