Files
mev-beta/pkg/arbitrage/database.go
2025-09-16 11:05:47 -05:00

410 lines
12 KiB
Go

package arbitrage
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"math/big"
"time"
"github.com/ethereum/go-ethereum/common"
"github.com/fraktal/mev-beta/internal/logger"
_ "github.com/mattn/go-sqlite3"
)
// SQLiteDatabase implements ArbitrageDatabase using SQLite
type SQLiteDatabase struct {
db *sql.DB
logger *logger.Logger
}
// NewSQLiteDatabase creates a new SQLite database for arbitrage data
func NewSQLiteDatabase(dbPath string, logger *logger.Logger) (*SQLiteDatabase, error) {
db, err := sql.Open("sqlite3", dbPath)
if err != nil {
return nil, fmt.Errorf("failed to open database: %w", err)
}
database := &SQLiteDatabase{
db: db,
logger: logger,
}
if err := database.createTables(); err != nil {
return nil, fmt.Errorf("failed to create tables: %w", err)
}
return database, nil
}
// createTables creates the necessary database tables
func (db *SQLiteDatabase) createTables() error {
queries := []string{
`CREATE TABLE IF NOT EXISTS arbitrage_opportunities (
id TEXT PRIMARY KEY,
path_json TEXT NOT NULL,
trigger_event_json TEXT NOT NULL,
detected_at INTEGER NOT NULL,
estimated_profit TEXT NOT NULL,
required_amount TEXT NOT NULL,
urgency INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
created_at INTEGER DEFAULT (strftime('%s', 'now'))
)`,
`CREATE TABLE IF NOT EXISTS arbitrage_executions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
opportunity_id TEXT,
transaction_hash TEXT NOT NULL,
gas_used INTEGER NOT NULL,
gas_price TEXT NOT NULL,
profit_realized TEXT NOT NULL,
success BOOLEAN NOT NULL,
error_message TEXT,
execution_time_ms INTEGER NOT NULL,
path_json TEXT NOT NULL,
executed_at INTEGER DEFAULT (strftime('%s', 'now')),
FOREIGN KEY(opportunity_id) REFERENCES arbitrage_opportunities(id)
)`,
`CREATE TABLE IF NOT EXISTS pool_data (
address TEXT PRIMARY KEY,
token0 TEXT NOT NULL,
token1 TEXT NOT NULL,
protocol TEXT NOT NULL,
fee INTEGER NOT NULL,
liquidity TEXT NOT NULL,
sqrt_price_x96 TEXT NOT NULL,
tick INTEGER,
block_number INTEGER NOT NULL,
transaction_hash TEXT NOT NULL,
log_index INTEGER NOT NULL,
last_updated INTEGER NOT NULL,
created_at INTEGER DEFAULT (strftime('%s', 'now'))
)`,
`CREATE TABLE IF NOT EXISTS swap_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
transaction_hash TEXT NOT NULL,
pool_address TEXT NOT NULL,
token0 TEXT NOT NULL,
token1 TEXT NOT NULL,
amount0 TEXT NOT NULL,
amount1 TEXT NOT NULL,
sqrt_price_x96 TEXT NOT NULL,
liquidity TEXT NOT NULL,
tick INTEGER,
block_number INTEGER NOT NULL,
log_index INTEGER NOT NULL,
timestamp INTEGER NOT NULL,
created_at INTEGER DEFAULT (strftime('%s', 'now'))
)`,
}
for _, query := range queries {
if _, err := db.db.Exec(query); err != nil {
return fmt.Errorf("failed to create table: %w", err)
}
}
// Create indexes for better performance
indexes := []string{
`CREATE INDEX IF NOT EXISTS idx_opportunities_detected_at ON arbitrage_opportunities(detected_at)`,
`CREATE INDEX IF NOT EXISTS idx_opportunities_urgency ON arbitrage_opportunities(urgency)`,
`CREATE INDEX IF NOT EXISTS idx_executions_executed_at ON arbitrage_executions(executed_at)`,
`CREATE INDEX IF NOT EXISTS idx_executions_success ON arbitrage_executions(success)`,
`CREATE INDEX IF NOT EXISTS idx_pool_data_updated ON pool_data(last_updated)`,
`CREATE INDEX IF NOT EXISTS idx_pool_data_tokens ON pool_data(token0, token1)`,
`CREATE INDEX IF NOT EXISTS idx_swap_events_pool ON swap_events(pool_address)`,
`CREATE INDEX IF NOT EXISTS idx_swap_events_block ON swap_events(block_number)`,
`CREATE INDEX IF NOT EXISTS idx_swap_events_timestamp ON swap_events(timestamp)`,
}
for _, index := range indexes {
if _, err := db.db.Exec(index); err != nil {
return fmt.Errorf("failed to create index: %w", err)
}
}
return nil
}
// SaveOpportunity saves an arbitrage opportunity to the database
func (db *SQLiteDatabase) SaveOpportunity(ctx context.Context, opportunity *ArbitrageOpportunity) error {
pathJSON, err := json.Marshal(opportunity.Path)
if err != nil {
return fmt.Errorf("failed to marshal path: %w", err)
}
eventJSON, err := json.Marshal(opportunity.TriggerEvent)
if err != nil {
return fmt.Errorf("failed to marshal trigger event: %w", err)
}
query := `INSERT INTO arbitrage_opportunities
(id, path_json, trigger_event_json, detected_at, estimated_profit, required_amount, urgency, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
_, err = db.db.ExecContext(ctx, query,
opportunity.ID,
string(pathJSON),
string(eventJSON),
opportunity.DetectedAt.Unix(),
opportunity.EstimatedProfit.String(),
opportunity.RequiredAmount.String(),
opportunity.Urgency,
opportunity.ExpiresAt.Unix(),
)
if err != nil {
return fmt.Errorf("failed to save opportunity: %w", err)
}
db.logger.Debug(fmt.Sprintf("Saved arbitrage opportunity %s to database", opportunity.ID))
return nil
}
// SaveExecution saves an arbitrage execution result to the database
func (db *SQLiteDatabase) SaveExecution(ctx context.Context, result *ExecutionResult) error {
pathJSON, err := json.Marshal(result.Path)
if err != nil {
return fmt.Errorf("failed to marshal path: %w", err)
}
var opportunityID *string
if result.Path != nil {
// Try to find the opportunity ID from the path
// This is a simplified approach - in production you'd want better linking
id := generateOpportunityIDFromPath(result.Path)
opportunityID = &id
}
var errorMessage *string
if result.Error != nil {
msg := result.Error.Error()
errorMessage = &msg
}
query := `INSERT INTO arbitrage_executions
(opportunity_id, transaction_hash, gas_used, gas_price, profit_realized, success,
error_message, execution_time_ms, path_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
_, err = db.db.ExecContext(ctx, query,
opportunityID,
result.TransactionHash.Hex(),
result.GasUsed,
result.GasPrice.String(),
result.ProfitRealized.String(),
result.Success,
errorMessage,
result.ExecutionTime.Milliseconds(),
string(pathJSON),
)
if err != nil {
return fmt.Errorf("failed to save execution: %w", err)
}
db.logger.Debug(fmt.Sprintf("Saved arbitrage execution %s to database", result.TransactionHash.Hex()))
return nil
}
// GetExecutionHistory retrieves historical arbitrage executions
func (db *SQLiteDatabase) GetExecutionHistory(ctx context.Context, limit int) ([]*ExecutionResult, error) {
query := `SELECT transaction_hash, gas_used, gas_price, profit_realized, success,
error_message, execution_time_ms, path_json, executed_at
FROM arbitrage_executions
ORDER BY executed_at DESC
LIMIT ?`
rows, err := db.db.QueryContext(ctx, query, limit)
if err != nil {
return nil, fmt.Errorf("failed to query execution history: %w", err)
}
defer rows.Close()
var results []*ExecutionResult
for rows.Next() {
var txHashStr, gasPriceStr, profitStr, pathJSON string
var gasUsed, executionTimeMs, executedAt int64
var success bool
var errorMessage *string
err := rows.Scan(&txHashStr, &gasUsed, &gasPriceStr, &profitStr, &success,
&errorMessage, &executionTimeMs, &pathJSON, &executedAt)
if err != nil {
return nil, fmt.Errorf("failed to scan execution row: %w", err)
}
// Parse path JSON
var path ArbitragePath
if err := json.Unmarshal([]byte(pathJSON), &path); err != nil {
db.logger.Warn(fmt.Sprintf("Failed to unmarshal path JSON: %v", err))
continue
}
result := &ExecutionResult{
TransactionHash: common.HexToHash(txHashStr),
GasUsed: uint64(gasUsed),
Success: success,
ExecutionTime: time.Duration(executionTimeMs) * time.Millisecond,
Path: &path,
}
// Parse gas price and profit
result.GasPrice, _ = parseBigInt(gasPriceStr)
result.ProfitRealized, _ = parseBigInt(profitStr)
// Parse error message
if errorMessage != nil {
result.Error = fmt.Errorf(*errorMessage)
}
results = append(results, result)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("rows iteration error: %w", err)
}
return results, nil
}
// SavePoolData saves pool data to the database
func (db *SQLiteDatabase) SavePoolData(ctx context.Context, poolData *SimplePoolData) error {
query := `INSERT OR REPLACE INTO pool_data
(address, token0, token1, protocol, fee, liquidity, sqrt_price_x96, tick,
block_number, transaction_hash, log_index, last_updated)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
_, err := db.db.ExecContext(ctx, query,
poolData.Address.Hex(),
poolData.Token0.Hex(),
poolData.Token1.Hex(),
"UniswapV3", // Default protocol
poolData.Fee,
poolData.Liquidity.String(),
poolData.SqrtPriceX96.String(),
poolData.Tick,
poolData.BlockNumber,
poolData.TxHash.Hex(),
poolData.LogIndex,
poolData.LastUpdated.Unix(),
)
if err != nil {
return fmt.Errorf("failed to save pool data: %w", err)
}
db.logger.Debug(fmt.Sprintf("Saved pool data %s to database", poolData.Address.Hex()))
return nil
}
// GetPoolData retrieves pool data from the database
func (db *SQLiteDatabase) GetPoolData(ctx context.Context, poolAddress common.Address) (*SimplePoolData, error) {
query := `SELECT address, token0, token1, protocol, fee, liquidity, sqrt_price_x96, tick,
block_number, transaction_hash, log_index, last_updated
FROM pool_data WHERE address = ?`
row := db.db.QueryRowContext(ctx, query, poolAddress.Hex())
var addrStr, token0Str, token1Str, protocol, liquidityStr, sqrtPriceStr, txHashStr string
var fee, tick, blockNumber, logIndex, lastUpdated int64
err := row.Scan(&addrStr, &token0Str, &token1Str, &protocol, &fee,
&liquidityStr, &sqrtPriceStr, &tick, &blockNumber, &txHashStr, &logIndex, &lastUpdated)
if err != nil {
if err == sql.ErrNoRows {
return nil, fmt.Errorf("pool data not found")
}
return nil, fmt.Errorf("failed to query pool data: %w", err)
}
// Parse the data
liquidity, ok1 := parseBigInt(liquidityStr)
sqrtPriceX96, ok2 := parseBigInt(sqrtPriceStr)
if !ok1 || !ok2 {
return nil, fmt.Errorf("failed to parse pool numeric data")
}
poolData := &SimplePoolData{
Address: common.HexToAddress(addrStr),
Token0: common.HexToAddress(token0Str),
Token1: common.HexToAddress(token1Str),
Fee: fee,
Liquidity: liquidity,
SqrtPriceX96: sqrtPriceX96,
Tick: int32(tick),
BlockNumber: uint64(blockNumber),
TxHash: common.HexToHash(txHashStr),
LogIndex: uint(logIndex),
LastUpdated: time.Unix(lastUpdated, 0),
}
return poolData, nil
}
// GetOpportunityCount returns the total number of opportunities detected
func (db *SQLiteDatabase) GetOpportunityCount(ctx context.Context) (int64, error) {
var count int64
err := db.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM arbitrage_opportunities").Scan(&count)
return count, err
}
// GetExecutionCount returns the total number of executions attempted
func (db *SQLiteDatabase) GetExecutionCount(ctx context.Context) (int64, error) {
var count int64
err := db.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM arbitrage_executions").Scan(&count)
return count, err
}
// GetSuccessfulExecutionCount returns the number of successful executions
func (db *SQLiteDatabase) GetSuccessfulExecutionCount(ctx context.Context) (int64, error) {
var count int64
err := db.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM arbitrage_executions WHERE success = 1").Scan(&count)
return count, err
}
// GetTotalProfit returns the total profit realized from all successful executions
func (db *SQLiteDatabase) GetTotalProfit(ctx context.Context) (string, error) {
var totalProfit sql.NullString
err := db.db.QueryRowContext(ctx,
"SELECT SUM(CAST(profit_realized AS REAL)) FROM arbitrage_executions WHERE success = 1").Scan(&totalProfit)
if err != nil {
return "0", err
}
if !totalProfit.Valid {
return "0", nil
}
return totalProfit.String, nil
}
// Close closes the database connection
func (db *SQLiteDatabase) Close() error {
return db.db.Close()
}
// Helper functions
// parseBigInt safely parses a big integer from string
func parseBigInt(s string) (*big.Int, bool) {
result := new(big.Int)
result, ok := result.SetString(s, 10)
return result, ok
}
// generateOpportunityIDFromPath generates a consistent ID from path data
func generateOpportunityIDFromPath(path *ArbitragePath) string {
if path == nil || len(path.Tokens) == 0 {
return fmt.Sprintf("unknown_%d", time.Now().UnixNano())
}
return fmt.Sprintf("path_%s_%d", path.Tokens[0].Hex()[:8], time.Now().UnixNano())
}