feat: Add automated profit withdrawal system
- UI page: /withdrawals with stats dashboard and config form - Settings API: GET/POST for .env configuration - Stats API: Real-time profit and withdrawal calculations - Execute API: Safe withdrawal with Drift SDK integration - Drift service: withdrawFromDrift() with USDC spot market (index 0) - Safety checks: Min withdrawal amount, min account balance, profit-only - Telegram notifications: Withdrawal alerts with Solscan links - Dashboard navigation: Added Withdrawals card (3-card grid) User goal: 10% of profits automatically withdrawn on schedule Current: Manual trigger ready, scheduled automation pending Files: 5 new (withdrawals page, 3 APIs, Drift service), 2 modified
This commit is contained in:
100
app/api/withdrawals/execute/route.ts
Normal file
100
app/api/withdrawals/execute/route.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Withdrawal Execution API
|
||||
*
|
||||
* POST: Execute withdrawal of trading profits
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { calculateWithdrawalAmount, withdrawFromDrift } from '@/lib/drift/withdraw'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { sendTelegramNotification } from '@/lib/notifications/telegram'
|
||||
|
||||
const ENV_PATH = path.join(process.cwd(), '.env')
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
console.log('🎯 Manual withdrawal triggered')
|
||||
|
||||
// Calculate withdrawal amount with safety checks
|
||||
const calculation = await calculateWithdrawalAmount()
|
||||
|
||||
if (!calculation.safeToWithdraw) {
|
||||
console.log(`⚠️ Withdrawal blocked: ${calculation.reason}`)
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: calculation.reason,
|
||||
availableProfit: calculation.availableProfit,
|
||||
withdrawalAmount: calculation.withdrawalAmount,
|
||||
})
|
||||
}
|
||||
|
||||
console.log(`✅ Withdrawal approved: $${calculation.withdrawalAmount.toFixed(2)}`)
|
||||
|
||||
// Execute withdrawal
|
||||
const result = await withdrawFromDrift(calculation.withdrawalAmount)
|
||||
|
||||
if (!result.success) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: result.error,
|
||||
}, { status: 500 })
|
||||
}
|
||||
|
||||
// Update LAST_WITHDRAWAL_TIME and TOTAL_WITHDRAWN in .env
|
||||
const currentTotal = parseFloat(process.env.TOTAL_WITHDRAWN || '0')
|
||||
const newTotal = currentTotal + calculation.withdrawalAmount
|
||||
const now = new Date().toISOString()
|
||||
|
||||
let envContent = fs.readFileSync(ENV_PATH, 'utf-8')
|
||||
|
||||
// Update LAST_WITHDRAWAL_TIME
|
||||
const timeRegex = /^LAST_WITHDRAWAL_TIME=.*$/m
|
||||
if (timeRegex.test(envContent)) {
|
||||
envContent = envContent.replace(timeRegex, `LAST_WITHDRAWAL_TIME=${now}`)
|
||||
} else {
|
||||
envContent += `\nLAST_WITHDRAWAL_TIME=${now}`
|
||||
}
|
||||
|
||||
// Update TOTAL_WITHDRAWN
|
||||
const totalRegex = /^TOTAL_WITHDRAWN=.*$/m
|
||||
if (totalRegex.test(envContent)) {
|
||||
envContent = envContent.replace(totalRegex, `TOTAL_WITHDRAWN=${newTotal}`)
|
||||
} else {
|
||||
envContent += `\nTOTAL_WITHDRAWN=${newTotal}`
|
||||
}
|
||||
|
||||
fs.writeFileSync(ENV_PATH, envContent)
|
||||
|
||||
// Send Telegram notification
|
||||
try {
|
||||
await sendTelegramNotification({
|
||||
type: 'withdrawal',
|
||||
amount: calculation.withdrawalAmount,
|
||||
signature: result.signature!,
|
||||
availableProfit: calculation.availableProfit,
|
||||
totalWithdrawn: newTotal,
|
||||
})
|
||||
} catch (telegramError) {
|
||||
console.error('Failed to send Telegram notification:', telegramError)
|
||||
// Don't fail the withdrawal if notification fails
|
||||
}
|
||||
|
||||
console.log(`✅ Withdrawal complete! $${calculation.withdrawalAmount.toFixed(2)} → wallet`)
|
||||
console.log(`📊 Total withdrawn: $${newTotal.toFixed(2)}`)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
amount: calculation.withdrawalAmount,
|
||||
signature: result.signature,
|
||||
totalWithdrawn: newTotal,
|
||||
timestamp: now,
|
||||
})
|
||||
} catch (error: any) {
|
||||
console.error('❌ Withdrawal execution failed:', error)
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: error.message || 'Withdrawal execution failed',
|
||||
}, { status: 500 })
|
||||
}
|
||||
}
|
||||
115
app/api/withdrawals/settings/route.ts
Normal file
115
app/api/withdrawals/settings/route.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Withdrawal Settings API
|
||||
*
|
||||
* GET: Retrieve current withdrawal settings
|
||||
* POST: Update withdrawal settings
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
const ENV_PATH = path.join(process.cwd(), '.env')
|
||||
|
||||
// Default withdrawal settings
|
||||
const DEFAULT_SETTINGS = {
|
||||
ENABLE_AUTO_WITHDRAWALS: false,
|
||||
WITHDRAWAL_INTERVAL_HOURS: 168, // Weekly
|
||||
WITHDRAWAL_PROFIT_PERCENT: 10, // 10% of profits
|
||||
MIN_WITHDRAWAL_AMOUNT: 50, // Minimum $50
|
||||
MIN_ACCOUNT_BALANCE: 500, // Never drop below $500
|
||||
WITHDRAWAL_DESTINATION: process.env.WALLET_PUBLIC_KEY || '',
|
||||
LAST_WITHDRAWAL_TIME: null,
|
||||
TOTAL_WITHDRAWN: 0,
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
// Read from .env file
|
||||
const envContent = fs.readFileSync(ENV_PATH, 'utf-8')
|
||||
const settings = { ...DEFAULT_SETTINGS }
|
||||
|
||||
// Parse each setting
|
||||
const boolMatch = envContent.match(/ENABLE_AUTO_WITHDRAWALS=(true|false)/)
|
||||
if (boolMatch) settings.ENABLE_AUTO_WITHDRAWALS = boolMatch[1] === 'true'
|
||||
|
||||
const intervalMatch = envContent.match(/WITHDRAWAL_INTERVAL_HOURS=(\d+\.?\d*)/)
|
||||
if (intervalMatch) settings.WITHDRAWAL_INTERVAL_HOURS = parseFloat(intervalMatch[1])
|
||||
|
||||
const percentMatch = envContent.match(/WITHDRAWAL_PROFIT_PERCENT=(\d+\.?\d*)/)
|
||||
if (percentMatch) settings.WITHDRAWAL_PROFIT_PERCENT = parseFloat(percentMatch[1])
|
||||
|
||||
const minAmountMatch = envContent.match(/MIN_WITHDRAWAL_AMOUNT=(\d+\.?\d*)/)
|
||||
if (minAmountMatch) settings.MIN_WITHDRAWAL_AMOUNT = parseFloat(minAmountMatch[1])
|
||||
|
||||
const minBalanceMatch = envContent.match(/MIN_ACCOUNT_BALANCE=(\d+\.?\d*)/)
|
||||
if (minBalanceMatch) settings.MIN_ACCOUNT_BALANCE = parseFloat(minBalanceMatch[1])
|
||||
|
||||
const lastWithdrawalMatch = envContent.match(/LAST_WITHDRAWAL_TIME=(.*)/)
|
||||
if (lastWithdrawalMatch && lastWithdrawalMatch[1] !== '') {
|
||||
settings.LAST_WITHDRAWAL_TIME = lastWithdrawalMatch[1]
|
||||
}
|
||||
|
||||
const totalWithdrawnMatch = envContent.match(/TOTAL_WITHDRAWN=(\d+\.?\d*)/)
|
||||
if (totalWithdrawnMatch) settings.TOTAL_WITHDRAWN = parseFloat(totalWithdrawnMatch[1])
|
||||
|
||||
// Get wallet address
|
||||
const walletMatch = envContent.match(/WALLET_PUBLIC_KEY=(.*)/)
|
||||
if (walletMatch) settings.WITHDRAWAL_DESTINATION = walletMatch[1]
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
settings,
|
||||
})
|
||||
} catch (error: any) {
|
||||
console.error('Failed to load withdrawal settings:', error)
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: error.message,
|
||||
settings: DEFAULT_SETTINGS,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const newSettings = await request.json()
|
||||
|
||||
// Read current .env
|
||||
let envContent = fs.readFileSync(ENV_PATH, 'utf-8')
|
||||
|
||||
// Update or add each setting
|
||||
const updates: Record<string, string> = {
|
||||
ENABLE_AUTO_WITHDRAWALS: String(newSettings.ENABLE_AUTO_WITHDRAWALS),
|
||||
WITHDRAWAL_INTERVAL_HOURS: String(newSettings.WITHDRAWAL_INTERVAL_HOURS),
|
||||
WITHDRAWAL_PROFIT_PERCENT: String(newSettings.WITHDRAWAL_PROFIT_PERCENT),
|
||||
MIN_WITHDRAWAL_AMOUNT: String(newSettings.MIN_WITHDRAWAL_AMOUNT),
|
||||
MIN_ACCOUNT_BALANCE: String(newSettings.MIN_ACCOUNT_BALANCE),
|
||||
LAST_WITHDRAWAL_TIME: newSettings.LAST_WITHDRAWAL_TIME || '',
|
||||
TOTAL_WITHDRAWN: String(newSettings.TOTAL_WITHDRAWN || 0),
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
const regex = new RegExp(`^${key}=.*$`, 'm')
|
||||
if (regex.test(envContent)) {
|
||||
envContent = envContent.replace(regex, `${key}=${value}`)
|
||||
} else {
|
||||
envContent += `\n${key}=${value}`
|
||||
}
|
||||
}
|
||||
|
||||
// Write back to .env
|
||||
fs.writeFileSync(ENV_PATH, envContent)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Withdrawal settings saved successfully',
|
||||
})
|
||||
} catch (error: any) {
|
||||
console.error('Failed to save withdrawal settings:', error)
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: error.message,
|
||||
}, { status: 500 })
|
||||
}
|
||||
}
|
||||
83
app/api/withdrawals/stats/route.ts
Normal file
83
app/api/withdrawals/stats/route.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Withdrawal Stats API
|
||||
*
|
||||
* Returns current account statistics for withdrawal calculations
|
||||
*/
|
||||
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getPrismaClient } from '@/lib/database/trades'
|
||||
import { initializeDriftService } from '@/lib/drift/client'
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const prisma = getPrismaClient()
|
||||
|
||||
// Get total invested (from roadmap: $546)
|
||||
const totalInvested = 546
|
||||
|
||||
// Get current Drift balance
|
||||
const driftService = await initializeDriftService()
|
||||
const health = await driftService.getAccountHealth()
|
||||
const currentBalance = parseFloat(health.freeCollateral)
|
||||
|
||||
// Calculate total P&L from database
|
||||
const trades = await prisma.trade.findMany({
|
||||
where: {
|
||||
exitReason: { not: null },
|
||||
},
|
||||
select: {
|
||||
realizedPnL: true,
|
||||
},
|
||||
})
|
||||
|
||||
const totalPnL = trades.reduce((sum, trade) => sum + (trade.realizedPnL || 0), 0)
|
||||
|
||||
// Get total withdrawn from .env
|
||||
const totalWithdrawn = parseFloat(process.env.TOTAL_WITHDRAWN || '0')
|
||||
|
||||
// Calculate available profit
|
||||
const availableProfit = Math.max(0, currentBalance - totalInvested)
|
||||
|
||||
// Calculate next withdrawal amount
|
||||
const withdrawalPercent = parseFloat(process.env.WITHDRAWAL_PROFIT_PERCENT || '10')
|
||||
const nextWithdrawalAmount = availableProfit * (withdrawalPercent / 100)
|
||||
|
||||
// Calculate next withdrawal time
|
||||
let nextWithdrawalTime: string | null = null
|
||||
if (process.env.ENABLE_AUTO_WITHDRAWALS === 'true' && process.env.LAST_WITHDRAWAL_TIME) {
|
||||
const lastWithdrawal = new Date(process.env.LAST_WITHDRAWAL_TIME)
|
||||
const intervalHours = parseFloat(process.env.WITHDRAWAL_INTERVAL_HOURS || '168')
|
||||
const nextTime = new Date(lastWithdrawal.getTime() + intervalHours * 60 * 60 * 1000)
|
||||
nextWithdrawalTime = nextTime.toISOString()
|
||||
} else if (process.env.ENABLE_AUTO_WITHDRAWALS === 'true') {
|
||||
// First withdrawal - schedule from now
|
||||
const intervalHours = parseFloat(process.env.WITHDRAWAL_INTERVAL_HOURS || '168')
|
||||
const nextTime = new Date(Date.now() + intervalHours * 60 * 60 * 1000)
|
||||
nextWithdrawalTime = nextTime.toISOString()
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
currentBalance: parseFloat(currentBalance.toFixed(2)),
|
||||
totalInvested,
|
||||
totalPnL: parseFloat(totalPnL.toFixed(2)),
|
||||
totalWithdrawn,
|
||||
availableProfit: parseFloat(availableProfit.toFixed(2)),
|
||||
nextWithdrawalAmount: parseFloat(nextWithdrawalAmount.toFixed(2)),
|
||||
nextWithdrawalTime,
|
||||
})
|
||||
} catch (error: any) {
|
||||
console.error('Failed to load withdrawal stats:', error)
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: error.message,
|
||||
currentBalance: 0,
|
||||
totalInvested: 546,
|
||||
totalPnL: 0,
|
||||
totalWithdrawn: 0,
|
||||
availableProfit: 0,
|
||||
nextWithdrawalAmount: 0,
|
||||
nextWithdrawalTime: null,
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user