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:
mindesbunister
2025-11-19 18:07:07 +01:00
parent c42bf94c1f
commit ca7b49f745
7 changed files with 962 additions and 1 deletions

View 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 })
}
}

View 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 })
}
}

View 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,
})
}
}