diff --git a/.env.example b/.env.example index b9e63443..74153e01 100644 --- a/.env.example +++ b/.env.example @@ -23,4 +23,6 @@ SOLANA_PRIVATE_KEY= DISCORD_TOKEN= XAI_API_KEY= TOGETHER_API_KEY= -MONAD_PRIVATE_KEY= \ No newline at end of file +DEBRIDGE_ACCESS_KEY= + +MONAD_PRIVATE_KEY= diff --git a/README.md b/README.md index 50e00c50..3cd58ae1 100644 --- a/README.md +++ b/README.md @@ -257,6 +257,50 @@ Each plugin has its own configuration options that can be specified in the agent ### GOAT +### Blockchain Networks +- Solana + - SOL/SPL transfers and swaps via Jupiter + - Staking and balance management + - Network monitoring and token queries + +- EVM Networks + - Ethereum/Base/Polygon + - ETH/ERC-20 transfers and swaps + - Kyberswap integration + - Balance and token queries + - Sonic + - Fast EVM transactions + - Custom slippage settings + - Token swaps via Sonic DEX + - Network switching (mainnet/testnet) + - DeBridge - Bridge tokens across multiple chains +- EternalAI + - Transform agents to smart contracts + - Deploy on 10+ blockchains + - Onchain system prompts + - Decentralized inference + +### Social Platforms +- Twitter/X + - Post and reply to tweets + - Timeline management + - Engagement features + +- Farcaster + - Cast creation and interactions + - Timeline and reply management + - Like/requote functionality + +- Discord + - Channel management + - Message operations + - Reaction handling + +- Echochambers + - Room messaging and context + - History tracking + - Topic management +======= - Interact with EVM chains through a unified interface - Manage ERC20 tokens: - Check token balances diff --git a/agents/example.json b/agents/example.json index 06fbd96a..daa59ba3 100644 --- a/agents/example.json +++ b/agents/example.json @@ -103,7 +103,9 @@ "name": "evm", "network": "ethereum" }, - + { + "name": "debridge" + }, { "name": "discord", "message_read_count": 10, diff --git a/src/actions/debridge_actions.py b/src/actions/debridge_actions.py new file mode 100644 index 00000000..1529c9aa --- /dev/null +++ b/src/actions/debridge_actions.py @@ -0,0 +1,139 @@ +import logging +from src.action_handler import register_action + +logger = logging.getLogger("actions.debridge_actions") + +@register_action("create-bridge-tx") +def create_bridge_tx(agent, **kwargs): + """Create Bridge TX using Debridge""" + try: + required_args = ['connection', 'srcChainId', 'srcChainTokenIn', 'srcChainTokenInAmount', 'dstChainId', 'dstChainTokenOut', 'dstChainTokenOutRecipient'] + for arg in required_args: + if arg not in kwargs: + logger.error(f"Missing required argument: {arg}") + return None + + response = agent.connection_manager.connections["debridge"].create_bridge_tx( + connection=kwargs['connection'], + srcChainId=kwargs['srcChainId'], + srcChainTokenIn=kwargs['srcChainTokenIn'], + srcChainTokenInAmount=kwargs['srcChainTokenInAmount'], + dstChainId=kwargs['dstChainId'], + dstChainTokenOut=kwargs['dstChainTokenOut'], + dstChainTokenOutRecipient=kwargs['dstChainTokenOutRecipient'], + dstChainTokenOutAmount=kwargs.get('dstChainTokenOutAmount', "auto"), + dstChainOrderAuthorityAddress=kwargs.get('dstChainOrderAuthorityAddress'), + affiliateFeeRecipient=kwargs.get('affiliateFeeRecipient'), + prependOperatingExpense=kwargs.get('prependOperatingExpense', True), + affiliateFeePercent=kwargs.get('affiliateFeePercent', 0), + dlnHook=kwargs.get('dlnHook') + ) + return response + except Exception as e: + logger.error(f"Failed to create bridge transaction: {str(e)}") + return None + +@register_action("get-order-status") +def get_order_status(agent, **kwargs): + """Get order status using Debridge""" + try: + response = agent.connection_manager.connections["debridge"].get_order_status( + id=kwargs.get('id'), + hash=kwargs.get('hash') + ) + return response + except Exception as e: + logger.error(f"Failed to get order status: {str(e)}") + return None + +@register_action("get-order-details") +def get_order_details(agent, **kwargs): + """Get order details using Debridge""" + try: + if 'id' not in kwargs: + logger.error("Missing required argument: id") + return None + + response = agent.connection_manager.connections["debridge"].get_order_details( + id=kwargs['id'] + ) + return response + except Exception as e: + logger.error(f"Failed to get order details: {str(e)}") + return None + +@register_action("cancel-tx") +def cancel_tx(agent, **kwargs): + """Cancel transaction using Debridge""" + try: + if 'id' not in kwargs: + logger.error("Missing required argument: id") + return None + + response = agent.connection_manager.connections["debridge"].cancel_tx( + id=kwargs['id'] + ) + return response + except Exception as e: + logger.error(f"Failed to cancel transaction: {str(e)}") + return None + +@register_action("extcall-cancel-tx") +def extcall_cancel_tx(agent, **kwargs): + """External call to cancel transaction using Debridge""" + try: + if 'id' not in kwargs: + logger.error("Missing required argument: id") + return None + + response = agent.connection_manager.connections["debridge"].extcall_cancel_tx( + id=kwargs['id'] + ) + return response + except Exception as e: + logger.error(f"Failed to perform external call to cancel transaction: {str(e)}") + return None + +@register_action("get-supported-chains") +def get_supported_chains(agent, **kwargs): + """Get supported chains using Debridge""" + try: + response = agent.connection_manager.connections["debridge"].get_supported_chains() + return response + except Exception as e: + logger.error(f"Failed to get supported chains: {str(e)}") + return None + +@register_action("get-token-list") +def get_token_list(agent, **kwargs): + """Get token list using Debridge""" + try: + if 'chainId' not in kwargs: + logger.error("Missing required argument: chainId") + return None + + response = agent.connection_manager.connections["debridge"].get_token_list( + chainId=kwargs['chainId'] + ) + return response + except Exception as e: + logger.error(f"Failed to get token list: {str(e)}") + return None + +@register_action("execute-bridge-tx") +def execute_bridge_tx(agent, **kwargs): + """Execute bridge transaction using Debridge""" + try: + if 'connection' not in kwargs: + logger.error("Missing required argument: connection") + return None + + response = agent.connection_manager.connections["debridge"].execute_bridge_tx( + connection=kwargs['connection'], + compute_unit_price=kwargs.get('compute_unit_price', 200_000), + compute_unit_limit=kwargs.get('compute_unit_limit') + ) + return response + except Exception as e: + logger.error(f"Failed to execute bridge transaction: {str(e)}") + return None diff --git a/src/connection_manager.py b/src/connection_manager.py index af58e411..8af09fcf 100644 --- a/src/connection_manager.py +++ b/src/connection_manager.py @@ -21,6 +21,7 @@ from src.connections.together_connection import TogetherAIConnection from src.connections.evm_connection import EVMConnection from src.connections.perplexity_connection import PerplexityConnection +from src.connections.debridge_connection import DebridgeConnection from src.connections.monad_connection import MonadConnection logger = logging.getLogger("connection_manager") @@ -74,6 +75,8 @@ def _class_name_to_type(class_name: str) -> Type[BaseConnection]: return EVMConnection elif class_name == "perplexity": return PerplexityConnection + elif class_name == "debridge": + return DebridgeConnection elif class_name == "monad": return MonadConnection return None @@ -90,7 +93,10 @@ def _register_connection(self, config_dic: Dict[str, Any]) -> None: try: name = config_dic["name"] connection_class = self._class_name_to_type(name) - connection = connection_class(config_dic) + if name == "debridge": + connection = DebridgeConnection(config_dic, self.connections) + else: + connection = connection_class(config_dic) self.connections[name] = connection except Exception as e: logging.error(f"Failed to initialize connection {name}: {e}") diff --git a/src/connections/debridge_connection.py b/src/connections/debridge_connection.py new file mode 100644 index 00000000..389cfc3f --- /dev/null +++ b/src/connections/debridge_connection.py @@ -0,0 +1,317 @@ +import logging +import requests +from typing import Dict, Any +import os +import json +from dotenv import load_dotenv +from .base_connection import BaseConnection, Action, ActionParameter + +logger = logging.getLogger("connections.debridge_connection") + + +class DebridgeConnectionError(Exception): + """Base exception for Debridge connection errors""" + pass + + +class DebridgeConnection(BaseConnection): + + def __init__(self, config: Dict[str, Any], connections: Dict[str, Any]): + super().__init__(config) + self.api_url = "https://dln.debridge.finance" + self._initialize() + self.pending_tx = None + self.connections = connections + + def _initialize(self): + """Initialize Debridge connection""" + try: + load_dotenv() + self.access_key = os.getenv("DEBRIDGE_ACCESS_KEY") + except Exception as e: + raise DebridgeConnectionError( + f"Failed to initialize Debridge connection: {str(e)}") + + def is_llm_provider(self) -> bool: + return False + + def validate_config(self, config: Dict[str, Any]) -> Dict[str, Any]: + """Validate the configuration parameters""" + return config + + def register_actions(self) -> None: + self.actions['create-bridge-tx'] = Action( + name='create-bridge-tx', + description='Create Bridge TX using Debridge', + parameters=[ + ActionParameter(name='connection', type=str, required=True, description='Connection name'), + ActionParameter(name='srcChainId', type=int, + required=True, description='Source chain ID'), + ActionParameter(name='srcChainTokenIn', type=str, + required=True, description='Source chain token address'), + ActionParameter(name='srcChainTokenInAmount', type=str, + required=True, description='Amount of source chain token'), + ActionParameter(name='dstChainId', type=int, + required=True, description='Destination chain ID'), + ActionParameter(name='dstChainTokenOut', type=str, required=True, + description='Destination chain token address'), + ActionParameter(name='dstChainTokenOutRecipient', type=str, required=True, + description='Recipient address on destination chain'), + ActionParameter(name='dstChainTokenOutAmount', type=str, + required=False, description='Amount of destination chain token'), + ActionParameter(name='dstChainOrderAuthorityAddress', type=str, required=False, + description='Destination chain order authority address'), + ActionParameter(name='affiliateFeeRecipient', type=str, required=False, + description='Affiliate fee recipient address'), + ActionParameter(name='prependOperatingExpense', type=bool, + required=False, description='Prepend operating expense'), + ActionParameter(name='dlnHook', type=str, required=False, + description='DLN hook'), + ActionParameter(name='affiliateFeePercent', type=float, + required=False, description='Affiliate fee percentage') + ] + ) + self.actions['execute-bridge-tx'] = Action( + name='execute-bridge-tx', + description='Execute bridge transaction using Debridge', + parameters=[ + ActionParameter(name='connection', type=str, required=True, description='Connection name'), + ActionParameter(name='compute_unit_price', type=int, required=False, description='Compute unit price'), + ActionParameter(name='compute_unit_limit', type=int, required=False, description='Compute unit limit') + ] + ) + self.actions['get-order-status'] = Action( + name='get-order-status', + description='Get order status using Debridge', + parameters=[ + ActionParameter(name='id', type=str, required=False, description='Order ID'), + ActionParameter(name='hash', type=str, required=False, description='Transaction hash') + ] + ) + self.actions['get-order-details'] = Action( + name='get-order-details', + description='Get order details using Debridge', + parameters=[ + ActionParameter(name='id', type=str, required=True, description='Order ID') + ] + ) + self.actions['cancel-tx'] = Action( + name='cancel-tx', + description='Cancel transaction using Debridge', + parameters=[ + ActionParameter(name='id', type=str, required=True, description='Order ID') + ] + ) + self.actions['extcall-cancel-tx'] = Action( + name='extcall-cancel-tx', + description='External call to cancel transaction using Debridge', + parameters=[ + ActionParameter(name='id', type=str, required=True, description='Order ID') + ] + ) + self.actions['get-supported-chains'] = Action( + name='get-supported-chains', + description='Get supported chains using Debridge', + parameters=[] + ) + self.actions['get-token-list'] = Action( + name='get-token-list', + description='Get token list using Debridge', + parameters=[ + ActionParameter(name='chainId', type=int, required=True, description='Chain ID') + ] + ) + + def configure(self) -> bool: + """Configure the Debridge connection""" + try: + self._initialize() + return True + except Exception as e: + logger.error(f"Failed to configure Debridge connection: {str(e)}") + return False + + def is_configured(self, verbose: bool = False) -> bool: + """Check if the connection is properly configured""" + try: + if not self.connections: + logger.error("No connections found") + return False + return True + except Exception as e: + if verbose: + logger.error(f"Configuration check failed: {str(e)}") + return False + + def create_bridge_tx( + self, + connection: str, + srcChainId: int, + srcChainTokenIn: str, + dstChainId: int, + dstChainTokenOut: str, + dstChainTokenOutRecipient: str, + dstChainOrderAuthorityAddress: str = None, + affiliateFeeRecipient: str = None, + prependOperatingExpense: bool = True, + srcChainTokenInAmount: str = "auto", + dstChainTokenOutAmount: str = "auto", + affiliateFeePercent: float = 0, + dlnHook: str = None + ) -> Dict: + """Create Bridge TX using Debridge""" + try: + connection_class: BaseConnection = self.connections[connection] + srcChainOrderAuthorityAddress = connection_class.get_address().split(" ")[-1] + logger.info(srcChainOrderAuthorityAddress) + params = { + "srcChainId": srcChainId, + "srcChainTokenIn": srcChainTokenIn, + "srcChainTokenInAmount": srcChainTokenInAmount, + "dstChainId": dstChainId, + "dstChainTokenOut": dstChainTokenOut, + "dstChainTokenOutAmount": dstChainTokenOutAmount, + "prependOperatingExpense": prependOperatingExpense, + "dstChainTokenOutRecipient": dstChainTokenOutRecipient, + "srcChainOrderAuthorityAddress": srcChainOrderAuthorityAddress, + "dstChainOrderAuthorityAddress": dstChainOrderAuthorityAddress, + "affiliateFeePercent": affiliateFeePercent, + "affiliateFeeRecipient": affiliateFeeRecipient, + "dlnHook": dlnHook, + "accesstoken": self.access_key, + "referralCode": 21064, + "deBridgeApp": "ZEREPY" + + } + + response = requests.get(f"{self.api_url}/v1.0/dln/order/create-tx", params=params) + response.raise_for_status() + data = response.json() + self.pending_tx = data + logger.info(json.dumps(data["estimation"], indent=4)) + + except Exception as e: + raise DebridgeConnectionError(f"Failed to bridge assets: {str(e)}") + + def execute_bridge_tx(self, connection: str, compute_unit_price: int = 200_000, compute_unit_limit: int = None) -> None: + """Execute bridge transaction using Debridge""" + try: + logger.info("Executing bridge transaction...") + + if not self.pending_tx: + raise ValueError("No pending transaction found") + + connection_class: BaseConnection = self.connections[connection] + + if connection == "solana": + data = { + "tx": self.pending_tx["tx"]["data"], + "compute_unit_price": compute_unit_price + } + if compute_unit_limit: + data["compute_unit_limit"] = compute_unit_limit + tx_url = connection_class.perform_action("send-transaction", data) + else: + if self.pending_tx["tx"]["allowanceTarget"]: + logger.info("The bridge transaction requires approval. \nApproving token...") + + connection_class._handle_token_approval( + token_address=self.pending_tx["estimation"]["srcChainTokenIn"]["address"], + spender_address=self.pending_tx["tx"]["allowanceTarget"], + amount=int(self.pending_tx["tx"]["allowanceValue"]) + ) + logger.info("Token approved. \nPlease create a new transaction.") + + self.pending_tx = None + return + tx_url = connection_class.perform_action("send-transaction", { + "tx": json.dumps({ + "tx": self.pending_tx["tx"], + "estimation": self.pending_tx["estimation"] + }) + }) + + self.pending_tx = None + return tx_url + + except Exception as e: + raise DebridgeConnectionError(f"Failed to execute bridge transaction: {str(e)}") + + def get_order_status(self, id: str = None, hash: str = None) -> Dict: + """Get order status using Debridge""" + try: + if (id): + response = requests.get(f"{self.api_url}/v1.0/dln/order/{id}/status", params={"accesstoken": self.access_key}) + elif (hash): + response = requests.get(f"{self.api_url}/v1.0/dln/tx/{hash}/order-ids", params={"accesstoken": self.access_key}) + else: + raise ValueError("Either 'id' or 'hash' must be provided") + + response.raise_for_status() + return response.json() + except Exception as e: + raise DebridgeConnectionError(f"Failed to get order status: {str(e)}") + + def get_order_details(self, id: str) -> Dict: + """Get order details using Debridge""" + try: + response = requests.get(f"{self.api_url}/v1.0/dln/order/{id}", params={"accesstoken": self.access_key}) + response.raise_for_status() + return response.json() + except Exception as e: + raise DebridgeConnectionError(f"Failed to get order details: {str(e)}") + + def cancel_tx(self, id: str) -> Dict: + """Cancel transaction using Debridge""" + try: + response = requests.post(f"{self.api_url}/v1.0/dln/order/{id}/cancel-tx", params={"accesstoken": self.access_key}) + response.raise_for_status() + return response.json() + except Exception as e: + raise DebridgeConnectionError(f"Failed to cancel transaction: {str(e)}") + + def extcall_cancel_tx(self, id: str) -> Dict: + """External call to cancel transaction using Debridge""" + try: + response = requests.post(f"{self.api_url}/v1.0/dln/order/{id}/extcall-cancel-tx", params={"accesstoken": self.access_key}) + response.raise_for_status() + return response.json() + except Exception as e: + raise DebridgeConnectionError(f"Failed to perform external call to cancel transaction: {str(e)}") + + def get_supported_chains(self) -> Dict: + """Get supported chains using Debridge""" + try: + response = requests.get(f"{self.api_url}/v1.0/supported-chains-info", params={"accesstoken": self.access_key}) + response.raise_for_status() + return response.json() + except Exception as e: + raise DebridgeConnectionError(f"Failed to get supported chains: {str(e)}") + + def get_token_list(self, chainId: int) -> Dict: + """Get token list using Debridge""" + try: + response = requests.get(f"{self.api_url}/v1.0/token-list", params={"chainId": chainId, "accesstoken": self.access_key}) + response.raise_for_status() + return response.json() + except Exception as e: + raise DebridgeConnectionError(f"Failed to get token list: {str(e)}") + + def perform_action(self, action_name: str, kwargs) -> Any: + """Execute a Debridge action with validation""" + if action_name not in self.actions: + raise KeyError(f"Unknown action: {action_name}") + + if not self.is_configured(verbose=True): + raise DebridgeConnectionError( + "Debridge is not properly configured") + + action = self.actions[action_name] + validation_errors = action.validate_params(kwargs) + if validation_errors: + raise DebridgeConnectionError( + f"Invalid parameters: {', '.join(validation_errors)}") + + method_name = action_name.replace('-', '_') + method = getattr(self, method_name) + return method(**kwargs) diff --git a/src/connections/ethereum_connection.py b/src/connections/ethereum_connection.py index cd32db00..73a56382 100644 --- a/src/connections/ethereum_connection.py +++ b/src/connections/ethereum_connection.py @@ -2,6 +2,7 @@ import os import time import requests +import json from typing import Dict, Any, Optional, Union from dotenv import load_dotenv, set_key from web3 import Web3 @@ -12,6 +13,8 @@ logger = logging.getLogger("connections.ethereum_connection") +ZERO_ADDRESS = "0x0000000000000000000000000000000000000000" + class EthereumConnectionError(Exception): """Base exception for Ethereum connection errors""" pass @@ -116,6 +119,13 @@ def register_actions(self) -> None: ActionParameter("slippage", False, float, "Max slippage percentage (default 0.5%)") ], description="Swap tokens using Kyberswap aggregator" + ), + "send-transaction": Action( + name="send-transaction", + parameters=[ + ActionParameter("tx", True, str, "Raw transaction data") + ], + description="Send a raw transaction to the Ethereum network" ) } @@ -260,7 +270,7 @@ def _get_raw_balance(self, address: str, token_address: Optional[str] = None) -> Web3.to_checksum_address(address) ).call() decimals = contract.functions.decimals().call() - return balanqce / (10 ** decimals) + return balance / (10 ** decimals) else: # Get native ETH balance balance = self._web3.eth.get_balance(Web3.to_checksum_address(address)) @@ -511,64 +521,64 @@ def _build_swap_tx( logger.error(f"Failed to build swap transaction: {str(e)}") raise - def _handle_token_approval( - self, - token_address: str, - spender_address: str, - amount: int - ) -> Optional[str]: - """Handle token approval for spender, returns tx hash if approval needed""" - try: - private_key = os.getenv('ETH_PRIVATE_KEY') - account = self._web3.eth.account.from_key(private_key) + def _handle_token_approval( + self, + token_address: str, + spender_address: str, + amount: int + ) -> Optional[str]: + """Handle token approval for spender, returns tx hash if approval needed""" + try: + private_key = os.getenv('ETH_PRIVATE_KEY') + account = self._web3.eth.account.from_key(private_key) + + token_contract = self._web3.eth.contract( + address=Web3.to_checksum_address(token_address), + abi=ERC20_ABI + ) + + # Check current allowance + current_allowance = token_contract.functions.allowance( + account.address, + spender_address + ).call() + + if current_allowance < amount: + # Prepare approval transaction + approve_tx = token_contract.functions.approve( + spender_address, + amount + ).build_transaction({ + 'from': account.address, + 'nonce': self._web3.eth.get_transaction_count(account.address), + 'gasPrice': self._web3.eth.gas_price, + 'chainId': self.chain_id + }) - token_contract = self._web3.eth.contract( - address=Web3.to_checksum_address(token_address), - abi=ERC20_ABI - ) + # Estimate gas for approval + try: + gas_estimate = self._web3.eth.estimate_gas(approve_tx) + approve_tx['gas'] = int(gas_estimate * 1.1) # Add 10% buffer + except Exception as e: + logger.warning(f"Approval gas estimation failed: {e}, using default") + approve_tx['gas'] = 100000 # Default gas for approvals - # Check current allowance - current_allowance = token_contract.functions.allowance( - account.address, - spender_address - ).call() + # Sign and send approval transaction + signed_approve = account.sign_transaction(approve_tx) + tx_hash = self._web3.eth.send_raw_transaction(signed_approve.rawTransaction) - if current_allowance < amount: - # Prepare approval transaction - approve_tx = token_contract.functions.approve( - spender_address, - amount - ).build_transaction({ - 'from': account.address, - 'nonce': self._web3.eth.get_transaction_count(account.address), - 'gasPrice': self._web3.eth.gas_price, - 'chainId': self.chain_id - }) - - # Estimate gas for approval - try: - gas_estimate = self._web3.eth.estimate_gas(approve_tx) - approve_tx['gas'] = int(gas_estimate * 1.1) # Add 10% buffer - except Exception as e: - logger.warning(f"Approval gas estimation failed: {e}, using default") - approve_tx['gas'] = 100000 # Default gas for approvals - - # Sign and send approval transaction - signed_approve = account.sign_transaction(approve_tx) - tx_hash = self._web3.eth.send_raw_transaction(signed_approve.rawTransaction) - - # Wait for approval to be mined - receipt = self._web3.eth.wait_for_transaction_receipt(tx_hash) - if receipt['status'] != 1: - raise ValueError("Token approval failed") - - return tx_hash.hex() - - return None + # Wait for approval to be mined + receipt = self._web3.eth.wait_for_transaction_receipt(tx_hash) + if receipt['status'] != 1: + raise ValueError("Token approval failed") + + return tx_hash.hex() + + return None - except Exception as e: - logger.error(f"Token approval failed: {str(e)}") - raise + except Exception as e: + logger.error(f"Token approval failed: {str(e)}") + raise def swap( self, @@ -627,6 +637,32 @@ def swap( except Exception as e: return f"Swap failed: {str(e)}" + + def send_transaction(self, tx: Dict[str, Any]) -> str: + """Send a raw transaction to the Ethereum network""" + try: + private_key = os.getenv('ETH_PRIVATE_KEY') + account = self._web3.eth.account.from_key(private_key) + + data = json.loads(tx) + tx = data["tx"] + tx["from"] = account.address + tx["nonce"] = self._web3.eth.get_transaction_count(account.address) + tx["gasPrice"] = self._web3.eth.gas_price + tx["chainId"] = self._web3.eth.chain_id + + if "value" in tx: + tx["value"] = int(tx["value"]) + try: + tx['gas'] = self._web3.eth.estimate_gas(tx) + except Exception as e: + raise ValueError(f"Gas estimation failed: {e}") + signed_tx = account.sign_transaction(tx) + tx_hash = self._web3.eth.send_raw_transaction(signed_tx.rawTransaction) + tx_link = self._get_explorer_link(tx_hash.hex()) + return tx_link + except Exception as e: + return f"Transaction failed: {str(e)}" def perform_action(self, action_name: str, kwargs: Dict[str, Any]) -> Any: """Execute an Ethereum action with validation""" diff --git a/src/connections/evm_connection.py b/src/connections/evm_connection.py index 3f6c23a8..8e41e6b0 100644 --- a/src/connections/evm_connection.py +++ b/src/connections/evm_connection.py @@ -2,6 +2,7 @@ import os import time import requests +import json from typing import Dict, Any, Optional, Union from dotenv import load_dotenv, set_key from web3 import Web3 @@ -12,6 +13,7 @@ logger = logging.getLogger("connections.evm_connection") +ZERO_ADDRESS = "0x0000000000000000000000000000000000000000" class EVMConnectionError(Exception): """Base exception for EVM connection errors""" @@ -56,18 +58,18 @@ def _initialize_web3(self) -> None: self._web3.middleware_onion.inject(geth_poa_middleware, layer=0) if not self._web3.is_connected(): - raise EthereumConnectionError("Failed to connect to Ethereum network") + raise EVMConnectionError("Failed to connect to Ethereum network") chain_id = self._web3.eth.chain_id if chain_id != self.chain_id: - raise EthereumConnectionError(f"Connected to wrong chain. Expected {self.chain_id}, got {chain_id}") + raise EVMConnectionError(f"Connected to wrong chain. Expected {self.chain_id}, got {chain_id}") logger.info(f"Connected to {self.network} network with chain ID: {chain_id}") break except Exception as e: if attempt == 2: - raise EthereumConnectionError(f"Failed to initialize Web3 after 3 attempts: {str(e)}") + raise EVMConnectionError(f"Failed to initialize Web3 after 3 attempts: {str(e)}") logger.warning(f"Web3 initialization attempt {attempt + 1} failed: {str(e)}") time.sleep(1) @@ -123,6 +125,13 @@ def register_actions(self) -> None: ActionParameter("slippage", False, float, "Max slippage percentage (default 0.5%)") ], description="Swap tokens using Kyberswap aggregator" + ), + "send-transaction": Action( + name="send-transaction", + parameters=[ + ActionParameter("tx", True, str, "Transaction object") + ], + description="Send a signed transaction" ) } @@ -490,6 +499,34 @@ def swap(self, token_in: str, token_out: str, amount: float, slippage: float = 0 except Exception as e: return f"Swap failed: {str(e)}" + + def send_transaction(self, tx: str) -> str: + """Send a signed transaction""" + try: + private_key = os.getenv('EVM_PRIVATE_KEY') or os.getenv('ETH_PRIVATE_KEY') + account = self._web3.eth.account.from_key(private_key) + + data = json.loads(tx) + tx = data["tx"] + tx["from"] = account.address + tx["nonce"] = self._web3.eth.get_transaction_count(account.address) + tx["gasPrice"] = self._web3.eth.gas_price + tx["chainId"] = self._web3.eth.chain_id + + if "value" in tx: + tx["value"] = int(tx["value"]) + try: + tx['gas'] = self._web3.eth.estimate_gas(tx) + except Exception as e: + raise ValueError(f"Gas estimation failed: {e}") + signed_tx = account.sign_transaction(tx) + tx_hash = self._web3.eth.send_raw_transaction(signed_tx.rawTransaction) + tx_link = self._get_explorer_link(tx_hash.hex()) + return tx_link + + except Exception as e: + logger.error(f"Failed to send transaction: {str(e)}") + raise def perform_action(self, action_name: str, kwargs: Dict[str, Any]) -> Any: """Execute an Ethereum action with validation""" @@ -497,7 +534,7 @@ def perform_action(self, action_name: str, kwargs: Dict[str, Any]) -> Any: raise KeyError(f"Unknown action: {action_name}") load_dotenv() if not self.is_configured(verbose=True): - raise EthereumConnectionError("Ethereum connection is not properly configured") + raise EVMConnectionError("Ethereum connection is not properly configured") action = self.actions[action_name] errors = action.validate_params(kwargs) if errors: diff --git a/src/connections/solana_connection.py b/src/connections/solana_connection.py index 3635104f..717e8451 100644 --- a/src/connections/solana_connection.py +++ b/src/connections/solana_connection.py @@ -1,7 +1,7 @@ import logging import os -import requests import asyncio + from typing import Dict, Any, Optional from src.connections.base_connection import BaseConnection, Action, ActionParameter @@ -16,6 +16,7 @@ from src.helpers.solana.performance import SolanaPerformanceTracker from src.helpers.solana.transfer import SolanaTransferHelper from src.helpers.solana.read import SolanaReadHelper +from src.helpers.solana.transaction_helper import TransactionHelper from dotenv import load_dotenv, set_key @@ -23,10 +24,9 @@ from jupiter_python_sdk.jupiter import Jupiter from solana.rpc.async_api import AsyncClient -from solana.rpc.commitment import Confirmed - -from solders.keypair import Keypair # type: ignore +from solders.keypair import Keypair +from solders.transaction import VersionedTransaction logger = logging.getLogger("connections.solana_connection") @@ -216,6 +216,15 @@ def register_actions(self) -> None: ], description="Launch a Pump & Fun token", ), + "send-transaction": Action( + name="send-transaction", + parameters=[ + ActionParameter("tx", True, str, "Hex-encoded transaction"), + ActionParameter("compute_unit_price", True, int, "Compute unit price"), + ActionParameter("compute_unit_limit", False, int, "Compute unit limit"), + ], + description="Send a hex-encoded transaction transaction", + ), } def configure(self) -> bool: @@ -283,6 +292,9 @@ def is_configured(self, verbose: bool = False) -> bool: logger.debug(f"Solana Configuration validation failed: {error_msg}") return False + def get_address(self) -> str: + return str(self._get_wallet().pubkey()) + def transfer( self, to_address: str, amount: float, token_mint: Optional[str] = None ) -> str: @@ -414,6 +426,31 @@ def launch_pump_token( # f"Launched Pump & Fun token {token_ticker}\nToken Mint: {res['mint']}" # ) # return res + + """WARNING: PARTIALLY TESTED""" + def send_transaction(self, tx: str, compute_unit_price: int, compute_unit_limit: Optional[int] = None) -> str: + """Send a hex-encoded transaction""" + # Clean the input + tx = tx.strip().replace(" ", "").replace("\n", "").lstrip("0x0") + + # Ensure hex string has even length + if len(tx) % 2 != 0: + tx = "0" + tx + try: + tx_bytes = bytes.fromhex(tx) # Convert to bytes + transaction = VersionedTransaction.from_bytes(tx_bytes) + except ValueError as e: + raise ValueError(f"Hex decoding error: {e}") + + res = self._send_transaction(transaction, compute_unit_price, compute_unit_limit) + res = asyncio.run(res) + return res + + async def _send_transaction(self, transaction: VersionedTransaction, compute_unit_price: int, compute_unit_limit: Optional[int] = None) -> str: + async_client = self._get_connection_async() + wallet = self._get_wallet() + + return await TransactionHelper.send_transaction(async_client, wallet, transaction, compute_unit_price, compute_unit_limit) def perform_action(self, action_name: str, kwargs) -> Any: """Execute a Solana action with validation""" diff --git a/src/connections/sonic_connection.py b/src/connections/sonic_connection.py index bc7ab527..b6ddcbfb 100644 --- a/src/connections/sonic_connection.py +++ b/src/connections/sonic_connection.py @@ -2,6 +2,7 @@ import os import requests import time +import json from typing import Dict, Any, Optional from dotenv import load_dotenv, set_key from web3 import Web3 @@ -12,6 +13,7 @@ logger = logging.getLogger("connections.sonic_connection") +ZERO_ADDRESS = "0x0000000000000000000000000000000000000000" class SonicConnectionError(Exception): """Base exception for Sonic connection errors""" @@ -56,6 +58,14 @@ def _initialize_web3(self): except Exception as e: logger.warning(f"Could not get chain ID: {e}") + def get_address(self) -> str: + try: + private_key = os.getenv('SONIC_PRIVATE_KEY') + account = self._web3.eth.account.from_key(private_key) + return f"Your Sonic address: {account.address}" + except Exception as e: + return f"Failed to get address: {str(e)}" + @property def is_llm_provider(self) -> bool: return False @@ -141,6 +151,13 @@ def register_actions(self) -> None: ActionParameter("slippage", False, float, "Max slippage percentage") ], description="Swap tokens" + ), + "send-transaction": Action( + name="send-transaction", + parameters=[ + ActionParameter("tx", True, str, "Raw transaction data") + ], + description="Send a raw transaction" ) } @@ -438,6 +455,33 @@ def swap(self, token_in: str, token_out: str, amount: float, slippage: float = 0 except Exception as e: logger.error(f"Swap failed: {e}") raise + def send_transaction(self, tx: str) -> str: + """Send a raw transaction to the network""" + try: + private_key = os.getenv('SONIC_PRIVATE_KEY') + account = self._web3.eth.account.from_key(private_key) + + data = json.loads(tx) + tx = data["tx"] + tx["from"] = account.address + tx["nonce"] = self._web3.eth.get_transaction_count(account.address) + tx["gasPrice"] = self._web3.eth.gas_price + tx["chainId"] = self._web3.eth.chain_id + + if "value" in tx: + tx["value"] = int(tx["value"]) + try: + tx['gas'] = self._web3.eth.estimate_gas(tx) + except Exception as e: + raise ValueError(f"Gas estimation failed: {e}") + signed_tx = account.sign_transaction(tx) + tx_hash = self._web3.eth.send_raw_transaction(signed_tx.rawTransaction) + tx_link = self._get_explorer_link(tx_hash.hex()) + return tx_link + except Exception as e: + logger.error(f"Failed to send transaction: {e}") + raise + def perform_action(self, action_name: str, kwargs) -> Any: """Execute a Sonic action with validation""" if action_name not in self.actions: diff --git a/src/helpers/solana/transaction_helper.py b/src/helpers/solana/transaction_helper.py new file mode 100644 index 00000000..0a8bc77c --- /dev/null +++ b/src/helpers/solana/transaction_helper.py @@ -0,0 +1,103 @@ +from typing import Optional +from solders.transaction import VersionedTransaction +from solders.instruction import CompiledInstruction +from solders.keypair import Keypair +from solders.rpc.responses import GetLatestBlockhashResp +from solana.rpc.async_api import AsyncClient +from solana.rpc.commitment import Confirmed, Processed +from solana.rpc.types import TxOpts +import solders.message +import json +import logging + +logger = logging.getLogger("helpers.transaction_helper") + +class TransactionHelper: + @staticmethod + def encode_number_to_array_le(num: int, array_size: int) -> bytearray: + result = bytearray(array_size) + for i in range(array_size): + result[i] = num & 0xFF + num >>= 8 + return result + + @staticmethod + def update_priority_fee(tx: VersionedTransaction, compute_unit_price: int, compute_unit_limit: Optional[int] = None): + compute_budget_offset = 1 + + # Modify compute unit price + compute_unit_price_data = bytearray(tx.message.instructions[1].data) + encoded_price = TransactionHelper.encode_number_to_array_le(compute_unit_price, 8) + + for i in range(len(encoded_price)): + compute_unit_price_data[i + compute_budget_offset] = encoded_price[i] + + # Replace instruction with modified data + tx.message.instructions[1] = CompiledInstruction( + program_id_index=tx.message.instructions[1].program_id_index, + accounts=tx.message.instructions[1].accounts, + data=bytes(compute_unit_price_data) + ) + + if compute_unit_limit: + # Modify compute unit limit + compute_unit_limit_data = bytearray(tx.message.instructions[0].data) + encoded_limit = TransactionHelper.encode_number_to_array_le(compute_unit_limit, 4) + + for i in range(len(encoded_limit)): + compute_unit_limit_data[i + compute_budget_offset] = encoded_limit[i] + + # Replace instruction with modified data + tx.message.instructions[0] = CompiledInstruction( + program_id_index=tx.message.instructions[0].program_id_index, + accounts=tx.message.instructions[0].accounts, + data=bytes(compute_unit_limit_data) + ) + + @staticmethod + async def send_transaction(async_client: AsyncClient, wallet: Keypair, transaction: VersionedTransaction, compute_unit_price: int, compute_unit_limit: Optional[int] = None) -> str: + try: + # Update priority fee + TransactionHelper.update_priority_fee(transaction, compute_unit_price, compute_unit_limit) + + # Fetch latest blockhash + latest_blockhash_resp: GetLatestBlockhashResp = await async_client.get_latest_blockhash(Confirmed) + latest_blockhash = latest_blockhash_resp.value.blockhash + + # Create message with updated blockhash + updated_message = solders.message.MessageV0( + header=transaction.message.header, + account_keys=transaction.message.account_keys, + recent_blockhash=latest_blockhash, + instructions=transaction.message.instructions, + address_table_lookups=transaction.message.address_table_lookups + ) + + # Sign the message + signature = wallet.sign_message( + solders.message.to_bytes_versioned(updated_message) + ) + logger.info(signature) + + # Create signed transaction with single signature + signed_tx = VersionedTransaction.populate( + updated_message, [signature] + ) + + # Send the transaction + opts = TxOpts(skip_preflight=False, preflight_commitment=Processed) + result = await async_client.send_raw_transaction( + txn=bytes(signed_tx), + opts=opts + ) + + # Log and return transaction ID + transaction_id = json.loads(result.to_json())["result"] + logger.debug(f"Transaction sent: https://explorer.solana.com/tx/{transaction_id}") + + # Use signature for confirmation + await async_client.confirm_transaction(signature, commitment=Confirmed) + return f"https://explorer.solana.com/tx/{transaction_id}" + + except Exception as e: + raise ValueError(f"Error sending transaction: {e}")