feat: add CoinIcon component for displaying cryptocurrency icons

feat: implement TradeModal for buying and selling coins with validation and transaction handling

feat: create server-side trade API for executing buy/sell transactions and updating user balances

feat: add transactions API to fetch user transaction history

feat: implement portfolio page to display user's holdings and recent transactions
This commit is contained in:
Face 2025-05-23 19:48:23 +03:00
parent 0784e0f3d3
commit a278d0c6a5
13 changed files with 1342 additions and 210 deletions

View file

@ -1,73 +1,196 @@
import { error, json } from '@sveltejs/kit';
import { db } from '$lib/server/db';
import { coin, user, priceHistory } from '$lib/server/db/schema';
import { coin, user, priceHistory, transaction } from '$lib/server/db/schema';
import { eq, desc } from 'drizzle-orm';
export async function GET({ params }) {
const { coinSymbol } = params;
function aggregatePriceHistory(priceData: any[], intervalMinutes: number = 60) {
if (priceData.length === 0) return [];
const sortedData = priceData.sort((a, b) =>
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
);
const intervalMs = intervalMinutes * 60 * 1000;
const candlesticks = new Map();
sortedData.forEach(point => {
const timestamp = new Date(point.timestamp).getTime();
const intervalStart = Math.floor(timestamp / intervalMs) * intervalMs;
if (!candlesticks.has(intervalStart)) {
candlesticks.set(intervalStart, {
time: Math.floor(intervalStart / 1000),
open: point.price,
high: point.price,
low: point.price,
close: point.price,
firstTimestamp: timestamp,
lastTimestamp: timestamp
});
} else {
const candle = candlesticks.get(intervalStart);
candle.high = Math.max(candle.high, point.price);
candle.low = Math.min(candle.low, point.price);
if (timestamp < candle.firstTimestamp) {
candle.open = point.price;
candle.firstTimestamp = timestamp;
}
if (timestamp > candle.lastTimestamp) {
candle.close = point.price;
candle.lastTimestamp = timestamp;
}
}
});
const candleArray = Array.from(candlesticks.values()).sort((a, b) => a.time - b.time);
const fixedCandles = [];
let lastClose = null;
const PRICE_CHANGE_THRESHOLD = 0.01;
for (const candle of candleArray) {
if (lastClose !== null && Math.abs(candle.open - lastClose) > lastClose * PRICE_CHANGE_THRESHOLD) {
candle.open = lastClose;
candle.high = Math.max(candle.high, lastClose);
candle.low = Math.min(candle.low, lastClose);
}
const finalCandle = {
time: candle.time,
open: candle.open,
high: Math.max(candle.open, candle.close, candle.high),
low: Math.min(candle.open, candle.close, candle.low),
close: candle.close
};
fixedCandles.push(finalCandle);
lastClose = finalCandle.close;
}
return fixedCandles;
}
function aggregateVolumeData(transactionData: any[], intervalMinutes: number = 60) {
if (transactionData.length === 0) return [];
const sortedData = transactionData.sort((a, b) =>
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
);
const intervalMs = intervalMinutes * 60 * 1000;
const volumeMap = new Map();
sortedData.forEach(tx => {
const timestamp = new Date(tx.timestamp).getTime();
const intervalStart = Math.floor(timestamp / intervalMs) * intervalMs;
if (!volumeMap.has(intervalStart)) {
volumeMap.set(intervalStart, {
time: Math.floor(intervalStart / 1000),
volume: 0
});
}
const volumePoint = volumeMap.get(intervalStart);
volumePoint.volume += tx.totalBaseCurrencyAmount;
});
return Array.from(volumeMap.values()).sort((a, b) => a.time - b.time);
}
export async function GET({ params, url }) {
const coinSymbol = params.coinSymbol?.toUpperCase();
const timeframe = url.searchParams.get('timeframe') || '1m';
if (!coinSymbol) {
throw error(400, 'Coin symbol is required');
}
const normalizedSymbol = coinSymbol.toUpperCase();
const timeframeMap = {
'1m': 1, '5m': 5, '15m': 15, '1h': 60, '4h': 240, '1d': 1440
} as const;
const intervalMinutes = timeframeMap[timeframe as keyof typeof timeframeMap] || 1;
const [coinData] = await db
.select({
id: coin.id,
name: coin.name,
symbol: coin.symbol,
creatorId: coin.creatorId,
creatorName: user.name,
creatorUsername: user.username,
creatorBio: user.bio,
creatorImage: user.image,
initialSupply: coin.initialSupply,
circulatingSupply: coin.circulatingSupply,
currentPrice: coin.currentPrice,
marketCap: coin.marketCap,
icon: coin.icon,
volume24h: coin.volume24h,
change24h: coin.change24h,
poolCoinAmount: coin.poolCoinAmount,
poolBaseCurrencyAmount: coin.poolBaseCurrencyAmount,
createdAt: coin.createdAt,
isListed: coin.isListed
})
.from(coin)
.leftJoin(user, eq(coin.creatorId, user.id))
.where(eq(coin.symbol, normalizedSymbol))
.limit(1);
try {
const [coinData] = await db
.select({
id: coin.id,
name: coin.name,
symbol: coin.symbol,
icon: coin.icon,
currentPrice: coin.currentPrice,
marketCap: coin.marketCap,
volume24h: coin.volume24h,
change24h: coin.change24h,
poolCoinAmount: coin.poolCoinAmount,
poolBaseCurrencyAmount: coin.poolBaseCurrencyAmount,
circulatingSupply: coin.circulatingSupply,
initialSupply: coin.initialSupply,
isListed: coin.isListed,
createdAt: coin.createdAt,
creatorId: coin.creatorId,
creatorName: user.name,
creatorUsername: user.username,
creatorBio: user.bio
})
.from(coin)
.leftJoin(user, eq(coin.creatorId, user.id))
.where(eq(coin.symbol, coinSymbol))
.limit(1);
if (!coinData) {
throw error(404, 'Coin not found');
}
if (!coinData) {
throw error(404, 'Coin not found');
}
const priceHistoryData = await db
.select({
price: priceHistory.price,
timestamp: priceHistory.timestamp
})
.from(priceHistory)
.where(eq(priceHistory.coinId, coinData.id))
.orderBy(desc(priceHistory.timestamp))
.limit(720);
const [rawPriceHistory, rawTransactions] = await Promise.all([
db.select({ price: priceHistory.price, timestamp: priceHistory.timestamp })
.from(priceHistory)
.where(eq(priceHistory.coinId, coinData.id))
.orderBy(desc(priceHistory.timestamp))
.limit(5000),
return json({
coin: {
...coinData,
currentPrice: Number(coinData.currentPrice),
marketCap: Number(coinData.marketCap),
volume24h: Number(coinData.volume24h || 0),
change24h: Number(coinData.change24h || 0),
initialSupply: Number(coinData.initialSupply),
circulatingSupply: Number(coinData.circulatingSupply),
poolCoinAmount: Number(coinData.poolCoinAmount),
poolBaseCurrencyAmount: Number(coinData.poolBaseCurrencyAmount)
},
priceHistory: priceHistoryData.map(p => ({
db.select({
totalBaseCurrencyAmount: transaction.totalBaseCurrencyAmount,
timestamp: transaction.timestamp
})
.from(transaction)
.where(eq(transaction.coinId, coinData.id))
.orderBy(desc(transaction.timestamp))
.limit(5000)
]);
const priceData = rawPriceHistory.map(p => ({
price: Number(p.price),
timestamp: p.timestamp
}))
});
}));
const transactionData = rawTransactions.map(t => ({
totalBaseCurrencyAmount: Number(t.totalBaseCurrencyAmount),
timestamp: t.timestamp
}));
const candlestickData = aggregatePriceHistory(priceData, intervalMinutes);
const volumeData = aggregateVolumeData(transactionData, intervalMinutes);
return json({
coin: {
...coinData,
currentPrice: Number(coinData.currentPrice),
marketCap: Number(coinData.marketCap),
volume24h: Number(coinData.volume24h),
change24h: Number(coinData.change24h),
poolCoinAmount: Number(coinData.poolCoinAmount),
poolBaseCurrencyAmount: Number(coinData.poolBaseCurrencyAmount),
circulatingSupply: Number(coinData.circulatingSupply),
initialSupply: Number(coinData.initialSupply)
},
candlestickData,
volumeData,
timeframe
});
} catch (e) {
console.error('Error fetching coin data:', e);
throw error(500, 'Failed to fetch coin data');
}
}

View file

@ -0,0 +1,279 @@
import { auth } from '$lib/auth';
import { error, json } from '@sveltejs/kit';
import { db } from '$lib/server/db';
import { coin, userPortfolio, user, transaction, priceHistory } from '$lib/server/db/schema';
import { eq, and, gte } from 'drizzle-orm';
async function calculate24hMetrics(coinId: number, currentPrice: number) {
const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
// Get price from 24h ago
const [priceData] = await db
.select({ price: priceHistory.price })
.from(priceHistory)
.where(and(
eq(priceHistory.coinId, coinId),
gte(priceHistory.timestamp, twentyFourHoursAgo)
))
.orderBy(priceHistory.timestamp)
.limit(1);
// Calculate 24h change
let change24h = 0;
if (priceData) {
const priceFrom24hAgo = Number(priceData.price);
if (priceFrom24hAgo > 0) {
change24h = ((currentPrice - priceFrom24hAgo) / priceFrom24hAgo) * 100;
}
}
// Calculate 24h volume
const volumeData = await db
.select({ totalBaseCurrencyAmount: transaction.totalBaseCurrencyAmount })
.from(transaction)
.where(and(
eq(transaction.coinId, coinId),
gte(transaction.timestamp, twentyFourHoursAgo)
));
const volume24h = volumeData.reduce((sum, tx) => sum + Number(tx.totalBaseCurrencyAmount), 0);
return { change24h: Number(change24h.toFixed(4)), volume24h: Number(volume24h.toFixed(4)) };
}
export async function POST({ params, request }) {
const session = await auth.api.getSession({
headers: request.headers
});
if (!session?.user) {
throw error(401, 'Not authenticated');
}
const { coinSymbol } = params;
const { type, amount } = await request.json();
if (!['BUY', 'SELL'].includes(type)) {
throw error(400, 'Invalid transaction type');
}
if (!amount || amount <= 0) {
throw error(400, 'Invalid amount');
}
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) {
throw error(404, 'Coin not found');
}
if (!coinData.isListed) {
throw error(400, 'This coin is delisted and cannot be traded');
}
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 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;
if (type === 'BUY') {
// Calculate price impact for buying
const k = poolCoinAmount * poolBaseCurrencyAmount;
const newPoolBaseCurrency = poolBaseCurrencyAmount + (amount * currentPrice);
const newPoolCoin = k / newPoolBaseCurrency;
const coinsBought = poolCoinAmount - newPoolCoin;
totalCost = amount * currentPrice;
newPrice = newPoolBaseCurrency / newPoolCoin;
if (userBalance < totalCost) {
throw error(400, `Insufficient funds. You need $${totalCost.toFixed(2)} but only have $${userBalance.toFixed(2)}`);
}
await db.transaction(async (tx) => {
// Update user balance
await tx.update(user)
.set({
baseCurrencyBalance: (userBalance - totalCost).toString(),
updatedAt: new Date()
})
.where(eq(user.id, userId));
// Update user portfolio
const [existingHolding] = await tx
.select({ quantity: userPortfolio.quantity })
.from(userPortfolio)
.where(and(
eq(userPortfolio.userId, userId),
eq(userPortfolio.coinId, coinData.id)
))
.limit(1);
if (existingHolding) {
const newQuantity = Number(existingHolding.quantity) + coinsBought;
await tx.update(userPortfolio)
.set({
quantity: newQuantity.toString(),
updatedAt: new Date()
})
.where(and(
eq(userPortfolio.userId, userId),
eq(userPortfolio.coinId, coinData.id)
));
} else {
await tx.insert(userPortfolio).values({
userId,
coinId: coinData.id,
quantity: coinsBought.toString()
});
}
// Record transaction
await tx.insert(transaction).values({
userId,
coinId: coinData.id,
type: 'BUY',
quantity: coinsBought.toString(),
pricePerCoin: currentPrice.toString(),
totalBaseCurrencyAmount: totalCost.toString()
});
// Record price history
await tx.insert(priceHistory).values({
coinId: coinData.id,
price: newPrice.toString()
});
// Calculate and update 24h metrics
const metrics = await calculate24hMetrics(coinData.id, newPrice);
await tx.update(coin)
.set({
currentPrice: newPrice.toString(),
marketCap: (Number(coinData.circulatingSupply) * newPrice).toString(),
poolCoinAmount: newPoolCoin.toString(),
poolBaseCurrencyAmount: newPoolBaseCurrency.toString(),
change24h: metrics.change24h.toString(),
volume24h: metrics.volume24h.toString(),
updatedAt: new Date()
})
.where(eq(coin.id, coinData.id));
});
return json({
success: true,
type: 'BUY',
coinsBought,
totalCost,
newPrice,
newBalance: userBalance - totalCost
});
} else {
// SELL logic
const [userHolding] = await db
.select({ quantity: userPortfolio.quantity })
.from(userPortfolio)
.where(and(
eq(userPortfolio.userId, userId),
eq(userPortfolio.coinId, coinData.id)
))
.limit(1);
if (!userHolding || Number(userHolding.quantity) < amount) {
throw error(400, `Insufficient coins. You have ${userHolding ? Number(userHolding.quantity) : 0} but trying to sell ${amount}`);
}
// Calculate price impact for selling
const k = poolCoinAmount * poolBaseCurrencyAmount;
const newPoolCoin = poolCoinAmount + amount;
const newPoolBaseCurrency = k / newPoolCoin;
const baseCurrencyReceived = poolBaseCurrencyAmount - newPoolBaseCurrency;
totalCost = baseCurrencyReceived;
newPrice = newPoolBaseCurrency / newPoolCoin;
// Execute sell transaction
await db.transaction(async (tx) => {
// Update user balance
await tx.update(user)
.set({
baseCurrencyBalance: (userBalance + totalCost).toString(),
updatedAt: new Date()
})
.where(eq(user.id, userId));
// Update user portfolio
const newQuantity = Number(userHolding.quantity) - amount;
if (newQuantity > 0) {
await tx.update(userPortfolio)
.set({
quantity: newQuantity.toString(),
updatedAt: new Date()
})
.where(and(
eq(userPortfolio.userId, userId),
eq(userPortfolio.coinId, coinData.id)
));
} else {
await tx.delete(userPortfolio)
.where(and(
eq(userPortfolio.userId, userId),
eq(userPortfolio.coinId, coinData.id)
));
}
// Record transaction
await tx.insert(transaction).values({
userId,
coinId: coinData.id,
type: 'SELL',
quantity: amount.toString(),
pricePerCoin: currentPrice.toString(),
totalBaseCurrencyAmount: totalCost.toString()
});
// Record price history
await tx.insert(priceHistory).values({
coinId: coinData.id,
price: newPrice.toString()
});
// Calculate and update 24h metrics - SINGLE coin table update
const metrics = await calculate24hMetrics(coinData.id, newPrice);
await tx.update(coin)
.set({
currentPrice: newPrice.toString(),
marketCap: (Number(coinData.circulatingSupply) * newPrice).toString(),
poolCoinAmount: newPoolCoin.toString(),
poolBaseCurrencyAmount: newPoolBaseCurrency.toString(),
change24h: metrics.change24h.toString(),
volume24h: metrics.volume24h.toString(),
updatedAt: new Date()
})
.where(eq(coin.id, coinData.id));
});
return json({
success: true,
type: 'SELL',
coinsSold: amount,
totalReceived: totalCost,
newPrice,
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 } from '$lib/server/db/schema';
import { coin, userPortfolio, user, priceHistory, transaction } 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';
@ -125,6 +125,15 @@ export async function POST({ request }) {
coinId: newCoin.id,
price: STARTING_PRICE.toString()
});
await tx.insert(transaction).values({
userId,
coinId: newCoin.id,
type: 'BUY',
quantity: FIXED_SUPPLY.toString(),
pricePerCoin: STARTING_PRICE.toString(),
totalBaseCurrencyAmount: (FIXED_SUPPLY * STARTING_PRICE).toString()
});
});
return json({

View file

@ -1,37 +1,38 @@
import { json } from '@sveltejs/kit';
import { db } from '$lib/server/db';
import { coin } from '$lib/server/db/schema';
import { desc, eq } from 'drizzle-orm';
import { eq, desc } from 'drizzle-orm';
export async function GET() {
const topCoins = await db
.select({
id: coin.id,
name: coin.name,
symbol: coin.symbol,
icon: coin.icon,
currentPrice: coin.currentPrice,
marketCap: coin.marketCap,
volume24h: coin.volume24h,
change24h: coin.change24h,
isListed: coin.isListed
})
.from(coin)
.where(eq(coin.isListed, true))
.orderBy(desc(coin.marketCap))
.limit(20);
try {
const coins = await db
.select({
symbol: coin.symbol,
name: coin.name,
icon: coin.icon,
currentPrice: coin.currentPrice,
change24h: coin.change24h, // Read directly from DB
marketCap: coin.marketCap,
volume24h: coin.volume24h // Read directly from DB
})
.from(coin)
.where(eq(coin.isListed, true))
.orderBy(desc(coin.marketCap))
.limit(50);
return json({
coins: topCoins.map(c => ({
id: c.id,
name: c.name,
const formattedCoins = coins.map(c => ({
symbol: c.symbol,
name: c.name,
icon: c.icon,
price: Number(c.currentPrice),
change24h: Number(c.change24h),
marketCap: Number(c.marketCap),
volume24h: Number(c.volume24h || 0),
change24h: Number(c.change24h || 0),
isListed: c.isListed
}))
});
volume24h: Number(c.volume24h)
}));
return json({ coins: formattedCoins });
} catch (e) {
console.error('Error fetching top coins:', e);
return json({ coins: [] });
}
}

View file

@ -5,9 +5,7 @@ import { user, userPortfolio, coin } from '$lib/server/db/schema';
import { eq } from 'drizzle-orm';
export async function GET({ request }) {
const session = await auth.api.getSession({
headers: request.headers
});
const session = await auth.api.getSession({ headers: request.headers });
if (!session?.user) {
throw error(401, 'Not authenticated');
@ -15,27 +13,30 @@ export async function GET({ request }) {
const userId = Number(session.user.id);
const [userData] = await db
.select({ baseCurrencyBalance: user.baseCurrencyBalance })
.from(user)
.where(eq(user.id, userId))
.limit(1);
const [userData, holdings] = await Promise.all([
db.select({ baseCurrencyBalance: user.baseCurrencyBalance })
.from(user)
.where(eq(user.id, userId))
.limit(1),
if (!userData) {
db.select({
quantity: userPortfolio.quantity,
currentPrice: coin.currentPrice,
symbol: coin.symbol,
icon: coin.icon,
change24h: coin.change24h
})
.from(userPortfolio)
.innerJoin(coin, eq(userPortfolio.coinId, coin.id))
.where(eq(userPortfolio.userId, userId))
]);
if (!userData[0]) {
throw error(404, 'User not found');
}
const holdings = await db
.select({
quantity: userPortfolio.quantity,
currentPrice: coin.currentPrice,
symbol: coin.symbol
})
.from(userPortfolio)
.innerJoin(coin, eq(userPortfolio.coinId, coin.id))
.where(eq(userPortfolio.userId, userId));
let totalCoinValue = 0;
const coinHoldings = holdings.map(holding => {
const quantity = Number(holding.quantity);
const price = Number(holding.currentPrice);
@ -44,13 +45,15 @@ export async function GET({ request }) {
return {
symbol: holding.symbol,
icon: holding.icon,
quantity,
currentPrice: price,
value
value,
change24h: Number(holding.change24h)
};
});
const baseCurrencyBalance = Number(userData.baseCurrencyBalance);
const baseCurrencyBalance = Number(userData[0].baseCurrencyBalance);
return json({
baseCurrencyBalance,

View file

@ -0,0 +1,51 @@
import { auth } from '$lib/auth';
import { error, json } from '@sveltejs/kit';
import { db } from '$lib/server/db';
import { transaction, coin } from '$lib/server/db/schema';
import { eq, desc } from 'drizzle-orm';
export async function GET({ request }) {
const session = await auth.api.getSession({
headers: request.headers
});
if (!session?.user) {
throw error(401, 'Not authenticated');
}
const userId = Number(session.user.id);
const transactions = await db
.select({
id: transaction.id,
type: transaction.type,
quantity: transaction.quantity,
pricePerCoin: transaction.pricePerCoin,
totalBaseCurrencyAmount: transaction.totalBaseCurrencyAmount,
timestamp: transaction.timestamp,
coinSymbol: coin.symbol,
coinName: coin.name,
coinIcon: coin.icon
})
.from(transaction)
.innerJoin(coin, eq(transaction.coinId, coin.id))
.where(eq(transaction.userId, userId))
.orderBy(desc(transaction.timestamp))
.limit(100);
return json({
transactions: transactions.map(t => ({
id: t.id,
type: t.type,
quantity: Number(t.quantity),
pricePerCoin: Number(t.pricePerCoin),
totalBaseCurrencyAmount: Number(t.totalBaseCurrencyAmount),
timestamp: t.timestamp,
coin: {
symbol: t.coinSymbol,
name: t.coinName,
icon: t.coinIcon
}
}))
});
}