docs(claude): add claude code rules

This commit is contained in:
smypmsa
2025-08-11 05:35:25 +00:00
parent f2efd56f81
commit 8ab8932168
56 changed files with 4193 additions and 2605 deletions
+41 -28
View File
@@ -46,8 +46,11 @@ async def start_bot(config_path: str):
# Validate platform support
try:
from platforms import platform_factory
if not platform_factory.registry.is_platform_supported(platform):
logging.error(f"Platform {platform.value} is not supported. Available platforms: {[p.value for p in platform_factory.get_supported_platforms()]}")
logging.error(
f"Platform {platform.value} is not supported. Available platforms: {[p.value for p in platform_factory.get_supported_platforms()]}"
)
return
except Exception as e:
logging.exception(f"Could not validate platform support: {e}")
@@ -57,8 +60,11 @@ async def start_bot(config_path: str):
listener_type = cfg["filters"]["listener_type"]
if not validate_platform_listener_combination(platform, listener_type):
from config_loader import get_supported_listeners_for_platform
supported = get_supported_listeners_for_platform(platform)
logging.error(f"Listener '{listener_type}' is not compatible with platform '{platform.value}'. Supported listeners: {supported}")
logging.error(
f"Listener '{listener_type}' is not compatible with platform '{platform.value}'. Supported listeners: {supported}"
)
return
# Initialize universal trader with platform-specific configuration
@@ -68,39 +74,31 @@ async def start_bot(config_path: str):
rpc_endpoint=cfg["rpc_endpoint"],
wss_endpoint=cfg["wss_endpoint"],
private_key=cfg["private_key"],
# Platform configuration - pass platform enum directly
platform=platform,
# Trade parameters
buy_amount=cfg["trade"]["buy_amount"],
buy_slippage=cfg["trade"]["buy_slippage"],
sell_slippage=cfg["trade"]["sell_slippage"],
# Extreme fast mode settings
extreme_fast_mode=cfg["trade"].get("extreme_fast_mode", False),
extreme_fast_token_amount=cfg["trade"].get("extreme_fast_token_amount", 30),
# Exit strategy configuration
exit_strategy=cfg["trade"].get("exit_strategy", "time_based"),
take_profit_percentage=cfg["trade"].get("take_profit_percentage"),
stop_loss_percentage=cfg["trade"].get("stop_loss_percentage"),
max_hold_time=cfg["trade"].get("max_hold_time"),
price_check_interval=cfg["trade"].get("price_check_interval", 10),
# Listener configuration
listener_type=cfg["filters"]["listener_type"],
# Geyser configuration (if applicable)
geyser_endpoint=cfg.get("geyser", {}).get("endpoint"),
geyser_api_token=cfg.get("geyser", {}).get("api_token"),
geyser_auth_type=cfg.get("geyser", {}).get("auth_type", "x-token"),
# PumpPortal configuration (if applicable)
pumpportal_url=cfg.get("pumpportal", {}).get(
"url", "wss://pumpportal.fun/api/data"
),
# Priority fee configuration
enable_dynamic_priority_fee=cfg.get("priority_fees", {}).get(
"enable_dynamic", False
@@ -109,19 +107,21 @@ async def start_bot(config_path: str):
"enable_fixed", True
),
fixed_priority_fee=cfg.get("priority_fees", {}).get("fixed_amount", 500000),
extra_priority_fee=cfg.get("priority_fees", {}).get("extra_percentage", 0.0),
extra_priority_fee=cfg.get("priority_fees", {}).get(
"extra_percentage", 0.0
),
hard_cap_prior_fee=cfg.get("priority_fees", {}).get("hard_cap", 500000),
# Retry and timeout settings
max_retries=cfg.get("retries", {}).get("max_attempts", 10),
wait_time_after_creation=cfg.get("retries", {}).get("wait_after_creation", 15),
wait_time_after_creation=cfg.get("retries", {}).get(
"wait_after_creation", 15
),
wait_time_after_buy=cfg.get("retries", {}).get("wait_after_buy", 15),
wait_time_before_new_token=cfg.get("retries", {}).get(
"wait_before_new_token", 15
),
max_token_age=cfg.get("filters", {}).get("max_token_age", 0.001),
token_wait_timeout=cfg.get("timing", {}).get("token_wait_timeout", 120),
# Cleanup settings
cleanup_mode=cfg.get("cleanup", {}).get("mode", "disabled"),
cleanup_force_close_with_burn=cfg.get("cleanup", {}).get(
@@ -130,7 +130,6 @@ async def start_bot(config_path: str):
cleanup_with_priority_fee=cfg.get("cleanup", {}).get(
"with_priority_fee", False
),
# Trading filters
match_string=cfg["filters"].get("match_string"),
bro_address=cfg["filters"].get("bro_address"),
@@ -139,7 +138,7 @@ async def start_bot(config_path: str):
)
await trader.start()
except Exception as e:
logging.exception(f"Failed to initialize or start trader: {e}")
raise
@@ -180,40 +179,52 @@ def run_all_bots():
# Validate platform configuration
try:
platform = get_platform_from_config(cfg)
# Check platform support
from platforms import platform_factory
if not platform_factory.registry.is_platform_supported(platform):
logging.error(f"Platform {platform.value} is not supported for bot '{bot_name}'. Available platforms: {[p.value for p in platform_factory.get_supported_platforms()]}")
logging.error(
f"Platform {platform.value} is not supported for bot '{bot_name}'. Available platforms: {[p.value for p in platform_factory.get_supported_platforms()]}"
)
skipped_bots += 1
continue
# Validate listener compatibility
listener_type = cfg["filters"]["listener_type"]
if not validate_platform_listener_combination(platform, listener_type):
from config_loader import get_supported_listeners_for_platform
supported = get_supported_listeners_for_platform(platform)
logging.error(f"Listener '{listener_type}' is not compatible with platform '{platform.value}' for bot '{bot_name}'. Supported listeners: {supported}")
logging.error(
f"Listener '{listener_type}' is not compatible with platform '{platform.value}' for bot '{bot_name}'. Supported listeners: {supported}"
)
skipped_bots += 1
continue
except Exception as e:
logging.exception(f"Invalid platform configuration for bot '{bot_name}': {e}. Skipping...")
logging.exception(
f"Invalid platform configuration for bot '{bot_name}': {e}. Skipping..."
)
skipped_bots += 1
continue
# Start bot in separate process or main process
if cfg.get("separate_process", False):
logging.info(f"Starting bot '{bot_name}' ({platform.value}) in separate process")
logging.info(
f"Starting bot '{bot_name}' ({platform.value}) in separate process"
)
p = multiprocessing.Process(
target=run_bot_process, args=(str(file),), name=f"bot-{bot_name}"
)
p.start()
processes.append(p)
else:
logging.info(f"Starting bot '{bot_name}' ({platform.value}) in main process")
logging.info(
f"Starting bot '{bot_name}' ({platform.value}) in main process"
)
asyncio.run(start_bot(str(file)))
except Exception as e:
logging.exception(f"Failed to start bot from {file}: {e}")
skipped_bots += 1
@@ -237,15 +248,17 @@ def main() -> None:
# Log supported platforms and listeners
try:
from platforms import platform_factory
supported_platforms = platform_factory.get_supported_platforms()
logging.info(f"Supported platforms: {[p.value for p in supported_platforms]}")
# Log listener compatibility for each platform
from config_loader import get_supported_listeners_for_platform
for platform in supported_platforms:
listeners = get_supported_listeners_for_platform(platform)
logging.info(f"Platform {platform.value} supports listeners: {listeners}")
except Exception as e:
logging.warning(f"Could not load platform information: {e}")
@@ -253,4 +266,4 @@ def main() -> None:
if __name__ == "__main__":
main()
main()
+120 -57
View File
@@ -25,14 +25,50 @@ REQUIRED_FIELDS = [
]
CONFIG_VALIDATION_RULES = [
("trade.buy_amount", (int, float), 0, float("inf"), "trade.buy_amount must be a positive number"),
(
"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"),
(
"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
@@ -66,17 +102,18 @@ def load_bot_config(path: str) -> dict:
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]
@@ -144,7 +181,9 @@ def validate_config(config: dict) -> None:
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")
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
@@ -156,7 +195,9 @@ def validate_config(config: dict) -> None:
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 ValueError(
f"Invalid platform '{platform_str}'. Must be one of: {[p.value for p in Platform]}"
)
raise
@@ -165,8 +206,11 @@ def validate_platform_config(config: dict, platform: Platform) -> None:
# 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()]}")
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
@@ -175,7 +219,7 @@ def validate_platform_config(config: dict, platform: Platform) -> None:
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}'. "
@@ -189,7 +233,7 @@ def validate_platform_config(config: dict, platform: Platform) -> None:
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
@@ -202,16 +246,20 @@ def get_platform_from_config(config: dict) -> Platform:
try:
return Platform(platform_str)
except ValueError:
raise ValueError(f"Invalid platform '{platform_str}'. Must be one of: {[p.value for p in Platform]}")
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:
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
"""
@@ -221,10 +269,10 @@ def validate_platform_listener_combination(platform: Platform, listener_type: st
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
"""
@@ -233,10 +281,10 @@ def get_supported_listeners_for_platform(platform: Platform) -> list[str]:
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
"""
@@ -251,17 +299,23 @@ def get_platform_specific_required_config(platform: Platform) -> list[str]:
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')}")
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}'")
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}'")
@@ -269,24 +323,28 @@ def print_config_summary(config: dict) -> None:
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'}")
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(
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
"""
@@ -296,50 +354,55 @@ def validate_all_platform_configs(config_dir: str = "bots") -> dict[str, Any]:
"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)
})
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
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)
})
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)}")
print(
f"Supported listeners for this platform: {get_supported_listeners_for_platform(platform)}"
)
except Exception as e:
print(f"Configuration error: {e}")
else:
@@ -350,8 +413,8 @@ if __name__ == "__main__":
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']:
if results["invalid_configs"]:
print("\nInvalid configurations:")
for invalid in results['invalid_configs']:
print(f" {invalid['file']}: {invalid['error']}")
for invalid in results["invalid_configs"]:
print(f" {invalid['file']}: {invalid['error']}")
+8 -8
View File
@@ -14,7 +14,9 @@ TOKEN_DECIMALS: Final[int] = 6
# Token account constants
TOKEN_ACCOUNT_SIZE: Final[int] = 165 # Size of a token account in bytes
TOKEN_ACCOUNT_RENT_EXEMPT_RESERVE: Final[int] = 2_039_280 # Rent-exempt minimum for token accounts
TOKEN_ACCOUNT_RENT_EXEMPT_RESERVE: Final[int] = (
2_039_280 # Rent-exempt minimum for token accounts
)
# Core system programs
SYSTEM_PROGRAM: Final[Pubkey] = Pubkey.from_string("11111111111111111111111111111111")
@@ -26,9 +28,7 @@ ASSOCIATED_TOKEN_PROGRAM: Final[Pubkey] = Pubkey.from_string(
)
# System accounts
RENT: Final[Pubkey] = Pubkey.from_string(
"SysvarRent111111111111111111111111111111111"
)
RENT: Final[Pubkey] = Pubkey.from_string("SysvarRent111111111111111111111111111111111")
# Native SOL token
SOL_MINT: Final[Pubkey] = Pubkey.from_string(
@@ -38,18 +38,18 @@ SOL_MINT: Final[Pubkey] = Pubkey.from_string(
class SystemAddresses:
"""System-level Solana addresses shared across all platforms."""
# Reference the module-level constants
SYSTEM_PROGRAM = SYSTEM_PROGRAM
TOKEN_PROGRAM = TOKEN_PROGRAM
ASSOCIATED_TOKEN_PROGRAM = ASSOCIATED_TOKEN_PROGRAM
RENT = RENT
SOL_MINT = SOL_MINT
@classmethod
def get_all_system_addresses(cls) -> dict[str, Pubkey]:
"""Get all system addresses as a dictionary.
Returns:
Dictionary mapping address names to Pubkey objects
"""
@@ -59,4 +59,4 @@ class SystemAddresses:
"associated_token_program": cls.ASSOCIATED_TOKEN_PROGRAM,
"rent": cls.RENT,
"sol_mint": cls.SOL_MINT,
}
}
+78 -91
View File
@@ -16,6 +16,7 @@ from solders.pubkey import Pubkey
class Platform(Enum):
"""Supported trading platforms."""
PUMP_FUN = "pump_fun"
LETS_BONK = "lets_bonk"
@@ -23,12 +24,13 @@ class Platform(Enum):
@dataclass
class TokenInfo:
"""Enhanced token information with platform support."""
# Core token data
name: str
symbol: str
uri: str
mint: Pubkey
# Platform-specific fields
platform: Platform
bonding_curve: Pubkey | None = None # pump.fun specific
@@ -36,12 +38,12 @@ class TokenInfo:
pool_state: Pubkey | None = None # LetsBonk specific
base_vault: Pubkey | None = None # LetsBonk specific
quote_vault: Pubkey | None = None # LetsBonk specific
# Common fields
user: Pubkey | None = None
creator: Pubkey | None = None
creator_vault: Pubkey | None = None
# Metadata
creation_timestamp: float | None = None
additional_data: dict[str, Any] | None = None
@@ -49,61 +51,63 @@ class TokenInfo:
class AddressProvider(ABC):
"""Abstract interface for platform-specific address management."""
@property
@abstractmethod
def platform(self) -> Platform:
"""Get the platform this provider serves."""
pass
@property
@abstractmethod
def program_id(self) -> Pubkey:
"""Get the main program ID for this platform."""
pass
@abstractmethod
def get_system_addresses(self) -> dict[str, Pubkey]:
"""Get all system addresses required for this platform.
Returns:
Dictionary mapping address names to Pubkey objects
"""
pass
@abstractmethod
def derive_pool_address(self, base_mint: Pubkey, quote_mint: Pubkey | None = None) -> Pubkey:
def derive_pool_address(
self, base_mint: Pubkey, quote_mint: Pubkey | None = None
) -> Pubkey:
"""Derive the pool/curve address for trading pair.
Args:
base_mint: Base token mint address
quote_mint: Quote token mint address (if applicable)
Returns:
Pool/curve address for the trading pair
"""
pass
@abstractmethod
def derive_user_token_account(self, user: Pubkey, mint: Pubkey) -> Pubkey:
"""Derive user's token account address.
Args:
user: User's wallet address
mint: Token mint address
Returns:
User's token account address
"""
pass
@abstractmethod
def get_additional_accounts(self, token_info: TokenInfo) -> dict[str, Pubkey]:
"""Get platform-specific additional accounts needed for trading.
Args:
token_info: Token information
Returns:
Dictionary of additional account addresses
"""
@@ -112,13 +116,13 @@ class AddressProvider(ABC):
class InstructionBuilder(ABC):
"""Abstract interface for building platform-specific trading instructions."""
@property
@abstractmethod
def platform(self) -> Platform:
"""Get the platform this builder serves."""
pass
@abstractmethod
async def build_buy_instruction(
self,
@@ -126,22 +130,22 @@ class InstructionBuilder(ABC):
user: Pubkey,
amount_in: int,
minimum_amount_out: int,
address_provider: AddressProvider
address_provider: AddressProvider,
) -> list[Instruction]:
"""Build buy instruction(s) for the platform.
Args:
token_info: Token information
user: User's wallet address
amount_in: Amount of quote tokens to spend
minimum_amount_out: Minimum base tokens expected
address_provider: Platform address provider
Returns:
List of instructions needed for the buy operation
"""
pass
@abstractmethod
async def build_sell_instruction(
self,
@@ -149,55 +153,49 @@ class InstructionBuilder(ABC):
user: Pubkey,
amount_in: int,
minimum_amount_out: int,
address_provider: AddressProvider
address_provider: AddressProvider,
) -> list[Instruction]:
"""Build sell instruction(s) for the platform.
Args:
token_info: Token information
user: User's wallet address
amount_in: Amount of base tokens to sell
minimum_amount_out: Minimum quote tokens expected
address_provider: Platform address provider
Returns:
List of instructions needed for the sell operation
"""
pass
@abstractmethod
def get_required_accounts_for_buy(
self,
token_info: TokenInfo,
user: Pubkey,
address_provider: AddressProvider
self, token_info: TokenInfo, user: Pubkey, address_provider: AddressProvider
) -> list[Pubkey]:
"""Get list of accounts required for buy operation (for priority fee calculation).
Args:
token_info: Token information
user: User's wallet address
address_provider: Platform address provider
Returns:
List of account addresses that will be accessed
"""
pass
@abstractmethod
def get_required_accounts_for_sell(
self,
token_info: TokenInfo,
user: Pubkey,
address_provider: AddressProvider
self, token_info: TokenInfo, user: Pubkey, address_provider: AddressProvider
) -> list[Pubkey]:
"""Get list of accounts required for sell operation (for priority fee calculation).
Args:
token_info: Token information
user: User's wallet address
address_provider: Platform address provider
Returns:
List of account addresses that will be accessed
"""
@@ -206,78 +204,74 @@ class InstructionBuilder(ABC):
class CurveManager(ABC):
"""Abstract interface for platform-specific price calculations and pool state management."""
@property
@abstractmethod
def platform(self) -> Platform:
"""Get the platform this manager serves."""
pass
@abstractmethod
async def get_pool_state(self, pool_address: Pubkey) -> dict[str, Any]:
"""Get the current state of a trading pool/curve.
Args:
pool_address: Address of the pool/curve
Returns:
Dictionary containing pool state data
"""
pass
@abstractmethod
async def calculate_price(self, pool_address: Pubkey) -> float:
"""Calculate current token price from pool state.
Args:
pool_address: Address of the pool/curve
Returns:
Current token price in quote token units
"""
pass
@abstractmethod
async def calculate_buy_amount_out(
self,
pool_address: Pubkey,
amount_in: int
self, pool_address: Pubkey, amount_in: int
) -> int:
"""Calculate expected tokens received for a buy operation.
Args:
pool_address: Address of the pool/curve
amount_in: Amount of quote tokens to spend
Returns:
Expected amount of base tokens to receive
"""
pass
@abstractmethod
async def calculate_sell_amount_out(
self,
pool_address: Pubkey,
amount_in: int
self, pool_address: Pubkey, amount_in: int
) -> int:
"""Calculate expected quote tokens received for a sell operation.
Args:
pool_address: Address of the pool/curve
amount_in: Amount of base tokens to sell
Returns:
Expected amount of quote tokens to receive
"""
pass
@abstractmethod
async def get_reserves(self, pool_address: Pubkey) -> tuple[int, int]:
"""Get current pool reserves.
Args:
pool_address: Address of the pool/curve
Returns:
Tuple of (base_reserves, quote_reserves)
"""
@@ -286,93 +280,86 @@ class CurveManager(ABC):
class EventParser(ABC):
"""Abstract interface for parsing platform-specific token creation events."""
@property
@abstractmethod
def platform(self) -> Platform:
"""Get the platform this parser serves."""
pass
@abstractmethod
def parse_token_creation_from_logs(
self,
logs: list[str],
signature: str
self, logs: list[str], signature: str
) -> TokenInfo | None:
"""Parse token creation from transaction logs.
Args:
logs: List of log strings from transaction
signature: Transaction signature
Returns:
TokenInfo if token creation found, None otherwise
"""
pass
@abstractmethod
def parse_token_creation_from_instruction(
self,
instruction_data: bytes,
accounts: list[int],
account_keys: list[bytes]
self, instruction_data: bytes, accounts: list[int], account_keys: list[bytes]
) -> TokenInfo | None:
"""Parse token creation from instruction data.
Args:
instruction_data: Raw instruction data
accounts: List of account indices
account_keys: List of account public keys
Returns:
TokenInfo if token creation found, None otherwise
"""
pass
@abstractmethod
def parse_token_creation_from_geyser(
self,
transaction_info: Any
self, transaction_info: Any
) -> TokenInfo | None:
"""Parse token creation from Geyser transaction data.
Args:
transaction_info: Geyser transaction information
Returns:
TokenInfo if token creation found, None otherwise
"""
pass
@abstractmethod
def parse_token_creation_from_block(
self,
block_data: dict[str, Any]
self, block_data: dict[str, Any]
) -> TokenInfo | None:
"""Parse token creation from block data.
Args:
block_data: Block data containing transactions
Returns:
TokenInfo if token creation found, None otherwise
"""
pass
@abstractmethod
def get_program_id(self) -> Pubkey:
"""Get the program ID this parser monitors.
Returns:
Program ID for event filtering
"""
pass
@abstractmethod
def get_instruction_discriminators(self) -> list[bytes]:
"""Get instruction discriminators for token creation.
Returns:
List of discriminator bytes to match
"""
pass
pass
+4 -4
View File
@@ -13,7 +13,7 @@ class BaseTokenListener(ABC):
def __init__(self, platform: Platform | None = None):
"""Initialize the listener with optional platform specification.
Args:
platform: Platform to monitor (if None, monitor all platforms)
"""
@@ -38,13 +38,13 @@ class BaseTokenListener(ABC):
def should_process_token(self, token_info: TokenInfo) -> bool:
"""Check if a token should be processed based on platform filter.
Args:
token_info: Token information
Returns:
True if token should be processed
"""
if self.platform is None:
return True # Process all platforms
return token_info.platform == self.platform
return token_info.platform == self.platform
+24 -14
View File
@@ -48,7 +48,7 @@ class ListenerFactory:
)
from monitoring.universal_geyser_listener import UniversalGeyserListener
listener = UniversalGeyserListener(
geyser_endpoint=geyser_endpoint,
geyser_api_token=geyser_api_token,
@@ -63,7 +63,7 @@ class ListenerFactory:
raise ValueError("WebSocket endpoint is required for logs listener")
from monitoring.universal_logs_listener import UniversalLogsListener
listener = UniversalLogsListener(
wss_endpoint=wss_endpoint,
platforms=platforms,
@@ -76,7 +76,7 @@ class ListenerFactory:
raise ValueError("WebSocket endpoint is required for blocks listener")
from monitoring.universal_block_listener import UniversalBlockListener
listener = UniversalBlockListener(
wss_endpoint=wss_endpoint,
platforms=platforms,
@@ -89,26 +89,36 @@ class ListenerFactory:
from monitoring.universal_pumpportal_listener import (
UniversalPumpPortalListener,
)
# Validate that requested platforms support PumpPortal
supported_pumpportal_platforms = [Platform.PUMP_FUN, Platform.LETS_BONK]
if platforms:
unsupported = [p for p in platforms if p not in supported_pumpportal_platforms]
unsupported = [
p for p in platforms if p not in supported_pumpportal_platforms
]
if unsupported:
logger.warning(f"Platforms {[p.value for p in unsupported]} do not support PumpPortal")
logger.warning(
f"Platforms {[p.value for p in unsupported]} do not support PumpPortal"
)
# Filter to only supported platforms
filtered_platforms = [p for p in platforms if p in supported_pumpportal_platforms]
filtered_platforms = [
p for p in platforms if p in supported_pumpportal_platforms
]
if not filtered_platforms:
raise ValueError("No supported platforms specified for PumpPortal listener")
raise ValueError(
"No supported platforms specified for PumpPortal listener"
)
platforms = filtered_platforms
listener = UniversalPumpPortalListener(
pumpportal_url=pumpportal_url,
platforms=platforms,
)
logger.info(f"Created Universal PumpPortal listener for platforms: {[p.value for p in (platforms or supported_pumpportal_platforms)]}")
logger.info(
f"Created Universal PumpPortal listener for platforms: {[p.value for p in (platforms or supported_pumpportal_platforms)]}"
)
return listener
else:
@@ -146,8 +156,8 @@ class ListenerFactory:
@staticmethod
def get_pumpportal_supported_platforms() -> list[Platform]:
"""Get list of platforms that support PumpPortal listener.
Returns:
List of platforms with PumpPortal support
"""
return [Platform.PUMP_FUN, Platform.LETS_BONK]
return [Platform.PUMP_FUN, Platform.LETS_BONK]
+21 -16
View File
@@ -33,25 +33,25 @@ class UniversalBlockListener(BaseTokenListener):
super().__init__()
self.wss_endpoint = wss_endpoint
self.ping_interval = 20 # seconds
# Import platform factory and get supported platforms
from platforms import platform_factory
if platforms is None:
# Monitor all supported platforms
self.platforms = platform_factory.get_supported_platforms()
else:
self.platforms = platforms
# Get event parsers for all platforms
self.platform_parsers = {}
self.platform_program_ids = []
for platform in self.platforms:
try:
# Create a simple dummy client that doesn't start blockhash updater
from core.client import SolanaClient
# Create a mock client class to avoid network operations
class DummyClient(SolanaClient):
def __init__(self):
@@ -61,16 +61,18 @@ class UniversalBlockListener(BaseTokenListener):
self._cached_blockhash = None
self._blockhash_lock = None
self._blockhash_updater_task = None
dummy_client = DummyClient()
implementations = get_platform_implementations(platform, dummy_client)
parser = implementations.event_parser
self.platform_parsers[platform] = parser
self.platform_program_ids.append(str(parser.get_program_id()))
logger.info(f"Registered platform {platform.value} with program ID {parser.get_program_id()}")
logger.info(
f"Registered platform {platform.value} with program ID {parser.get_program_id()}"
)
except Exception as e:
logger.warning(f"Could not register platform {platform.value}: {e}")
@@ -117,7 +119,10 @@ class UniversalBlockListener(BaseTokenListener):
)
continue
if creator_address and str(token_info.user) != creator_address:
if (
creator_address
and str(token_info.user) != creator_address
):
logger.info(
f"Token not created by {creator_address}. Skipping..."
)
@@ -220,10 +225,10 @@ class UniversalBlockListener(BaseTokenListener):
for platform, parser in self.platform_parsers.items():
# Check if the parser has a block parsing method
if hasattr(parser, 'parse_token_creation_from_block'):
token_info = parser.parse_token_creation_from_block({
"transactions": [tx]
})
if hasattr(parser, "parse_token_creation_from_block"):
token_info = parser.parse_token_creation_from_block(
{"transactions": [tx]}
)
if token_info:
return token_info
@@ -237,4 +242,4 @@ class UniversalBlockListener(BaseTokenListener):
except Exception:
logger.exception("Error processing WebSocket message")
return None
return None
+28 -20
View File
@@ -30,7 +30,7 @@ class UniversalGeyserListener(BaseTokenListener):
super().__init__()
self.geyser_endpoint = geyser_endpoint
self.geyser_api_token = geyser_api_token
valid_auth_types = {"x-token", "basic"}
self.auth_type: str = (geyser_auth_type or "x-token").lower()
if self.auth_type not in valid_auth_types:
@@ -38,21 +38,21 @@ class UniversalGeyserListener(BaseTokenListener):
f"Unsupported auth_type={self.auth_type!r}. "
f"Expected one of {valid_auth_types}"
)
if platforms is None:
self.platforms = platform_factory.get_supported_platforms()
else:
self.platforms = platforms
# Get event parsers for all platforms
self.platform_parsers = {}
self.platform_program_ids = set()
for platform in self.platforms:
try:
# Create a simple dummy client that doesn't start blockhash updater
from core.client import SolanaClient
# Create a mock client class to avoid network operations
class DummyClient(SolanaClient):
def __init__(self):
@@ -62,22 +62,26 @@ class UniversalGeyserListener(BaseTokenListener):
self._cached_blockhash = None
self._blockhash_lock = None
self._blockhash_updater_task = None
dummy_client = DummyClient()
implementations = platform_factory.create_for_platform(platform, dummy_client)
implementations = platform_factory.create_for_platform(
platform, dummy_client
)
parser = implementations.event_parser
self.platform_parsers[platform] = parser
self.platform_program_ids.add(parser.get_program_id())
logger.info(f"Registered platform {platform.value} with program ID {parser.get_program_id()}")
logger.info(
f"Registered platform {platform.value} with program ID {parser.get_program_id()}"
)
except Exception as e:
logger.warning(f"Could not register platform {platform.value}: {e}")
async def _create_geyser_connection(self):
"""Establish a secure connection to the Geyser endpoint."""
if self.auth_type == "x-token":
auth = grpc.metadata_call_credentials(
lambda _, callback: callback(
@@ -92,20 +96,20 @@ class UniversalGeyserListener(BaseTokenListener):
)
creds = grpc.composite_channel_credentials(grpc.ssl_channel_credentials(), auth)
channel = grpc.aio.secure_channel(self.geyser_endpoint, creds)
return geyser_pb2_grpc.GeyserStub(channel), channel
def _create_subscription_request(self):
"""Create a subscription request for all monitored platforms."""
request = geyser_pb2.SubscribeRequest()
# Add all platform program IDs to the filter
for program_id in self.platform_program_ids:
filter_name = f"platform_filter_{program_id}"
request.transactions[filter_name].account_include.append(str(program_id))
request.transactions[filter_name].failed = False
request.commitment = geyser_pb2.CommitmentLevel.PROCESSED
return request
@@ -126,8 +130,12 @@ class UniversalGeyserListener(BaseTokenListener):
request = self._create_subscription_request()
logger.info(f"Connected to Geyser endpoint: {self.geyser_endpoint}")
logger.info(f"Monitoring platforms: {[p.value for p in self.platforms]}")
logger.info(f"Monitoring program IDs: {[str(pid) for pid in self.platform_program_ids]}")
logger.info(
f"Monitoring platforms: {[p.value for p in self.platforms]}"
)
logger.info(
f"Monitoring program IDs: {[str(pid) for pid in self.platform_program_ids]}"
)
try:
async for update in stub.Subscribe(iter([request])):
@@ -192,7 +200,7 @@ class UniversalGeyserListener(BaseTokenListener):
continue
program_id = Pubkey.from_bytes(msg.account_keys[program_idx])
# Find the matching platform parser
for platform, parser in self.platform_parsers.items():
if program_id == parser.get_program_id():
@@ -207,4 +215,4 @@ class UniversalGeyserListener(BaseTokenListener):
except Exception:
logger.exception("Error processing Geyser update")
return None
return None
+24 -14
View File
@@ -1,6 +1,7 @@
"""
Universal logs listener that works with any platform through the interface system.
"""
import asyncio
import json
from collections.abc import Awaitable, Callable
@@ -31,25 +32,25 @@ class UniversalLogsListener(BaseTokenListener):
super().__init__()
self.wss_endpoint = wss_endpoint
self.ping_interval = 20 # seconds
# Import platform factory and get supported platforms
from platforms import platform_factory
if platforms is None:
# Monitor all supported platforms
self.platforms = platform_factory.get_supported_platforms()
else:
self.platforms = platforms
# Get event parsers for all platforms
self.platform_parsers = {}
self.platform_program_ids = []
for platform in self.platforms:
try:
# Create a simple dummy client that doesn't start blockhash updater
from core.client import SolanaClient
# Create a mock client class to avoid network operations
class DummyClient(SolanaClient):
def __init__(self):
@@ -59,16 +60,20 @@ class UniversalLogsListener(BaseTokenListener):
self._cached_blockhash = None
self._blockhash_lock = None
self._blockhash_updater_task = None
dummy_client = DummyClient()
implementations = platform_factory.create_for_platform(platform, dummy_client)
implementations = platform_factory.create_for_platform(
platform, dummy_client
)
parser = implementations.event_parser
self.platform_parsers[platform] = parser
self.platform_program_ids.append(str(parser.get_program_id()))
logger.info(f"Registered platform {platform.value} with program ID {parser.get_program_id()}")
logger.info(
f"Registered platform {platform.value} with program ID {parser.get_program_id()}"
)
except Exception as e:
logger.warning(f"Could not register platform {platform.value}: {e}")
@@ -115,7 +120,10 @@ class UniversalLogsListener(BaseTokenListener):
)
continue
if creator_address and str(token_info.user) != creator_address:
if (
creator_address
and str(token_info.user) != creator_address
):
logger.info(
f"Token not created by {creator_address}. Skipping..."
)
@@ -159,7 +167,9 @@ class UniversalLogsListener(BaseTokenListener):
response = await websocket.recv()
response_data = json.loads(response)
if "result" in response_data:
logger.info(f"Subscription confirmed with ID: {response_data['result']}")
logger.info(
f"Subscription confirmed with ID: {response_data['result']}"
)
else:
logger.warning(f"Unexpected subscription response: {response}")
@@ -209,4 +219,4 @@ class UniversalLogsListener(BaseTokenListener):
except Exception:
logger.exception("Error processing WebSocket message")
return None
return None
+20 -10
View File
@@ -32,23 +32,23 @@ class UniversalPumpPortalListener(BaseTokenListener):
super().__init__()
self.pumpportal_url = pumpportal_url
self.ping_interval = 20 # seconds
# Get platform-specific processors
from platforms.letsbonk.pumpportal_processor import LetsBonkPumpPortalProcessor
from platforms.pumpfun.pumpportal_processor import PumpFunPumpPortalProcessor
# Create processor instances
all_processors = [
PumpFunPumpPortalProcessor(),
LetsBonkPumpPortalProcessor(),
]
# Filter processors based on requested platforms
if platforms is None:
self.processors = all_processors
else:
self.processors = [p for p in all_processors if p.platform in platforms]
# Build mapping of pool names to processors for quick lookup
self.pool_to_processors: dict[str, list] = {}
for processor in self.processors:
@@ -56,8 +56,10 @@ class UniversalPumpPortalListener(BaseTokenListener):
if pool_name not in self.pool_to_processors:
self.pool_to_processors[pool_name] = []
self.pool_to_processors[pool_name].append(processor)
logger.info(f"Initialized Universal PumpPortal listener for platforms: {[p.platform.value for p in self.processors]}")
logger.info(
f"Initialized Universal PumpPortal listener for platforms: {[p.platform.value for p in self.processors]}"
)
logger.info(f"Monitoring pools: {list(self.pool_to_processors.keys())}")
async def listen_for_tokens(
@@ -100,8 +102,14 @@ class UniversalPumpPortalListener(BaseTokenListener):
continue
if creator_address:
creator_str = str(token_info.creator) if token_info.creator else ""
user_str = str(token_info.user) if token_info.user else ""
creator_str = (
str(token_info.creator)
if token_info.creator
else ""
)
user_str = (
str(token_info.user) if token_info.user else ""
)
if creator_address not in [creator_str, user_str]:
logger.info(
f"Token not created by {creator_address}. Skipping..."
@@ -197,7 +205,9 @@ class UniversalPumpPortalListener(BaseTokenListener):
if processor.can_process(token_data):
token_info = processor.process_token_data(token_data)
if token_info:
logger.debug(f"Successfully processed token using {processor.platform.value} processor")
logger.debug(
f"Successfully processed token using {processor.platform.value} processor"
)
return token_info
logger.debug(f"No processor could handle token data from pool {pool_name}")
@@ -213,4 +223,4 @@ class UniversalPumpPortalListener(BaseTokenListener):
except Exception:
logger.exception("Error processing PumpPortal WebSocket message")
return None
return None
+115 -94
View File
@@ -25,6 +25,7 @@ logger = get_logger(__name__)
@dataclass
class PlatformImplementations:
"""Container for all platform-specific implementations."""
address_provider: AddressProvider
instruction_builder: InstructionBuilder
curve_manager: CurveManager
@@ -33,21 +34,21 @@ class PlatformImplementations:
class PlatformRegistry:
"""Registry for platform implementations."""
def __init__(self):
self._implementations: dict[Platform, dict[str, type]] = {}
self._instances: dict[tuple[Platform, str], PlatformImplementations] = {}
def register_platform(
self,
platform: Platform,
address_provider_class: type[AddressProvider],
instruction_builder_class: type[InstructionBuilder],
curve_manager_class: type[CurveManager],
event_parser_class: type[EventParser]
event_parser_class: type[EventParser],
) -> None:
"""Register platform implementations.
Args:
platform: Platform enum value
address_provider_class: AddressProvider implementation class
@@ -56,114 +57,119 @@ class PlatformRegistry:
event_parser_class: EventParser implementation class
"""
self._implementations[platform] = {
'address_provider': address_provider_class,
'instruction_builder': instruction_builder_class,
'curve_manager': curve_manager_class,
'event_parser': event_parser_class
"address_provider": address_provider_class,
"instruction_builder": instruction_builder_class,
"curve_manager": curve_manager_class,
"event_parser": event_parser_class,
}
def create_platform_implementations(
self,
platform: Platform,
client: SolanaClient,
**kwargs: Any
self, platform: Platform, client: SolanaClient, **kwargs: Any
) -> PlatformImplementations:
"""Create platform implementation instances with IDL support.
Args:
platform: Platform to create implementations for
client: Solana RPC client
**kwargs: Additional arguments for implementation constructors
Returns:
PlatformImplementations containing all interface implementations
Raises:
ValueError: If platform is not registered
"""
if platform not in self._implementations:
raise ValueError(f"Platform {platform} is not registered")
# Use client address as cache key to allow multiple clients
cache_key = (platform, str(client.rpc_endpoint))
# Check if we already have instances for this platform + client combo
if cache_key in self._instances:
return self._instances[cache_key]
impl_classes = self._implementations[platform]
# Check if platform has IDL support and prepare IDL parser
idl_parser = None
if has_idl_support(platform):
try:
idl_manager = get_idl_manager()
idl_parser = idl_manager.get_parser(platform, verbose=kwargs.get('verbose_idl', False))
logger.info(f"IDL parser loaded for {platform.value} platform implementations")
idl_parser = idl_manager.get_parser(
platform, verbose=kwargs.get("verbose_idl", False)
)
logger.info(
f"IDL parser loaded for {platform.value} platform implementations"
)
except Exception as e:
logger.warning(f"Failed to load IDL parser for {platform.value}: {e}")
# Create instances - pass IDL parser to classes that need it
address_provider = impl_classes['address_provider']()
address_provider = impl_classes["address_provider"]()
# For platforms with IDL support, pass the parser to relevant classes
if idl_parser and platform in [Platform.LETS_BONK, Platform.PUMP_FUN]:
instruction_builder = impl_classes['instruction_builder'](idl_parser=idl_parser)
curve_manager = impl_classes['curve_manager'](client, idl_parser=idl_parser)
event_parser = impl_classes['event_parser'](idl_parser=idl_parser)
instruction_builder = impl_classes["instruction_builder"](
idl_parser=idl_parser
)
curve_manager = impl_classes["curve_manager"](client, idl_parser=idl_parser)
event_parser = impl_classes["event_parser"](idl_parser=idl_parser)
else:
# Fallback for platforms without IDL support
instruction_builder = impl_classes['instruction_builder']()
curve_manager = impl_classes['curve_manager'](client)
event_parser = impl_classes['event_parser']()
instruction_builder = impl_classes["instruction_builder"]()
curve_manager = impl_classes["curve_manager"](client)
event_parser = impl_classes["event_parser"]()
implementations = PlatformImplementations(
address_provider=address_provider,
instruction_builder=instruction_builder,
curve_manager=curve_manager,
event_parser=event_parser
event_parser=event_parser,
)
# Cache the instances
self._instances[cache_key] = implementations
return implementations
def get_platform_implementations(self, platform: Platform, client_endpoint: str) -> PlatformImplementations | None:
def get_platform_implementations(
self, platform: Platform, client_endpoint: str
) -> PlatformImplementations | None:
"""Get cached platform implementations.
Args:
platform: Platform to get implementations for
client_endpoint: Client endpoint for cache lookup
Returns:
PlatformImplementations if available, None otherwise
"""
cache_key = (platform, client_endpoint)
return self._instances.get(cache_key)
def get_supported_platforms(self) -> list[Platform]:
"""Get list of supported platforms.
Returns:
List of registered platforms
"""
return list(self._implementations.keys())
def is_platform_supported(self, platform: Platform) -> bool:
"""Check if a platform is supported.
Args:
platform: Platform to check
Returns:
True if platform is registered, False otherwise
"""
return platform in self._implementations
def clear_implementation_cache(self, platform: Platform | None = None) -> None:
"""Clear cached platform implementations.
Args:
platform: Specific platform to clear, or None to clear all
"""
@@ -171,7 +177,9 @@ class PlatformRegistry:
logger.info("Clearing all cached platform implementations")
self._instances.clear()
else:
keys_to_remove = [key for key in self._instances.keys() if key[0] == platform]
keys_to_remove = [
key for key in self._instances.keys() if key[0] == platform
]
for key in keys_to_remove:
del self._instances[key]
logger.info(f"Cleared cached implementations for {platform.value}")
@@ -179,11 +187,11 @@ class PlatformRegistry:
class PlatformFactory:
"""Factory for creating platform-specific implementations with IDL support."""
def __init__(self):
self.registry = PlatformRegistry()
self._setup_default_platforms()
def _setup_default_platforms(self) -> None:
"""Setup default platform registrations."""
# Import and register pump.fun platform
@@ -194,18 +202,18 @@ class PlatformFactory:
PumpFunEventParser,
PumpFunInstructionBuilder,
)
self.registry.register_platform(
Platform.PUMP_FUN,
PumpFunAddressProvider,
PumpFunInstructionBuilder,
PumpFunCurveManager,
PumpFunEventParser
PumpFunEventParser,
)
except ImportError as e:
print(f"Warning: Could not register pump.fun platform: {e}")
# Import and register LetsBonk platform
try:
from platforms.letsbonk import (
@@ -214,105 +222,116 @@ class PlatformFactory:
LetsBonkEventParser,
LetsBonkInstructionBuilder,
)
self.registry.register_platform(
Platform.LETS_BONK,
LetsBonkAddressProvider,
LetsBonkInstructionBuilder,
LetsBonkCurveManager,
LetsBonkEventParser
LetsBonkEventParser,
)
except ImportError as e:
print(f"Warning: Could not register LetsBonk platform: {e}")
def create_for_platform(
self,
platform: Platform,
client: SolanaClient,
**config: Any
self, platform: Platform, client: SolanaClient, **config: Any
) -> PlatformImplementations:
"""Create all implementations for a specific platform.
Args:
platform: Platform to create implementations for
client: Solana RPC client
**config: Platform-specific configuration (including verbose_idl)
Returns:
PlatformImplementations containing all interface implementations
"""
return self.registry.create_platform_implementations(platform, client, **config)
def get_address_provider(self, platform: Platform, client: SolanaClient) -> AddressProvider:
def get_address_provider(
self, platform: Platform, client: SolanaClient
) -> AddressProvider:
"""Get address provider for platform.
Args:
platform: Platform to get provider for
client: Solana RPC client
Returns:
AddressProvider implementation
"""
implementations = self.registry.create_platform_implementations(platform, client)
implementations = self.registry.create_platform_implementations(
platform, client
)
return implementations.address_provider
def get_instruction_builder(self, platform: Platform, client: SolanaClient) -> InstructionBuilder:
def get_instruction_builder(
self, platform: Platform, client: SolanaClient
) -> InstructionBuilder:
"""Get instruction builder for platform.
Args:
platform: Platform to get builder for
client: Solana RPC client
Returns:
InstructionBuilder implementation
"""
implementations = self.registry.create_platform_implementations(platform, client)
implementations = self.registry.create_platform_implementations(
platform, client
)
return implementations.instruction_builder
def get_curve_manager(self, platform: Platform, client: SolanaClient) -> CurveManager:
def get_curve_manager(
self, platform: Platform, client: SolanaClient
) -> CurveManager:
"""Get curve manager for platform.
Args:
platform: Platform to get manager for
client: Solana RPC client
Returns:
CurveManager implementation
"""
implementations = self.registry.create_platform_implementations(platform, client)
implementations = self.registry.create_platform_implementations(
platform, client
)
return implementations.curve_manager
def get_event_parser(self, platform: Platform, client: SolanaClient) -> EventParser:
"""Get event parser for platform.
Args:
platform: Platform to get parser for
client: Solana RPC client
Returns:
EventParser implementation
"""
implementations = self.registry.create_platform_implementations(platform, client)
implementations = self.registry.create_platform_implementations(
platform, client
)
return implementations.event_parser
def get_supported_platforms(self) -> list[Platform]:
"""Get list of supported platforms.
Returns:
List of supported platforms
"""
return self.registry.get_supported_platforms()
def clear_caches(self, platform: Platform | None = None) -> None:
"""Clear all caches for better memory management.
Args:
platform: Specific platform to clear, or None to clear all
"""
# Clear implementation cache
self.registry.clear_implementation_cache(platform)
# Clear IDL parser cache
idl_manager = get_idl_manager()
idl_manager.clear_cache(platform)
@@ -322,13 +341,15 @@ class PlatformFactory:
platform_factory = PlatformFactory()
def get_platform_implementations(platform: Platform, client: SolanaClient) -> PlatformImplementations:
def get_platform_implementations(
platform: Platform, client: SolanaClient
) -> PlatformImplementations:
"""Convenience function to get platform implementations.
Args:
platform: Platform to get implementations for
client: Solana RPC client
Returns:
PlatformImplementations containing all interface implementations
"""
@@ -340,10 +361,10 @@ def register_platform_implementations(
address_provider_class: type[AddressProvider],
instruction_builder_class: type[InstructionBuilder],
curve_manager_class: type[CurveManager],
event_parser_class: type[EventParser]
event_parser_class: type[EventParser],
) -> None:
"""Register platform implementations with the global factory.
Args:
platform: Platform enum value
address_provider_class: AddressProvider implementation class
@@ -356,5 +377,5 @@ def register_platform_implementations(
address_provider_class,
instruction_builder_class,
curve_manager_class,
event_parser_class
)
event_parser_class,
)
+6 -6
View File
@@ -13,9 +13,9 @@ from .pumpportal_processor import LetsBonkPumpPortalProcessor
# Export implementations for direct use if needed
__all__ = [
'LetsBonkAddressProvider',
'LetsBonkCurveManager',
'LetsBonkEventParser',
'LetsBonkInstructionBuilder',
'LetsBonkPumpPortalProcessor'
]
"LetsBonkAddressProvider",
"LetsBonkCurveManager",
"LetsBonkEventParser",
"LetsBonkInstructionBuilder",
"LetsBonkPumpPortalProcessor",
]
+87 -72
View File
@@ -18,35 +18,41 @@ from interfaces.core import AddressProvider, Platform, TokenInfo
@dataclass
class LetsBonkAddresses:
"""LetsBonk (Raydium LaunchLab) program addresses."""
# Raydium LaunchLab program addresses
PROGRAM: Final[Pubkey] = Pubkey.from_string("LanMV9sAd7wArD4vJFi2qDdfnVhFxYSUg6eADduJ3uj")
GLOBAL_CONFIG: Final[Pubkey] = Pubkey.from_string("6s1xP3hpbAfFoNtUNF8mfHsjr2Bd97JxFJRWLbL6aHuX")
PLATFORM_CONFIG: Final[Pubkey] = Pubkey.from_string("FfYek5vEz23cMkWsdJwG2oa6EphsvXSHrGpdALN4g6W1")
PROGRAM: Final[Pubkey] = Pubkey.from_string(
"LanMV9sAd7wArD4vJFi2qDdfnVhFxYSUg6eADduJ3uj"
)
GLOBAL_CONFIG: Final[Pubkey] = Pubkey.from_string(
"6s1xP3hpbAfFoNtUNF8mfHsjr2Bd97JxFJRWLbL6aHuX"
)
PLATFORM_CONFIG: Final[Pubkey] = Pubkey.from_string(
"FfYek5vEz23cMkWsdJwG2oa6EphsvXSHrGpdALN4g6W1"
)
class LetsBonkAddressProvider(AddressProvider):
"""LetsBonk (Raydium LaunchLab) implementation of AddressProvider interface."""
@property
def platform(self) -> Platform:
"""Get the platform this provider serves."""
return Platform.LETS_BONK
@property
def program_id(self) -> Pubkey:
"""Get the main program ID for this platform."""
return LetsBonkAddresses.PROGRAM
def get_system_addresses(self) -> dict[str, Pubkey]:
"""Get all system addresses required for LetsBonk.
Returns:
Dictionary mapping address names to Pubkey objects
"""
# Get system addresses from the single source of truth
system_addresses = SystemAddresses.get_all_system_addresses()
# Add LetsBonk specific addresses
letsbonk_addresses = {
# Raydium LaunchLab specific addresses
@@ -54,179 +60,184 @@ class LetsBonkAddressProvider(AddressProvider):
"global_config": LetsBonkAddresses.GLOBAL_CONFIG,
"platform_config": LetsBonkAddresses.PLATFORM_CONFIG,
}
# Combine system and platform-specific addresses
return {**system_addresses, **letsbonk_addresses}
def derive_pool_address(self, base_mint: Pubkey, quote_mint: Pubkey | None = None) -> Pubkey:
def derive_pool_address(
self, base_mint: Pubkey, quote_mint: Pubkey | None = None
) -> Pubkey:
"""Derive the pool state address for a token pair.
For LetsBonk, this derives the pool state PDA using base_mint and WSOL.
Args:
base_mint: Base token mint address
quote_mint: Quote token mint (defaults to WSOL)
Returns:
Pool state address
"""
if quote_mint is None:
quote_mint = SystemAddresses.SOL_MINT
pool_state, _ = Pubkey.find_program_address(
[b"pool", bytes(base_mint), bytes(quote_mint)],
LetsBonkAddresses.PROGRAM
[b"pool", bytes(base_mint), bytes(quote_mint)], LetsBonkAddresses.PROGRAM
)
return pool_state
def derive_base_vault(self, base_mint: Pubkey, quote_mint: Pubkey | None = None) -> Pubkey:
def derive_base_vault(
self, base_mint: Pubkey, quote_mint: Pubkey | None = None
) -> Pubkey:
"""Derive the base vault address for a token pair.
Args:
base_mint: Base token mint address
quote_mint: Quote token mint (defaults to WSOL)
Returns:
Base vault address
"""
if quote_mint is None:
quote_mint = SystemAddresses.SOL_MINT
# First derive the pool state address
pool_state = self.derive_pool_address(base_mint, quote_mint)
# Then derive the base vault using pool_vault seed
base_vault, _ = Pubkey.find_program_address(
[b"pool_vault", bytes(pool_state), bytes(base_mint)],
LetsBonkAddresses.PROGRAM
LetsBonkAddresses.PROGRAM,
)
return base_vault
def derive_quote_vault(self, base_mint: Pubkey, quote_mint: Pubkey | None = None) -> Pubkey:
def derive_quote_vault(
self, base_mint: Pubkey, quote_mint: Pubkey | None = None
) -> Pubkey:
"""Derive the quote vault address for a token pair.
Args:
base_mint: Base token mint address
quote_mint: Quote token mint (defaults to WSOL)
Returns:
Quote vault address
"""
if quote_mint is None:
quote_mint = SystemAddresses.SOL_MINT
# First derive the pool state address
pool_state = self.derive_pool_address(base_mint, quote_mint)
# Then derive the quote vault using pool_vault seed
quote_vault, _ = Pubkey.find_program_address(
[b"pool_vault", bytes(pool_state), bytes(quote_mint)],
LetsBonkAddresses.PROGRAM
LetsBonkAddresses.PROGRAM,
)
return quote_vault
def derive_user_token_account(self, user: Pubkey, mint: Pubkey) -> Pubkey:
"""Derive user's associated token account address.
Args:
user: User's wallet address
mint: Token mint address
Returns:
User's associated token account address
"""
return get_associated_token_address(user, mint)
def get_additional_accounts(self, token_info: TokenInfo) -> dict[str, Pubkey]:
"""Get LetsBonk-specific additional accounts needed for trading.
Args:
token_info: Token information
Returns:
Dictionary of additional account addresses
"""
accounts = {}
# Add pool state - derive if not present or use existing
if token_info.pool_state:
accounts["pool_state"] = token_info.pool_state
else:
accounts["pool_state"] = self.derive_pool_address(token_info.mint)
# Add vault addresses - derive if not present or use existing
if token_info.base_vault:
accounts["base_vault"] = token_info.base_vault
else:
accounts["base_vault"] = self.derive_base_vault(token_info.mint)
if token_info.quote_vault:
accounts["quote_vault"] = token_info.quote_vault
else:
accounts["quote_vault"] = self.derive_quote_vault(token_info.mint)
# Derive authority PDA
accounts["authority"] = self.derive_authority_pda()
# Derive event authority PDA
accounts["event_authority"] = self.derive_event_authority_pda()
return accounts
def derive_authority_pda(self) -> Pubkey:
"""Derive the authority PDA for Raydium LaunchLab.
This PDA acts as the authority for pool vault operations.
Returns:
Authority PDA address
"""
AUTH_SEED = b"vault_auth_seed"
authority_pda, _ = Pubkey.find_program_address(
[AUTH_SEED],
LetsBonkAddresses.PROGRAM
[AUTH_SEED], LetsBonkAddresses.PROGRAM
)
return authority_pda
def derive_event_authority_pda(self) -> Pubkey:
"""Derive the event authority PDA for Raydium LaunchLab.
This PDA is used for emitting program events during swaps.
Returns:
Event authority PDA address
"""
EVENT_AUTHORITY_SEED = b"__event_authority"
event_authority_pda, _ = Pubkey.find_program_address(
[EVENT_AUTHORITY_SEED],
LetsBonkAddresses.PROGRAM
[EVENT_AUTHORITY_SEED], LetsBonkAddresses.PROGRAM
)
return event_authority_pda
def create_wsol_account_with_seed(self, payer: Pubkey, seed: str) -> Pubkey:
"""Create a WSOL account address using createAccountWithSeed pattern.
Args:
payer: The account that will pay for and own the new account
seed: String seed for deterministic account generation
Returns:
New WSOL account address
"""
return Pubkey.create_with_seed(payer, seed, SystemAddresses.TOKEN_PROGRAM)
def get_buy_instruction_accounts(self, token_info: TokenInfo, user: Pubkey) -> dict[str, Pubkey]:
def get_buy_instruction_accounts(
self, token_info: TokenInfo, user: Pubkey
) -> dict[str, Pubkey]:
"""Get all accounts needed for a buy instruction.
Args:
token_info: Token information
user: User's wallet address
Returns:
Dictionary of account addresses for buy instruction
"""
additional_accounts = self.get_additional_accounts(token_info)
return {
"payer": user,
"authority": additional_accounts["authority"],
@@ -243,19 +254,21 @@ class LetsBonkAddressProvider(AddressProvider):
"event_authority": additional_accounts["event_authority"],
"program": LetsBonkAddresses.PROGRAM,
}
def get_sell_instruction_accounts(self, token_info: TokenInfo, user: Pubkey) -> dict[str, Pubkey]:
def get_sell_instruction_accounts(
self, token_info: TokenInfo, user: Pubkey
) -> dict[str, Pubkey]:
"""Get all accounts needed for a sell instruction.
Args:
token_info: Token information
user: User's wallet address
Returns:
Dictionary of account addresses for sell instruction
"""
additional_accounts = self.get_additional_accounts(token_info)
return {
"payer": user,
"authority": additional_accounts["authority"],
@@ -272,14 +285,16 @@ class LetsBonkAddressProvider(AddressProvider):
"event_authority": additional_accounts["event_authority"],
"program": LetsBonkAddresses.PROGRAM,
}
def get_wsol_account_creation_accounts(self, user: Pubkey, wsol_account: Pubkey) -> dict[str, Pubkey]:
def get_wsol_account_creation_accounts(
self, user: Pubkey, wsol_account: Pubkey
) -> dict[str, Pubkey]:
"""Get accounts needed for WSOL account creation and initialization.
Args:
user: User's wallet address
wsol_account: WSOL account to be created
Returns:
Dictionary of account addresses for WSOL operations
"""
@@ -291,4 +306,4 @@ class LetsBonkAddressProvider(AddressProvider):
"system_program": SystemAddresses.SYSTEM_PROGRAM,
"token_program": SystemAddresses.TOKEN_PROGRAM,
"rent": SystemAddresses.RENT,
}
}
+72 -71
View File
@@ -21,10 +21,10 @@ logger = get_logger(__name__)
class LetsBonkCurveManager(CurveManager):
"""LetsBonk (Raydium LaunchLab) implementation of CurveManager interface."""
def __init__(self, client: SolanaClient, idl_parser: IDLParser):
"""Initialize LetsBonk curve manager with injected IDL parser.
Args:
client: Solana RPC client
idl_parser: Pre-loaded IDL parser for LetsBonk platform
@@ -32,20 +32,20 @@ class LetsBonkCurveManager(CurveManager):
self.client = client
self.address_provider = LetsBonkAddressProvider()
self._idl_parser = idl_parser
logger.info("LetsBonk curve manager initialized with injected IDL parser")
@property
def platform(self) -> Platform:
"""Get the platform this manager serves."""
return Platform.LETS_BONK
async def get_pool_state(self, pool_address: Pubkey) -> dict[str, Any]:
"""Get the current state of a LetsBonk pool.
Args:
pool_address: Address of the pool state account
Returns:
Dictionary containing pool state data
"""
@@ -53,136 +53,130 @@ class LetsBonkCurveManager(CurveManager):
account = await self.client.get_account_info(pool_address)
if not account.data:
raise ValueError(f"No data in pool state account {pool_address}")
# Decode pool state using injected IDL parser
pool_state_data = self._decode_pool_state_with_idl(account.data)
return pool_state_data
except Exception as e:
logger.exception("Failed to get pool state")
raise ValueError(f"Invalid pool state: {e!s}")
async def calculate_price(self, pool_address: Pubkey) -> float:
"""Calculate current token price from pool state.
Args:
pool_address: Address of the pool state
Returns:
Current token price in SOL
"""
pool_state = await self.get_pool_state(pool_address)
# Use virtual reserves for price calculation
virtual_base = pool_state["virtual_base"]
virtual_quote = pool_state["virtual_quote"]
if virtual_base <= 0 or virtual_quote <= 0:
raise ValueError("Invalid reserve state")
# Price = quote_reserves / base_reserves (how much SOL per token)
price_lamports = virtual_quote / virtual_base
price_sol = price_lamports * (10**TOKEN_DECIMALS) / LAMPORTS_PER_SOL
return price_sol
async def calculate_buy_amount_out(
self,
pool_address: Pubkey,
amount_in: int
self, pool_address: Pubkey, amount_in: int
) -> int:
"""Calculate expected tokens received for a buy operation.
Uses the constant product AMM formula.
Args:
pool_address: Address of the pool state
amount_in: Amount of SOL to spend (in lamports)
Returns:
Expected amount of tokens to receive (in raw token units)
"""
pool_state = await self.get_pool_state(pool_address)
virtual_base = pool_state["virtual_base"]
virtual_quote = pool_state["virtual_quote"]
# Constant product formula: tokens_out = (amount_in * virtual_base) / (virtual_quote + amount_in)
numerator = amount_in * virtual_base
denominator = virtual_quote + amount_in
if denominator == 0:
return 0
tokens_out = numerator // denominator
return tokens_out
async def calculate_sell_amount_out(
self,
pool_address: Pubkey,
amount_in: int
self, pool_address: Pubkey, amount_in: int
) -> int:
"""Calculate expected SOL received for a sell operation.
Uses the constant product AMM formula.
Args:
pool_address: Address of the pool state
amount_in: Amount of tokens to sell (in raw token units)
Returns:
Expected amount of SOL to receive (in lamports)
"""
pool_state = await self.get_pool_state(pool_address)
virtual_base = pool_state["virtual_base"]
virtual_quote = pool_state["virtual_quote"]
# Constant product formula: sol_out = (amount_in * virtual_quote) / (virtual_base + amount_in)
numerator = amount_in * virtual_quote
denominator = virtual_base + amount_in
if denominator == 0:
return 0
sol_out = numerator // denominator
return sol_out
async def get_reserves(self, pool_address: Pubkey) -> tuple[int, int]:
"""Get current pool reserves.
Args:
pool_address: Address of the pool state
Returns:
Tuple of (base_reserves, quote_reserves) in raw units
"""
pool_state = await self.get_pool_state(pool_address)
return (pool_state["virtual_base"], pool_state["virtual_quote"])
def _decode_pool_state_with_idl(self, data: bytes) -> dict[str, Any]:
"""Decode pool state data using injected IDL parser.
Args:
data: Raw account data
Returns:
Dictionary with decoded pool state
Raises:
ValueError: If IDL parsing fails
"""
# Use injected IDL parser to decode PoolState account data
decoded_pool_state = self._idl_parser.decode_account_data(
data,
"PoolState",
skip_discriminator=True
data, "PoolState", skip_discriminator=True
)
if not decoded_pool_state:
raise ValueError("Failed to decode pool state with IDL parser")
# Extract the fields we need for trading calculations
# Based on the PoolState structure from the IDL
pool_data = {
@@ -193,28 +187,31 @@ class LetsBonkCurveManager(CurveManager):
"status": decoded_pool_state.get("status", 0),
"supply": decoded_pool_state.get("supply", 0),
}
# Calculate additional metrics
if pool_data["virtual_base"] > 0:
pool_data["price_per_token"] = (
(pool_data["virtual_quote"] / pool_data["virtual_base"])
* (10**TOKEN_DECIMALS) / LAMPORTS_PER_SOL
(pool_data["virtual_quote"] / pool_data["virtual_base"])
* (10**TOKEN_DECIMALS)
/ LAMPORTS_PER_SOL
)
else:
pool_data["price_per_token"] = 0
logger.debug(f"Decoded pool state: virtual_base={pool_data['virtual_base']}, "
f"virtual_quote={pool_data['virtual_quote']}, "
f"price={pool_data['price_per_token']:.8f} SOL")
logger.debug(
f"Decoded pool state: virtual_base={pool_data['virtual_base']}, "
f"virtual_quote={pool_data['virtual_quote']}, "
f"price={pool_data['price_per_token']:.8f} SOL"
)
return pool_data
async def validate_pool_state_structure(self, pool_address: Pubkey) -> bool:
"""Validate that the pool state structure matches IDL expectations.
Args:
pool_address: Address of the pool state
Returns:
True if structure is valid, False otherwise
"""
@@ -222,23 +219,27 @@ class LetsBonkCurveManager(CurveManager):
# This would be used during development/testing to ensure
# the IDL parsing is working correctly
pool_state = await self.get_pool_state(pool_address)
required_fields = [
"virtual_base", "virtual_quote",
"real_base", "real_quote"
"virtual_base",
"virtual_quote",
"real_base",
"real_quote",
]
for field in required_fields:
if field not in pool_state:
logger.error(f"Missing required field: {field}")
return False
if not isinstance(pool_state[field], int):
logger.error(f"Field {field} is not an integer: {type(pool_state[field])}")
logger.error(
f"Field {field} is not an integer: {type(pool_state[field])}"
)
return False
return True
except Exception:
logger.exception("Pool state validation failed")
return False
return False
+87 -70
View File
@@ -23,59 +23,56 @@ logger = get_logger(__name__)
class LetsBonkEventParser(EventParser):
"""LetsBonk implementation of EventParser interface with IDL-based parsing."""
def __init__(self, idl_parser: IDLParser):
"""Initialize LetsBonk event parser with injected IDL parser.
Args:
idl_parser: Pre-loaded IDL parser for LetsBonk platform
"""
self.address_provider = LetsBonkAddressProvider()
self._idl_parser = idl_parser
# Get discriminators from injected IDL parser
discriminators = self._idl_parser.get_instruction_discriminators()
self._initialize_discriminator_bytes = discriminators["initialize"]
self._initialize_discriminator = struct.unpack("<Q", self._initialize_discriminator_bytes)[0]
self._initialize_discriminator = struct.unpack(
"<Q", self._initialize_discriminator_bytes
)[0]
logger.info("LetsBonk event parser initialized with injected IDL parser")
@property
def platform(self) -> Platform:
"""Get the platform this parser serves."""
return Platform.LETS_BONK
def parse_token_creation_from_logs(
self,
logs: list[str],
signature: str
self, logs: list[str], signature: str
) -> TokenInfo | None:
"""Parse token creation from LetsBonk transaction logs.
Args:
logs: List of log strings from transaction
signature: Transaction signature
Returns:
TokenInfo if token creation found, None otherwise
"""
# LetsBonk doesn't emit specific logs for token creation like pump.fun
# Token creation is identified through instruction parsing
return None
def parse_token_creation_from_instruction(
self,
instruction_data: bytes,
accounts: list[int],
account_keys: list[bytes]
self, instruction_data: bytes, accounts: list[int], account_keys: list[bytes]
) -> TokenInfo | None:
"""Parse token creation from LetsBonk instruction data using injected IDL parser.
Args:
instruction_data: Raw instruction data
accounts: List of account indices
account_keys: List of account public keys
Returns:
TokenInfo if token creation found, None otherwise
"""
@@ -93,14 +90,16 @@ class LetsBonkEventParser(EventParser):
return Pubkey.from_bytes(account_keys[account_index])
# Parse instruction data using injected IDL parser
decoded = self._idl_parser.decode_instruction(instruction_data, account_keys, accounts)
if not decoded or decoded['instruction_name'] != 'initialize':
decoded = self._idl_parser.decode_instruction(
instruction_data, account_keys, accounts
)
if not decoded or decoded["instruction_name"] != "initialize":
return None
args = decoded.get('args', {})
args = decoded.get("args", {})
# Extract MintParams from the decoded arguments
base_mint_param = args.get('base_mint_param', {})
base_mint_param = args.get("base_mint_param", {})
if not base_mint_param:
return None
@@ -109,7 +108,7 @@ class LetsBonkEventParser(EventParser):
# 0: creator (signer)
# 1: creator_ata (not needed for TokenInfo)
# 2: global_config
# 3: platform_config
# 3: platform_config
# 4: creator
# 5: pool_state
# 6: base_mint
@@ -117,7 +116,7 @@ class LetsBonkEventParser(EventParser):
# 8: base_vault
# 9: quote_vault
# ... other accounts
creator = get_account_key(0) # First signer account (creator)
pool_state = get_account_key(5) # pool_state account
base_mint = get_account_key(6) # base_mint account
@@ -125,8 +124,10 @@ class LetsBonkEventParser(EventParser):
quote_vault = get_account_key(9) # quote_vault account
if not all([creator, pool_state, base_mint, base_vault, quote_vault]):
logger.debug(f"Missing required accounts: creator={creator}, pool_state={pool_state}, "
f"base_mint={base_mint}, base_vault={base_vault}, quote_vault={quote_vault}")
logger.debug(
f"Missing required accounts: creator={creator}, pool_state={pool_state}, "
f"base_mint={base_mint}, base_vault={base_vault}, quote_vault={quote_vault}"
)
return None
return TokenInfo(
@@ -146,21 +147,20 @@ class LetsBonkEventParser(EventParser):
except Exception as e:
logger.debug(f"Failed to parse initialize instruction: {e}")
return None
def parse_token_creation_from_geyser(
self,
transaction_info: Any
self, transaction_info: Any
) -> TokenInfo | None:
"""Parse token creation from Geyser transaction data.
Args:
transaction_info: Geyser transaction information
Returns:
TokenInfo if token creation found, None otherwise
"""
try:
if not hasattr(transaction_info, 'transaction'):
if not hasattr(transaction_info, "transaction"):
return None
tx = transaction_info.transaction.transaction.transaction
@@ -183,10 +183,14 @@ class LetsBonkEventParser(EventParser):
for acc_idx in ix.accounts:
if acc_idx < len(msg.account_keys):
acc_key = msg.account_keys[acc_idx]
if bytes(acc_key) == bytes(self.address_provider.get_system_addresses()["platform_config"]):
if bytes(acc_key) == bytes(
self.address_provider.get_system_addresses()[
"platform_config"
]
):
has_platform_config = True
break
if not has_platform_config:
continue
@@ -202,29 +206,29 @@ class LetsBonkEventParser(EventParser):
except Exception as e:
logger.debug(f"Failed to parse geyser transaction: {e}")
return None
def get_program_id(self) -> Pubkey:
"""Get the Raydium LaunchLab program ID this parser monitors.
Returns:
Raydium LaunchLab program ID
"""
return self.address_provider.program_id
def get_instruction_discriminators(self) -> list[bytes]:
"""Get instruction discriminators for token creation.
Returns:
List of discriminator bytes to match
"""
return [self._initialize_discriminator_bytes]
def parse_token_creation_from_block(self, block_data: dict) -> TokenInfo | None:
"""Parse token creation from block data (for block listener).
Args:
block_data: Block data from WebSocket
Returns:
TokenInfo if token creation found, None otherwise
"""
@@ -243,82 +247,95 @@ class LetsBonkEventParser(EventParser):
tx_data_encoded = tx_data[0]
tx_data_decoded = base64.b64decode(tx_data_encoded)
transaction = VersionedTransaction.from_bytes(tx_data_decoded)
for ix in transaction.message.instructions:
program_id = transaction.message.account_keys[ix.program_id_index]
program_id = transaction.message.account_keys[
ix.program_id_index
]
# Check if instruction is from LetsBonk program
if str(program_id) != str(self.get_program_id()):
continue
ix_data = bytes(ix.data)
# Check for initialize discriminator
if len(ix_data) >= 8:
discriminator = struct.unpack("<Q", ix_data[:8])[0]
if discriminator == self._initialize_discriminator:
# Token creation should have substantial data and many accounts
if len(ix_data) <= 8 or len(ix.accounts) < 10:
continue
account_keys_bytes = [
bytes(key)
for key in transaction.message.account_keys
]
# Parse the instruction
token_info = self.parse_token_creation_from_instruction(
ix_data, ix.accounts, account_keys_bytes
token_info = (
self.parse_token_creation_from_instruction(
ix_data, ix.accounts, account_keys_bytes
)
)
if token_info:
return token_info
except Exception as e:
logger.debug(f"Failed to parse block transaction: {e}")
continue
# Handle already decoded transaction data
elif isinstance(tx_data, dict) and "message" in tx_data:
try:
message = tx_data["message"]
if "instructions" not in message or "accountKeys" not in message:
if (
"instructions" not in message
or "accountKeys" not in message
):
continue
for ix in message["instructions"]:
if "programIdIndex" not in ix or "accounts" not in ix or "data" not in ix:
if (
"programIdIndex" not in ix
or "accounts" not in ix
or "data" not in ix
):
continue
program_idx = ix["programIdIndex"]
if program_idx >= len(message["accountKeys"]):
continue
program_id_str = message["accountKeys"][program_idx]
if program_id_str != str(self.get_program_id()):
continue
# Decode instruction data
ix_data = base64.b64decode(ix["data"])
if len(ix_data) >= 8:
discriminator = struct.unpack("<Q", ix_data[:8])[0]
if discriminator == self._initialize_discriminator:
if len(ix_data) <= 8 or len(ix["accounts"]) < 10:
continue
# Convert account keys to bytes for parsing
account_keys_bytes = [
Pubkey.from_string(key).to_bytes()
Pubkey.from_string(key).to_bytes()
for key in message["accountKeys"]
]
token_info = self.parse_token_creation_from_instruction(
ix_data, ix["accounts"], account_keys_bytes
token_info = (
self.parse_token_creation_from_instruction(
ix_data, ix["accounts"], account_keys_bytes
)
)
if token_info:
return token_info
except Exception as e:
logger.debug(f"Failed to parse decoded block transaction: {e}")
continue
@@ -327,4 +344,4 @@ class LetsBonkEventParser(EventParser):
except Exception as e:
logger.debug(f"Failed to parse block data: {e}")
return None
return None
+194 -144
View File
@@ -29,69 +29,69 @@ logger = get_logger(__name__)
class LetsBonkInstructionBuilder(InstructionBuilder):
"""LetsBonk (Raydium LaunchLab) implementation of InstructionBuilder interface with IDL-based discriminators."""
def __init__(self, idl_parser: IDLParser):
"""Initialize LetsBonk instruction builder with injected IDL parser.
Args:
idl_parser: Pre-loaded IDL parser for LetsBonk platform
"""
self._idl_parser = idl_parser
# Get discriminators from injected IDL parser
discriminators = self._idl_parser.get_instruction_discriminators()
self._buy_exact_in_discriminator = discriminators["buy_exact_in"]
self._sell_exact_in_discriminator = discriminators["sell_exact_in"]
logger.info("LetsBonk instruction builder initialized with injected IDL parser")
@property
def platform(self) -> Platform:
"""Get the platform this builder serves."""
return Platform.LETS_BONK
async def build_buy_instruction(
self,
token_info: TokenInfo,
user: Pubkey,
amount_in: int,
minimum_amount_out: int,
address_provider: AddressProvider
address_provider: AddressProvider,
) -> list[Instruction]:
"""Build buy instruction(s) for LetsBonk using buy_exact_in.
Args:
token_info: Token information
user: User's wallet address
amount_in: Amount of SOL to spend (in lamports)
minimum_amount_out: Minimum tokens expected (raw token units)
address_provider: Platform address provider
Returns:
List of instructions needed for the buy operation
"""
instructions = []
# Get all required accounts
accounts_info = address_provider.get_buy_instruction_accounts(token_info, user)
# 1. Create idempotent ATA for base token
ata_instruction = create_idempotent_associated_token_account(
user, # payer
user, # owner
user, # owner
token_info.mint, # mint
SystemAddresses.TOKEN_PROGRAM, # token program
)
instructions.append(ata_instruction)
# 2. Create WSOL account with seed (temporary account for the transaction)
wsol_seed = self._generate_wsol_seed(user)
wsol_account = address_provider.create_wsol_account_with_seed(user, wsol_seed)
# Account creation cost + amount to spend
account_creation_lamports = TOKEN_ACCOUNT_RENT_EXEMPT_RESERVE
total_lamports = amount_in + account_creation_lamports
create_wsol_ix = create_account_with_seed(
CreateAccountWithSeedParams(
from_pubkey=user,
@@ -100,97 +100,129 @@ class LetsBonkInstructionBuilder(InstructionBuilder):
seed=wsol_seed,
lamports=total_lamports,
space=TOKEN_ACCOUNT_SIZE,
owner=SystemAddresses.TOKEN_PROGRAM
owner=SystemAddresses.TOKEN_PROGRAM,
)
)
instructions.append(create_wsol_ix)
# 3. Initialize WSOL account
initialize_wsol_ix = self._create_initialize_account_instruction(
wsol_account,
SystemAddresses.SOL_MINT,
user
wsol_account, SystemAddresses.SOL_MINT, user
)
instructions.append(initialize_wsol_ix)
# 4. Build buy_exact_in instruction with correct account ordering
# Based on the IDL and manual examples, the account order should be:
buy_accounts = [
AccountMeta(pubkey=user, is_signer=True, is_writable=True), # payer
AccountMeta(pubkey=accounts_info["authority"], is_signer=False, is_writable=False), # authority
AccountMeta(pubkey=accounts_info["global_config"], is_signer=False, is_writable=False), # global_config
AccountMeta(pubkey=accounts_info["platform_config"], is_signer=False, is_writable=False), # platform_config
AccountMeta(pubkey=accounts_info["pool_state"], is_signer=False, is_writable=True), # pool_state
AccountMeta(pubkey=accounts_info["user_base_token"], is_signer=False, is_writable=True), # user_base_token
AccountMeta(pubkey=wsol_account, is_signer=False, is_writable=True), # user_quote_token (WSOL account)
AccountMeta(pubkey=accounts_info["base_vault"], is_signer=False, is_writable=True), # base_vault
AccountMeta(pubkey=accounts_info["quote_vault"], is_signer=False, is_writable=True), # quote_vault
AccountMeta(pubkey=token_info.mint, is_signer=False, is_writable=False), # base_token_mint
AccountMeta(pubkey=SystemAddresses.SOL_MINT, is_signer=False, is_writable=False), # quote_token_mint
AccountMeta(pubkey=SystemAddresses.TOKEN_PROGRAM, is_signer=False, is_writable=False), # base_token_program
AccountMeta(pubkey=SystemAddresses.TOKEN_PROGRAM, is_signer=False, is_writable=False), # quote_token_program
AccountMeta(pubkey=accounts_info["event_authority"], is_signer=False, is_writable=False), # event_authority
AccountMeta(pubkey=accounts_info["program"], is_signer=False, is_writable=False), # program
AccountMeta(pubkey=user, is_signer=True, is_writable=True), # payer
AccountMeta(
pubkey=accounts_info["authority"], is_signer=False, is_writable=False
), # authority
AccountMeta(
pubkey=accounts_info["global_config"],
is_signer=False,
is_writable=False,
), # global_config
AccountMeta(
pubkey=accounts_info["platform_config"],
is_signer=False,
is_writable=False,
), # platform_config
AccountMeta(
pubkey=accounts_info["pool_state"], is_signer=False, is_writable=True
), # pool_state
AccountMeta(
pubkey=accounts_info["user_base_token"],
is_signer=False,
is_writable=True,
), # user_base_token
AccountMeta(
pubkey=wsol_account, is_signer=False, is_writable=True
), # user_quote_token (WSOL account)
AccountMeta(
pubkey=accounts_info["base_vault"], is_signer=False, is_writable=True
), # base_vault
AccountMeta(
pubkey=accounts_info["quote_vault"], is_signer=False, is_writable=True
), # quote_vault
AccountMeta(
pubkey=token_info.mint, is_signer=False, is_writable=False
), # base_token_mint
AccountMeta(
pubkey=SystemAddresses.SOL_MINT, is_signer=False, is_writable=False
), # quote_token_mint
AccountMeta(
pubkey=SystemAddresses.TOKEN_PROGRAM, is_signer=False, is_writable=False
), # base_token_program
AccountMeta(
pubkey=SystemAddresses.TOKEN_PROGRAM, is_signer=False, is_writable=False
), # quote_token_program
AccountMeta(
pubkey=accounts_info["event_authority"],
is_signer=False,
is_writable=False,
), # event_authority
AccountMeta(
pubkey=accounts_info["program"], is_signer=False, is_writable=False
), # program
]
# Build instruction data: discriminator + amount_in + minimum_amount_out + share_fee_rate
SHARE_FEE_RATE = 0 # No sharing fee
instruction_data = (
self._buy_exact_in_discriminator +
struct.pack("<Q", amount_in) + # amount_in (u64) - SOL to spend
struct.pack("<Q", minimum_amount_out) + # minimum_amount_out (u64) - min tokens
struct.pack("<Q", SHARE_FEE_RATE) # share_fee_rate (u64): 0
self._buy_exact_in_discriminator
+ struct.pack("<Q", amount_in) # amount_in (u64) - SOL to spend
+ struct.pack(
"<Q", minimum_amount_out
) # minimum_amount_out (u64) - min tokens
+ struct.pack("<Q", SHARE_FEE_RATE) # share_fee_rate (u64): 0
)
buy_instruction = Instruction(
program_id=accounts_info["program"],
data=instruction_data,
accounts=buy_accounts
accounts=buy_accounts,
)
instructions.append(buy_instruction)
# 5. Close WSOL account to reclaim SOL
close_wsol_ix = self._create_close_account_instruction(
wsol_account,
user,
user
)
close_wsol_ix = self._create_close_account_instruction(wsol_account, user, user)
instructions.append(close_wsol_ix)
return instructions
async def build_sell_instruction(
self,
token_info: TokenInfo,
user: Pubkey,
amount_in: int,
minimum_amount_out: int,
address_provider: AddressProvider
address_provider: AddressProvider,
) -> list[Instruction]:
"""Build sell instruction(s) for LetsBonk using sell_exact_in.
Args:
token_info: Token information
user: User's wallet address
amount_in: Amount of tokens to sell (raw token units)
minimum_amount_out: Minimum SOL expected (in lamports)
address_provider: Platform address provider
Returns:
List of instructions needed for the sell operation
"""
instructions = []
# Get all required accounts
accounts_info = address_provider.get_sell_instruction_accounts(token_info, user)
# 1. Create WSOL account with seed (to receive SOL)
wsol_seed = self._generate_wsol_seed(user)
wsol_account = address_provider.create_wsol_account_with_seed(user, wsol_seed)
# Minimal account creation cost
account_creation_lamports = TOKEN_ACCOUNT_RENT_EXEMPT_RESERVE
create_wsol_ix = create_account_with_seed(
CreateAccountWithSeedParams(
from_pubkey=user,
@@ -199,82 +231,111 @@ class LetsBonkInstructionBuilder(InstructionBuilder):
seed=wsol_seed,
lamports=account_creation_lamports,
space=TOKEN_ACCOUNT_SIZE,
owner=SystemAddresses.TOKEN_PROGRAM
owner=SystemAddresses.TOKEN_PROGRAM,
)
)
instructions.append(create_wsol_ix)
# 2. Initialize WSOL account
initialize_wsol_ix = self._create_initialize_account_instruction(
wsol_account,
SystemAddresses.SOL_MINT,
user
wsol_account, SystemAddresses.SOL_MINT, user
)
instructions.append(initialize_wsol_ix)
# 3. Build sell_exact_in instruction with correct account ordering
sell_accounts = [
AccountMeta(pubkey=user, is_signer=True, is_writable=True), # payer
AccountMeta(pubkey=accounts_info["authority"], is_signer=False, is_writable=False), # authority
AccountMeta(pubkey=accounts_info["global_config"], is_signer=False, is_writable=False), # global_config
AccountMeta(pubkey=accounts_info["platform_config"], is_signer=False, is_writable=False), # platform_config
AccountMeta(pubkey=accounts_info["pool_state"], is_signer=False, is_writable=True), # pool_state
AccountMeta(pubkey=accounts_info["user_base_token"], is_signer=False, is_writable=True), # user_base_token (tokens being sold)
AccountMeta(pubkey=wsol_account, is_signer=False, is_writable=True), # user_quote_token (WSOL received)
AccountMeta(pubkey=accounts_info["base_vault"], is_signer=False, is_writable=True), # base_vault (receives tokens)
AccountMeta(pubkey=accounts_info["quote_vault"], is_signer=False, is_writable=True), # quote_vault (sends WSOL)
AccountMeta(pubkey=token_info.mint, is_signer=False, is_writable=False), # base_token_mint
AccountMeta(pubkey=SystemAddresses.SOL_MINT, is_signer=False, is_writable=False), # quote_token_mint
AccountMeta(pubkey=SystemAddresses.TOKEN_PROGRAM, is_signer=False, is_writable=False), # base_token_program
AccountMeta(pubkey=SystemAddresses.TOKEN_PROGRAM, is_signer=False, is_writable=False), # quote_token_program
AccountMeta(pubkey=accounts_info["event_authority"], is_signer=False, is_writable=False), # event_authority
AccountMeta(pubkey=accounts_info["program"], is_signer=False, is_writable=False), # program
AccountMeta(pubkey=user, is_signer=True, is_writable=True), # payer
AccountMeta(
pubkey=accounts_info["authority"], is_signer=False, is_writable=False
), # authority
AccountMeta(
pubkey=accounts_info["global_config"],
is_signer=False,
is_writable=False,
), # global_config
AccountMeta(
pubkey=accounts_info["platform_config"],
is_signer=False,
is_writable=False,
), # platform_config
AccountMeta(
pubkey=accounts_info["pool_state"], is_signer=False, is_writable=True
), # pool_state
AccountMeta(
pubkey=accounts_info["user_base_token"],
is_signer=False,
is_writable=True,
), # user_base_token (tokens being sold)
AccountMeta(
pubkey=wsol_account, is_signer=False, is_writable=True
), # user_quote_token (WSOL received)
AccountMeta(
pubkey=accounts_info["base_vault"], is_signer=False, is_writable=True
), # base_vault (receives tokens)
AccountMeta(
pubkey=accounts_info["quote_vault"], is_signer=False, is_writable=True
), # quote_vault (sends WSOL)
AccountMeta(
pubkey=token_info.mint, is_signer=False, is_writable=False
), # base_token_mint
AccountMeta(
pubkey=SystemAddresses.SOL_MINT, is_signer=False, is_writable=False
), # quote_token_mint
AccountMeta(
pubkey=SystemAddresses.TOKEN_PROGRAM, is_signer=False, is_writable=False
), # base_token_program
AccountMeta(
pubkey=SystemAddresses.TOKEN_PROGRAM, is_signer=False, is_writable=False
), # quote_token_program
AccountMeta(
pubkey=accounts_info["event_authority"],
is_signer=False,
is_writable=False,
), # event_authority
AccountMeta(
pubkey=accounts_info["program"], is_signer=False, is_writable=False
), # program
]
# Build instruction data: discriminator + amount_in + minimum_amount_out + share_fee_rate
SHARE_FEE_RATE = 0 # No sharing fee
instruction_data = (
self._sell_exact_in_discriminator +
struct.pack("<Q", amount_in) + # amount_in (u64) - tokens to sell
struct.pack("<Q", minimum_amount_out) + # minimum_amount_out (u64) - min SOL
struct.pack("<Q", SHARE_FEE_RATE) # share_fee_rate (u64): 0
self._sell_exact_in_discriminator
+ struct.pack("<Q", amount_in) # amount_in (u64) - tokens to sell
+ struct.pack(
"<Q", minimum_amount_out
) # minimum_amount_out (u64) - min SOL
+ struct.pack("<Q", SHARE_FEE_RATE) # share_fee_rate (u64): 0
)
sell_instruction = Instruction(
program_id=accounts_info["program"],
data=instruction_data,
accounts=sell_accounts
accounts=sell_accounts,
)
instructions.append(sell_instruction)
# 4. Close WSOL account to reclaim SOL
close_wsol_ix = self._create_close_account_instruction(
wsol_account,
user,
user
)
close_wsol_ix = self._create_close_account_instruction(wsol_account, user, user)
instructions.append(close_wsol_ix)
return instructions
def get_required_accounts_for_buy(
self,
token_info: TokenInfo,
user: Pubkey,
address_provider: AddressProvider
self, token_info: TokenInfo, user: Pubkey, address_provider: AddressProvider
) -> list[Pubkey]:
"""Get list of accounts required for buy operation (for priority fee calculation).
Args:
token_info: Token information
user: User's wallet address
address_provider: Platform address provider
Returns:
List of account addresses that will be accessed
"""
accounts_info = address_provider.get_buy_instruction_accounts(token_info, user)
return [
accounts_info["pool_state"],
accounts_info["user_base_token"],
@@ -284,25 +345,22 @@ class LetsBonkInstructionBuilder(InstructionBuilder):
SystemAddresses.SOL_MINT,
accounts_info["program"],
]
def get_required_accounts_for_sell(
self,
token_info: TokenInfo,
user: Pubkey,
address_provider: AddressProvider
self, token_info: TokenInfo, user: Pubkey, address_provider: AddressProvider
) -> list[Pubkey]:
"""Get list of accounts required for sell operation (for priority fee calculation).
Args:
token_info: Token information
user: User's wallet address
address_provider: Platform address provider
Returns:
List of account addresses that will be accessed
"""
accounts_info = address_provider.get_sell_instruction_accounts(token_info, user)
return [
accounts_info["pool_state"],
accounts_info["user_base_token"],
@@ -312,33 +370,30 @@ class LetsBonkInstructionBuilder(InstructionBuilder):
SystemAddresses.SOL_MINT,
accounts_info["program"],
]
def _generate_wsol_seed(self, user: Pubkey) -> str:
"""Generate a unique seed for WSOL account creation.
Args:
user: User's wallet address
Returns:
Unique seed string for WSOL account
"""
# Generate a unique seed based on timestamp and user pubkey
seed_data = f"{int(time.time())}{user!s}"
return hashlib.sha256(seed_data.encode()).hexdigest()[:32]
def _create_initialize_account_instruction(
self,
account: Pubkey,
mint: Pubkey,
owner: Pubkey
self, account: Pubkey, mint: Pubkey, owner: Pubkey
) -> Instruction:
"""Create an InitializeAccount instruction for the Token Program.
Args:
account: The account to initialize
mint: The token mint
owner: The account owner
Returns:
Instruction for initializing the account
"""
@@ -346,31 +401,28 @@ class LetsBonkInstructionBuilder(InstructionBuilder):
AccountMeta(pubkey=account, is_signer=False, is_writable=True),
AccountMeta(pubkey=mint, is_signer=False, is_writable=False),
AccountMeta(pubkey=owner, is_signer=False, is_writable=False),
AccountMeta(pubkey=SystemAddresses.RENT, is_signer=False, is_writable=False),
AccountMeta(
pubkey=SystemAddresses.RENT, is_signer=False, is_writable=False
),
]
# InitializeAccount instruction discriminator (instruction 1 in Token Program)
data = bytes([1])
return Instruction(
program_id=SystemAddresses.TOKEN_PROGRAM,
data=data,
accounts=accounts
program_id=SystemAddresses.TOKEN_PROGRAM, data=data, accounts=accounts
)
def _create_close_account_instruction(
self,
account: Pubkey,
destination: Pubkey,
owner: Pubkey
self, account: Pubkey, destination: Pubkey, owner: Pubkey
) -> Instruction:
"""Create a CloseAccount instruction for the Token Program.
Args:
account: The account to close
destination: Where to send the remaining lamports
owner: The account owner (must sign)
Returns:
Instruction for closing the account
"""
@@ -379,34 +431,32 @@ class LetsBonkInstructionBuilder(InstructionBuilder):
AccountMeta(pubkey=destination, is_signer=False, is_writable=True),
AccountMeta(pubkey=owner, is_signer=True, is_writable=False),
]
# CloseAccount instruction discriminator (instruction 9 in Token Program)
data = bytes([9])
return Instruction(
program_id=SystemAddresses.TOKEN_PROGRAM,
data=data,
accounts=accounts
program_id=SystemAddresses.TOKEN_PROGRAM, data=data, accounts=accounts
)
def calculate_token_amount_raw(self, token_amount_decimal: float) -> int:
"""Convert decimal token amount to raw token units.
Args:
token_amount_decimal: Token amount in decimal form
Returns:
Token amount in raw units (adjusted for decimals)
"""
return int(token_amount_decimal * 10**TOKEN_DECIMALS)
def calculate_token_amount_decimal(self, token_amount_raw: int) -> float:
"""Convert raw token amount to decimal form.
Args:
token_amount_raw: Token amount in raw units
Returns:
Token amount in decimal form
"""
return token_amount_raw / 10**TOKEN_DECIMALS
return token_amount_raw / 10**TOKEN_DECIMALS
+18 -16
View File
@@ -14,39 +14,39 @@ logger = get_logger(__name__)
class LetsBonkPumpPortalProcessor:
"""PumpPortal processor for LetsBonk tokens."""
def __init__(self):
"""Initialize the processor with address provider."""
self.address_provider = LetsBonkAddressProvider()
@property
def platform(self) -> Platform:
"""Get the platform this processor handles."""
return Platform.LETS_BONK
@property
def supported_pool_names(self) -> list[str]:
"""Get the pool names this processor supports from PumpPortal."""
return ["bonk"] # PumpPortal pool name for LetsBonk/bonk pools
def can_process(self, token_data: dict) -> bool:
"""Check if this processor can handle the given token data.
Args:
token_data: Token data from PumpPortal
Returns:
True if this processor can handle the token data
"""
pool = token_data.get("pool", "").lower()
return pool in self.supported_pool_names
def process_token_data(self, token_data: dict) -> TokenInfo | None:
"""Process LetsBonk token data from PumpPortal.
Args:
token_data: Token data from PumpPortal WebSocket
Returns:
TokenInfo if token creation found, None otherwise
"""
@@ -62,7 +62,9 @@ class LetsBonkPumpPortalProcessor:
# This would need to be adjusted based on actual PumpPortal data for LetsBonk tokens
if not all([name, symbol, mint_str, creator_str]):
logger.warning("Missing required fields in PumpPortal LetsBonk token data")
logger.warning(
"Missing required fields in PumpPortal LetsBonk token data"
)
return None
# Convert string addresses to Pubkey objects
@@ -72,7 +74,7 @@ class LetsBonkPumpPortalProcessor:
# Derive LetsBonk-specific addresses
pool_state = self.address_provider.derive_pool_address(mint)
# For LetsBonk, vault addresses might need to be derived differently
# or provided in the PumpPortal data. For now, we'll derive them
# using the standard pattern, but this might need adjustment
@@ -91,15 +93,15 @@ class LetsBonkPumpPortalProcessor:
quote_vault=None, # Will be filled from additional_accounts
)
)
# Extract vault addresses if available
base_vault = additional_accounts.get("base_vault")
quote_vault = additional_accounts.get("quote_vault")
# If vaults aren't available from additional_accounts,
# If vaults aren't available from additional_accounts,
# we might need to derive them or leave them None
# and let the trading logic handle the derivation
return TokenInfo(
name=name,
symbol=symbol,
@@ -115,4 +117,4 @@ class LetsBonkPumpPortalProcessor:
except Exception:
logger.exception("Failed to process PumpPortal LetsBonk token data")
return None
return None
+6 -6
View File
@@ -13,9 +13,9 @@ from .pumpportal_processor import PumpFunPumpPortalProcessor
# Export implementations for direct use if needed
__all__ = [
'PumpFunAddressProvider',
'PumpFunCurveManager',
'PumpFunEventParser',
'PumpFunInstructionBuilder',
'PumpFunPumpPortalProcessor'
]
"PumpFunAddressProvider",
"PumpFunCurveManager",
"PumpFunEventParser",
"PumpFunInstructionBuilder",
"PumpFunPumpPortalProcessor",
]
+77 -59
View File
@@ -39,7 +39,7 @@ class PumpFunAddresses:
def find_global_volume_accumulator() -> Pubkey:
"""
Derive the Program Derived Address (PDA) for the global volume accumulator.
Returns:
Pubkey of the derived global volume accumulator account
"""
@@ -53,10 +53,10 @@ class PumpFunAddresses:
def find_user_volume_accumulator(user: Pubkey) -> Pubkey:
"""
Derive the Program Derived Address (PDA) for a user's volume accumulator.
Args:
user: Pubkey of the user account
Returns:
Pubkey of the derived user volume accumulator account
"""
@@ -69,26 +69,26 @@ class PumpFunAddresses:
class PumpFunAddressProvider(AddressProvider):
"""Pump.Fun implementation of AddressProvider interface."""
@property
def platform(self) -> Platform:
"""Get the platform this provider serves."""
return Platform.PUMP_FUN
@property
def program_id(self) -> Pubkey:
"""Get the main program ID for this platform."""
return PumpFunAddresses.PROGRAM
def get_system_addresses(self) -> dict[str, Pubkey]:
"""Get all system addresses required for pump.fun.
Returns:
Dictionary mapping address names to Pubkey objects
"""
# Get system addresses from the single source of truth
system_addresses = SystemAddresses.get_all_system_addresses()
# Add pump.fun specific addresses
pumpfun_addresses = {
# Pump.fun specific addresses
@@ -98,82 +98,85 @@ class PumpFunAddressProvider(AddressProvider):
"fee": PumpFunAddresses.FEE,
"liquidity_migrator": PumpFunAddresses.LIQUIDITY_MIGRATOR,
}
# Combine system and platform-specific addresses
return {**system_addresses, **pumpfun_addresses}
def derive_pool_address(self, base_mint: Pubkey, quote_mint: Pubkey | None = None) -> Pubkey:
def derive_pool_address(
self, base_mint: Pubkey, quote_mint: Pubkey | None = None
) -> Pubkey:
"""Derive the bonding curve address for a token.
For pump.fun, this is the bonding curve PDA derived from the mint.
Args:
base_mint: Token mint address
quote_mint: Not used for pump.fun (SOL is always the quote)
Returns:
Bonding curve address
"""
bonding_curve, _ = Pubkey.find_program_address(
[b"bonding-curve", bytes(base_mint)],
PumpFunAddresses.PROGRAM
[b"bonding-curve", bytes(base_mint)], PumpFunAddresses.PROGRAM
)
return bonding_curve
def derive_user_token_account(self, user: Pubkey, mint: Pubkey) -> Pubkey:
"""Derive user's associated token account address.
Args:
user: User's wallet address
mint: Token mint address
Returns:
User's associated token account address
"""
return get_associated_token_address(user, mint)
def get_additional_accounts(self, token_info: TokenInfo) -> dict[str, Pubkey]:
"""Get pump.fun-specific additional accounts needed for trading.
Args:
token_info: Token information
Returns:
Dictionary of additional account addresses
"""
accounts = {}
# Add bonding curve if available
if token_info.bonding_curve:
accounts["bonding_curve"] = token_info.bonding_curve
# Add associated bonding curve if available
if token_info.associated_bonding_curve:
accounts["associated_bonding_curve"] = token_info.associated_bonding_curve
# Add creator vault if available
if token_info.creator_vault:
accounts["creator_vault"] = token_info.creator_vault
# Derive associated bonding curve if not provided
if not token_info.associated_bonding_curve and token_info.bonding_curve:
accounts["associated_bonding_curve"] = self.derive_associated_bonding_curve(
token_info.mint, token_info.bonding_curve
)
# Derive creator vault if not provided but creator is available
if not token_info.creator_vault and token_info.creator:
accounts["creator_vault"] = self.derive_creator_vault(token_info.creator)
return accounts
def derive_associated_bonding_curve(self, mint: Pubkey, bonding_curve: Pubkey) -> Pubkey:
def derive_associated_bonding_curve(
self, mint: Pubkey, bonding_curve: Pubkey
) -> Pubkey:
"""Derive the associated bonding curve (ATA of bonding curve for the token).
Args:
mint: Token mint address
bonding_curve: Bonding curve address
Returns:
Associated bonding curve address
"""
@@ -186,93 +189,108 @@ class PumpFunAddressProvider(AddressProvider):
SystemAddresses.ASSOCIATED_TOKEN_PROGRAM,
)
return derived_address
def derive_creator_vault(self, creator: Pubkey) -> Pubkey:
"""Derive the creator vault address.
Args:
creator: Creator address
Returns:
Creator vault address
"""
creator_vault, _ = Pubkey.find_program_address(
[b"creator-vault", bytes(creator)],
PumpFunAddresses.PROGRAM
[b"creator-vault", bytes(creator)], PumpFunAddresses.PROGRAM
)
return creator_vault
def derive_global_volume_accumulator(self) -> Pubkey:
"""Derive the global volume accumulator PDA.
Returns:
Global volume accumulator address
"""
return PumpFunAddresses.find_global_volume_accumulator()
def derive_user_volume_accumulator(self, user: Pubkey) -> Pubkey:
"""Derive the user volume accumulator PDA.
Args:
user: User address
Returns:
User volume accumulator address
"""
return PumpFunAddresses.find_user_volume_accumulator(user)
def get_buy_instruction_accounts(self, token_info: TokenInfo, user: Pubkey) -> dict[str, Pubkey]:
def get_buy_instruction_accounts(
self, token_info: TokenInfo, user: Pubkey
) -> dict[str, Pubkey]:
"""Get all accounts needed for a buy instruction.
Args:
token_info: Token information
user: User's wallet address
Returns:
Dictionary of account addresses for buy instruction
"""
additional_accounts = self.get_additional_accounts(token_info)
return {
"global": PumpFunAddresses.GLOBAL,
"fee": PumpFunAddresses.FEE,
"mint": token_info.mint,
"bonding_curve": additional_accounts.get("bonding_curve", token_info.bonding_curve),
"associated_bonding_curve": additional_accounts.get("associated_bonding_curve", token_info.associated_bonding_curve),
"bonding_curve": additional_accounts.get(
"bonding_curve", token_info.bonding_curve
),
"associated_bonding_curve": additional_accounts.get(
"associated_bonding_curve", token_info.associated_bonding_curve
),
"user_token_account": self.derive_user_token_account(user, token_info.mint),
"user": user,
"system_program": SystemAddresses.SYSTEM_PROGRAM,
"token_program": SystemAddresses.TOKEN_PROGRAM,
"creator_vault": additional_accounts.get("creator_vault", token_info.creator_vault),
"creator_vault": additional_accounts.get(
"creator_vault", token_info.creator_vault
),
"event_authority": PumpFunAddresses.EVENT_AUTHORITY,
"program": PumpFunAddresses.PROGRAM,
"global_volume_accumulator": self.derive_global_volume_accumulator(),
"user_volume_accumulator": self.derive_user_volume_accumulator(user),
}
def get_sell_instruction_accounts(self, token_info: TokenInfo, user: Pubkey) -> dict[str, Pubkey]:
def get_sell_instruction_accounts(
self, token_info: TokenInfo, user: Pubkey
) -> dict[str, Pubkey]:
"""Get all accounts needed for a sell instruction.
Args:
token_info: Token information
user: User's wallet address
Returns:
Dictionary of account addresses for sell instruction
"""
additional_accounts = self.get_additional_accounts(token_info)
return {
"global": PumpFunAddresses.GLOBAL,
"fee": PumpFunAddresses.FEE,
"mint": token_info.mint,
"bonding_curve": additional_accounts.get("bonding_curve", token_info.bonding_curve),
"associated_bonding_curve": additional_accounts.get("associated_bonding_curve", token_info.associated_bonding_curve),
"bonding_curve": additional_accounts.get(
"bonding_curve", token_info.bonding_curve
),
"associated_bonding_curve": additional_accounts.get(
"associated_bonding_curve", token_info.associated_bonding_curve
),
"user_token_account": self.derive_user_token_account(user, token_info.mint),
"user": user,
"system_program": SystemAddresses.SYSTEM_PROGRAM,
"creator_vault": additional_accounts.get("creator_vault", token_info.creator_vault),
"creator_vault": additional_accounts.get(
"creator_vault", token_info.creator_vault
),
"token_program": SystemAddresses.TOKEN_PROGRAM,
"event_authority": PumpFunAddresses.EVENT_AUTHORITY,
"program": PumpFunAddresses.PROGRAM,
}
}
+115 -96
View File
@@ -20,30 +20,30 @@ logger = get_logger(__name__)
class PumpFunCurveManager(CurveManager):
"""Pump.Fun implementation of CurveManager interface using IDL-based decoding."""
def __init__(self, client: SolanaClient, idl_parser: IDLParser):
"""Initialize pump.fun curve manager with injected IDL parser.
Args:
client: Solana RPC client
idl_parser: Pre-loaded IDL parser for pump.fun platform
"""
self.client = client
self._idl_parser = idl_parser
logger.info("Pump.Fun curve manager initialized with injected IDL parser")
@property
def platform(self) -> Platform:
"""Get the platform this manager serves."""
return Platform.PUMP_FUN
async def get_pool_state(self, pool_address: Pubkey) -> dict[str, Any]:
"""Get the current state of a pump.fun bonding curve.
Args:
pool_address: Address of the bonding curve
Returns:
Dictionary containing bonding curve state data
"""
@@ -51,140 +51,139 @@ class PumpFunCurveManager(CurveManager):
account = await self.client.get_account_info(pool_address)
if not account.data:
raise ValueError(f"No data in bonding curve account {pool_address}")
# Decode bonding curve state using injected IDL parser
curve_state_data = self._decode_curve_state_with_idl(account.data)
return curve_state_data
except Exception as e:
logger.exception("Failed to get curve state")
raise ValueError(f"Invalid bonding curve state: {e!s}")
async def calculate_price(self, pool_address: Pubkey) -> float:
"""Calculate current token price from bonding curve state.
Args:
pool_address: Address of the bonding curve
Returns:
Current token price in SOL
"""
pool_state = await self.get_pool_state(pool_address)
# Use virtual reserves for price calculation
virtual_token_reserves = pool_state["virtual_token_reserves"]
virtual_sol_reserves = pool_state["virtual_sol_reserves"]
if virtual_token_reserves <= 0:
return 0.0
# Price = sol_reserves / token_reserves
price_lamports = virtual_sol_reserves / virtual_token_reserves
return price_lamports * (10**TOKEN_DECIMALS) / LAMPORTS_PER_SOL
async def calculate_buy_amount_out(
self,
pool_address: Pubkey,
amount_in: int
self, pool_address: Pubkey, amount_in: int
) -> int:
"""Calculate expected tokens received for a buy operation.
Uses the pump.fun bonding curve formula to calculate token output.
Args:
pool_address: Address of the bonding curve
amount_in: Amount of SOL to spend (in lamports)
Returns:
Expected amount of tokens to receive (in raw token units)
"""
pool_state = await self.get_pool_state(pool_address)
virtual_token_reserves = pool_state["virtual_token_reserves"]
virtual_sol_reserves = pool_state["virtual_sol_reserves"]
# Use virtual reserves for bonding curve calculation
# Formula: tokens_out = (amount_in * virtual_token_reserves) / (virtual_sol_reserves + amount_in)
numerator = amount_in * virtual_token_reserves
denominator = virtual_sol_reserves + amount_in
if denominator == 0:
return 0
tokens_out = numerator // denominator
return tokens_out
async def calculate_sell_amount_out(
self,
pool_address: Pubkey,
amount_in: int
self, pool_address: Pubkey, amount_in: int
) -> int:
"""Calculate expected SOL received for a sell operation.
Uses the pump.fun bonding curve formula to calculate SOL output.
Args:
pool_address: Address of the bonding curve
amount_in: Amount of tokens to sell (in raw token units)
Returns:
Expected amount of SOL to receive (in lamports)
"""
pool_state = await self.get_pool_state(pool_address)
virtual_token_reserves = pool_state["virtual_token_reserves"]
virtual_sol_reserves = pool_state["virtual_sol_reserves"]
# Use virtual reserves for bonding curve calculation
# Formula: sol_out = (amount_in * virtual_sol_reserves) / (virtual_token_reserves + amount_in)
numerator = amount_in * virtual_sol_reserves
denominator = virtual_token_reserves + amount_in
if denominator == 0:
return 0
sol_out = numerator // denominator
return sol_out
async def get_reserves(self, pool_address: Pubkey) -> tuple[int, int]:
"""Get current bonding curve reserves.
Args:
pool_address: Address of the bonding curve
Returns:
Tuple of (token_reserves, sol_reserves) in raw units
"""
pool_state = await self.get_pool_state(pool_address)
return (pool_state["virtual_token_reserves"], pool_state["virtual_sol_reserves"])
return (
pool_state["virtual_token_reserves"],
pool_state["virtual_sol_reserves"],
)
def _decode_curve_state_with_idl(self, data: bytes) -> dict[str, Any]:
"""Decode bonding curve state data using injected IDL parser.
Args:
data: Raw account data
Returns:
Dictionary with decoded bonding curve state
Raises:
ValueError: If IDL parsing fails
"""
# Use injected IDL parser to decode BondingCurve account data
decoded_curve_state = self._idl_parser.decode_account_data(
data,
"BondingCurve",
skip_discriminator=True
data, "BondingCurve", skip_discriminator=True
)
if not decoded_curve_state:
raise ValueError("Failed to decode bonding curve state with IDL parser")
# Extract the fields we need for trading calculations
# Based on the BondingCurve structure from the IDL
curve_data = {
"virtual_token_reserves": decoded_curve_state.get("virtual_token_reserves", 0),
"virtual_token_reserves": decoded_curve_state.get(
"virtual_token_reserves", 0
),
"virtual_sol_reserves": decoded_curve_state.get("virtual_sol_reserves", 0),
"real_token_reserves": decoded_curve_state.get("real_token_reserves", 0),
"real_sol_reserves": decoded_curve_state.get("real_sol_reserves", 0),
@@ -192,106 +191,121 @@ class PumpFunCurveManager(CurveManager):
"complete": decoded_curve_state.get("complete", False),
"creator": decoded_curve_state.get("creator", ""),
}
# Calculate additional metrics
if curve_data["virtual_token_reserves"] > 0:
curve_data["price_per_token"] = (
(curve_data["virtual_sol_reserves"] / curve_data["virtual_token_reserves"])
* (10**TOKEN_DECIMALS) / LAMPORTS_PER_SOL
(
curve_data["virtual_sol_reserves"]
/ curve_data["virtual_token_reserves"]
)
* (10**TOKEN_DECIMALS)
/ LAMPORTS_PER_SOL
)
else:
curve_data["price_per_token"] = 0
# Add convenience decimal fields
curve_data["token_reserves_decimal"] = curve_data["virtual_token_reserves"] / 10**TOKEN_DECIMALS
curve_data["sol_reserves_decimal"] = curve_data["virtual_sol_reserves"] / LAMPORTS_PER_SOL
logger.debug(f"Decoded curve state: virtual_token_reserves={curve_data['virtual_token_reserves']}, "
f"virtual_sol_reserves={curve_data['virtual_sol_reserves']}, "
f"price={curve_data['price_per_token']:.8f} SOL")
curve_data["token_reserves_decimal"] = (
curve_data["virtual_token_reserves"] / 10**TOKEN_DECIMALS
)
curve_data["sol_reserves_decimal"] = (
curve_data["virtual_sol_reserves"] / LAMPORTS_PER_SOL
)
logger.debug(
f"Decoded curve state: virtual_token_reserves={curve_data['virtual_token_reserves']}, "
f"virtual_sol_reserves={curve_data['virtual_sol_reserves']}, "
f"price={curve_data['price_per_token']:.8f} SOL"
)
return curve_data
# Additional convenience methods for pump.fun specific operations
async def calculate_expected_tokens(self, pool_address: Pubkey, sol_amount: float) -> float:
async def calculate_expected_tokens(
self, pool_address: Pubkey, sol_amount: float
) -> float:
"""Calculate the expected token amount for a given SOL input.
This is a convenience method that converts between decimal and raw units.
Args:
pool_address: Address of the bonding curve
sol_amount: Amount of SOL to spend (in decimal SOL)
Returns:
Expected token amount (in decimal tokens)
"""
sol_lamports = int(sol_amount * LAMPORTS_PER_SOL)
tokens_raw = await self.calculate_buy_amount_out(pool_address, sol_lamports)
return tokens_raw / 10**TOKEN_DECIMALS
async def calculate_expected_sol(self, pool_address: Pubkey, token_amount: float) -> float:
async def calculate_expected_sol(
self, pool_address: Pubkey, token_amount: float
) -> float:
"""Calculate the expected SOL amount for a given token input.
This is a convenience method that converts between decimal and raw units.
Args:
pool_address: Address of the bonding curve
token_amount: Amount of tokens to sell (in decimal tokens)
Returns:
Expected SOL amount (in decimal SOL)
"""
tokens_raw = int(token_amount * 10**TOKEN_DECIMALS)
sol_lamports = await self.calculate_sell_amount_out(pool_address, tokens_raw)
return sol_lamports / LAMPORTS_PER_SOL
async def is_curve_complete(self, pool_address: Pubkey) -> bool:
"""Check if the bonding curve is complete (migrated to Raydium).
Args:
pool_address: Address of the bonding curve
Returns:
True if curve is complete, False otherwise
"""
pool_state = await self.get_pool_state(pool_address)
return pool_state.get("complete", False)
async def get_curve_progress(self, pool_address: Pubkey) -> dict[str, Any]:
"""Get bonding curve completion progress information.
Args:
pool_address: Address of the bonding curve
Returns:
Dictionary with progress information
"""
pool_state = await self.get_pool_state(pool_address)
# Calculate progress based on SOL raised vs target
# This is approximate since the exact target isn't stored in the curve state
sol_raised = pool_state["real_sol_reserves"] / LAMPORTS_PER_SOL
# Estimate progress based on typical pump.fun graduation requirements
# (This could be made more accurate with additional on-chain data)
estimated_target_sol = 85.0 # Typical pump.fun graduation target
progress_percentage = min((sol_raised / estimated_target_sol) * 100, 100.0)
return {
"complete": pool_state.get("complete", False),
"sol_raised": sol_raised,
"estimated_target_sol": estimated_target_sol,
"progress_percentage": progress_percentage,
"tokens_available": pool_state["virtual_token_reserves"] / 10**TOKEN_DECIMALS,
"tokens_available": pool_state["virtual_token_reserves"]
/ 10**TOKEN_DECIMALS,
"market_cap_sol": sol_raised, # Approximate market cap
}
def validate_curve_state_structure(self, pool_address: Pubkey) -> bool:
"""Validate that the curve state structure matches IDL expectations.
Args:
pool_address: Address of the bonding curve
Returns:
True if structure is valid, False otherwise
"""
@@ -299,24 +313,29 @@ class PumpFunCurveManager(CurveManager):
# This would be used during development/testing to ensure
# the IDL parsing is working correctly
pool_state = self.get_pool_state(pool_address)
required_fields = [
"virtual_token_reserves", "virtual_sol_reserves",
"real_token_reserves", "real_sol_reserves",
"token_total_supply", "complete"
"virtual_token_reserves",
"virtual_sol_reserves",
"real_token_reserves",
"real_sol_reserves",
"token_total_supply",
"complete",
]
for field in required_fields:
if field not in pool_state:
logger.error(f"Missing required field: {field}")
return False
if field != "complete" and not isinstance(pool_state[field], int):
logger.error(f"Field {field} is not an integer: {type(pool_state[field])}")
logger.error(
f"Field {field} is not an integer: {type(pool_state[field])}"
)
return False
return True
except Exception:
logger.exception("Curve state validation failed")
return False
return False
+221 -130
View File
@@ -27,40 +27,50 @@ class PumpFunEventParser(EventParser):
def __init__(self, idl_parser: IDLParser):
"""Initialize pump.fun event parser with injected IDL parser.
Args:
idl_parser: Pre-loaded IDL parser for pump.fun platform
"""
self._idl_parser = idl_parser
event_discriminators = self._idl_parser.get_event_discriminators()
self._create_event_discriminator_bytes = event_discriminators["CreateEvent"]
self._create_event_discriminator = struct.unpack("<Q", self._create_event_discriminator_bytes)[0]
self._create_event_discriminator = struct.unpack(
"<Q", self._create_event_discriminator_bytes
)[0]
instruction_discriminators = self._idl_parser.get_instruction_discriminators()
self._create_instruction_discriminator_bytes = instruction_discriminators["create"]
self._create_instruction_discriminator = struct.unpack("<Q", self._create_instruction_discriminator_bytes)[0]
logger.info("Pump.Fun event parser initialized with IDL-based event and instruction parsing")
logger.info(f"CreateEvent discriminator: {self._create_event_discriminator_bytes.hex()}")
logger.info(f"create instruction discriminator: {self._create_instruction_discriminator_bytes.hex()}")
self._create_instruction_discriminator_bytes = instruction_discriminators[
"create"
]
self._create_instruction_discriminator = struct.unpack(
"<Q", self._create_instruction_discriminator_bytes
)[0]
logger.info(
"Pump.Fun event parser initialized with IDL-based event and instruction parsing"
)
logger.info(
f"CreateEvent discriminator: {self._create_event_discriminator_bytes.hex()}"
)
logger.info(
f"create instruction discriminator: {self._create_instruction_discriminator_bytes.hex()}"
)
@property
def platform(self) -> Platform:
"""Get the platform this parser serves."""
return Platform.PUMP_FUN
def parse_token_creation_from_logs(
self,
logs: list[str],
signature: str
self, logs: list[str], signature: str
) -> TokenInfo | None:
"""Parse token creation from pump.fun transaction logs using IDL event parsing.
Args:
logs: List of log strings from transaction
signature: Transaction signature
Returns:
TokenInfo if token creation found, None otherwise
"""
@@ -73,13 +83,13 @@ class PumpFunEventParser(EventParser):
return None
logger.info(f"🔍 Parsing token creation from logs for signature: {signature}")
# Look for event data in the logs (CreateEvent data!)
# We need to find the Program data that comes after "Instruction: Create"
try:
create_instruction_found = False
program_data_entries = []
# First, collect all Program data entries and note when Create instruction happens
for i, log in enumerate(logs):
if "Program log: Instruction: Create" in log:
@@ -89,87 +99,141 @@ class PumpFunEventParser(EventParser):
# Extract base64 encoded event data
encoded_data = log.split("Program data: ")[1].strip()
program_data_entries.append((i, encoded_data, log))
logger.info(f"📊 Found Program data at log index {i}, length: {len(encoded_data)}")
logger.info(
f"📊 Found Program data at log index {i}, length: {len(encoded_data)}"
)
if not create_instruction_found:
logger.info("❌ No Create instruction found in logs")
return None
if not program_data_entries:
logger.info("❌ No Program data entries found in logs")
return None
logger.info(f"🔍 Found {len(program_data_entries)} Program data entries to check")
logger.info(
f"🔍 Found {len(program_data_entries)} Program data entries to check"
)
# Try each Program data entry
for entry_idx, (log_idx, encoded_data, full_log) in enumerate(program_data_entries):
for entry_idx, (log_idx, encoded_data, full_log) in enumerate(
program_data_entries
):
try:
logger.info(f"🧪 Trying Program data entry {entry_idx + 1}/{len(program_data_entries)} (log index {log_idx})")
logger.info(
f"🧪 Trying Program data entry {entry_idx + 1}/{len(program_data_entries)} (log index {log_idx})"
)
decoded_data = base64.b64decode(encoded_data)
if len(decoded_data) < 8:
logger.info(f"⚠️ Program data too short: {len(decoded_data)} bytes")
logger.info(
f"⚠️ Program data too short: {len(decoded_data)} bytes"
)
continue
# Check discriminator from program data
discriminator = decoded_data[:8]
discriminator_int = struct.unpack("<Q", discriminator)[0]
logger.info(f"🔍 Program data discriminator: {discriminator.hex()} (int: {discriminator_int})")
logger.info(f"🎯 Expected CreateEvent discriminator: {self._create_event_discriminator_bytes.hex()} (int: {self._create_event_discriminator})")
logger.info(
f"🔍 Program data discriminator: {discriminator.hex()} (int: {discriminator_int})"
)
logger.info(
f"🎯 Expected CreateEvent discriminator: {self._create_event_discriminator_bytes.hex()} (int: {self._create_event_discriminator})"
)
# Try to decode as CreateEvent using IDL parser
decoded_event = self._idl_parser.decode_event_data(decoded_data, "CreateEvent")
decoded_event = self._idl_parser.decode_event_data(
decoded_data, "CreateEvent"
)
if not decoded_event:
logger.info("❌ IDL parser returned None for CreateEvent")
continue
if decoded_event.get('event_name') != 'CreateEvent':
logger.info(f"❌ Wrong event type: {decoded_event.get('event_name', 'None')}")
if decoded_event.get("event_name") != "CreateEvent":
logger.info(
f"❌ Wrong event type: {decoded_event.get('event_name', 'None')}"
)
continue
logger.info(f"✅ Successfully decoded event: {decoded_event.get('event_name', 'Unknown')}")
logger.info(f"🔍 Event fields: {list(decoded_event.get('fields', {}).keys())}")
logger.info(
f"✅ Successfully decoded event: {decoded_event.get('event_name', 'Unknown')}"
)
logger.info(
f"🔍 Event fields: {list(decoded_event.get('fields', {}).keys())}"
)
fields = decoded_event.get('fields', {})
fields = decoded_event.get("fields", {})
if not fields:
logger.info("❌ No fields found in decoded event")
continue
# Validate required fields exist
required_fields = ["mint", "bonding_curve", "user", "creator", "name", "symbol", "uri"]
missing_fields = [field for field in required_fields if field not in fields]
required_fields = [
"mint",
"bonding_curve",
"user",
"creator",
"name",
"symbol",
"uri",
]
missing_fields = [
field for field in required_fields if field not in fields
]
if missing_fields:
logger.info(f"❌ Missing required fields: {missing_fields}")
continue
logger.info(f"🎯 Token found: {fields.get('symbol', 'Unknown')} ({fields.get('name', 'Unknown')})")
logger.info(
f"🎯 Token found: {fields.get('symbol', 'Unknown')} ({fields.get('name', 'Unknown')})"
)
# Convert string representations to Pubkey objects
# Note: IDL parser returns pubkeys as base58 strings
try:
mint = Pubkey.from_string(fields["mint"]) if isinstance(fields["mint"], str) else fields["mint"]
bonding_curve = Pubkey.from_string(fields["bonding_curve"]) if isinstance(fields["bonding_curve"], str) else fields["bonding_curve"]
user = Pubkey.from_string(fields["user"]) if isinstance(fields["user"], str) else fields["user"]
creator = Pubkey.from_string(fields["creator"]) if isinstance(fields["creator"], str) else fields["creator"]
mint = (
Pubkey.from_string(fields["mint"])
if isinstance(fields["mint"], str)
else fields["mint"]
)
bonding_curve = (
Pubkey.from_string(fields["bonding_curve"])
if isinstance(fields["bonding_curve"], str)
else fields["bonding_curve"]
)
user = (
Pubkey.from_string(fields["user"])
if isinstance(fields["user"], str)
else fields["user"]
)
creator = (
Pubkey.from_string(fields["creator"])
if isinstance(fields["creator"], str)
else fields["creator"]
)
logger.info(f"🔑 Mint: {mint}")
logger.info(f"🔑 Bonding Curve: {bonding_curve}")
logger.info(f"🔑 User: {user}")
logger.info(f"🔑 Creator: {creator}")
except Exception as e:
logger.info(f"❌ Failed to convert pubkey fields: {e}")
continue
# Derive additional addresses
associated_bonding_curve = self._derive_associated_bonding_curve(mint, bonding_curve)
associated_bonding_curve = self._derive_associated_bonding_curve(
mint, bonding_curve
)
creator_vault = self._derive_creator_vault(creator)
logger.info(f"✅ Successfully parsed CreateEvent for token: {fields.get('symbol', 'Unknown')}")
logger.info(
f"✅ Successfully parsed CreateEvent for token: {fields.get('symbol', 'Unknown')}"
)
return TokenInfo(
name=fields["name"],
symbol=fields["symbol"],
@@ -183,35 +247,36 @@ class PumpFunEventParser(EventParser):
creator_vault=creator_vault,
creation_timestamp=monotonic(),
)
except Exception as e:
logger.info(f"❌ Failed to decode Program data entry {entry_idx + 1}: {e}")
logger.info(
f"❌ Failed to decode Program data entry {entry_idx + 1}: {e}"
)
continue
logger.info("❌ No valid CreateEvent found in any Program data entries")
return None
except Exception:
logger.exception("Failed to parse token creation from logs")
return None
def parse_token_creation_from_instruction(
self,
instruction_data: bytes,
accounts: list[int],
account_keys: list[bytes]
self, instruction_data: bytes, accounts: list[int], account_keys: list[bytes]
) -> TokenInfo | None:
"""Parse token creation from pump.fun instruction data using injected IDL parser.
Args:
instruction_data: Raw instruction data
accounts: List of account indices
account_keys: List of account public keys
Returns:
TokenInfo if token creation found, None otherwise
"""
if not instruction_data.startswith(self._create_instruction_discriminator_bytes):
if not instruction_data.startswith(
self._create_instruction_discriminator_bytes
):
return None
try:
@@ -225,12 +290,14 @@ class PumpFunEventParser(EventParser):
return Pubkey.from_bytes(account_keys[account_index])
# Parse instruction data using injected IDL parser
decoded = self._idl_parser.decode_instruction(instruction_data, account_keys, accounts)
if not decoded or decoded['instruction_name'] != 'create':
decoded = self._idl_parser.decode_instruction(
instruction_data, account_keys, accounts
)
if not decoded or decoded["instruction_name"] != "create":
return None
args = decoded.get('args', {})
args = decoded.get("args", {})
# Extract account information based on IDL account order
mint = get_account_key(0)
bonding_curve = get_account_key(2)
@@ -241,7 +308,11 @@ class PumpFunEventParser(EventParser):
return None
# Create creator vault
creator = Pubkey.from_string(args.get("creator", str(user))) if args.get("creator") else user
creator = (
Pubkey.from_string(args.get("creator", str(user)))
if args.get("creator")
else user
)
creator_vault = self._derive_creator_vault(creator)
return TokenInfo(
@@ -261,21 +332,20 @@ class PumpFunEventParser(EventParser):
except Exception as e:
logger.debug(f"Failed to parse create instruction: {e}")
return None
def parse_token_creation_from_geyser(
self,
transaction_info: Any
self, transaction_info: Any
) -> TokenInfo | None:
"""Parse token creation from Geyser transaction data.
Args:
transaction_info: Geyser transaction information
Returns:
TokenInfo if token creation found, None otherwise
"""
try:
if not hasattr(transaction_info, 'transaction'):
if not hasattr(transaction_info, "transaction"):
return None
tx = transaction_info.transaction.transaction.transaction
@@ -304,37 +374,37 @@ class PumpFunEventParser(EventParser):
except Exception as e:
logger.debug(f"Failed to parse geyser transaction: {e}")
return None
def get_program_id(self) -> Pubkey:
"""Get the pump.fun program ID this parser monitors.
Returns:
Pump.fun program ID
"""
return PumpFunAddresses.PROGRAM
def get_instruction_discriminators(self) -> list[bytes]:
"""Get instruction discriminators for token creation.
Returns:
List of discriminator bytes to match
"""
return [self._create_instruction_discriminator_bytes]
def get_event_discriminators(self) -> list[bytes]:
"""Get event discriminators for token creation.
Returns:
List of event discriminator bytes to match
"""
return [self._create_event_discriminator_bytes]
def parse_token_creation_from_block(self, block_data: dict) -> TokenInfo | None:
"""Parse token creation from block data (for block listener).
Args:
block_data: Block data from WebSocket
Returns:
TokenInfo if token creation found, None otherwise
"""
@@ -353,21 +423,26 @@ class PumpFunEventParser(EventParser):
tx_data_encoded = tx_data[0]
tx_data_decoded = base64.b64decode(tx_data_encoded)
transaction = VersionedTransaction.from_bytes(tx_data_decoded)
for ix in transaction.message.instructions:
program_id = transaction.message.account_keys[ix.program_id_index]
program_id = transaction.message.account_keys[
ix.program_id_index
]
# Check if instruction is from pump.fun program
if str(program_id) != str(self.get_program_id()):
continue
ix_data = bytes(ix.data)
# Check for create discriminator
if len(ix_data) >= 8:
discriminator = struct.unpack("<Q", ix_data[:8])[0]
if discriminator == self._create_instruction_discriminator:
if (
discriminator
== self._create_instruction_discriminator
):
# Token creation should have substantial data and many accounts
if len(ix_data) <= 8 or len(ix.accounts) < 10:
continue
@@ -376,59 +451,73 @@ class PumpFunEventParser(EventParser):
bytes(key)
for key in transaction.message.account_keys
]
# Parse the instruction
token_info = self.parse_token_creation_from_instruction(
ix_data, ix.accounts, account_keys_bytes
token_info = (
self.parse_token_creation_from_instruction(
ix_data, ix.accounts, account_keys_bytes
)
)
if token_info:
return token_info
except Exception as e:
logger.debug(f"Failed to parse block transaction: {e}")
continue
# Handle already decoded transaction data
elif isinstance(tx_data, dict) and "message" in tx_data:
try:
message = tx_data["message"]
if "instructions" not in message or "accountKeys" not in message:
if (
"instructions" not in message
or "accountKeys" not in message
):
continue
for ix in message["instructions"]:
if "programIdIndex" not in ix or "accounts" not in ix or "data" not in ix:
if (
"programIdIndex" not in ix
or "accounts" not in ix
or "data" not in ix
):
continue
program_idx = ix["programIdIndex"]
if program_idx >= len(message["accountKeys"]):
continue
program_id_str = message["accountKeys"][program_idx]
if program_id_str != str(self.get_program_id()):
continue
# Decode instruction data
ix_data = base64.b64decode(ix["data"])
if len(ix_data) >= 8:
discriminator = struct.unpack("<Q", ix_data[:8])[0]
if discriminator == self._create_instruction_discriminator:
if (
discriminator
== self._create_instruction_discriminator
):
if len(ix_data) <= 8 or len(ix["accounts"]) < 10:
continue
# Convert account keys to bytes for parsing
account_keys_bytes = [
Pubkey.from_string(key).to_bytes()
Pubkey.from_string(key).to_bytes()
for key in message["accountKeys"]
]
token_info = self.parse_token_creation_from_instruction(
ix_data, ix["accounts"], account_keys_bytes
token_info = (
self.parse_token_creation_from_instruction(
ix_data, ix["accounts"], account_keys_bytes
)
)
if token_info:
return token_info
except Exception as e:
logger.debug(f"Failed to parse decoded block transaction: {e}")
continue
@@ -438,13 +527,13 @@ class PumpFunEventParser(EventParser):
except Exception as e:
logger.debug(f"Failed to parse block data: {e}")
return None
def _derive_creator_vault(self, creator: Pubkey) -> Pubkey:
"""Derive the creator vault for a creator.
Args:
creator: Creator address
Returns:
Creator vault address
"""
@@ -453,14 +542,16 @@ class PumpFunEventParser(EventParser):
PumpFunAddresses.PROGRAM,
)
return derived_address
def _derive_associated_bonding_curve(self, mint: Pubkey, bonding_curve: Pubkey) -> Pubkey:
def _derive_associated_bonding_curve(
self, mint: Pubkey, bonding_curve: Pubkey
) -> Pubkey:
"""Derive the associated bonding curve (ATA of bonding curve for the token).
Args:
mint: Token mint address
bonding_curve: Bonding curve address
Returns:
Associated bonding curve address
"""
@@ -474,12 +565,12 @@ class PumpFunEventParser(EventParser):
)
return derived_address
@property
@property
def verbose(self) -> bool:
"""Check if verbose logging is enabled."""
return getattr(self, '_verbose', False)
return getattr(self, "_verbose", False)
@verbose.setter
def verbose(self, value: bool) -> None:
"""Set verbose logging."""
self._verbose = value
self._verbose = value
+139 -77
View File
@@ -21,52 +21,52 @@ logger = get_logger(__name__)
class PumpFunInstructionBuilder(InstructionBuilder):
"""Pump.Fun implementation of InstructionBuilder interface with IDL-based discriminators."""
def __init__(self, idl_parser: IDLParser):
"""Initialize pump.fun instruction builder with injected IDL parser.
Args:
idl_parser: Pre-loaded IDL parser for pump.fun platform
"""
self._idl_parser = idl_parser
# Get discriminators from injected IDL parser
discriminators = self._idl_parser.get_instruction_discriminators()
self._buy_discriminator = discriminators["buy"]
self._sell_discriminator = discriminators["sell"]
logger.info("Pump.Fun instruction builder initialized with injected IDL parser")
@property
def platform(self) -> Platform:
"""Get the platform this builder serves."""
return Platform.PUMP_FUN
async def build_buy_instruction(
self,
token_info: TokenInfo,
user: Pubkey,
amount_in: int,
minimum_amount_out: int,
address_provider: AddressProvider
address_provider: AddressProvider,
) -> list[Instruction]:
"""Build buy instruction(s) for pump.fun.
Args:
token_info: Token information
user: User's wallet address
amount_in: Amount of SOL to spend (in lamports)
minimum_amount_out: Minimum tokens expected (raw token units)
address_provider: Platform address provider
Returns:
List of instructions needed for the buy operation
"""
instructions = []
# Get all required accounts
accounts_info = address_provider.get_buy_instruction_accounts(token_info, user)
# 1. Create idempotent ATA instruction (won't fail if ATA already exists)
ata_instruction = create_idempotent_associated_token_account(
user, # payer
@@ -75,116 +75,181 @@ class PumpFunInstructionBuilder(InstructionBuilder):
SystemAddresses.TOKEN_PROGRAM, # token program
)
instructions.append(ata_instruction)
# 2. Build buy instruction
buy_accounts = [
AccountMeta(pubkey=accounts_info["global"], is_signer=False, is_writable=False),
AccountMeta(
pubkey=accounts_info["global"], is_signer=False, is_writable=False
),
AccountMeta(pubkey=accounts_info["fee"], is_signer=False, is_writable=True),
AccountMeta(pubkey=accounts_info["mint"], is_signer=False, is_writable=False),
AccountMeta(pubkey=accounts_info["bonding_curve"], is_signer=False, is_writable=True),
AccountMeta(pubkey=accounts_info["associated_bonding_curve"], is_signer=False, is_writable=True),
AccountMeta(pubkey=accounts_info["user_token_account"], is_signer=False, is_writable=True),
AccountMeta(
pubkey=accounts_info["mint"], is_signer=False, is_writable=False
),
AccountMeta(
pubkey=accounts_info["bonding_curve"], is_signer=False, is_writable=True
),
AccountMeta(
pubkey=accounts_info["associated_bonding_curve"],
is_signer=False,
is_writable=True,
),
AccountMeta(
pubkey=accounts_info["user_token_account"],
is_signer=False,
is_writable=True,
),
AccountMeta(pubkey=accounts_info["user"], is_signer=True, is_writable=True),
AccountMeta(pubkey=accounts_info["system_program"], is_signer=False, is_writable=False),
AccountMeta(pubkey=accounts_info["token_program"], is_signer=False, is_writable=False),
AccountMeta(pubkey=accounts_info["creator_vault"], is_signer=False, is_writable=True),
AccountMeta(pubkey=accounts_info["event_authority"], is_signer=False, is_writable=False),
AccountMeta(pubkey=accounts_info["program"], is_signer=False, is_writable=False),
AccountMeta(pubkey=accounts_info["global_volume_accumulator"], is_signer=False, is_writable=True),
AccountMeta(pubkey=accounts_info["user_volume_accumulator"], is_signer=False, is_writable=True),
AccountMeta(
pubkey=accounts_info["system_program"],
is_signer=False,
is_writable=False,
),
AccountMeta(
pubkey=accounts_info["token_program"],
is_signer=False,
is_writable=False,
),
AccountMeta(
pubkey=accounts_info["creator_vault"], is_signer=False, is_writable=True
),
AccountMeta(
pubkey=accounts_info["event_authority"],
is_signer=False,
is_writable=False,
),
AccountMeta(
pubkey=accounts_info["program"], is_signer=False, is_writable=False
),
AccountMeta(
pubkey=accounts_info["global_volume_accumulator"],
is_signer=False,
is_writable=True,
),
AccountMeta(
pubkey=accounts_info["user_volume_accumulator"],
is_signer=False,
is_writable=True,
),
]
# Build instruction data: discriminator + token_amount + max_sol_cost
instruction_data = (
self._buy_discriminator +
struct.pack("<Q", minimum_amount_out) + # token amount in raw units
struct.pack("<Q", amount_in) # max SOL cost in lamports
self._buy_discriminator
+ struct.pack("<Q", minimum_amount_out) # token amount in raw units
+ struct.pack("<Q", amount_in) # max SOL cost in lamports
)
buy_instruction = Instruction(
program_id=accounts_info["program"],
data=instruction_data,
accounts=buy_accounts
accounts=buy_accounts,
)
instructions.append(buy_instruction)
return instructions
async def build_sell_instruction(
self,
token_info: TokenInfo,
user: Pubkey,
amount_in: int,
minimum_amount_out: int,
address_provider: AddressProvider
address_provider: AddressProvider,
) -> list[Instruction]:
"""Build sell instruction(s) for pump.fun.
Args:
token_info: Token information
user: User's wallet address
amount_in: Amount of tokens to sell (raw token units)
minimum_amount_out: Minimum SOL expected (in lamports)
address_provider: Platform address provider
Returns:
List of instructions needed for the sell operation
"""
instructions = []
# Get all required accounts
accounts_info = address_provider.get_sell_instruction_accounts(token_info, user)
# Build sell instruction accounts
sell_accounts = [
AccountMeta(pubkey=accounts_info["global"], is_signer=False, is_writable=False),
AccountMeta(
pubkey=accounts_info["global"], is_signer=False, is_writable=False
),
AccountMeta(pubkey=accounts_info["fee"], is_signer=False, is_writable=True),
AccountMeta(pubkey=accounts_info["mint"], is_signer=False, is_writable=False),
AccountMeta(pubkey=accounts_info["bonding_curve"], is_signer=False, is_writable=True),
AccountMeta(pubkey=accounts_info["associated_bonding_curve"], is_signer=False, is_writable=True),
AccountMeta(pubkey=accounts_info["user_token_account"], is_signer=False, is_writable=True),
AccountMeta(
pubkey=accounts_info["mint"], is_signer=False, is_writable=False
),
AccountMeta(
pubkey=accounts_info["bonding_curve"], is_signer=False, is_writable=True
),
AccountMeta(
pubkey=accounts_info["associated_bonding_curve"],
is_signer=False,
is_writable=True,
),
AccountMeta(
pubkey=accounts_info["user_token_account"],
is_signer=False,
is_writable=True,
),
AccountMeta(pubkey=accounts_info["user"], is_signer=True, is_writable=True),
AccountMeta(pubkey=accounts_info["system_program"], is_signer=False, is_writable=False),
AccountMeta(pubkey=accounts_info["creator_vault"], is_signer=False, is_writable=True),
AccountMeta(pubkey=accounts_info["token_program"], is_signer=False, is_writable=False),
AccountMeta(pubkey=accounts_info["event_authority"], is_signer=False, is_writable=False),
AccountMeta(pubkey=accounts_info["program"], is_signer=False, is_writable=False),
AccountMeta(
pubkey=accounts_info["system_program"],
is_signer=False,
is_writable=False,
),
AccountMeta(
pubkey=accounts_info["creator_vault"], is_signer=False, is_writable=True
),
AccountMeta(
pubkey=accounts_info["token_program"],
is_signer=False,
is_writable=False,
),
AccountMeta(
pubkey=accounts_info["event_authority"],
is_signer=False,
is_writable=False,
),
AccountMeta(
pubkey=accounts_info["program"], is_signer=False, is_writable=False
),
]
# Build instruction data: discriminator + token_amount + min_sol_output
instruction_data = (
self._sell_discriminator +
struct.pack("<Q", amount_in) + # token amount in raw units
struct.pack("<Q", minimum_amount_out) # min SOL output in lamports
self._sell_discriminator
+ struct.pack("<Q", amount_in) # token amount in raw units
+ struct.pack("<Q", minimum_amount_out) # min SOL output in lamports
)
sell_instruction = Instruction(
program_id=accounts_info["program"],
data=instruction_data,
accounts=sell_accounts
accounts=sell_accounts,
)
instructions.append(sell_instruction)
return instructions
def get_required_accounts_for_buy(
self,
token_info: TokenInfo,
user: Pubkey,
address_provider: AddressProvider
self, token_info: TokenInfo, user: Pubkey, address_provider: AddressProvider
) -> list[Pubkey]:
"""Get list of accounts required for buy operation (for priority fee calculation).
Args:
token_info: Token information
user: User's wallet address
address_provider: Platform address provider
Returns:
List of account addresses that will be accessed
"""
accounts_info = address_provider.get_buy_instruction_accounts(token_info, user)
return [
accounts_info["mint"],
accounts_info["bonding_curve"],
@@ -196,25 +261,22 @@ class PumpFunInstructionBuilder(InstructionBuilder):
accounts_info["user_volume_accumulator"],
accounts_info["program"],
]
def get_required_accounts_for_sell(
self,
token_info: TokenInfo,
user: Pubkey,
address_provider: AddressProvider
self, token_info: TokenInfo, user: Pubkey, address_provider: AddressProvider
) -> list[Pubkey]:
"""Get list of accounts required for sell operation (for priority fee calculation).
Args:
token_info: Token information
user: User's wallet address
address_provider: Platform address provider
Returns:
List of account addresses that will be accessed
"""
accounts_info = address_provider.get_sell_instruction_accounts(token_info, user)
return [
accounts_info["mint"],
accounts_info["bonding_curve"],
@@ -224,25 +286,25 @@ class PumpFunInstructionBuilder(InstructionBuilder):
accounts_info["creator_vault"],
accounts_info["program"],
]
def calculate_token_amount_raw(self, token_amount_decimal: float) -> int:
"""Convert decimal token amount to raw token units.
Args:
token_amount_decimal: Token amount in decimal form
Returns:
Token amount in raw units (adjusted for decimals)
"""
return int(token_amount_decimal * 10**TOKEN_DECIMALS)
def calculate_token_amount_decimal(self, token_amount_raw: int) -> float:
"""Convert raw token amount to decimal form.
Args:
token_amount_raw: Token amount in raw units
Returns:
Token amount in decimal form
"""
return token_amount_raw / 10**TOKEN_DECIMALS
return token_amount_raw / 10**TOKEN_DECIMALS
+14 -12
View File
@@ -14,39 +14,39 @@ logger = get_logger(__name__)
class PumpFunPumpPortalProcessor:
"""PumpPortal processor for pump.fun tokens."""
def __init__(self):
"""Initialize the processor with address provider."""
self.address_provider = PumpFunAddressProvider()
@property
def platform(self) -> Platform:
"""Get the platform this processor handles."""
return Platform.PUMP_FUN
@property
def supported_pool_names(self) -> list[str]:
"""Get the pool names this processor supports from PumpPortal."""
return ["pump"] # PumpPortal pool name for pump.fun
def can_process(self, token_data: dict) -> bool:
"""Check if this processor can handle the given token data.
Args:
token_data: Token data from PumpPortal
Returns:
True if this processor can handle the token data
"""
pool = token_data.get("pool", "").lower()
return pool in self.supported_pool_names
def process_token_data(self, token_data: dict) -> TokenInfo | None:
"""Process pump.fun token data from PumpPortal.
Args:
token_data: Token data from PumpPortal WebSocket
Returns:
TokenInfo if token creation found, None otherwise
"""
@@ -81,8 +81,10 @@ class PumpFunPumpPortalProcessor:
creator = user
# Derive additional addresses using platform provider
associated_bonding_curve = self.address_provider.derive_associated_bonding_curve(
mint, bonding_curve
associated_bonding_curve = (
self.address_provider.derive_associated_bonding_curve(
mint, bonding_curve
)
)
creator_vault = self.address_provider.derive_creator_vault(creator)
@@ -101,4 +103,4 @@ class PumpFunPumpPortalProcessor:
except Exception:
logger.exception("Failed to process PumpPortal token data")
return None
return None
+78 -68
View File
@@ -19,6 +19,7 @@ from interfaces.core import Platform, TokenInfo
@dataclass
class TradeResult:
"""Enhanced result of a trading operation with platform support."""
success: bool
platform: Platform = Platform.PUMP_FUN # Add platform tracking
tx_signature: str | None = None
@@ -28,7 +29,7 @@ class TradeResult:
def to_dict(self) -> dict[str, Any]:
"""Convert to dictionary for logging/serialization.
Returns:
Dictionary representation of the trade result
"""
@@ -51,7 +52,7 @@ class Trader(ABC):
Args:
token_info: Enhanced token information with platform support
Returns:
TradeResult with operation outcome including platform info
"""
@@ -60,7 +61,7 @@ class Trader(ABC):
def _get_relevant_accounts(self, token_info: TokenInfo) -> list[Pubkey]:
"""
Get the list of accounts relevant for calculating the priority fee.
This is now platform-agnostic and should be overridden by platform-specific traders.
Args:
@@ -71,13 +72,13 @@ class Trader(ABC):
"""
# Basic implementation - platform-specific traders should override this
accounts = [token_info.mint]
if token_info.bonding_curve:
accounts.append(token_info.bonding_curve)
if token_info.pool_state: # For other platforms
accounts.append(token_info.pool_state)
return accounts
@@ -85,6 +86,7 @@ class Trader(ABC):
@dataclass
class TokenInfo_Legacy:
"""Legacy token information structure for backward compatibility."""
name: str
symbol: str
uri: str
@@ -138,13 +140,13 @@ class TokenInfo_Legacy:
def upgrade_token_info(legacy_token_info: TokenInfo_Legacy) -> TokenInfo:
"""Convert legacy TokenInfo to enhanced TokenInfo.
This function allows existing code that creates legacy TokenInfo objects
to be upgraded to the new enhanced format.
Args:
legacy_token_info: Legacy TokenInfo instance
Returns:
Enhanced TokenInfo with platform information
"""
@@ -164,29 +166,31 @@ def upgrade_token_info(legacy_token_info: TokenInfo_Legacy) -> TokenInfo:
def create_legacy_token_info(enhanced_token_info: TokenInfo) -> TokenInfo_Legacy:
"""Convert enhanced TokenInfo back to legacy TokenInfo if needed.
This function allows the enhanced TokenInfo to be used with existing
code that expects the legacy format.
Args:
enhanced_token_info: Enhanced TokenInfo instance
Returns:
Legacy TokenInfo instance
Raises:
ValueError: If enhanced TokenInfo doesn't have required pump.fun fields
"""
if enhanced_token_info.platform != Platform.PUMP_FUN:
raise ValueError("Can only convert pump.fun tokens to legacy format")
if not all([
enhanced_token_info.bonding_curve,
enhanced_token_info.associated_bonding_curve,
enhanced_token_info.creator_vault
]):
if not all(
[
enhanced_token_info.bonding_curve,
enhanced_token_info.associated_bonding_curve,
enhanced_token_info.creator_vault,
]
):
raise ValueError("Enhanced TokenInfo missing required pump.fun fields")
return TokenInfo_Legacy(
name=enhanced_token_info.name,
symbol=enhanced_token_info.symbol,
@@ -210,10 +214,10 @@ def create_pump_fun_token_info(
user: Pubkey,
creator: Pubkey | None = None,
creator_vault: Pubkey | None = None,
**kwargs
**kwargs,
) -> TokenInfo:
"""Convenience function to create pump.fun TokenInfo with proper platform setting.
Args:
name: Token name
symbol: Token symbol
@@ -225,7 +229,7 @@ def create_pump_fun_token_info(
creator: Creator address (defaults to user if not provided)
creator_vault: Creator vault address (will be derived if not provided)
**kwargs: Additional fields for TokenInfo
Returns:
Enhanced TokenInfo configured for pump.fun
"""
@@ -234,7 +238,7 @@ def create_pump_fun_token_info(
# We can't import PumpAddresses here, so this will need to be handled elsewhere
# For now, leave it as None and let the platform implementation handle it
pass
return TokenInfo(
name=name,
symbol=symbol,
@@ -246,7 +250,7 @@ def create_pump_fun_token_info(
user=user,
creator=creator or user,
creator_vault=creator_vault,
**kwargs
**kwargs,
)
@@ -260,10 +264,10 @@ def create_lets_bonk_token_info(
quote_vault: Pubkey,
user: Pubkey,
creator: Pubkey | None = None,
**kwargs
**kwargs,
) -> TokenInfo:
"""Convenience function to create LetsBonk TokenInfo with proper platform setting.
Args:
name: Token name
symbol: Token symbol
@@ -275,7 +279,7 @@ def create_lets_bonk_token_info(
user: User/trader address
creator: Creator address (defaults to user if not provided)
**kwargs: Additional fields for TokenInfo
Returns:
Enhanced TokenInfo configured for LetsBonk
"""
@@ -290,16 +294,16 @@ def create_lets_bonk_token_info(
quote_vault=quote_vault,
user=user,
creator=creator or user,
**kwargs
**kwargs,
)
def is_pump_fun_token(token_info: TokenInfo) -> bool:
"""Check if a TokenInfo is for pump.fun platform.
Args:
token_info: Token information to check
Returns:
True if token is for pump.fun platform
"""
@@ -308,10 +312,10 @@ def is_pump_fun_token(token_info: TokenInfo) -> bool:
def is_lets_bonk_token(token_info: TokenInfo) -> bool:
"""Check if a TokenInfo is for LetsBonk platform.
Args:
token_info: Token information to check
Returns:
True if token is for LetsBonk platform
"""
@@ -320,10 +324,10 @@ def is_lets_bonk_token(token_info: TokenInfo) -> bool:
def get_platform_specific_fields(token_info: TokenInfo) -> dict[str, Any]:
"""Get platform-specific fields from TokenInfo.
Args:
token_info: Token information
Returns:
Dictionary of platform-specific fields
"""
@@ -345,51 +349,57 @@ def get_platform_specific_fields(token_info: TokenInfo) -> dict[str, Any]:
def validate_token_info(token_info: TokenInfo) -> bool:
"""Validate that TokenInfo has required fields for its platform.
Args:
token_info: Token information to validate
Returns:
True if TokenInfo is valid for its platform
"""
# Check common required fields
if not all([
token_info.name,
token_info.symbol,
token_info.mint,
token_info.platform,
]):
if not all(
[
token_info.name,
token_info.symbol,
token_info.mint,
token_info.platform,
]
):
return False
# Check platform-specific required fields
if token_info.platform == Platform.PUMP_FUN:
return all([
token_info.bonding_curve,
token_info.associated_bonding_curve,
])
return all(
[
token_info.bonding_curve,
token_info.associated_bonding_curve,
]
)
elif token_info.platform == Platform.LETS_BONK:
return all([
token_info.pool_state,
token_info.base_vault,
token_info.quote_vault,
])
return all(
[
token_info.pool_state,
token_info.base_vault,
token_info.quote_vault,
]
)
return True
# Backward compatibility exports
__all__ = [
'Platform', # Platform enum
'TokenInfo', # Enhanced TokenInfo (main export)
'TokenInfo_Legacy', # Legacy TokenInfo for compatibility
'TradeResult', # Enhanced TradeResult
'Trader', # Enhanced Trader base class
'create_legacy_token_info',
'create_lets_bonk_token_info',
'create_pump_fun_token_info',
'get_platform_specific_fields',
'is_lets_bonk_token',
'is_pump_fun_token',
'upgrade_token_info',
'validate_token_info',
]
"Platform", # Platform enum
"TokenInfo", # Enhanced TokenInfo (main export)
"TokenInfo_Legacy", # Legacy TokenInfo for compatibility
"TradeResult", # Enhanced TradeResult
"Trader", # Enhanced Trader base class
"create_legacy_token_info",
"create_lets_bonk_token_info",
"create_pump_fun_token_info",
"get_platform_specific_fields",
"is_lets_bonk_token",
"is_pump_fun_token",
"upgrade_token_info",
"validate_token_info",
]
+39 -27
View File
@@ -45,7 +45,9 @@ class PlatformAwareBuyer(Trader):
"""Execute buy operation using platform-specific implementations."""
try:
# Get platform-specific implementations
implementations = get_platform_implementations(token_info.platform, self.client)
implementations = get_platform_implementations(
token_info.platform, self.client
)
address_provider = implementations.address_provider
instruction_builder = implementations.instruction_builder
curve_manager = implementations.curve_manager
@@ -60,10 +62,12 @@ class PlatformAwareBuyer(Trader):
else:
# Get pool address based on platform using platform-agnostic method
pool_address = self._get_pool_address(token_info, address_provider)
# Regular behavior with RPC call
token_price_sol = await curve_manager.calculate_price(pool_address)
token_amount = self.amount / token_price_sol if token_price_sol > 0 else 0
token_amount = (
self.amount / token_price_sol if token_price_sol > 0 else 0
)
# Calculate minimum token amount with slippage
minimum_token_amount = token_amount * (1 - self.slippage)
@@ -78,7 +82,7 @@ class PlatformAwareBuyer(Trader):
self.wallet.pubkey,
max_amount_lamports, # amount_in (SOL)
minimum_token_amount_raw, # minimum_amount_out (tokens)
address_provider
address_provider,
)
# Get accounts for priority fee calculation
@@ -125,21 +129,21 @@ class PlatformAwareBuyer(Trader):
except Exception as e:
logger.exception("Buy operation failed")
return TradeResult(
success=False,
platform=token_info.platform,
error_message=str(e)
success=False, platform=token_info.platform, error_message=str(e)
)
def _get_pool_address(self, token_info: TokenInfo, address_provider: AddressProvider) -> Pubkey:
def _get_pool_address(
self, token_info: TokenInfo, address_provider: AddressProvider
) -> Pubkey:
"""Get the pool/curve address for price calculations using platform-agnostic method."""
# Try to get the address from token_info first, then derive if needed
if token_info.platform == Platform.PUMP_FUN:
if hasattr(token_info, 'bonding_curve') and token_info.bonding_curve:
if hasattr(token_info, "bonding_curve") and token_info.bonding_curve:
return token_info.bonding_curve
elif token_info.platform == Platform.LETS_BONK:
if hasattr(token_info, 'pool_state') and token_info.pool_state:
if hasattr(token_info, "pool_state") and token_info.pool_state:
return token_info.pool_state
# Fallback to deriving the address using platform provider
return address_provider.derive_pool_address(token_info.mint)
@@ -166,7 +170,9 @@ class PlatformAwareSeller(Trader):
"""Execute sell operation using platform-specific implementations."""
try:
# Get platform-specific implementations
implementations = get_platform_implementations(token_info.platform, self.client)
implementations = get_platform_implementations(
token_info.platform, self.client
)
address_provider = implementations.address_provider
instruction_builder = implementations.instruction_builder
curve_manager = implementations.curve_manager
@@ -175,8 +181,10 @@ class PlatformAwareSeller(Trader):
user_token_account = address_provider.derive_user_token_account(
self.wallet.pubkey, token_info.mint
)
token_balance = await self.client.get_token_account_balance(user_token_account)
token_balance = await self.client.get_token_account_balance(
user_token_account
)
token_balance_decimal = token_balance / 10**TOKEN_DECIMALS
logger.info(f"Token balance: {token_balance_decimal}")
@@ -184,9 +192,9 @@ class PlatformAwareSeller(Trader):
if token_balance == 0:
logger.info("No tokens to sell.")
return TradeResult(
success=False,
success=False,
platform=token_info.platform,
error_message="No tokens to sell"
error_message="No tokens to sell",
)
# Get pool address and current price using platform-agnostic method
@@ -197,9 +205,13 @@ class PlatformAwareSeller(Trader):
# Calculate minimum SOL output with slippage
expected_sol_output = float(token_balance_decimal) * float(token_price_sol)
min_sol_output = int((expected_sol_output * (1 - self.slippage)) * LAMPORTS_PER_SOL)
min_sol_output = int(
(expected_sol_output * (1 - self.slippage)) * LAMPORTS_PER_SOL
)
logger.info(f"Selling {token_balance_decimal} tokens on {token_info.platform.value}")
logger.info(
f"Selling {token_balance_decimal} tokens on {token_info.platform.value}"
)
logger.info(f"Expected SOL output: {expected_sol_output:.8f} SOL")
logger.info(
f"Minimum SOL output (with {self.slippage * 100}% slippage): {min_sol_output / LAMPORTS_PER_SOL:.8f} SOL"
@@ -211,7 +223,7 @@ class PlatformAwareSeller(Trader):
self.wallet.pubkey,
token_balance, # amount_in (tokens)
min_sol_output, # minimum_amount_out (SOL)
address_provider
address_provider,
)
# Get accounts for priority fee calculation
@@ -251,20 +263,20 @@ class PlatformAwareSeller(Trader):
except Exception as e:
logger.exception("Sell operation failed")
return TradeResult(
success=False,
platform=token_info.platform,
error_message=str(e)
success=False, platform=token_info.platform, error_message=str(e)
)
def _get_pool_address(self, token_info: TokenInfo, address_provider: AddressProvider) -> Pubkey:
def _get_pool_address(
self, token_info: TokenInfo, address_provider: AddressProvider
) -> Pubkey:
"""Get the pool/curve address for price calculations using platform-agnostic method."""
# Try to get the address from token_info first, then derive if needed
if token_info.platform == Platform.PUMP_FUN:
if hasattr(token_info, 'bonding_curve') and token_info.bonding_curve:
if hasattr(token_info, "bonding_curve") and token_info.bonding_curve:
return token_info.bonding_curve
elif token_info.platform == Platform.LETS_BONK:
if hasattr(token_info, 'pool_state') and token_info.pool_state:
if hasattr(token_info, "pool_state") and token_info.pool_state:
return token_info.pool_state
# Fallback to deriving the address using platform provider
return address_provider.derive_pool_address(token_info.mint)
return address_provider.derive_pool_address(token_info.mint)
+132 -44
View File
@@ -108,6 +108,7 @@ class UniversalTrader:
# Validate platform support
try:
from platforms import platform_factory
if not platform_factory.registry.is_platform_supported(self.platform):
raise ValueError(f"Platform {self.platform.value} is not supported")
except Exception:
@@ -130,7 +131,7 @@ class UniversalTrader:
extreme_fast_token_amount,
extreme_fast_mode,
)
self.seller = PlatformAwareSeller(
self.solana_client,
self.wallet,
@@ -193,17 +194,27 @@ class UniversalTrader:
async def start(self) -> None:
"""Start the trading bot and listen for new tokens."""
logger.info(f"Starting Universal Trader for {self.platform.value}")
logger.info(f"Match filter: {self.match_string if self.match_string else 'None'}")
logger.info(f"Creator filter: {self.bro_address if self.bro_address else 'None'}")
logger.info(
f"Match filter: {self.match_string if self.match_string else 'None'}"
)
logger.info(
f"Creator filter: {self.bro_address if self.bro_address else 'None'}"
)
logger.info(f"Marry mode: {self.marry_mode}")
logger.info(f"YOLO mode: {self.yolo_mode}")
logger.info(f"Exit strategy: {self.exit_strategy}")
if self.exit_strategy == "tp_sl":
logger.info(f"Take profit: {self.take_profit_percentage * 100 if self.take_profit_percentage else 'None'}%")
logger.info(f"Stop loss: {self.stop_loss_percentage * 100 if self.stop_loss_percentage else 'None'}%")
logger.info(f"Max hold time: {self.max_hold_time if self.max_hold_time else 'None'} seconds")
logger.info(
f"Take profit: {self.take_profit_percentage * 100 if self.take_profit_percentage else 'None'}%"
)
logger.info(
f"Stop loss: {self.stop_loss_percentage * 100 if self.stop_loss_percentage else 'None'}%"
)
logger.info(
f"Max hold time: {self.max_hold_time if self.max_hold_time else 'None'} seconds"
)
logger.info(f"Max token age: {self.max_token_age} seconds")
try:
@@ -216,16 +227,22 @@ class UniversalTrader:
# Choose operating mode based on yolo_mode
if not self.yolo_mode:
# Single token mode: process one token and exit
logger.info("Running in single token mode - will process one token and exit")
logger.info(
"Running in single token mode - will process one token and exit"
)
token_info = await self._wait_for_token()
if token_info:
await self._handle_token(token_info)
logger.info("Finished processing single token. Exiting...")
else:
logger.info(f"No suitable token found within timeout period ({self.token_wait_timeout}s). Exiting...")
logger.info(
f"No suitable token found within timeout period ({self.token_wait_timeout}s). Exiting..."
)
else:
# Continuous mode: process tokens until interrupted
logger.info("Running in continuous mode - will process tokens until interrupted")
logger.info(
"Running in continuous mode - will process tokens until interrupted"
)
processor_task = asyncio.create_task(self._process_token_queue())
try:
@@ -278,12 +295,16 @@ class UniversalTrader:
# Wait for a token with a timeout
try:
logger.info(f"Waiting for a suitable token (timeout: {self.token_wait_timeout}s)...")
logger.info(
f"Waiting for a suitable token (timeout: {self.token_wait_timeout}s)..."
)
await asyncio.wait_for(token_found.wait(), timeout=self.token_wait_timeout)
logger.info(f"Found token: {found_token.symbol} ({found_token.mint})")
return found_token
except TimeoutError:
logger.info(f"Timed out after waiting {self.token_wait_timeout}s for a token")
logger.info(
f"Timed out after waiting {self.token_wait_timeout}s for a token"
)
return None
finally:
listener_task.cancel()
@@ -327,7 +348,9 @@ class UniversalTrader:
self.token_timestamps[token_key] = monotonic()
await self.token_queue.put(token_info)
logger.info(f"Queued new token: {token_info.symbol} ({token_info.mint}) on {token_info.platform.value}")
logger.info(
f"Queued new token: {token_info.symbol} ({token_info.mint}) on {token_info.platform.value}"
)
async def _process_token_queue(self) -> None:
"""Continuously process tokens from the queue, only if they're fresh."""
@@ -338,15 +361,21 @@ class UniversalTrader:
# Check if token is still "fresh"
current_time = monotonic()
token_age = current_time - self.token_timestamps.get(token_key, current_time)
token_age = current_time - self.token_timestamps.get(
token_key, current_time
)
if token_age > self.max_token_age:
logger.info(f"Skipping token {token_info.symbol} - too old ({token_age:.1f}s > {self.max_token_age}s)")
logger.info(
f"Skipping token {token_info.symbol} - too old ({token_age:.1f}s > {self.max_token_age}s)"
)
continue
self.processed_tokens.add(token_key)
logger.info(f"Processing fresh token: {token_info.symbol} (age: {token_age:.1f}s)")
logger.info(
f"Processing fresh token: {token_info.symbol} (age: {token_age:.1f}s)"
)
await self._handle_token(token_info)
except asyncio.CancelledError:
@@ -362,17 +391,23 @@ class UniversalTrader:
try:
# Validate that token is for our platform
if token_info.platform != self.platform:
logger.warning(f"Token platform mismatch: expected {self.platform.value}, got {token_info.platform.value}")
logger.warning(
f"Token platform mismatch: expected {self.platform.value}, got {token_info.platform.value}"
)
return
# Wait for pool/curve to stabilize (unless in extreme fast mode)
if not self.extreme_fast_mode:
await self._save_token_info(token_info)
logger.info(f"Waiting for {self.wait_time_after_creation} seconds for the pool/curve to stabilize...")
logger.info(
f"Waiting for {self.wait_time_after_creation} seconds for the pool/curve to stabilize..."
)
await asyncio.sleep(self.wait_time_after_creation)
# Buy token
logger.info(f"Buying {self.buy_amount:.6f} SOL worth of {token_info.symbol} on {token_info.platform.value}...")
logger.info(
f"Buying {self.buy_amount:.6f} SOL worth of {token_info.symbol} on {token_info.platform.value}..."
)
buy_result: TradeResult = await self.buyer.execute(token_info)
if buy_result.success:
@@ -382,16 +417,28 @@ class UniversalTrader:
# Only wait for next token in yolo mode
if self.yolo_mode:
logger.info(f"YOLO mode enabled. Waiting {self.wait_time_before_new_token} seconds before looking for next token...")
logger.info(
f"YOLO mode enabled. Waiting {self.wait_time_before_new_token} seconds before looking for next token..."
)
await asyncio.sleep(self.wait_time_before_new_token)
except Exception:
logger.exception(f"Error handling token {token_info.symbol}")
async def _handle_successful_buy(self, token_info: TokenInfo, buy_result: TradeResult) -> None:
async def _handle_successful_buy(
self, token_info: TokenInfo, buy_result: TradeResult
) -> None:
"""Handle successful token purchase."""
logger.info(f"Successfully bought {token_info.symbol} on {token_info.platform.value}")
self._log_trade("buy", token_info, buy_result.price, buy_result.amount, buy_result.tx_signature)
logger.info(
f"Successfully bought {token_info.symbol} on {token_info.platform.value}"
)
self._log_trade(
"buy",
token_info,
buy_result.price,
buy_result.amount,
buy_result.tx_signature,
)
self.traded_mints.add(token_info.mint)
# Choose exit strategy
@@ -405,7 +452,9 @@ class UniversalTrader:
else:
logger.info("Marry mode enabled. Skipping sell operation.")
async def _handle_failed_buy(self, token_info: TokenInfo, buy_result: TradeResult) -> None:
async def _handle_failed_buy(
self, token_info: TokenInfo, buy_result: TradeResult
) -> None:
"""Handle failed token purchase."""
logger.error(f"Failed to buy {token_info.symbol}: {buy_result.error_message}")
# Close ATA if enabled
@@ -419,7 +468,9 @@ class UniversalTrader:
self.cleanup_force_close_with_burn,
)
async def _handle_tp_sl_exit(self, token_info: TokenInfo, buy_result: TradeResult) -> None:
async def _handle_tp_sl_exit(
self, token_info: TokenInfo, buy_result: TradeResult
) -> None:
"""Handle take profit/stop loss exit strategy."""
# Create position
position = Position.create_from_buy_result(
@@ -451,7 +502,13 @@ class UniversalTrader:
if sell_result.success:
logger.info(f"Successfully sold {token_info.symbol}")
self._log_trade("sell", token_info, sell_result.price, sell_result.amount, sell_result.tx_signature)
self._log_trade(
"sell",
token_info,
sell_result.price,
sell_result.amount,
sell_result.tx_signature,
)
# Close ATA if enabled
await handle_cleanup_after_sell(
self.solana_client,
@@ -463,11 +520,17 @@ class UniversalTrader:
self.cleanup_force_close_with_burn,
)
else:
logger.error(f"Failed to sell {token_info.symbol}: {sell_result.error_message}")
logger.error(
f"Failed to sell {token_info.symbol}: {sell_result.error_message}"
)
async def _monitor_position_until_exit(self, token_info: TokenInfo, position: Position) -> None:
async def _monitor_position_until_exit(
self, token_info: TokenInfo, position: Position
) -> None:
"""Monitor a position until exit conditions are met."""
logger.info(f"Starting position monitoring (check interval: {self.price_check_interval}s)")
logger.info(
f"Starting position monitoring (check interval: {self.price_check_interval}s)"
)
# Get pool address for price monitoring using platform-agnostic method
pool_address = self._get_pool_address(token_info)
@@ -487,7 +550,9 @@ class UniversalTrader:
# Log PnL before exit
pnl = position.get_pnl(current_price)
logger.info(f"Position PnL: {pnl['price_change_pct']:.2f}% ({pnl['unrealized_pnl_sol']:.6f} SOL)")
logger.info(
f"Position PnL: {pnl['price_change_pct']:.2f}% ({pnl['unrealized_pnl_sol']:.6f} SOL)"
)
# Execute sell
sell_result = await self.seller.execute(token_info)
@@ -496,12 +561,22 @@ class UniversalTrader:
# Close position with actual exit price
position.close_position(sell_result.price, exit_reason)
logger.info(f"Successfully exited position: {exit_reason.value}")
self._log_trade("sell", token_info, sell_result.price, sell_result.amount, sell_result.tx_signature)
logger.info(
f"Successfully exited position: {exit_reason.value}"
)
self._log_trade(
"sell",
token_info,
sell_result.price,
sell_result.amount,
sell_result.tx_signature,
)
# Log final PnL
final_pnl = position.get_pnl()
logger.info(f"Final PnL: {final_pnl['price_change_pct']:.2f}% ({final_pnl['unrealized_pnl_sol']:.6f} SOL)")
logger.info(
f"Final PnL: {final_pnl['price_change_pct']:.2f}% ({final_pnl['unrealized_pnl_sol']:.6f} SOL)"
)
# Close ATA if enabled
await handle_cleanup_after_sell(
@@ -514,30 +589,36 @@ class UniversalTrader:
self.cleanup_force_close_with_burn,
)
else:
logger.error(f"Failed to exit position: {sell_result.error_message}")
logger.error(
f"Failed to exit position: {sell_result.error_message}"
)
# Keep monitoring in case sell can be retried
break
else:
# Log current status
pnl = position.get_pnl(current_price)
logger.debug(f"Position status: {current_price:.8f} SOL ({pnl['price_change_pct']:+.2f}%)")
logger.debug(
f"Position status: {current_price:.8f} SOL ({pnl['price_change_pct']:+.2f}%)"
)
# Wait before next price check
await asyncio.sleep(self.price_check_interval)
except Exception:
logger.exception("Error monitoring position")
await asyncio.sleep(self.price_check_interval) # Continue monitoring despite errors
await asyncio.sleep(
self.price_check_interval
) # Continue monitoring despite errors
def _get_pool_address(self, token_info: TokenInfo) -> Pubkey:
"""Get the pool/curve address for price monitoring using platform-agnostic method."""
address_provider = self.platform_implementations.address_provider
# Use platform-specific logic to get the appropriate address
if hasattr(token_info, 'bonding_curve') and token_info.bonding_curve:
if hasattr(token_info, "bonding_curve") and token_info.bonding_curve:
return token_info.bonding_curve
elif hasattr(token_info, 'pool_state') and token_info.pool_state:
elif hasattr(token_info, "pool_state") and token_info.pool_state:
return token_info.pool_state
else:
# Fallback to deriving the address using platform provider
@@ -561,7 +642,7 @@ class UniversalTrader:
"creator": str(token_info.creator) if token_info.creator else None,
"creation_timestamp": token_info.creation_timestamp,
}
# Add platform-specific fields only if they exist
platform_fields = {
"bonding_curve": token_info.bonding_curve,
@@ -571,7 +652,7 @@ class UniversalTrader:
"base_vault": token_info.base_vault,
"quote_vault": token_info.quote_vault,
}
for field_name, field_value in platform_fields.items():
if field_value is not None:
token_dict[field_name] = str(field_value)
@@ -582,7 +663,14 @@ class UniversalTrader:
except OSError:
logger.exception("Failed to save token information")
def _log_trade(self, action: str, token_info: TokenInfo, price: float, amount: float, tx_hash: str | None) -> None:
def _log_trade(
self,
action: str,
token_info: TokenInfo,
price: float,
amount: float,
tx_hash: str | None,
) -> None:
"""Log trade information."""
try:
trades_dir = Path("trades")
@@ -607,4 +695,4 @@ class UniversalTrader:
# Backward compatibility alias
PumpTrader = UniversalTrader # Legacy name for backward compatibility
PumpTrader = UniversalTrader # Legacy name for backward compatibility
+81 -68
View File
@@ -17,85 +17,91 @@ logger = get_logger(__name__)
class IDLManager:
"""Centralized manager for IDL parsers across all platforms."""
def __init__(self):
"""Initialize the IDL manager."""
self._parsers: dict[Platform, IDLParser] = {}
self._idl_paths: dict[Platform, str] = {}
self._setup_platform_idl_paths()
def _setup_platform_idl_paths(self) -> None:
"""Setup IDL file paths for each platform."""
# Get the project root directory (3 levels up from this file)
current_file = Path(__file__)
project_root = current_file.parent.parent.parent
# Define IDL paths for each platform
self._idl_paths = {
Platform.LETS_BONK: project_root / "idl" / "raydium_launchlab_idl.json",
Platform.PUMP_FUN: project_root / "idl" / "pump_fun_idl.json",
}
def get_parser(self, platform: Platform, verbose: bool = False) -> IDLParser:
"""Get or create an IDL parser for the specified platform.
Args:
platform: Platform to get parser for
verbose: Whether to enable verbose logging in the parser
Returns:
IDLParser instance for the platform
Raises:
ValueError: If platform is not supported or IDL file not found
"""
# Return cached parser if available
if platform in self._parsers:
return self._parsers[platform]
# Check if platform has IDL support
if platform not in self._idl_paths:
raise ValueError(f"Platform {platform.value} does not have IDL support configured")
raise ValueError(
f"Platform {platform.value} does not have IDL support configured"
)
idl_path = self._idl_paths[platform]
# Verify IDL file exists
if not idl_path.exists():
raise FileNotFoundError(f"IDL file not found for {platform.value} at {idl_path}")
raise FileNotFoundError(
f"IDL file not found for {platform.value} at {idl_path}"
)
# Load and cache the parser
logger.info(f"Loading IDL parser for {platform.value} from {idl_path}")
parser = IDLParser(idl_path, verbose=verbose)
self._parsers[platform] = parser
instruction_count = len(parser.get_instruction_names())
event_count = len(parser.get_event_names())
logger.info(f"IDL parser loaded for {platform.value} with {instruction_count} instructions and {event_count} events")
logger.info(
f"IDL parser loaded for {platform.value} with {instruction_count} instructions and {event_count} events"
)
return parser
def has_idl_support(self, platform: Platform) -> bool:
"""Check if a platform has IDL support configured.
Args:
platform: Platform to check
Returns:
True if platform has IDL support
"""
return platform in self._idl_paths
def get_supported_platforms(self) -> list[Platform]:
"""Get list of platforms with IDL support.
Returns:
List of platforms that have IDL files configured
"""
return list(self._idl_paths.keys())
def clear_cache(self, platform: Platform | None = None) -> None:
"""Clear cached parsers.
Args:
platform: Specific platform to clear, or None to clear all
"""
@@ -105,12 +111,12 @@ class IDLManager:
elif platform in self._parsers:
logger.info(f"Clearing cached IDL parser for {platform.value}")
del self._parsers[platform]
def preload_parser(self, platform: Platform, verbose: bool = False) -> None:
"""Preload IDL parser for a platform.
This can be useful for warming up the parser during initialization.
Args:
platform: Platform to preload parser for
verbose: Whether to enable verbose logging in the parser
@@ -120,101 +126,105 @@ class IDLManager:
self.get_parser(platform, verbose)
else:
logger.debug(f"IDL parser for {platform.value} already loaded")
# --------------------------------------------------------------------------
# Instruction-related convenience methods
# --------------------------------------------------------------------------
def get_instruction_discriminators(self, platform: Platform) -> dict[str, bytes]:
"""Get instruction discriminators for a platform.
Args:
platform: Platform to get discriminators for
Returns:
Dictionary mapping instruction names to discriminator bytes
"""
parser = self.get_parser(platform)
return parser.get_instruction_discriminators()
def get_instruction_names(self, platform: Platform) -> list[str]:
"""Get available instruction names for a platform.
Args:
platform: Platform to get instruction names for
Returns:
List of instruction names
"""
parser = self.get_parser(platform)
return parser.get_instruction_names()
# --------------------------------------------------------------------------
# Event-related convenience methods
# --------------------------------------------------------------------------
def get_event_discriminators(self, platform: Platform) -> dict[str, bytes]:
"""Get event discriminators for a platform.
Args:
platform: Platform to get event discriminators for
Returns:
Dictionary mapping event names to discriminator bytes
"""
parser = self.get_parser(platform)
return parser.get_event_discriminators()
def get_event_names(self, platform: Platform) -> list[str]:
"""Get available event names for a platform.
Args:
platform: Platform to get event names for
Returns:
List of event names
"""
parser = self.get_parser(platform)
return parser.get_event_names()
def decode_event_from_logs(self, platform: Platform, logs: list[str], event_name: str | None = None) -> dict | None:
def decode_event_from_logs(
self, platform: Platform, logs: list[str], event_name: str | None = None
) -> dict | None:
"""Decode event data from transaction logs for a platform.
Args:
platform: Platform to use for decoding
logs: List of log strings from transaction
event_name: Optional specific event name to look for
Returns:
Decoded event data if found, None otherwise
"""
parser = self.get_parser(platform)
return parser.find_event_in_logs(logs, event_name)
def decode_event_data(self, platform: Platform, event_data: bytes, event_name: str | None = None) -> dict | None:
def decode_event_data(
self, platform: Platform, event_data: bytes, event_name: str | None = None
) -> dict | None:
"""Decode raw event data for a platform.
Args:
platform: Platform to use for decoding
event_data: Raw event data bytes
event_name: Optional event name to decode as
Returns:
Decoded event data if successful, None otherwise
"""
parser = self.get_parser(platform)
return parser.decode_event_data(event_data, event_name)
# --------------------------------------------------------------------------
# Platform information methods
# --------------------------------------------------------------------------
def get_platform_capabilities(self, platform: Platform) -> dict[str, Any]:
"""Get comprehensive capability information for a platform.
Args:
platform: Platform to get capabilities for
Returns:
Dictionary with platform capabilities
"""
@@ -227,12 +237,12 @@ class IDLManager:
"instruction_count": 0,
"event_count": 0,
}
try:
parser = self.get_parser(platform)
instruction_names = parser.get_instruction_names()
event_names = parser.get_event_names()
return {
"platform": platform.value,
"has_idl_support": True,
@@ -260,7 +270,7 @@ _idl_manager: IDLManager | None = None
def get_idl_manager() -> IDLManager:
"""Get the global IDL manager instance.
Returns:
Global IDLManager instance
"""
@@ -272,11 +282,11 @@ def get_idl_manager() -> IDLManager:
def get_idl_parser(platform: Platform, verbose: bool = False) -> IDLParser:
"""Convenience function to get an IDL parser for a platform.
Args:
platform: Platform to get parser for
verbose: Whether to enable verbose logging in the parser
Returns:
IDLParser instance for the platform
"""
@@ -285,10 +295,10 @@ def get_idl_parser(platform: Platform, verbose: bool = False) -> IDLParser:
def has_idl_support(platform: Platform) -> bool:
"""Check if a platform has IDL support.
Args:
platform: Platform to check
Returns:
True if platform has IDL support
"""
@@ -297,7 +307,7 @@ def has_idl_support(platform: Platform) -> bool:
def preload_platform_idl(platform: Platform, verbose: bool = False) -> None:
"""Preload IDL parser for a platform.
Args:
platform: Platform to preload parser for
verbose: Whether to enable verbose logging
@@ -309,12 +319,13 @@ def preload_platform_idl(platform: Platform, verbose: bool = False) -> None:
# Convenience functions for event handling
# --------------------------------------------------------------------------
def get_event_discriminators(platform: Platform) -> dict[str, bytes]:
"""Convenience function to get event discriminators for a platform.
Args:
platform: Platform to get event discriminators for
Returns:
Dictionary mapping event names to discriminator bytes
"""
@@ -323,25 +334,27 @@ def get_event_discriminators(platform: Platform) -> dict[str, bytes]:
def get_event_names(platform: Platform) -> list[str]:
"""Convenience function to get event names for a platform.
Args:
platform: Platform to get event names for
Returns:
List of event names
"""
return get_idl_manager().get_event_names(platform)
def decode_event_from_logs(platform: Platform, logs: list[str], event_name: str | None = None) -> dict | None:
def decode_event_from_logs(
platform: Platform, logs: list[str], event_name: str | None = None
) -> dict | None:
"""Convenience function to decode events from logs.
Args:
platform: Platform to use for decoding
logs: List of log strings from transaction
event_name: Optional specific event name to look for
Returns:
Decoded event data if found, None otherwise
"""
return get_idl_manager().decode_event_from_logs(platform, logs, event_name)
return get_idl_manager().decode_event_from_logs(platform, logs, event_name)
+177 -133
View File
@@ -24,17 +24,20 @@ class IDLParser:
# to its struct format character and size in bytes.
_PRIMITIVE_TYPE_INFO = {
# type_name: (format_char, size_in_bytes)
'u8': ('<B', 1),
'u16': ('<H', 2),
'u32': ('<I', 4),
'u64': ('<Q', 8),
'i8': ('<b', 1),
'i16': ('<h', 2),
'i32': ('<i', 4),
'i64': ('<q', 8),
'bool': ('<?', 1),
'pubkey': (None, PUBLIC_KEY_SIZE),
'string': (None, STRING_LENGTH_PREFIX_SIZE), # Min size is for the length prefix
"u8": ("<B", 1),
"u16": ("<H", 2),
"u32": ("<I", 4),
"u64": ("<Q", 8),
"i8": ("<b", 1),
"i16": ("<h", 2),
"i32": ("<i", 4),
"i64": ("<q", 8),
"bool": ("<?", 1),
"pubkey": (None, PUBLIC_KEY_SIZE),
"string": (
None,
STRING_LENGTH_PREFIX_SIZE,
), # Min size is for the length prefix
}
def __init__(self, idl_path: str, verbose: bool = False):
@@ -63,13 +66,15 @@ class IDLParser:
def get_instruction_discriminators(self) -> dict[str, bytes]:
"""Get a mapping of instruction names to their discriminators."""
return {instr['name']: disc for disc, instr in self.instructions.items()}
return {instr["name"]: disc for disc, instr in self.instructions.items()}
def get_instruction_names(self) -> list[str]:
"""Get a list of all available instruction names."""
return [instr['name'] for instr in self.instructions.values()]
return [instr["name"] for instr in self.instructions.values()]
def validate_instruction_data_length(self, ix_data: bytes, discriminator: bytes) -> bool:
def validate_instruction_data_length(
self, ix_data: bytes, discriminator: bytes
) -> bool:
"""Validate that instruction data meets minimum length requirements."""
if discriminator not in self.instruction_min_sizes:
return True # Allow if we don't know the expected size
@@ -78,7 +83,7 @@ class IDLParser:
actual_size = len(ix_data)
if actual_size < expected_min_size:
instruction_name = self.instructions[discriminator]['name']
instruction_name = self.instructions[discriminator]["name"]
if self.verbose:
print(
f"⚠️ Instruction data for '{instruction_name}' is shorter than the expected minimum "
@@ -88,7 +93,9 @@ class IDLParser:
return True
def decode_instruction(self, ix_data: bytes, keys: list[bytes], accounts: list[int]) -> dict[str, Any] | None:
def decode_instruction(
self, ix_data: bytes, keys: list[bytes], accounts: list[int]
) -> dict[str, Any] | None:
"""Decode instruction data using IDL definitions."""
if len(ix_data) < DISCRIMINATOR_SIZE:
return None
@@ -102,14 +109,16 @@ class IDLParser:
instruction = self.instructions[discriminator]
data_args = ix_data[DISCRIMINATOR_SIZE:]
# Decode instruction arguments
args = {}
decode_offset = 0
for arg in instruction.get('args', []):
for arg in instruction.get("args", []):
try:
value, decode_offset = self._decode_type(data_args, decode_offset, arg['type'])
args[arg['name']] = value
value, decode_offset = self._decode_type(
data_args, decode_offset, arg["type"]
)
args[arg["name"]] = value
except Exception as e:
if self.verbose:
print(f"❌ Decode error in argument '{arg['name']}': {e}")
@@ -120,19 +129,19 @@ class IDLParser:
if index < len(accounts):
account_index = accounts[index]
if account_index < len(keys):
return base58.b58encode(keys[account_index]).decode('utf-8')
return None # Return None for invalid indices
return base58.b58encode(keys[account_index]).decode("utf-8")
return None # Return None for invalid indices
# Build account info based on instruction definition
account_info = {}
instruction_accounts = instruction.get('accounts', [])
instruction_accounts = instruction.get("accounts", [])
for i, account_def in enumerate(instruction_accounts):
account_info[account_def['name']] = get_account_key(i)
account_info[account_def["name"]] = get_account_key(i)
return {
'instruction_name': instruction['name'],
'args': args,
'accounts': account_info
"instruction_name": instruction["name"],
"args": args,
"accounts": account_info,
}
# --------------------------------------------------------------------------
@@ -141,20 +150,22 @@ class IDLParser:
def get_event_discriminators(self) -> dict[str, bytes]:
"""Get a mapping of event names to their discriminators."""
return {event['name']: disc for disc, event in self.events.items()}
return {event["name"]: disc for disc, event in self.events.items()}
def get_event_names(self) -> list[str]:
"""Get a list of all available event names."""
return [event['name'] for event in self.events.values()]
return [event["name"] for event in self.events.values()]
def decode_event_data(self, event_data: bytes, event_name: str | None = None) -> dict[str, Any] | None:
def decode_event_data(
self, event_data: bytes, event_name: str | None = None
) -> dict[str, Any] | None:
"""
Decode event data using IDL event definitions.
Args:
event_data: Raw event data bytes (typically from base64 decoded log data)
event_name: Optional event name to decode as. If None, will try to match discriminator.
Returns:
Decoded event data as a dictionary, or None if decoding fails.
"""
@@ -162,89 +173,94 @@ class IDLParser:
return None
discriminator = event_data[:DISCRIMINATOR_SIZE]
# Find event definition by discriminator in events section
if discriminator not in self.events:
if self.verbose:
print(f"Unknown event discriminator: {discriminator.hex()}")
return None
event_def = self.events[discriminator]
# If event_name provided, validate it matches
if event_name and event_def['name'] != event_name:
if event_name and event_def["name"] != event_name:
if self.verbose:
print(f"Event name mismatch: expected {event_name}, got {event_def['name']}")
print(
f"Event name mismatch: expected {event_name}, got {event_def['name']}"
)
return None
# Get the actual structure definition from types section
event_name_actual = event_def['name']
event_name_actual = event_def["name"]
if event_name_actual not in self.types:
if self.verbose:
print(f"Event type {event_name_actual} not found in types section")
return None
type_def = self.types[event_name_actual]
event_type = type_def.get('type', {})
event_type = type_def.get("type", {})
# Decode event fields
try:
event_fields = {}
data_part = event_data[DISCRIMINATOR_SIZE:]
decode_offset = 0
if event_type.get('kind') != 'struct':
if event_type.get("kind") != "struct":
if self.verbose:
print(f"Event {event_name_actual} is not a struct type: {event_type.get('kind', 'NO KIND')}")
print(
f"Event {event_name_actual} is not a struct type: {event_type.get('kind', 'NO KIND')}"
)
print(f"Available keys in type_def: {list(type_def.keys())}")
print(f"Event type structure: {event_type}")
return None
# Decode each field in the struct
fields = event_type.get('fields', [])
fields = event_type.get("fields", [])
if self.verbose:
print(f"Decoding {len(fields)} fields for event {event_name_actual}")
for field in fields:
if self.verbose:
print(f"Decoding field: {field['name']} ({field['type']})")
try:
value, decode_offset = self._decode_type(data_part, decode_offset, field['type'])
event_fields[field['name']] = value
value, decode_offset = self._decode_type(
data_part, decode_offset, field["type"]
)
event_fields[field["name"]] = value
if self.verbose:
if field['type'] == 'string':
if field["type"] == "string":
print(f" -> '{value}'")
elif field['type'] == 'pubkey':
elif field["type"] == "pubkey":
print(f" -> {value}")
else:
print(f" -> {value}")
except Exception as e:
if self.verbose:
print(f"Error decoding field {field['name']}: {e}")
# Don't return None here, continue with other fields
continue
return {
'event_name': event_name_actual,
'fields': event_fields
}
return {"event_name": event_name_actual, "fields": event_fields}
except Exception as e:
if self.verbose:
print(f"❌ Error decoding event {event_name_actual}: {e}")
return None
def find_event_in_logs(self, logs: list[str], target_event_name: str | None = None) -> dict[str, Any] | None:
def find_event_in_logs(
self, logs: list[str], target_event_name: str | None = None
) -> dict[str, Any] | None:
"""
Find and decode event data from transaction logs.
Args:
logs: List of log strings from a transaction
target_event_name: Optional specific event name to look for
Returns:
Decoded event data if found, None otherwise
"""
@@ -254,34 +270,39 @@ class IDLParser:
# Extract base64 encoded data
encoded_data = log.split("Program data: ")[1].strip()
decoded_data = base64.b64decode(encoded_data)
# Try to decode as event
event_data = self.decode_event_data(decoded_data, target_event_name)
if event_data:
return event_data
except Exception as e:
if self.verbose:
print(f"Failed to decode log data: {e}")
continue
return None
# --------------------------------------------------------------------------
# Public Methods (External API) - Account Data
# --------------------------------------------------------------------------
def decode_account_data(self, account_data: bytes, account_type_name: str, skip_discriminator: bool = True) -> dict[str, Any] | None:
def decode_account_data(
self,
account_data: bytes,
account_type_name: str,
skip_discriminator: bool = True,
) -> dict[str, Any] | None:
"""
Decode account data using a specific account type from the IDL.
Args:
account_data: Raw account data bytes.
account_type_name: Name of the account type in the IDL (e.g., "MyAccount").
skip_discriminator: Whether to skip the first 8 bytes, which Anchor uses as a
type discriminator for account data. Set to False if your
data does not have this prefix.
Returns:
Decoded account data as a dictionary, or None if decoding fails.
"""
@@ -295,7 +316,9 @@ class IDLParser:
if skip_discriminator:
if len(account_data) < DISCRIMINATOR_SIZE:
if self.verbose:
print(f"Account data too short to contain a discriminator: {len(account_data)} bytes")
print(
f"Account data too short to contain a discriminator: {len(account_data)} bytes"
)
return None
data = account_data[DISCRIMINATOR_SIZE:]
@@ -313,34 +336,36 @@ class IDLParser:
def _build_instruction_map(self):
"""Build a map of discriminators to instruction definitions."""
for instruction in self.idl.get('instructions', []):
for instruction in self.idl.get("instructions", []):
# The discriminator from the JSON IDL is a list of u8 integers.
discriminator = bytes(instruction['discriminator'])
discriminator = bytes(instruction["discriminator"])
self.instructions[discriminator] = instruction
def _build_event_map(self):
"""Build a map of discriminators to event definitions."""
for event in self.idl.get('events', []):
for event in self.idl.get("events", []):
# The discriminator from the JSON IDL is a list of u8 integers.
discriminator = bytes(event['discriminator'])
discriminator = bytes(event["discriminator"])
self.events[discriminator] = event
if self.verbose:
print(f"📅 Loaded event: {event['name']} with discriminator {discriminator.hex()}")
print(
f"📅 Loaded event: {event['name']} with discriminator {discriminator.hex()}"
)
def _build_type_map(self):
"""Build a map of type names to their definitions."""
for type_def in self.idl.get('types', []):
self.types[type_def['name']] = type_def
for type_def in self.idl.get("types", []):
self.types[type_def["name"]] = type_def
def _calculate_instruction_sizes(self):
"""Calculate minimum data sizes for each instruction."""
for discriminator, instruction in self.instructions.items():
try:
min_size = DISCRIMINATOR_SIZE
for arg in instruction.get('args', []):
min_size += self._calculate_type_min_size(arg['type'])
for arg in instruction.get("args", []):
min_size += self._calculate_type_min_size(arg["type"])
self.instruction_min_sizes[discriminator] = min_size
if self.verbose and instruction['name'] == 'initialize':
if self.verbose and instruction["name"] == "initialize":
print(f"📏 Initialize instruction min size: {min_size} bytes")
except Exception as e:
if self.verbose:
@@ -351,17 +376,19 @@ class IDLParser:
"""Calculate minimum size in bytes for a type definition."""
if isinstance(type_def, str):
return self._get_primitive_size(type_def)
if isinstance(type_def, dict):
if 'defined' in type_def:
if "defined" in type_def:
type_name = self._get_defined_type_name(type_def)
return self._calculate_defined_type_min_size(type_name)
if 'array' in type_def:
element_type, array_length = type_def['array']
if "array" in type_def:
element_type, array_length = type_def["array"]
element_size = self._calculate_type_min_size(element_type)
return element_size * array_length
raise ValueError(f"Invalid or unknown type definition for size calculation: {type_def}")
raise ValueError(
f"Invalid or unknown type definition for size calculation: {type_def}"
)
def _get_primitive_size(self, type_name: str) -> int:
"""Get size in bytes for primitive types from the central map."""
@@ -370,51 +397,62 @@ class IDLParser:
def _get_defined_type_name(self, type_def: dict[str, Any]) -> str:
"""Extracts the type name from a 'defined' type, handling old and new IDL formats."""
defined_value = type_def['defined']
defined_value = type_def["defined"]
# New format: {'defined': {'name': 'MyType'}}
# Old format: {'defined': 'MyType'}
return defined_value['name'] if isinstance(defined_value, dict) else defined_value
return (
defined_value["name"] if isinstance(defined_value, dict) else defined_value
)
def _calculate_defined_type_min_size(self, type_name: str) -> int:
"""Calculate minimum size for user-defined types (structs and enums)."""
if type_name not in self.types:
raise ValueError(f"Unknown defined type: {type_name}")
type_def = self.types[type_name]['type']
if type_def['kind'] == 'struct':
return sum(self._calculate_type_min_size(field['type']) for field in type_def['fields'])
if type_def['kind'] == 'enum':
type_def = self.types[type_name]["type"]
if type_def["kind"] == "struct":
return sum(
self._calculate_type_min_size(field["type"])
for field in type_def["fields"]
)
if type_def["kind"] == "enum":
# The size of an enum is its discriminator plus the size of its LARGEST variant,
# as the data layout must accommodate any possible variant.
max_variant_size = 0
for variant in type_def['variants']:
for variant in type_def["variants"]:
variant_size = 0
for field in variant.get('fields', []):
for field in variant.get("fields", []):
# A field can be a type string/dict (tuple variant) or a dict with a 'type' key (struct variant)
field_type = field['type'] if isinstance(field, dict) else field
field_type = field["type"] if isinstance(field, dict) else field
variant_size += self._calculate_type_min_size(field_type)
max_variant_size = max(max_variant_size, variant_size)
return ENUM_DISCRIMINATOR_SIZE + max_variant_size
raise ValueError(f"Unsupported type kind for size calculation: {type_def['kind']}")
raise ValueError(
f"Unsupported type kind for size calculation: {type_def['kind']}"
)
def _decode_type(self, data: bytes, offset: int, type_def: str | dict) -> tuple[Any, int]:
def _decode_type(
self, data: bytes, offset: int, type_def: str | dict
) -> tuple[Any, int]:
"""Decode a value based on its type definition."""
if isinstance(type_def, str):
return self._decode_primitive(data, offset, type_def)
if isinstance(type_def, dict):
if 'defined' in type_def:
if "defined" in type_def:
type_name = self._get_defined_type_name(type_def)
return self._decode_defined_type(data, offset, type_name)
if 'array' in type_def:
return self._decode_array(data, offset, type_def['array'])
if "array" in type_def:
return self._decode_array(data, offset, type_def["array"])
raise ValueError(f"Invalid or unknown type definition for decoding: {type_def}")
def _decode_array(self, data: bytes, offset: int, array_def: list) -> tuple[list[Any], int]:
def _decode_array(
self, data: bytes, offset: int, array_def: list
) -> tuple[list[Any], int]:
"""Decode fixed-size array types."""
element_type, array_length = array_def
array_data = []
@@ -423,20 +461,22 @@ class IDLParser:
array_data.append(value)
return array_data, offset
def _decode_primitive(self, data: bytes, offset: int, type_name: str) -> tuple[Any, int]:
def _decode_primitive(
self, data: bytes, offset: int, type_name: str
) -> tuple[Any, int]:
"""Decode primitive types."""
if type_name not in self._PRIMITIVE_TYPE_INFO:
raise ValueError(f"Unknown primitive type: {type_name}")
if type_name == 'string':
length = struct.unpack_from('<I', data, offset)[0]
if type_name == "string":
length = struct.unpack_from("<I", data, offset)[0]
offset += STRING_LENGTH_PREFIX_SIZE
value = data[offset:offset + length].decode('utf-8')
value = data[offset : offset + length].decode("utf-8")
return value, offset + length
if type_name == 'pubkey':
if type_name == "pubkey":
end = offset + PUBLIC_KEY_SIZE
value = base58.b58encode(data[offset:end]).decode('utf-8')
value = base58.b58encode(data[offset:end]).decode("utf-8")
return value, end
# Handle all numeric and bool types from the map
@@ -444,47 +484,51 @@ class IDLParser:
value = struct.unpack_from(fmt, data, offset)[0]
return value, offset + size
def _decode_defined_type(self, data: bytes, offset: int, type_name: str) -> tuple[dict[str, Any], int]:
def _decode_defined_type(
self, data: bytes, offset: int, type_name: str
) -> tuple[dict[str, Any], int]:
"""Decode user-defined types (structs and enums)."""
if type_name not in self.types:
raise ValueError(f"Unknown defined type: {type_name}")
type_def = self.types[type_name]['type']
if type_def['kind'] == 'struct':
type_def = self.types[type_name]["type"]
if type_def["kind"] == "struct":
struct_data = {}
for field in type_def['fields']:
value, offset = self._decode_type(data, offset, field['type'])
struct_data[field['name']] = value
for field in type_def["fields"]:
value, offset = self._decode_type(data, offset, field["type"])
struct_data[field["name"]] = value
return struct_data, offset
if type_def['kind'] == 'enum':
variant_index = struct.unpack_from('<B', data, offset)[0]
if type_def["kind"] == "enum":
variant_index = struct.unpack_from("<B", data, offset)[0]
offset += ENUM_DISCRIMINATOR_SIZE
variants = type_def['variants']
variants = type_def["variants"]
if variant_index >= len(variants):
raise ValueError(f"Invalid enum variant index {variant_index} for type {type_name}")
raise ValueError(
f"Invalid enum variant index {variant_index} for type {type_name}"
)
variant = variants[variant_index]
result = {"variant": variant['name']}
variant_fields = variant.get('fields', [])
result = {"variant": variant["name"]}
variant_fields = variant.get("fields", [])
if variant_fields:
# Check if it's a struct variant (fields are dicts) or tuple variant (fields are strings/dicts)
if isinstance(variant_fields[0], dict):
struct_data = {}
for field in variant_fields:
value, offset = self._decode_type(data, offset, field['type'])
struct_data[field['name']] = value
result['data'] = struct_data
else: # Tuple variant
value, offset = self._decode_type(data, offset, field["type"])
struct_data[field["name"]] = value
result["data"] = struct_data
else: # Tuple variant
tuple_data = []
for field_type in variant_fields:
value, offset = self._decode_type(data, offset, field_type)
tuple_data.append(value)
result['data'] = tuple_data
result["data"] = tuple_data
return result, offset
raise ValueError(f"Unsupported type kind for decoding: {type_def['kind']}")
@@ -501,4 +545,4 @@ def load_idl_parser(idl_path: str, verbose: bool = False) -> IDLParser:
Returns:
Initialized IDLParser instance
"""
return IDLParser(idl_path, verbose)
return IDLParser(idl_path, verbose)