Files
pumpfun-bonkfun-bot_github/src/config_loader.py
T
Anton Sauchyk 02343b775b feat(pumpfun): migrate to buy_v2/sell_v2 and support non-SOL quote assets (#176)
Refresh the vendored IDLs from pump-fun/pump-public-docs @ 9c82f61 and move all
pump.fun trading onto the v2 instruction interface. This is required, not
optional: legacy buy/sell cannot trade coins paired against a quote asset other
than SOL, and USDC is already whitelisted in the on-chain Global account.

Protocol changes absorbed:

- buy_v2 (27 accounts) / sell_v2 (26 accounts) replace the legacy instructions.
  Every account is mandatory and the order is identical for all coins, so the
  conditional cashback/mayhem account lists are gone. Legacy remains available
  via PumpFunInstructionBuilder(use_legacy_instructions=True).
- BondingCurve is 151 bytes: virtual_sol_reserves -> virtual_quote_reserves,
  real_sol_reserves -> real_quote_reserves, plus quote_mint at offset 83. Old
  field names are kept as aliases so existing callers keep working.
- v2 instruction data drops the track_volume OptionBool; amounts are in the
  quote mint's raw units rather than always lamports.
- create_v2 carries a non-SOL quote mint as optional remaining accounts 17-19,
  and CreateEvent gained quote_mint, so extreme_fast_mode can resolve the quote
  asset without an extra fetch.

USDC support: new trade.quote_amounts and filters.allowed_quote_mints config,
accepting "sol"/"usdc" aliases or raw mints. Amounts are per-quote-mint because
1 USDC and 1 SOL are not interchangeable. A coin whose quote mint has no
configured amount is skipped rather than traded at the wrong size, so SOL-only
configs are unaffected.

Bug fixes found while verifying:

- The logs and blocks listeners set no websocket max_size, so any frame over
  1 MiB closed the connection with 1009 and the token in it was lost. Raised
  to 32 MiB.
- PumpSwap priced against the raw quote vault balance, ignoring the new
  Pool.virtual_quote_reserves (i128 at offset 245; live pools are 301 bytes).
  Upstream's note that this field is 0 everywhere is out of date: a live pool
  carries 17.58 SOL against a 148 SOL vault, a 10.15% price error.
- The seller read curve state once at confirmed commitment and silently fell
  back to create-time values, risking a stale creator_vault and ConstraintSeeds.
  It now retries at processed, matching the buyer.
- Account cleanup would burn wrapped SOL when force_burn was set, destroying
  value that closing the account returns. WSOL is now closed without burning.
- The mint scripts treated a landed transaction as a successful one, so a
  reverted buy printed as success. They now assert the on-chain result.

Compute unit limits retuned from mainnet measurements: buy 100k -> 180k,
sell 60k -> 120k. Mint-and-buy is no longer atomic, because create_v2 plus
buy_v2 exceeds the 1232-byte transaction limit; both mint scripts send two
transactions.

Adds learning-examples/pump_v2.py as one shared, standalone v2 toolkit for the
example scripts, and three verification scripts: an offline layout check
against the IDL, a no-funds mainnet simulation, and a live listener matrix that
buys, sells and closes the ATA per listener.

Verified on mainnet: all four listeners (geyser, logs, blocks, pumpportal) and
all eight example scripts completed a real buy, sell and ATA close, each
confirmed by reading the transaction result back rather than trusting
confirmation alone. The USDC path is verified structurally only; no USDC-paired
coin could be found on-chain to exercise it.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 17:58:33 +02:00

454 lines
14 KiB
Python

"""
Updated configuration validation with comprehensive platform support.
"""
import os
from pathlib import Path
from typing import Any
import yaml
from dotenv import load_dotenv
from interfaces.core import Platform
# Existing validation rules (keeping all existing ones)
REQUIRED_FIELDS = [
"name",
"rpc_endpoint",
"wss_endpoint",
"private_key",
"trade.buy_amount",
"trade.buy_slippage",
"trade.sell_slippage",
"filters.listener_type",
"filters.max_token_age",
]
CONFIG_VALIDATION_RULES = [
(
"trade.buy_amount",
(int, float),
0,
float("inf"),
"trade.buy_amount must be a positive number",
),
("trade.buy_slippage", float, 0, 1, "trade.buy_slippage must be between 0 and 1"),
("trade.sell_slippage", float, 0, 1, "trade.sell_slippage must be between 0 and 1"),
(
"priority_fees.fixed_amount",
int,
0,
float("inf"),
"priority_fees.fixed_amount must be a non-negative integer",
),
(
"priority_fees.extra_percentage",
float,
0,
1,
"priority_fees.extra_percentage must be between 0 and 1",
),
(
"priority_fees.hard_cap",
int,
0,
float("inf"),
"priority_fees.hard_cap must be a non-negative integer",
),
(
"retries.max_attempts",
int,
0,
100,
"retries.max_attempts must be between 0 and 100",
),
(
"filters.max_token_age",
(int, float),
0,
float("inf"),
"filters.max_token_age must be a non-negative number",
),
]
# Valid values for enum-like fields
VALID_VALUES = {
"filters.listener_type": ["logs", "blocks", "geyser", "pumpportal"],
"cleanup.mode": ["disabled", "on_fail", "after_sell", "post_session"],
"trade.exit_strategy": ["time_based", "tp_sl", "manual"],
"platform": ["pump_fun", "lets_bonk"],
}
# Platform-specific listener compatibility
PLATFORM_LISTENER_COMPATIBILITY = {
Platform.PUMP_FUN: ["logs", "blocks", "geyser", "pumpportal"],
Platform.LETS_BONK: ["blocks", "geyser", "pumpportal"],
}
def load_bot_config(path: str) -> dict:
"""Load and validate a bot configuration from a YAML file."""
config_path = Path(path)
with config_path.open() as f:
config = yaml.safe_load(f)
env_file = config.get("env_file")
if env_file:
config_path = Path(path)
env_path = config_path.parent / env_file
if env_path.exists():
load_dotenv(env_path, override=True)
else:
load_dotenv(env_file, override=True)
resolve_env_vars(config)
# Set default platform if not specified (backward compatibility)
if "platform" not in config:
config["platform"] = "pump_fun"
validate_config(config)
return config
def resolve_env_vars(config: dict) -> None:
"""Recursively resolve environment variables in the configuration."""
def resolve_env(value):
if isinstance(value, str) and value.startswith("${") and value.endswith("}"):
env_var = value[2:-1]
env_value = os.getenv(env_var)
if env_value is None:
raise ValueError(f"Environment variable '{env_var}' not found")
return env_value
return value
def resolve_all(d):
for k, v in d.items():
if isinstance(v, dict):
resolve_all(v)
else:
d[k] = resolve_env(v)
resolve_all(config)
def get_nested_value(config: dict, path: str) -> Any:
"""Get a nested value from the configuration using dot notation."""
keys = path.split(".")
value = config
for key in keys:
if not isinstance(value, dict) or key not in value:
raise ValueError(f"Missing required config key: {path}")
value = value[key]
return value
def validate_config(config: dict) -> None:
"""Validate the configuration against defined rules with platform support."""
# Validate required fields
for field in REQUIRED_FIELDS:
get_nested_value(config, field)
# Validate config rules
for path, expected_type, min_val, max_val, error_msg in CONFIG_VALIDATION_RULES:
try:
value = get_nested_value(config, path)
if not isinstance(value, expected_type):
raise ValueError(f"Type error: {error_msg}")
if isinstance(value, (int, float)) and not (min_val <= value <= max_val):
raise ValueError(f"Range error: {error_msg}")
except ValueError as e:
if str(e).startswith(("Type error:", "Range error:")):
raise
continue
# Validate enum-like fields
for path, valid_values in VALID_VALUES.items():
try:
value = get_nested_value(config, path)
if value not in valid_values:
raise ValueError(f"{path} must be one of {valid_values}")
except ValueError as e:
if "Missing required config key" not in str(e):
raise
# Cannot enable both dynamic and fixed priority fees
try:
dynamic = get_nested_value(config, "priority_fees.enable_dynamic")
fixed = get_nested_value(config, "priority_fees.enable_fixed")
if dynamic and fixed:
raise ValueError(
"Cannot enable both dynamic and fixed priority fees simultaneously"
)
except ValueError as e:
if "Missing required config key" not in str(e):
raise
# Quote-asset configuration must resolve before the bot starts trading,
# otherwise a bad mint alias only surfaces on the first non-SOL coin.
validate_quote_config(config)
# Platform-specific validation
platform_str = config.get("platform", "pump_fun")
try:
platform = Platform(platform_str)
validate_platform_config(config, platform)
except ValueError as e:
if "is not a valid" in str(e):
raise ValueError(
f"Invalid platform '{platform_str}'. Must be one of: {[p.value for p in Platform]}"
)
raise
def validate_quote_config(config: dict) -> None:
"""Validate trade.quote_amounts and filters.allowed_quote_mints.
Args:
config: Loaded bot configuration
Raises:
ValueError: If a quote mint alias/address or amount is invalid
"""
from core.pubkeys import resolve_quote_amounts, resolve_quote_mint
quote_amounts = config.get("trade", {}).get("quote_amounts")
if quote_amounts is not None:
if not isinstance(quote_amounts, dict):
raise ValueError(
"trade.quote_amounts must be a mapping of quote mint to amount"
)
resolve_quote_amounts(quote_amounts)
allowed = config.get("filters", {}).get("allowed_quote_mints")
if allowed is not None:
if not isinstance(allowed, list) or not allowed:
raise ValueError(
"filters.allowed_quote_mints must be a non-empty list of quote mints"
)
for mint in allowed:
resolve_quote_mint(mint)
def validate_platform_config(config: dict, platform: Platform) -> None:
"""Validate platform-specific configuration requirements."""
# Check if platform is supported
try:
from platforms import platform_factory
if not platform_factory.registry.is_platform_supported(platform):
raise ValueError(
f"Platform {platform.value} is not supported. Available platforms: {[p.value for p in platform_factory.get_supported_platforms()]}"
)
except ImportError:
# If platform factory not available, just validate enum
pass
# Validate listener compatibility with platform
try:
listener_type = get_nested_value(config, "filters.listener_type")
compatible_listeners = PLATFORM_LISTENER_COMPATIBILITY.get(platform, [])
if listener_type not in compatible_listeners:
raise ValueError(
f"Listener type '{listener_type}' is not compatible with platform '{platform.value}'. "
f"Compatible listeners: {compatible_listeners}"
)
except ValueError as e:
if "Missing required config key" not in str(e):
raise
# Platform-specific configuration validation
if platform == Platform.PUMP_FUN:
# pump.fun doesn't require additional config beyond base requirements
pass
elif platform == Platform.LETS_BONK:
# LetsBonk may require additional configuration in the future
# For now, it uses the same base configuration as pump.fun
pass
def get_platform_from_config(config: dict) -> Platform:
"""Extract platform enum from configuration."""
platform_str = config.get("platform", "pump_fun")
try:
return Platform(platform_str)
except ValueError:
raise ValueError(
f"Invalid platform '{platform_str}'. Must be one of: {[p.value for p in Platform]}"
)
def validate_platform_listener_combination(
platform: Platform, listener_type: str
) -> bool:
"""Check if a platform and listener type are compatible.
Args:
platform: Platform enum
listener_type: Listener type string
Returns:
True if combination is valid
"""
compatible_listeners = PLATFORM_LISTENER_COMPATIBILITY.get(platform, [])
return listener_type in compatible_listeners
def get_supported_listeners_for_platform(platform: Platform) -> list[str]:
"""Get list of supported listener types for a platform.
Args:
platform: Platform enum
Returns:
List of supported listener types
"""
return PLATFORM_LISTENER_COMPATIBILITY.get(platform, [])
def get_platform_specific_required_config(platform: Platform) -> list[str]:
"""Get platform-specific required configuration paths.
Args:
platform: Platform enum
Returns:
List of additional required config paths for the platform
"""
if platform == Platform.PUMP_FUN:
return [] # No additional requirements
elif platform == Platform.LETS_BONK:
return [] # No additional requirements yet
else:
return []
def print_config_summary(config: dict) -> None:
"""Print a summary of the loaded configuration with platform info."""
platform_str = config.get("platform", "pump_fun")
print(f"Bot name: {config.get('name', 'unnamed')}")
print(f"Platform: {platform_str}")
print(
f"Listener type: {config.get('filters', {}).get('listener_type', 'not configured')}"
)
# Validate platform-listener combination
try:
platform = Platform(platform_str)
listener_type = config.get("filters", {}).get("listener_type")
if listener_type and not validate_platform_listener_combination(
platform, listener_type
):
print(
f"WARNING: Listener '{listener_type}' may not be compatible with platform '{platform_str}'"
)
except ValueError:
print(f"WARNING: Invalid platform '{platform_str}'")
trade = config.get("trade", {})
print("Trade settings:")
print(f" - Buy amount: {trade.get('buy_amount', 'not configured')} SOL")
print(f" - Buy slippage: {trade.get('buy_slippage', 'not configured') * 100}%")
print(
f" - Extreme fast mode: {'enabled' if trade.get('extreme_fast_mode') else 'disabled'}"
)
fees = config.get("priority_fees", {})
print("Priority fees:")
if fees.get("enable_dynamic"):
print(" - Dynamic fees enabled")
elif fees.get("enable_fixed"):
print(
f" - Fixed fee: {fees.get('fixed_amount', 'not configured')} microlamports"
)
print("Configuration loaded successfully!")
def validate_all_platform_configs(config_dir: str = "bots") -> dict[str, Any]:
"""Validate all bot configurations in a directory.
Args:
config_dir: Directory containing bot config files
Returns:
Dictionary with validation results
"""
results = {
"valid_configs": [],
"invalid_configs": [],
"platform_distribution": {},
"listener_distribution": {},
}
config_files = list(Path(config_dir).glob("*.yaml"))
for config_file in config_files:
try:
config = load_bot_config(config_file)
platform = get_platform_from_config(config)
listener_type = config.get("filters", {}).get("listener_type", "unknown")
results["valid_configs"].append(
{
"file": config_file,
"name": config.get("name"),
"platform": platform.value,
"listener": listener_type,
"enabled": config.get("enabled", True),
}
)
# Track distributions
platform_key = platform.value
results["platform_distribution"][platform_key] = (
results["platform_distribution"].get(platform_key, 0) + 1
)
results["listener_distribution"][listener_type] = (
results["listener_distribution"].get(listener_type, 0) + 1
)
except Exception as e:
results["invalid_configs"].append({"file": config_file, "error": str(e)})
return results
if __name__ == "__main__":
# Example usage with platform configuration validation
import sys
if len(sys.argv) > 1:
config_path = sys.argv[1]
try:
config = load_bot_config(config_path)
print_config_summary(config)
platform = get_platform_from_config(config)
print(f"Detected platform: {platform}")
print(
f"Supported listeners for this platform: {get_supported_listeners_for_platform(platform)}"
)
except Exception as e:
print(f"Configuration error: {e}")
else:
# Validate all configs in bots directory
results = validate_all_platform_configs()
print("Configuration validation results:")
print(f"Valid configs: {len(results['valid_configs'])}")
print(f"Invalid configs: {len(results['invalid_configs'])}")
print(f"Platform distribution: {results['platform_distribution']}")
print(f"Listener distribution: {results['listener_distribution']}")
if results["invalid_configs"]:
print("\nInvalid configurations:")
for invalid in results["invalid_configs"]:
print(f" {invalid['file']}: {invalid['error']}")