320 lines
10 KiB
TypeScript
320 lines
10 KiB
TypeScript
import { NextRequest, NextResponse } from 'next/server'
|
|
import { getServerSession } from 'next-auth'
|
|
import { authOptions } from '@/lib/auth'
|
|
import { connectToDatabase } from '@/lib/mongodb'
|
|
import { ObjectId } from 'mongodb'
|
|
import { checkRateLimit } from '@/lib/rate-limit'
|
|
|
|
const DISCORD_WEBHOOK_URL = '';
|
|
const TRADE_ROLE_ID = '1465848690785652900';
|
|
async function sendDiscordPriceAlert(coin: any, trade: any, priceChangePercent: number, oldPrice: number, newPrice: number) {
|
|
try {
|
|
const embed = {
|
|
title: `${coin.name} Price Alert :bangbang:`,
|
|
description: `**[$${coin.ticker}](https://pummmp.fun/coin/${coin._id})** price ${priceChangePercent > 0 ? 'increased' : 'decreased'} by **${Math.abs(priceChangePercent).toFixed(2)}%** ${priceChangePercent > 0 ? '↗' : '↘'}`,
|
|
color: priceChangePercent > 0 ? 0x00ff00 : 0xff0000,
|
|
fields: [
|
|
{
|
|
name: 'New Price <:price:1465842044986724425>',
|
|
value: `\`$${newPrice.toFixed(9)}\``,
|
|
inline: true
|
|
},
|
|
{
|
|
name: 'Market Cap <a:marketcap:1465842943674810626>',
|
|
value: `\`${(coin.marketCap * (newPrice / coin.price)).toLocaleString()} SOL\``,
|
|
inline: true
|
|
},
|
|
{
|
|
name: 'Trader <:trade:1465843050604396738>',
|
|
value: trade.userVerified ? `${trade.username} <:verified:1465841044275859722>` : trade.username,
|
|
inline: false
|
|
},
|
|
{
|
|
name: `Action ${trade.type === 'buy' ? '<:greencandle:1465842982556139590>' : '<:redcandle:1465843012222324767>'}`,
|
|
value: `${trade.type === 'buy' ? 'Bought' : 'Sold'} **${trade.amount.toLocaleString()}** $${coin.ticker}`,
|
|
inline: true
|
|
},
|
|
{
|
|
name: 'Value',
|
|
value: `> **${trade.total.toFixed(4)} SOL**`,
|
|
inline: false
|
|
},
|
|
{
|
|
name: 'Change',
|
|
value: `> \`$${oldPrice.toFixed(9)}\` → \`$${newPrice.toFixed(9)}\``,
|
|
inline: true
|
|
}
|
|
],
|
|
timestamp: new Date().toISOString(),
|
|
footer: {
|
|
text: 'pummmp.fun',
|
|
icon_url: 'https://pummmp.fun/logo.png'
|
|
}
|
|
}
|
|
|
|
await fetch(DISCORD_WEBHOOK_URL, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
content: `<@&${TRADE_ROLE_ID}>`,
|
|
embeds: [embed]
|
|
})
|
|
})
|
|
} catch (error) {
|
|
console.error('[/api/trade] Failed to send Discord webhook:', error)
|
|
}
|
|
}
|
|
|
|
export async function POST(request: NextRequest) {
|
|
try {
|
|
const session = await getServerSession(authOptions)
|
|
|
|
if (!session?.user?.id) {
|
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
}
|
|
|
|
if (checkRateLimit(session.user.id, 'trade', 20, 60000)) {
|
|
return NextResponse.json({ error: 'Rate limit exceeded. Please slow down.' }, { status: 429 })
|
|
}
|
|
|
|
const { db } = await connectToDatabase()
|
|
const body = await request.json()
|
|
const { coinId, type, amount } = body
|
|
|
|
if (!coinId || !type || !amount || amount <= 0) {
|
|
return NextResponse.json({ error: 'Invalid trade parameters' }, { status: 400 })
|
|
}
|
|
|
|
const coin = await db.collection('coins').findOne({ _id: new ObjectId(coinId) })
|
|
if (!coin) {
|
|
return NextResponse.json({ error: 'Coin not found' }, { status: 404 })
|
|
}
|
|
|
|
const user = await db.collection('users').findOne({ _id: new ObjectId(session.user.id) })
|
|
if (!user) {
|
|
return NextResponse.json({ error: 'User not found' }, { status: 404 })
|
|
}
|
|
|
|
// Check trade ban
|
|
if (user.tradeBannedUntil) {
|
|
const banExpiry = new Date(user.tradeBannedUntil);
|
|
const nowCheck = new Date();
|
|
if (banExpiry > nowCheck) {
|
|
const isPermanent = banExpiry.getFullYear && banExpiry.getFullYear() === 9999;
|
|
return NextResponse.json({ error: `You are banned from trading ${isPermanent ? 'permanently' : 'for a while'}` }, { status: 403 })
|
|
} else {
|
|
try { await db.collection('users').updateOne({ _id: user._id }, { $unset: { tradeBannedUntil: 1 } }) } catch (e) {}
|
|
}
|
|
}
|
|
|
|
const vSol = coin.virtualSolReserves || 30;
|
|
const vTokens = coin.virtualTokenReserves || 1073000000;
|
|
const k = vSol * vTokens;
|
|
let solAmount = 0;
|
|
let newPrice = coin.price;
|
|
let newVSol = vSol;
|
|
let newVTokens = vTokens;
|
|
|
|
if (type === 'buy') {
|
|
if (amount >= vTokens - 1) {
|
|
return NextResponse.json({ error: 'Cannot buy entire supply' }, { status: 400 })
|
|
}
|
|
|
|
newVTokens = vTokens - amount;
|
|
newVSol = k / newVTokens;
|
|
solAmount = newVSol - vSol;
|
|
|
|
if (user.balance < solAmount) {
|
|
return NextResponse.json({ error: 'Insufficient balance' }, { status: 400 })
|
|
}
|
|
|
|
newPrice = newVSol / newVTokens;
|
|
const existingPosition = user.portfolio?.find((p: any) => p.coinId === coinId)
|
|
|
|
if (existingPosition) {
|
|
const newAmount = existingPosition.amount + amount
|
|
const newAvgPrice = (existingPosition.amount * existingPosition.avgBuyPrice + solAmount) / newAmount
|
|
|
|
await db.collection('users').updateOne(
|
|
{ _id: new ObjectId(session.user.id), 'portfolio.coinId': coinId },
|
|
{
|
|
$inc: { balance: -solAmount },
|
|
$set: {
|
|
'portfolio.$.amount': newAmount,
|
|
'portfolio.$.avgBuyPrice': newAvgPrice,
|
|
},
|
|
}
|
|
)
|
|
} else {
|
|
await db.collection('users').updateOne(
|
|
{ _id: new ObjectId(session.user.id) },
|
|
{
|
|
$inc: { balance: -solAmount },
|
|
$push: {
|
|
portfolio: {
|
|
coinId,
|
|
amount,
|
|
avgBuyPrice: solAmount / amount,
|
|
},
|
|
} as any,
|
|
}
|
|
)
|
|
}
|
|
|
|
// check for graduation (if virtualSolReserves > 85)
|
|
// real pump.fun is ~85 SOL.
|
|
const GRADUATION_THRESHOLD = 85;
|
|
const shouldGraduate = !coin.graduated && newVSol >= GRADUATION_THRESHOLD;
|
|
|
|
// if graduating, we "lock" a portion of liquidity or just mark it
|
|
// we also notify via socket (handled by client listening to trade)
|
|
|
|
const updateData: any = {
|
|
$set: {
|
|
price: newPrice,
|
|
marketCap: newPrice * coin.supply,
|
|
virtualSolReserves: newVSol,
|
|
virtualTokenReserves: newVTokens,
|
|
},
|
|
$inc: {
|
|
volume24h: solAmount, // volume in SOL
|
|
holders: existingPosition ? 0 : 1,
|
|
liquidity: solAmount, // increase real liquidity
|
|
},
|
|
$push: {
|
|
priceHistory: {
|
|
timestamp: new Date(),
|
|
price: newPrice,
|
|
volume: solAmount,
|
|
},
|
|
} as any,
|
|
}
|
|
|
|
if (shouldGraduate) {
|
|
updateData.$set.graduated = true;
|
|
updateData.$set.graduatedAt = new Date();
|
|
}
|
|
|
|
await db.collection('coins').updateOne(
|
|
{ _id: new ObjectId(coinId) },
|
|
updateData
|
|
)
|
|
|
|
} else if (type === 'sell') {
|
|
const position = user.portfolio?.find((p: any) => p.coinId === coinId)
|
|
|
|
if (!position || position.amount < amount) {
|
|
return NextResponse.json({ error: 'Insufficient tokens' }, { status: 400 })
|
|
}
|
|
|
|
newVTokens = vTokens + amount;
|
|
newVSol = k / newVTokens;
|
|
solAmount = vSol - newVSol; // ? this is what user receives
|
|
|
|
newPrice = newVSol / newVTokens;
|
|
|
|
const newAmount = position.amount - amount
|
|
|
|
if (newAmount === 0) {
|
|
await db.collection('users').updateOne(
|
|
{ _id: new ObjectId(session.user.id) },
|
|
{
|
|
$inc: { balance: solAmount },
|
|
$pull: { portfolio: { coinId } } as any,
|
|
}
|
|
)
|
|
|
|
await db.collection('coins').updateOne(
|
|
{ _id: new ObjectId(coinId) },
|
|
{ $inc: { holders: -1 } }
|
|
)
|
|
} else {
|
|
await db.collection('users').updateOne(
|
|
{ _id: new ObjectId(session.user.id), 'portfolio.coinId': coinId },
|
|
{
|
|
$inc: { balance: solAmount },
|
|
$set: { 'portfolio.$.amount': newAmount },
|
|
}
|
|
)
|
|
}
|
|
|
|
await db.collection('coins').updateOne(
|
|
{ _id: new ObjectId(coinId) },
|
|
{
|
|
$set: {
|
|
price: newPrice,
|
|
marketCap: newPrice * coin.supply,
|
|
virtualSolReserves: newVSol,
|
|
virtualTokenReserves: newVTokens,
|
|
},
|
|
$inc: {
|
|
volume24h: solAmount,
|
|
liquidity: -solAmount, // decrease real liquidity
|
|
},
|
|
$push: {
|
|
priceHistory: {
|
|
timestamp: new Date(),
|
|
price: newPrice,
|
|
volume: solAmount,
|
|
},
|
|
} as any,
|
|
}
|
|
)
|
|
}
|
|
|
|
const updatedCoin = await db.collection('coins').findOne({ _id: new ObjectId(coinId) });
|
|
if (updatedCoin && !updatedCoin.graduated) {
|
|
const GRADUATION_THRESHOLD = 45;
|
|
const currentVirtualSol = updatedCoin.virtualSolReserves || 0;
|
|
const shouldGraduate = currentVirtualSol >= GRADUATION_THRESHOLD;
|
|
|
|
if (shouldGraduate) {
|
|
await db.collection('coins').updateOne(
|
|
{ _id: new ObjectId(coinId) },
|
|
{
|
|
$set: {
|
|
graduated: true,
|
|
graduatedAt: new Date(),
|
|
}
|
|
}
|
|
);
|
|
console.log(`Coin ${coinId} graduated with ${currentVirtualSol} virtual SOL reserves`);
|
|
}
|
|
}
|
|
|
|
// record trade
|
|
const trade = {
|
|
userId: session.user.id,
|
|
username: user.name || 'Anonymous',
|
|
userImage: user.image || '',
|
|
userVerified: !!user.verified,
|
|
userIsAdmin: !!user.isAdmin,
|
|
userIsBetaTester: !!user.isBetaTester,
|
|
userIsBugHunter: !!user.isBugHunter,
|
|
coinId,
|
|
type,
|
|
amount, // ? token amount
|
|
price: newPrice,
|
|
total: solAmount, // ? SOL value
|
|
timestamp: new Date(),
|
|
}
|
|
|
|
const result = await db.collection('trades').insertOne(trade)
|
|
const savedTrade = { ...trade, _id: result.insertedId }
|
|
|
|
const oldPrice = coin.price
|
|
const priceChangePercent = ((newPrice - oldPrice) / oldPrice) * 100
|
|
|
|
// Send alert for price changes > 25%
|
|
if (Math.abs(priceChangePercent) >= 25) {
|
|
await sendDiscordPriceAlert(coin, savedTrade, priceChangePercent, oldPrice, newPrice)
|
|
}
|
|
|
|
return NextResponse.json({ success: true, trade: savedTrade })
|
|
} catch (error) {
|
|
console.error('Error executing trade:', error)
|
|
return NextResponse.json({ error: 'Failed to execute trade' }, { status: 500 })
|
|
}
|
|
}
|