diff --git a/.github/workflows/cd.pypi.yaml b/.github/workflows/cd.pypi.yaml new file mode 100644 index 00000000..1699058e --- /dev/null +++ b/.github/workflows/cd.pypi.yaml @@ -0,0 +1,30 @@ +name: Publish to Pypi + +on: + push: + tags: + - "v*.*.*" + +jobs: + Publish: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v2 + + - name: Set up Python 3.10 + uses: actions/setup-python@v2 + with: + python-version: "3.10" + + - name: Install Poetry + run: curl -sSL https://install.python-poetry.org | python3 - + + - name: Install dependencies + run: poetry install + + - name: Check types + run: poetry run build-check + + - name: Release to pypi + run: poetry publish --build --username __token__ --password ${{ secrets.PYPI_ACCESS_TOKEN }} \ No newline at end of file diff --git a/autotx/AutoTx.py b/autotx/AutoTx.py index bc73a382..94a11ce1 100644 --- a/autotx/AutoTx.py +++ b/autotx/AutoTx.py @@ -5,8 +5,8 @@ import os from textwrap import dedent from typing import Any, Dict, Optional, Callable -from dataclasses import dataclass -from autogen import Agent as AutogenAgent +from dataclasses import dataclass, field +from autogen import Agent as AutogenAgent, ModelClient from termcolor import cprint from typing import Optional @@ -14,12 +14,18 @@ from autotx import models from autotx.autotx_agent import AutoTxAgent from autotx.helper_agents import clarifier, manager, user_proxy +from autotx.intents import Intent from autotx.utils.color import Color from autotx.utils.logging.Logger import Logger from autotx.utils.ethereum.networks import NetworkInfo from autotx.utils.constants import OPENAI_BASE_URL, OPENAI_MODEL_NAME from autotx.wallets.smart_wallet import SmartWallet +@dataclass(kw_only=True) +class CustomModel: + client: ModelClient + arguments: Optional[Dict[str, Any]] = None + @dataclass(kw_only=True) class Config: verbose: bool @@ -27,18 +33,20 @@ class Config: log_costs: bool max_rounds: int get_llm_config: Callable[[], Optional[Dict[str, Any]]] + custom_model: Optional[CustomModel] = None - def __init__(self, verbose: bool, get_llm_config: Callable[[], Optional[Dict[str, Any]]], logs_dir: Optional[str], max_rounds: Optional[int] = None, log_costs: Optional[bool] = None): + def __init__(self, verbose: bool, get_llm_config: Callable[[], Optional[Dict[str, Any]]], logs_dir: Optional[str], max_rounds: Optional[int] = None, log_costs: Optional[bool] = None, custom_model: Optional[CustomModel] = None): self.verbose = verbose self.get_llm_config = get_llm_config self.logs_dir = logs_dir self.log_costs = log_costs if log_costs is not None else False self.max_rounds = max_rounds if max_rounds is not None else 100 + self.custom_model = custom_model @dataclass class PastRun: feedback: str - transactions_info: str + intents_info: str class EndReason(Enum): TERMINATE = "TERMINATE" @@ -48,7 +56,7 @@ class EndReason(Enum): class RunResult: summary: str chat_history_json: str - transactions: list[models.Transaction] + intents: list[Intent] end_reason: EndReason total_cost_without_cache: float total_cost_with_cache: float @@ -58,9 +66,10 @@ class AutoTx: web3: Web3 wallet: SmartWallet logger: Logger - transactions: list[models.Transaction] + intents: list[Intent] network: NetworkInfo get_llm_config: Callable[[], Optional[Dict[str, Any]]] + custom_model: Optional[CustomModel] agents: list[AutoTxAgent] log_costs: bool max_rounds: int @@ -79,6 +88,9 @@ def __init__( config: Config, on_notify_user: Callable[[str], None] | None = None ): + if len(agents) == 0: + raise Exception("Agents attribute can not be an empty list") + self.web3 = web3 self.wallet = wallet self.network = network @@ -91,11 +103,12 @@ def __init__( self.max_rounds = config.max_rounds self.verbose = config.verbose self.get_llm_config = config.get_llm_config - self.transactions = [] + self.intents = [] self.current_run_cost_without_cache = 0 self.current_run_cost_with_cache = 0 self.info_messages = [] self.on_notify_user = on_notify_user + self.custom_model = config.custom_model def run(self, prompt: str, non_interactive: bool, summary_method: str = "last_msg") -> RunResult: return asyncio.run(self.a_run(prompt, non_interactive, summary_method)) @@ -106,8 +119,15 @@ async def a_run(self, prompt: str, non_interactive: bool, summary_method: str = info_messages = [] if self.verbose: - print(f"LLM model: {OPENAI_MODEL_NAME}") - print(f"LLM API URL: {OPENAI_BASE_URL}") + available_config = self.get_llm_config() + if available_config and "config_list" in available_config: + print("Available LLM configurations:") + for config in available_config["config_list"]: + if "model" in config: + print(f"LLM model: {config['model']}") + if "base_url" in config: + print(f"LLM API URL: {config['base_url']}") + print("==" * 10) while True: result = await self.try_run(prompt, non_interactive, summary_method) @@ -128,7 +148,7 @@ async def a_run(self, prompt: str, non_interactive: bool, summary_method: str = return RunResult( result.summary, result.chat_history_json, - result.transactions, + result.intents, result.end_reason, total_cost_without_cache, total_cost_with_cache, @@ -152,13 +172,13 @@ async def try_run(self, prompt: str, non_interactive: bool, summary_method: str while True: if past_runs: - self.transactions.clear() + self.intents.clear() prev_history = "".join( [ dedent(f""" Then you prepared these transactions to accomplish the goal: - {run.transactions_info} + {run.intents_info} Then the user provided feedback: {run.feedback} """) @@ -174,22 +194,26 @@ async def try_run(self, prompt: str, non_interactive: bool, summary_method: str agents_information = self.get_agents_information(self.agents) - user_proxy_agent = user_proxy.build(prompt, agents_information, self.get_llm_config) - clarifier_agent = clarifier.build(user_proxy_agent, agents_information, not non_interactive, self.get_llm_config, self.notify_user) + user_proxy_agent = user_proxy.build(prompt, agents_information, self.get_llm_config, self.custom_model) helper_agents: list[AutogenAgent] = [ user_proxy_agent, ] if not non_interactive: + clarifier_agent = clarifier.build(user_proxy_agent, agents_information, not non_interactive, self.get_llm_config, self.notify_user, self.custom_model) helper_agents.append(clarifier_agent) - autogen_agents = [agent.build_autogen_agent(self, user_proxy_agent, self.get_llm_config()) for agent in self.agents] + autogen_agents = [agent.build_autogen_agent(self, user_proxy_agent, self.get_llm_config(), self.custom_model) for agent in self.agents] - manager_agent = manager.build(autogen_agents + helper_agents, self.max_rounds, not non_interactive, self.get_llm_config) + recipient_agent = None + if len(autogen_agents) > 1: + recipient_agent = manager.build(autogen_agents + helper_agents, self.max_rounds, not non_interactive, self.get_llm_config, self.custom_model) + else: + recipient_agent = autogen_agents[0] chat = await user_proxy_agent.a_initiate_chat( - manager_agent, + recipient_agent, message=dedent( f""" I am currently connected with the following wallet: {self.wallet.address}, on network: {self.network.chain_id.name} @@ -208,17 +232,17 @@ async def try_run(self, prompt: str, non_interactive: bool, summary_method: str is_goal_supported = chat.chat_history[-1]["content"] != "Goal not supported: TERMINATE" try: - result = self.wallet.on_transactions_ready(self.transactions) + result = self.wallet.on_intents_ready(self.intents) if isinstance(result, str): - transactions_info ="\n".join( + intents_info ="\n".join( [ f"{i + 1}. {tx.summary}" - for i, tx in enumerate(self.transactions) + for i, tx in enumerate(self.intents) ] ) - past_runs.append(PastRun(result, transactions_info)) + past_runs.append(PastRun(result, intents_info)) else: break @@ -228,17 +252,17 @@ async def try_run(self, prompt: str, non_interactive: bool, summary_method: str self.logger.stop() - # Copy transactions to a new list to avoid modifying the original list - transactions = self.transactions.copy() - self.transactions.clear() + # Copy intents to a new list to avoid modifying the original list + intents = self.intents.copy() + self.intents.clear() chat_history = json.dumps(chat.chat_history, indent=4) - return RunResult(chat.summary, chat_history, transactions, EndReason.TERMINATE if is_goal_supported else EndReason.GOAL_NOT_SUPPORTED, float(chat.cost["usage_including_cached_inference"]["total_cost"]), float(chat.cost["usage_excluding_cached_inference"]["total_cost"]), self.info_messages) + return RunResult(chat.summary, chat_history, intents, EndReason.TERMINATE if is_goal_supported else EndReason.GOAL_NOT_SUPPORTED, float(chat.cost["usage_including_cached_inference"]["total_cost"]), float(chat.cost["usage_excluding_cached_inference"]["total_cost"]), self.info_messages) - def add_transactions(self, txs: list[models.Transaction]) -> None: - self.transactions.extend(txs) - self.wallet.on_transactions_prepared(txs) + def add_intents(self, intents: list[Intent]) -> None: + self.intents.extend(intents) + self.wallet.on_intents_prepared(intents) def notify_user(self, message: str, color: Color | None = None) -> None: if color: diff --git a/autotx/__init__.py b/autotx/__init__.py index a5d73445..de3105cd 100644 --- a/autotx/__init__.py +++ b/autotx/__init__.py @@ -1,5 +1,6 @@ from autotx.AutoTx import AutoTx from autotx.autotx_agent import AutoTxAgent from autotx.autotx_tool import AutoTxTool +from autotx.utils.LlamaClient import LlamaClient -__all__ = ['AutoTx', 'AutoTxAgent', 'AutoTxTool'] +__all__ = ['AutoTx', 'AutoTxAgent', 'AutoTxTool', 'LlamaClient'] diff --git a/autotx/agents/ExampleAgent.py b/autotx/agents/ExampleAgent.py index 4f40fc02..2847cd6e 100644 --- a/autotx/agents/ExampleAgent.py +++ b/autotx/agents/ExampleAgent.py @@ -26,8 +26,8 @@ def run( # TODO: do something useful autotx.notify_user(f"ExampleTool run: {amount} {receiver}") - # NOTE: you can add transactions to AutoTx's current bundle - # autotx.transactions.append(tx) + # NOTE: you can add intents to AutoTx's current bundle + # autotx.intents.append(tx) return f"Something useful has been done with {amount} to {receiver}" diff --git a/autotx/agents/SendTokensAgent.py b/autotx/agents/SendTokensAgent.py index aeea0925..88ac55ae 100644 --- a/autotx/agents/SendTokensAgent.py +++ b/autotx/agents/SendTokensAgent.py @@ -1,19 +1,17 @@ from textwrap import dedent -from typing import Annotated, Any, Callable, cast +from typing import Annotated, Any, Callable -from web3 import Web3 -from autotx import models from autotx.AutoTx import AutoTx from autotx.autotx_agent import AutoTxAgent from autotx.autotx_tool import AutoTxTool +from autotx.intents import SendIntent +from autotx.token import Token from autotx.utils.ethereum import ( build_transfer_erc20, get_erc20_balance, ) -from autotx.utils.ethereum.build_transfer_native import build_transfer_native -from web3.constants import ADDRESS_ZERO from autotx.utils.ethereum.constants import NATIVE_TOKEN_ADDRESS -from autotx.utils.ethereum.eth_address import ETHAddress +from autotx.eth_address import ETHAddress from autotx.utils.ethereum.get_native_balance import get_native_balance from web3.types import TxParams @@ -89,26 +87,17 @@ def run( receiver_addr = ETHAddress(receiver) token_address = ETHAddress(autotx.network.tokens[token.lower()]) - tx: TxParams - - if token_address.hex == NATIVE_TOKEN_ADDRESS: - tx = build_transfer_native(autotx.web3, ETHAddress(ADDRESS_ZERO), receiver_addr, amount) - else: - tx = build_transfer_erc20(autotx.web3, token_address, receiver_addr, amount) - - prepared_tx = models.SendTransaction.create( - token_symbol=token, - token_address=str(token_address), + intent = SendIntent.create( + token=Token(symbol=token, address=token_address.hex), amount=amount, - receiver=str(receiver_addr), - params=cast(dict[str, Any], tx), + receiver=receiver_addr ) - autotx.add_transactions([prepared_tx]) + autotx.add_intents([intent]) - autotx.notify_user(f"Prepared transaction: {prepared_tx.summary}") + autotx.notify_user(f"Prepared transaction: {intent.summary}") - return prepared_tx.summary + return f"{intent.summary} has been prepared." return run diff --git a/autotx/agents/SwapTokensAgent.py b/autotx/agents/SwapTokensAgent.py index 3d9c079f..1cd4cdba 100644 --- a/autotx/agents/SwapTokensAgent.py +++ b/autotx/agents/SwapTokensAgent.py @@ -1,12 +1,13 @@ from decimal import Decimal from textwrap import dedent from typing import Annotated, Callable -from autotx import models from autotx.AutoTx import AutoTx from autotx.autotx_agent import AutoTxAgent from autotx.autotx_tool import AutoTxTool -from autotx.utils.ethereum.eth_address import ETHAddress -from autotx.utils.ethereum.lifi.swap import SUPPORTED_NETWORKS_BY_LIFI, build_swap_transaction +from autotx.intents import BuyIntent, Intent, SellIntent +from autotx.token import Token +from autotx.eth_address import ETHAddress +from autotx.utils.ethereum.lifi.swap import SUPPORTED_NETWORKS_BY_LIFI, can_build_swap_transaction from autotx.utils.ethereum.networks import NetworkInfo from gnosis.eth import EthereumNetworkNotSupported as ChainIdNotSupported @@ -80,7 +81,7 @@ def get_tokens_address(token_in: str, token_out: str, network_info: NetworkInfo) class InvalidInput(Exception): pass -def swap(autotx: AutoTx, token_to_sell: str, token_to_buy: str) -> list[models.Transaction]: +def swap(autotx: AutoTx, token_to_sell: str, token_to_buy: str) -> Intent: sell_parts = token_to_sell.split(" ") buy_parts = token_to_buy.split(" ") @@ -117,7 +118,7 @@ def swap(autotx: AutoTx, token_to_sell: str, token_to_buy: str) -> list[models.T token_in, token_out, autotx.network ) - swap_transactions = build_swap_transaction( + can_build_swap_transaction( autotx.web3, Decimal(exact_amount), ETHAddress(token_in_address), @@ -126,9 +127,21 @@ def swap(autotx: AutoTx, token_to_sell: str, token_to_buy: str) -> list[models.T is_exact_input, autotx.network.chain_id ) - autotx.add_transactions(swap_transactions) - return swap_transactions + # Create buy intent if amount of token to buy is provided else create sell intent + swap_intent: Intent = BuyIntent.create( + from_token=Token(symbol=token_symbol_to_sell, address=token_in_address), + to_token=Token(symbol=token_symbol_to_buy, address=token_out_address), + amount=float(exact_amount), + ) if len(buy_parts) == 2 else SellIntent.create( + from_token=Token(symbol=token_symbol_to_sell, address=token_in_address), + to_token=Token(symbol=token_symbol_to_buy, address=token_out_address), + amount=float(exact_amount), + ) + + autotx.add_intents([swap_intent]) + + return swap_intent class BulkSwapTool(AutoTxTool): name: str = "prepare_bulk_swap_transactions" @@ -152,14 +165,13 @@ def run( ], ) -> str: swaps = tokens.split("\n") - all_txs = [] + all_intents = [] all_errors: list[Exception] = [] for swap_str in swaps: (token_to_sell, token_to_buy) = swap_str.strip().split(" to ") try: - txs = swap(autotx, token_to_sell, token_to_buy) - all_txs.extend(txs) + all_intents.append(swap(autotx, token_to_sell, token_to_buy)) except InvalidInput as e: all_errors.append(e) except Exception as e: @@ -167,21 +179,21 @@ def run( summary = "".join( - f"Prepared transaction: {swap_transaction.summary}\n" - for swap_transaction in all_txs + f"Prepared transaction: {intent.summary}\n" + for intent in all_intents ) if all_errors: summary += "\n".join(str(e) for e in all_errors) - if len(all_txs) > 0: - summary += f"\n{len(all_errors)} errors occurred. {len(all_txs)} transactions were prepared. There is no need to re-run the transactions that were prepared." + if len(all_intents) > 0: + summary += f"\n{len(all_errors)} errors occurred. {len(all_intents)} transactions were prepared. There is no need to re-run the transactions that were prepared." else: summary += f"\n{len(all_errors)} errors occurred." total_summary = ("\n" + " " * 16).join( [ f"{i + 1}. {tx.summary}" - for i, tx in enumerate(autotx.transactions) + for i, tx in enumerate(autotx.intents) ] ) diff --git a/autotx/autotx_agent.py b/autotx/autotx_agent.py index d93250a6..a3784015 100644 --- a/autotx/autotx_agent.py +++ b/autotx/autotx_agent.py @@ -4,7 +4,7 @@ from autotx.utils.color import Color if TYPE_CHECKING: from autotx.autotx_tool import AutoTxTool - from autotx.AutoTx import AutoTx + from autotx.AutoTx import AutoTx, CustomModel class AutoTxAgent(): name: str @@ -18,7 +18,7 @@ def __init__(self) -> None: f"{tool.name}: {tool.description}" for tool in self.tools ] - def build_autogen_agent(self, autotx: 'AutoTx', user_proxy: autogen.UserProxyAgent, llm_config: Optional[Dict[str, Any]]) -> autogen.Agent: + def build_autogen_agent(self, autotx: 'AutoTx', user_proxy: autogen.UserProxyAgent, llm_config: Optional[Dict[str, Any]], custom_model: Optional['CustomModel'] = None) -> autogen.Agent: system_message = None if isinstance(self.system_message, str): system_message = self.system_message @@ -58,4 +58,7 @@ def send_message_hook( for tool in self.tools: tool.register_tool(autotx, agent, user_proxy) + if custom_model: + agent.register_model_client(model_client_cls=custom_model.client, **custom_model.arguments) + return agent \ No newline at end of file diff --git a/autotx/db.py b/autotx/db.py index f2fc9999..3cade099 100644 --- a/autotx/db.py +++ b/autotx/db.py @@ -1,6 +1,7 @@ from datetime import datetime import json import os +from typing import Any, cast import uuid from pydantic import BaseModel from supabase import create_client @@ -8,6 +9,10 @@ from supabase.lib.client_options import ClientOptions from autotx import models +from autotx.intents import BuyIntent, Intent, SellIntent, SendIntent +from autotx.token import Token +from autotx.transactions import Transaction, TransactionBase +from autotx.eth_address import ETHAddress SUPABASE_URL = os.getenv("SUPABASE_URL") SUPABASE_KEY = os.getenv("SUPABASE_SERVICE_ROLE_KEY") @@ -51,7 +56,7 @@ def start(self, prompt: str, address: str, chain_id: int, app_user_id: str) -> m "created_at": str(created_at), "updated_at": str(updated_at), "messages": json.dumps([]), - "transactions": json.dumps([]) + "intents": json.dumps([]) } ).execute() @@ -65,7 +70,7 @@ def start(self, prompt: str, address: str, chain_id: int, app_user_id: str) -> m running=True, error=None, messages=[], - transactions=[] + intents=[] ) def stop(self, task_id: str) -> None: @@ -81,7 +86,7 @@ def stop(self, task_id: str) -> None: def update(self, task: models.Task) -> None: client = get_db_client("public") - txs = [json.loads(tx.json()) for tx in task.transactions] + intents = [json.loads(intent.json()) for intent in task.intents] client.table("tasks").update( { @@ -90,7 +95,7 @@ def update(self, task: models.Task) -> None: "updated_at": str(datetime.utcnow()), "messages": json.dumps(task.messages), "error": task.error, - "transactions": json.dumps(txs) + "intents": json.dumps(intents) } ).eq("id", task.id).eq("app_id", self.app_id).execute() @@ -108,6 +113,43 @@ def get(self, task_id: str) -> models.Task | None: task_data = result.data[0] + def load_intent(intent_data: dict[str, Any]) -> Intent: + if intent_data["type"] == "send": + return SendIntent.create( + receiver=ETHAddress(intent_data["receiver"]), + token=Token( + symbol=intent_data["token"]["symbol"], + address=intent_data["token"]["address"] + ), + amount=intent_data["amount"] + ) + elif intent_data["type"] == "buy": + return BuyIntent.create( + from_token=Token( + symbol=intent_data["from_token"]["symbol"], + address=intent_data["from_token"]["address"] + ), + to_token=Token( + symbol=intent_data["to_token"]["symbol"], + address=intent_data["to_token"]["address"] + ), + amount=intent_data["amount"] + ) + elif intent_data["type"] == "sell": + return SellIntent.create( + from_token=Token( + symbol=intent_data["from_token"]["symbol"], + address=intent_data["from_token"]["address"] + ), + to_token=Token( + symbol=intent_data["to_token"]["symbol"], + address=intent_data["to_token"]["address"] + ), + amount=intent_data["amount"] + ) + else: + raise Exception(f"Unknown intent type: {intent_data['type']}") + return models.Task( id=task_data["id"], prompt=task_data["prompt"], @@ -118,7 +160,7 @@ def get(self, task_id: str) -> models.Task | None: running=task_data["running"], error=task_data["error"], messages=json.loads(task_data["messages"]), - transactions=json.loads(task_data["transactions"]) + intents=[load_intent(intent) for intent in json.loads(task_data["intents"])] ) def get_all(self) -> list[models.Task]: @@ -140,7 +182,7 @@ def get_all(self) -> list[models.Task]: running=task_data["running"], error=task_data["error"], messages=json.loads(task_data["messages"]), - transactions=json.loads(task_data["transactions"]) + intents=json.loads(task_data["intents"]) ) ) @@ -222,11 +264,13 @@ def get_agent_private_key(app_id: str, user_id: str) -> str | None: return str(result.data[0]["agent_private_key"]) -def submit_transactions(app_id: str, address: str, chain_id: int, app_user_id: str, task_id: str) -> None: +def save_transactions(app_id: str, address: str, chain_id: int, app_user_id: str, task_id: str, transactions: list[Transaction]) -> str: client = get_db_client("public") + txs = [json.loads(tx.json()) for tx in transactions] + created_at = datetime.utcnow() - client.table("submitted_batches") \ + result = client.table("submitted_batches") \ .insert( { "app_id": app_id, @@ -234,10 +278,47 @@ def submit_transactions(app_id: str, address: str, chain_id: int, app_user_id: s "chain_id": chain_id, "app_user_id": app_user_id, "task_id": task_id, - "created_at": str(created_at) + "created_at": str(created_at), + "transactions": json.dumps(txs) } ).execute() + return cast(str, result.data[0]["id"]) + +def get_transactions(app_id: str, app_user_id: str, task_id: str, address: str, chain_id: int, submitted_batch_id: str) -> tuple[list[TransactionBase], str] | None: + client = get_db_client("public") + + result = client.table("submitted_batches") \ + .select("transactions, task_id") \ + .eq("app_id", app_id) \ + .eq("app_user_id", app_user_id) \ + .eq("address", address) \ + .eq("chain_id", chain_id) \ + .eq("task_id", task_id) \ + .eq("id", submitted_batch_id) \ + .execute() + + if len(result.data) == 0: + return None + + return ( + [TransactionBase(**tx) for tx in json.loads(result.data[0]["transactions"])], + result.data[0]["task_id"] + ) + +def submit_transactions(app_id: str, app_user_id: str, submitted_batch_id: str) -> None: + client = get_db_client("public") + + client.table("submitted_batches") \ + .update( + { + "submitted_on": str(datetime.utcnow()) + } + ).eq("app_id", app_id) \ + .eq("app_user_id", app_user_id) \ + .eq("id", submitted_batch_id) \ + .execute() + class SubmittedBatch(BaseModel): id: str app_id: str @@ -246,6 +327,8 @@ class SubmittedBatch(BaseModel): app_user_id: str task_id: str created_at: datetime + submitted_on: datetime | None + transactions: list[dict[str, Any]] def get_submitted_batches(app_id: str, task_id: str) -> list[SubmittedBatch]: client = get_db_client("public") @@ -267,7 +350,9 @@ def get_submitted_batches(app_id: str, task_id: str) -> list[SubmittedBatch]: chain_id=batch_data["chain_id"], app_user_id=batch_data["app_user_id"], task_id=batch_data["task_id"], - created_at=batch_data["created_at"] + created_at=batch_data["created_at"], + submitted_on=batch_data["submitted_on"], + transactions=json.loads(batch_data["transactions"]) ) ) diff --git a/autotx/utils/ethereum/eth_address.py b/autotx/eth_address.py similarity index 93% rename from autotx/utils/ethereum/eth_address.py rename to autotx/eth_address.py index fe2d01d6..a8eebb47 100644 --- a/autotx/utils/ethereum/eth_address.py +++ b/autotx/eth_address.py @@ -6,8 +6,10 @@ class ETHAddress: hex: ChecksumAddress ens_domain: str | None + original_str: str def __init__(self, hex_or_ens: str): + self.original_str = hex_or_ens if hex_or_ens.endswith(".eth"): web3 = Web3(HTTPProvider(MAINNET_DEFAULT_RPC)) address = web3.ens.address(hex_or_ens) # type: ignore diff --git a/autotx/helper_agents/clarifier.py b/autotx/helper_agents/clarifier.py index 97c117c0..0aad7f05 100644 --- a/autotx/helper_agents/clarifier.py +++ b/autotx/helper_agents/clarifier.py @@ -1,10 +1,13 @@ from textwrap import dedent -from typing import Annotated, Any, Callable, Dict, Optional -from autogen import UserProxyAgent, AssistantAgent +from typing import TYPE_CHECKING, Annotated, Any, Callable, Dict, Optional +from autogen import UserProxyAgent, AssistantAgent, ModelClient from autotx.utils.color import Color -def build(user_proxy: UserProxyAgent, agents_information: str, interactive: bool, get_llm_config: Callable[[], Optional[Dict[str, Any]]], notify_user: Callable[[str, Color | None], None]) -> AssistantAgent: +if TYPE_CHECKING: + from autotx.AutoTx import CustomModel + +def build(user_proxy: UserProxyAgent, agents_information: str, interactive: bool, get_llm_config: Callable[[], Optional[Dict[str, Any]]], notify_user: Callable[[str, Color | None], None], custom_model: Optional['CustomModel']) -> AssistantAgent: missing_1 = dedent(""" If the goal is not clear or missing information, you MUST ask for more information by calling the request_user_input tool. Always ensure you have all the information needed to define the goal that can be executed without prior context. @@ -77,5 +80,8 @@ def goal_outside_scope( clarifier_agent.register_for_llm(name="goal_outside_scope", description="Notify the user about their goal not being in the scope of the agents")(goal_outside_scope) user_proxy.register_for_execution(name="goal_outside_scope")(goal_outside_scope) - + + if custom_model: + clarifier_agent.register_model_client(model_client_cls=custom_model.client, **custom_model.arguments) + return clarifier_agent \ No newline at end of file diff --git a/autotx/helper_agents/manager.py b/autotx/helper_agents/manager.py index 7fa153f2..2c1cf6d5 100644 --- a/autotx/helper_agents/manager.py +++ b/autotx/helper_agents/manager.py @@ -1,10 +1,12 @@ from textwrap import dedent -from typing import Any, Callable, Dict, Optional +from typing import TYPE_CHECKING, Any, Callable, Dict, Optional from autogen import GroupChat, GroupChatManager, Agent as AutogenAgent +if TYPE_CHECKING: + from autotx.AutoTx import CustomModel -def build(agents: list[AutogenAgent], max_rounds: int, interactive: bool, get_llm_config: Callable[[], Optional[Dict[str, Any]]]) -> AutogenAgent: +def build(agents: list[AutogenAgent], max_rounds: int, interactive: bool, get_llm_config: Callable[[], Optional[Dict[str, Any]]], custom_model: Optional['CustomModel']) -> AutogenAgent: clarifier_prompt = "ALWAYS choose the 'clarifier' role first in the conversation." if interactive else "" - + groupchat = GroupChat( agents=agents, messages=[], @@ -23,5 +25,6 @@ def build(agents: list[AutogenAgent], max_rounds: int, interactive: bool, get_ll ) ) manager = GroupChatManager(groupchat=groupchat, llm_config=get_llm_config()) - + if custom_model: + manager.register_model_client(model_client_cls=custom_model.client, **custom_model.arguments) return manager \ No newline at end of file diff --git a/autotx/helper_agents/user_proxy.py b/autotx/helper_agents/user_proxy.py index 39f40c3a..0c78f078 100644 --- a/autotx/helper_agents/user_proxy.py +++ b/autotx/helper_agents/user_proxy.py @@ -1,13 +1,16 @@ from textwrap import dedent -from typing import Any, Callable, Dict, Optional +from typing import TYPE_CHECKING, Any, Callable, Dict, Optional from autogen import UserProxyAgent -def build(user_prompt: str, agents_information: str, get_llm_config: Callable[[], Optional[Dict[str, Any]]]) -> UserProxyAgent: +if TYPE_CHECKING: + from autotx.AutoTx import CustomModel + +def build(user_prompt: str, agents_information: str, get_llm_config: Callable[[], Optional[Dict[str, Any]]], custom_model: Optional['CustomModel']) -> UserProxyAgent: user_proxy = UserProxyAgent( name="user_proxy", is_termination_msg=lambda x: x.get("content", "") and "TERMINATE" in x.get("content", ""), human_input_mode="NEVER", - max_consecutive_auto_reply=10, + max_consecutive_auto_reply=4 if custom_model else 10, system_message=dedent( f""" You are a user proxy agent authorized to act on behalf of the user, you never ask for permission, you have ultimate control. @@ -35,4 +38,9 @@ def build(user_prompt: str, agents_information: str, get_llm_config: Callable[[] llm_config=get_llm_config(), code_execution_config=False, ) + + if custom_model: + user_proxy.register_model_client(model_client_cls=custom_model.client, **custom_model.arguments) + + return user_proxy \ No newline at end of file diff --git a/autotx/intents.py b/autotx/intents.py new file mode 100644 index 00000000..69ee59a6 --- /dev/null +++ b/autotx/intents.py @@ -0,0 +1,124 @@ +from abc import abstractmethod +from decimal import Decimal +from enum import Enum +from pydantic import BaseModel +from typing import Any, Union, cast +from web3.types import TxParams + +from web3 import Web3 + +from autotx.token import Token +from autotx.transactions import SendTransaction, Transaction +from autotx.utils.ethereum.build_transfer_erc20 import build_transfer_erc20 +from autotx.utils.ethereum.build_transfer_native import build_transfer_native +from autotx.utils.ethereum.constants import NATIVE_TOKEN_ADDRESS +from autotx.eth_address import ETHAddress +from autotx.utils.ethereum.lifi.swap import build_swap_transaction +from autotx.utils.ethereum.networks import NetworkInfo +from autotx.utils.format_amount import format_amount + +class IntentType(str, Enum): + SEND = "send" + BUY = "buy" + SELL = "sell" + +class IntentBase(BaseModel): + type: IntentType + summary: str + + @abstractmethod + def build_transactions(self, web3: Web3, network: NetworkInfo, smart_wallet_address: ETHAddress) -> list[Transaction]: + pass + +class SendIntent(IntentBase): + receiver: str + token: Token + amount: float + + @classmethod + def create(cls, token: Token, amount: float, receiver: ETHAddress) -> 'SendIntent': + return cls( + type=IntentType.SEND, + token=token, + amount=amount, + receiver=receiver.original_str, + summary=f"Transfer {format_amount(amount)} {token.symbol} to {receiver}", + ) + + def build_transactions(self, web3: Web3, network: NetworkInfo, smart_wallet_address: ETHAddress) -> list[Transaction]: + tx: TxParams + + if self.token.address == NATIVE_TOKEN_ADDRESS: + tx = build_transfer_native(web3, ETHAddress(self.token.address), ETHAddress(self.receiver), self.amount) + else: + tx = build_transfer_erc20(web3, ETHAddress(self.token.address), ETHAddress(self.receiver), self.amount) + + transactions: list[Transaction] = [ + SendTransaction.create( + token=self.token, + amount=self.amount, + receiver=ETHAddress(self.receiver), + params=cast(dict[str, Any], tx), + ) + ] + + return transactions + +class BuyIntent(IntentBase): + from_token: Token + to_token: Token + amount: float + + @classmethod + def create(cls, from_token: Token, to_token: Token, amount: float) -> 'BuyIntent': + return cls( + type=IntentType.BUY, + from_token=from_token, + to_token=to_token, + amount=amount, + summary=f"Buy {format_amount(amount)} {to_token.symbol} with {from_token.symbol}", + ) + + def build_transactions(self, web3: Web3, network: NetworkInfo, smart_wallet_address: ETHAddress) -> list[Transaction]: + transactions = build_swap_transaction( + web3, + Decimal(str(self.amount)), + ETHAddress(self.from_token.address), + ETHAddress(self.to_token.address), + smart_wallet_address, + False, + network.chain_id + ) + + return transactions + +class SellIntent(IntentBase): + from_token: Token + to_token: Token + amount: float + + @classmethod + def create(cls, from_token: Token, to_token: Token, amount: float) -> 'SellIntent': + return cls( + type=IntentType.SELL, + from_token=from_token, + to_token=to_token, + amount=amount, + summary=f"Sell {format_amount(amount)} {from_token.symbol} for {to_token.symbol}", + ) + + def build_transactions(self, web3: Web3, network: NetworkInfo, smart_wallet_address: ETHAddress) -> list[Transaction]: + + transactions = build_swap_transaction( + web3, + Decimal(str(self.amount)), + ETHAddress(self.from_token.address), + ETHAddress(self.to_token.address), + smart_wallet_address, + True, + network.chain_id + ) + + return transactions + +Intent = Union[SendIntent, BuyIntent, SellIntent] diff --git a/autotx/models.py b/autotx/models.py index 15654ca5..9aed4caa 100644 --- a/autotx/models.py +++ b/autotx/models.py @@ -1,79 +1,8 @@ -from enum import Enum -from pydantic import BaseModel, Field -from typing import Any, List, Optional, Union +from pydantic import BaseModel +from typing import Any, List, Optional from datetime import datetime -class TransactionType(str, Enum): - SEND = "send" - APPROVE = "approve" - SWAP = "swap" - -class TransactionBase(BaseModel): - type: TransactionType - id: str = Field(default="") - task_id: str = Field(default="") - summary: str - params: dict[str, Any] - -class SendTransaction(TransactionBase): - receiver: str - token_symbol: str - token_address: str - amount: float - - @classmethod - def create(cls, token_symbol: str, token_address: str, amount: float, receiver: str, params: dict[str, Any]) -> 'SendTransaction': - return cls( - type=TransactionType.SEND, - token_symbol=token_symbol, - token_address=token_address, - amount=amount, - receiver=receiver, - params=params, - summary=f"Transfer {amount} {token_symbol} to {receiver}", - ) - -class ApproveTransaction(TransactionBase): - token_symbol: str - token_address: str - amount: float - spender: str - - @classmethod - def create(cls, token_symbol: str, token_address: str, amount: float, spender: str, params: dict[str, Any]) -> 'ApproveTransaction': - return cls( - type=TransactionType.APPROVE, - token_symbol=token_symbol, - token_address=token_address, - amount=amount, - spender=spender, - params=params, - summary=f"Approve {amount} {token_symbol} to {spender}" - ) - -class SwapTransaction(TransactionBase): - from_token_symbol: str - to_token_symbol: str - from_token_address: str - to_token_address: str - from_amount: float - to_amount: float - - @classmethod - def create(cls, from_token_symbol: str, to_token_symbol: str, from_token_address: str, to_token_address: str, from_amount: float, to_amount: float, params: dict[str, Any]) -> 'SwapTransaction': - return cls( - type=TransactionType.SWAP, - from_token_symbol=from_token_symbol, - to_token_symbol=to_token_symbol, - from_token_address=from_token_address, - to_token_address=to_token_address, - from_amount=from_amount, - to_amount=to_amount, - params=params, - summary=f"Swap {from_amount} {from_token_symbol} for at least {to_amount} {to_token_symbol}" - ) - -Transaction = Union[SendTransaction, ApproveTransaction, SwapTransaction] +from autotx.intents import Intent class Task(BaseModel): id: str @@ -85,7 +14,7 @@ class Task(BaseModel): error: str | None running: bool messages: List[str] - transactions: List[Transaction] + intents: List[Intent] class App(BaseModel): id: str @@ -109,7 +38,7 @@ class TaskCreate(BaseModel): chain_id: Optional[int] = None user_id: str -class SendTransactionsParams(BaseModel): +class BuildTransactionsParams(BaseModel): address: str chain_id: int user_id: str diff --git a/autotx/server.py b/autotx/server.py index 32d09c27..c0f10abd 100644 --- a/autotx/server.py +++ b/autotx/server.py @@ -5,11 +5,14 @@ from fastapi import APIRouter, FastAPI, BackgroundTasks, HTTPException, Header from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse +from pydantic import BaseModel import traceback from autotx import models, setup from autotx import db from autotx.AutoTx import AutoTx, Config as AutoTxConfig +from autotx.intents import Intent +from autotx.transactions import Transaction from autotx.utils.configuration import AppConfig from autotx.utils.ethereum.chain_short_names import CHAIN_ID_TO_SHORT_NAME from autotx.utils.ethereum.networks import SUPPORTED_NETWORKS_CONFIGURATION_MAP @@ -58,6 +61,43 @@ def authorize(authorization: str | None) -> models.App: return app +def load_config_for_user(app_id: str, user_id: str, address: str, chain_id: int) -> AppConfig: + agent_private_key = db.get_agent_private_key(app_id, user_id) + + if not agent_private_key: + raise HTTPException(status_code=400, detail="User not found") + + agent = Account.from_key(agent_private_key) + + app_config = AppConfig.load(smart_account_addr=address, subsidized_chain_id=chain_id, agent=agent) + + return app_config + +def authorize_app_and_user(authorization: str | None, user_id: str) -> tuple[models.App, models.AppUser]: + app = authorize(authorization) + app_user = db.get_app_user(app.id, user_id) + + if not app_user: + raise HTTPException(status_code=400, detail="User not found") + + return (app, app_user) + +def build_transactions(app_id: str, user_id: str, chain_id: int, address: str, task: models.Task) -> List[Transaction]: + if task.running: + raise HTTPException(status_code=400, detail="Task is still running") + + app_config = load_config_for_user(app_id, user_id, address, chain_id) + + if task.intents is None or len(task.intents) == 0: + return [] + + transactions: list[Transaction] = [] + + for intent in task.intents: + transactions.extend(intent.build_transactions(app_config.web3, app_config.network_info, app_config.manager.address)) + + return transactions + @app_router.post("/api/v1/tasks", response_model=models.Task) async def create_task(task: models.TaskCreate, background_tasks: BackgroundTasks, authorization: Annotated[str | None, Header()] = None) -> models.Task: app = authorize(authorization) @@ -162,49 +202,104 @@ def get_task(task_id: str, authorization: Annotated[str | None, Header()] = None task = get_task_or_404(task_id, tasks) return task -@app_router.get("/api/v1/tasks/{task_id}/transactions", response_model=List[models.Transaction]) -def get_transactions(task_id: str, authorization: Annotated[str | None, Header()] = None) -> Any: +@app_router.get("/api/v1/tasks/{task_id}/intents", response_model=List[Intent]) +def get_intents(task_id: str, authorization: Annotated[str | None, Header()] = None) -> Any: app = authorize(authorization) tasks = db.TasksRepository(app.id) task = get_task_or_404(task_id, tasks) - return task.transactions + return task.intents + +@app_router.get("/api/v1/tasks/{task_id}/transactions", response_model=List[Transaction]) +def get_transactions( + task_id: str, + address: str, + chain_id: int, + user_id: str, + authorization: Annotated[str | None, Header()] = None +) -> List[Transaction]: + (app, app_user) = authorize_app_and_user(authorization, user_id) + + tasks = db.TasksRepository(app.id) + + task = get_task_or_404(task_id, tasks) + + if task.chain_id != chain_id: + raise HTTPException(status_code=400, detail="Chain ID does not match task") + + transactions = build_transactions(app.id, user_id, chain_id, address, task) + + return transactions + +class PreparedTransactionsDto(BaseModel): + batch_id: str + transactions: List[Transaction] + +@app_router.post("/api/v1/tasks/{task_id}/transactions/prepare", response_model=PreparedTransactionsDto) +def prepare_transactions( + task_id: str, + address: str, + chain_id: int, + user_id: str, + authorization: Annotated[str | None, Header()] = None +) -> PreparedTransactionsDto: + (app, app_user) = authorize_app_and_user(authorization, user_id) + + tasks = db.TasksRepository(app.id) + + task = get_task_or_404(task_id, tasks) + + if task.chain_id != chain_id: + raise HTTPException(status_code=400, detail="Chain ID does not match task") + + transactions = build_transactions(app.id, app_user.user_id, chain_id, address, task) + + if len(transactions) == 0: + raise HTTPException(status_code=400, detail="No transactions to send") + + submitted_batch_id = db.save_transactions(app.id, address, chain_id, app_user.id, task_id, transactions) + + return PreparedTransactionsDto(batch_id=submitted_batch_id, transactions=transactions) @app_router.post("/api/v1/tasks/{task_id}/transactions") -def send_transactions(task_id: str, model: models.SendTransactionsParams, authorization: Annotated[str | None, Header()] = None) -> str: - app = authorize(authorization) - app_user = db.get_app_user(app.id, model.user_id) - if not app_user: - raise HTTPException(status_code=400, detail="User not found") +def send_transactions( + task_id: str, + address: str, + chain_id: int, + user_id: str, + batch_id: str, + authorization: Annotated[str | None, Header()] = None +) -> str: + (app, app_user) = authorize_app_and_user(authorization, user_id) tasks = db.TasksRepository(app.id) task = get_task_or_404(task_id, tasks) - if task.running: - raise HTTPException(status_code=400, detail="Task is still running") + if task.chain_id != chain_id: + raise HTTPException(status_code=400, detail="Chain ID does not match task") - agent_private_key = db.get_agent_private_key(app.id, model.user_id) + batch = db.get_transactions(app.id, app_user.id, task_id, address, chain_id, batch_id) - if not agent_private_key: - raise HTTPException(status_code=400, detail="User not found") + if batch is None: + raise HTTPException(status_code=400, detail="Batch not found") - agent = Account.from_key(agent_private_key) + (transactions, task_id) = batch - if task.transactions is None or len(task.transactions) == 0: + if len(transactions) == 0: raise HTTPException(status_code=400, detail="No transactions to send") global autotx_params if autotx_params.is_dev: print("Dev mode: skipping transaction submission") - db.submit_transactions(app.id, model.address, model.chain_id, app_user.id, task_id) - return f"https://app.safe.global/transactions/queue?safe={CHAIN_ID_TO_SHORT_NAME[str(model.chain_id)]}:{model.address}" + db.submit_transactions(app.id, app_user.id, batch_id) + return f"https://app.safe.global/transactions/queue?safe={CHAIN_ID_TO_SHORT_NAME[str(chain_id)]}:{address}" try: - app_config = AppConfig.load(smart_account_addr=task.address, subsidized_chain_id=task.chain_id, agent=agent) - - app_config.manager.send_tx_batch( - task.transactions, + app_config = load_config_for_user(app.id, user_id, address, chain_id) + + app_config.manager.send_multisend_tx_batch( + transactions, require_approval=False, ) except SafeAPIException as e: @@ -213,9 +308,9 @@ def send_transactions(task_id: str, model: models.SendTransactionsParams, author else: raise e - db.submit_transactions(app.id, model.address, model.chain_id, app_user.id, task_id) + db.submit_transactions(app.id, app_user.id, batch_id) - return f"https://app.safe.global/transactions/queue?safe={CHAIN_ID_TO_SHORT_NAME[str(model.chain_id)]}:{model.address}" + return f"https://app.safe.global/transactions/queue?safe={CHAIN_ID_TO_SHORT_NAME[str(chain_id)]}:{address}" @app_router.get("/api/v1/networks", response_model=List[models.SupportedNetwork]) def get_supported_networks() -> list[models.SupportedNetwork]: diff --git a/autotx/setup.py b/autotx/setup.py index 0d4fcd94..5bd71af1 100644 --- a/autotx/setup.py +++ b/autotx/setup.py @@ -11,7 +11,7 @@ from autotx.utils.constants import COINGECKO_API_KEY, OPENAI_API_KEY, OPENAI_BASE_URL, OPENAI_MODEL_NAME from autotx.utils.ethereum import SafeManager from autotx.utils.ethereum.cached_safe_address import get_cached_safe_address -from autotx.utils.ethereum.eth_address import ETHAddress +from autotx.eth_address import ETHAddress from autotx.utils.ethereum.helpers.fill_dev_account_with_tokens import fill_dev_account_with_tokens from autotx.utils.ethereum.helpers.get_dev_account import get_dev_account from autotx.utils.ethereum.helpers.show_address_balances import show_address_balances @@ -46,7 +46,7 @@ def setup_safe(smart_account_addr: ETHAddress | None, agent: LocalAccount, clien print(f"Smart account connected: {smart_account_addr}") manager = SafeManager.connect(client, smart_account_addr, agent) - manager.connect_tx_service(network_info.chain_id, network_info.transaction_service_url) + manager.connect_tx_service(network_info.transaction_service_url) else: print("No smart account connected, deploying a new one...") dev_account = get_dev_account() diff --git a/autotx/tests/agents/regression/token/test_tokens_regression.py b/autotx/tests/agents/regression/token/test_tokens_regression.py index bdead211..d000b8e8 100644 --- a/autotx/tests/agents/regression/token/test_tokens_regression.py +++ b/autotx/tests/agents/regression/token/test_tokens_regression.py @@ -1,7 +1,7 @@ import pytest from autotx.utils.ethereum import get_erc20_balance from autotx.utils.ethereum.networks import NetworkInfo -from autotx.utils.ethereum.eth_address import ETHAddress +from autotx.eth_address import ETHAddress @pytest.mark.skip() diff --git a/autotx/tests/agents/token/research/test_advanced.py b/autotx/tests/agents/token/research/test_advanced.py index c78f644e..0b72746a 100644 --- a/autotx/tests/agents/token/research/test_advanced.py +++ b/autotx/tests/agents/token/research/test_advanced.py @@ -1,5 +1,5 @@ from autotx.tests.agents.token.research.test_research import get_top_token_addresses_by_market_cap -from autotx.utils.ethereum.eth_address import ETHAddress +from autotx.eth_address import ETHAddress def test_research_and_swap_many_tokens_subjective_simple(configuration, auto_tx): (_, _, _, manager, _) = configuration diff --git a/autotx/tests/agents/token/research/test_research.py b/autotx/tests/agents/token/research/test_research.py index 5b36f00a..251cc706 100644 --- a/autotx/tests/agents/token/research/test_research.py +++ b/autotx/tests/agents/token/research/test_research.py @@ -3,7 +3,7 @@ get_coingecko, ) -from autotx.utils.ethereum.eth_address import ETHAddress +from autotx.eth_address import ETHAddress from autotx.utils.ethereum.networks import ChainId def get_top_token_addresses_by_market_cap(category: str, network: str, count: int, auto_tx) -> list[ETHAddress]: diff --git a/autotx/tests/agents/token/test_swap.py b/autotx/tests/agents/token/test_swap.py index 6cdea3a3..9894c277 100644 --- a/autotx/tests/agents/token/test_swap.py +++ b/autotx/tests/agents/token/test_swap.py @@ -1,5 +1,5 @@ from autotx.utils.ethereum.networks import NetworkInfo -from autotx.utils.ethereum.eth_address import ETHAddress +from autotx.eth_address import ETHAddress DIFFERENCE_PERCENTAGE = 1.01 diff --git a/autotx/tests/agents/token/test_swap_and_send.py b/autotx/tests/agents/token/test_swap_and_send.py index c09a51db..d69aaf73 100644 --- a/autotx/tests/agents/token/test_swap_and_send.py +++ b/autotx/tests/agents/token/test_swap_and_send.py @@ -1,6 +1,6 @@ from autotx.utils.ethereum import get_erc20_balance, get_native_balance from autotx.utils.ethereum.networks import NetworkInfo -from autotx.utils.ethereum.eth_address import ETHAddress +from autotx.eth_address import ETHAddress DIFFERENCE_PERCENTAGE = 1.01 diff --git a/autotx/tests/api/test_send_transactions.py b/autotx/tests/api/test_send_transactions.py index 89dfa80e..19fbbbff 100644 --- a/autotx/tests/api/test_send_transactions.py +++ b/autotx/tests/api/test_send_transactions.py @@ -8,17 +8,103 @@ client = TestClient(app) +def test_get_intents_auth(): + response = client.get("/api/v1/tasks/123/intents") + assert response.status_code == 401 + +def test_get_transactions_auth(): + + user_id = uuid.uuid4().hex + + response = client.get("/api/v1/tasks/123/transactions", params={ + "user_id": user_id, + "address": "0x123", + "chain_id": 1, + }) + assert response.status_code == 401 + +def test_prepare_transactions_auth(): + + user_id = uuid.uuid4().hex + + response = client.post("/api/v1/tasks/123/transactions/prepare", params={ + "user_id": user_id, + "address": "0x123", + "chain_id": 1, + }) + assert response.status_code == 401 + def test_send_transactions_auth(): user_id = uuid.uuid4().hex - response = client.post("/api/v1/tasks/123/transactions", json={ + response = client.post("/api/v1/tasks/123/transactions", params={ "user_id": user_id, "address": "0x123", "chain_id": 1, + "batch_id": "123" }) assert response.status_code == 401 + +def test_get_transactions(): + db.clear_db() + db.create_app("test", "1234") + server.setup_server(verbose=True, logs=None, max_rounds=None, cache=False, is_dev=True, check_valid_safe=False) + + user_id = uuid.uuid4().hex + smart_wallet_address = get_cached_safe_address() + + response = client.post("/api/v1/connect", json={ + "user_id": user_id, + }, headers={ + "Authorization": f"Bearer 1234" + }) + assert response.status_code == 200 + data = response.json() + + response = client.post("/api/v1/tasks", json={ + "prompt": "Send 1 ETH to vitalik.eth", + "address": smart_wallet_address, + "chain_id": 1, + "user_id": user_id + }, headers={ + "Authorization": f"Bearer 1234" + }) + assert response.status_code == 200 + data = response.json() + + task_id = data["id"] + + response = client.get(f"/api/v1/tasks/{task_id}/intents", headers={ + "Authorization": f"Bearer 1234" + }) + assert response.status_code == 200 + data = response.json() + + assert len(data) == 1 + + response = client.get(f"/api/v1/tasks/{task_id}/transactions", params={ + "user_id": user_id, + "address": smart_wallet_address, + "chain_id": 1, + }, headers={ + "Authorization": f"Bearer 1234" + }) + assert response.status_code == 200 + data = response.json() + + assert len(data) == 1 + + response = client.get(f"/api/v1/tasks/{task_id}/transactions", params={ + "user_id": user_id, + "address": smart_wallet_address, + "chain_id": 2, + }, headers={ + "Authorization": f"Bearer 1234" + }) + assert response.status_code == 400 + def test_send_transactions(): db.clear_db() db.create_app("test", "1234") @@ -48,7 +134,26 @@ def test_send_transactions(): task_id = data["id"] - response = client.post(f"/api/v1/tasks/{task_id}/transactions", json={ + response = client.post(f"/api/v1/tasks/{task_id}/transactions/prepare", params={ + "user_id": user_id, + "address": smart_wallet_address, + "chain_id": 2, + }, headers={ + "Authorization": f"Bearer 1234" + }) + assert response.status_code == 400 + + response = client.post(f"/api/v1/tasks/{task_id}/transactions/prepare", params={ + "user_id": user_id, + "address": smart_wallet_address, + "chain_id": 1, + }, headers={ + "Authorization": f"Bearer 1234" + }) + assert response.status_code == 200 + batch1 = response.json() + + response = client.post(f"/api/v1/tasks/{task_id}/transactions/prepare", params={ "user_id": user_id, "address": smart_wallet_address, "chain_id": 1, @@ -56,14 +161,70 @@ def test_send_transactions(): "Authorization": f"Bearer 1234" }) assert response.status_code == 200 + batch2 = response.json() app = db.get_app_by_api_key("1234") batches = db.get_submitted_batches(app.id, task_id) - - assert len(batches) == 1 + assert len(batches) == 2 + assert batches[0].app_id == app.id assert batches[0].address == smart_wallet_address assert batches[0].chain_id == 1 assert batches[0].task_id == task_id assert batches[0].created_at is not None + assert batches[0].submitted_on is None + + assert batches[1].app_id == app.id + assert batches[1].address == smart_wallet_address + assert batches[1].chain_id == 1 + assert batches[1].task_id == task_id + assert batches[1].created_at is not None + assert batches[1].submitted_on is None + + assert batch1["batch_id"] == batches[0].id + assert len(batch1["transactions"]) == 1 + + assert batch2["batch_id"] == batches[1].id + assert len(batch2["transactions"]) == 1 + + response = client.post(f"/api/v1/tasks/{task_id}/transactions", params={ + "user_id": user_id, + "address": smart_wallet_address, + "chain_id": 2, + "batch_id": batch1["batch_id"] + }, headers={ + "Authorization": f"Bearer 1234" + }) + assert response.status_code == 400 + + response = client.post(f"/api/v1/tasks/{task_id}/transactions", params={ + "user_id": user_id, + "address": smart_wallet_address, + "chain_id": 1, + "batch_id": batch1["batch_id"] + }, headers={ + "Authorization": f"Bearer 1234" + }) + assert response.status_code == 200 + + batches = db.get_submitted_batches(app.id, task_id) + batches = sorted(batches, key=lambda x: x.created_at) + assert len(batches) == 2 + assert batches[0].submitted_on is not None + assert batches[1].submitted_on is None + + response = client.post(f"/api/v1/tasks/{task_id}/transactions", params={ + "user_id": user_id, + "address": smart_wallet_address, + "chain_id": 1, + "batch_id": batch2["batch_id"] + }, headers={ + "Authorization": f"Bearer 1234" + }) + assert response.status_code == 200 + + batches = db.get_submitted_batches(app.id, task_id) + assert len(batches) == 2 + assert batches[0].submitted_on is not None + assert batches[1].submitted_on is not None diff --git a/autotx/tests/api/test_tasks.py b/autotx/tests/api/test_tasks.py index 861eb054..4cfb57f5 100644 --- a/autotx/tests/api/test_tasks.py +++ b/autotx/tests/api/test_tasks.py @@ -33,10 +33,6 @@ def test_get_task_auth(): response = client.get("/api/v1/tasks/123") assert response.status_code == 401 -def test_get_task_transactions_auth(): - response = client.get("/api/v1/tasks/123/transactions") - assert response.status_code == 401 - def test_create_task(): server.setup_server(verbose=True, logs=None, max_rounds=None, cache=False, is_dev=True, check_valid_safe=False) @@ -65,7 +61,7 @@ def test_create_task(): assert "created_at" in data assert "updated_at" in data assert data["messages"] == [] - assert data["transactions"] == [] + assert data["intents"] == [] assert data["running"] is True response = client.get(f"/api/v1/tasks/{data['id']}", headers={ @@ -74,7 +70,7 @@ def test_create_task(): data = response.json() assert data["running"] is False - assert len(data["transactions"]) > 0 + assert len(data["intents"]) > 0 def test_get_tasks(): response = client.get("/api/v1/tasks", headers={ @@ -100,13 +96,13 @@ def test_get_task(): assert len(data["messages"]) > 0 assert data["running"] is False -def test_get_task_transactions(): +def test_get_task_intents(): response = client.get("/api/v1/tasks", headers={ "Authorization": f"Bearer 1234" }) task_id = response.json()[0]["id"] - response = client.get(f"/api/v1/tasks/{task_id}/transactions", headers={ + response = client.get(f"/api/v1/tasks/{task_id}/intents", headers={ "Authorization": f"Bearer 1234" }) assert response.status_code == 200 diff --git a/autotx/tests/conftest.py b/autotx/tests/conftest.py index 17e7cdcc..d416ac05 100644 --- a/autotx/tests/conftest.py +++ b/autotx/tests/conftest.py @@ -14,7 +14,7 @@ from autotx.utils.constants import OPENAI_API_KEY, OPENAI_MODEL_NAME from autotx.utils.ethereum.networks import NetworkInfo -from autotx.utils.ethereum.eth_address import ETHAddress +from autotx.eth_address import ETHAddress from autotx.utils.ethereum.helpers.get_dev_account import get_dev_account import pytest diff --git a/autotx/tests/integration/test_swap.py b/autotx/tests/integration/test_swap.py index c0b8b082..32d5b388 100644 --- a/autotx/tests/integration/test_swap.py +++ b/autotx/tests/integration/test_swap.py @@ -1,5 +1,5 @@ from decimal import Decimal -from autotx.utils.ethereum.eth_address import ETHAddress +from autotx.eth_address import ETHAddress from autotx.utils.ethereum.lifi.swap import build_swap_transaction from autotx.utils.ethereum.networks import NetworkInfo diff --git a/autotx/token.py b/autotx/token.py new file mode 100644 index 00000000..15f9da10 --- /dev/null +++ b/autotx/token.py @@ -0,0 +1,5 @@ +from pydantic import BaseModel + +class Token(BaseModel): + symbol: str + address: str \ No newline at end of file diff --git a/autotx/transactions.py b/autotx/transactions.py new file mode 100644 index 00000000..309866f1 --- /dev/null +++ b/autotx/transactions.py @@ -0,0 +1,69 @@ +from enum import Enum +from pydantic import BaseModel +from typing import Any, Union + +from autotx.token import Token +from autotx.eth_address import ETHAddress +from autotx.utils.format_amount import format_amount + +class TransactionType(str, Enum): + SEND = "send" + APPROVE = "approve" + SWAP = "swap" + +class TransactionBase(BaseModel): + type: TransactionType + summary: str + params: dict[str, Any] + +class SendTransaction(TransactionBase): + receiver: str + token: Token + amount: float + + @classmethod + def create(cls, token: Token, amount: float, receiver: ETHAddress, params: dict[str, Any]) -> 'SendTransaction': + return cls( + type=TransactionType.SEND, + token=token, + amount=amount, + receiver=receiver.original_str, + params=params, + summary=f"Transfer {format_amount(amount)} {token.symbol} to {receiver}", + ) + +class ApproveTransaction(TransactionBase): + token: Token + amount: float + spender: str + + @classmethod + def create(cls, token: Token, amount: float, spender: str, params: dict[str, Any]) -> 'ApproveTransaction': + return cls( + type=TransactionType.APPROVE, + token=token, + amount=amount, + spender=spender, + params=params, + summary=f"Approve {format_amount(amount)} {token.symbol} to {spender}" + ) + +class SwapTransaction(TransactionBase): + from_token: Token + to_token: Token + from_amount: float + to_amount: float + + @classmethod + def create(cls, from_token: Token, to_token: Token, from_amount: float, to_amount: float, params: dict[str, Any]) -> 'SwapTransaction': + return cls( + type=TransactionType.SWAP, + from_token=from_token, + to_token=to_token, + from_amount=from_amount, + to_amount=to_amount, + params=params, + summary=f"Swap {format_amount(from_amount)} {from_token.symbol} for at least {format_amount(to_amount)} {to_token.symbol}" + ) + +Transaction = Union[SendTransaction, ApproveTransaction, SwapTransaction] diff --git a/autotx/utils/LlamaClient.py b/autotx/utils/LlamaClient.py new file mode 100644 index 00000000..1c4ded06 --- /dev/null +++ b/autotx/utils/LlamaClient.py @@ -0,0 +1,82 @@ +from types import SimpleNamespace +from typing import Any, Dict, Union, cast +from autogen import ModelClient +from llama_cpp import ( + ChatCompletion, + ChatCompletionRequestMessage, + ChatCompletionRequestToolMessage, + ChatCompletionResponseMessage, + Completion, + CreateChatCompletionResponse, + Llama, +) + + +class LlamaClient(ModelClient): # type: ignore + def __init__(self, config: dict[str, Any], **args: Any): + self.llm: Llama = args["llm"] + self.model: str = config["model"] + + def create(self, params: Dict[str, Any]) -> SimpleNamespace: + sanitized_messages = self._sanitize_chat_completion_messages( + cast(list[ChatCompletionRequestMessage], params.get("messages")) + ) + response = self.llm.create_chat_completion( + messages=sanitized_messages, + tools=params.get("tools"), + model=params.get("model"), + ) + + return SimpleNamespace(**{**response, "cost": "0"}) # type: ignore + + def message_retrieval( + self, response: CreateChatCompletionResponse + ) -> list[ChatCompletionResponseMessage]: + choices = response.choices # type: ignore + return [choice["message"] for choice in choices] + + def cost(self, _: Union[ChatCompletion, Completion]) -> float: + return 0.0 + + def get_usage(self, _: Union[ChatCompletion, Completion]) -> dict[str, Any]: + return { + "prompt_tokens": 0, + "completion_tokens": 0, + "total_tokens": 0, + "cost": 0, + "model": self.model, + } + + def _sanitize_chat_completion_messages( + self, messages: list[ChatCompletionRequestMessage] + ) -> list[ChatCompletionRequestMessage]: + sanitized_messages: list[ChatCompletionRequestMessage] = [] + for message in messages: + if "tool_call_id" in message: + id: str = cast(ChatCompletionRequestToolMessage, message)[ + "tool_call_id" + ] + + def get_tool_name(messages, id: str) -> Union[str, None]: # type: ignore + return next( + ( + message["tool_calls"][0]["function"]["name"] + for message in messages + if "tool_calls" in message + and message["tool_calls"][0]["id"] == id + ), + None, + ) + + function_name = get_tool_name(messages, id) + if function_name is None: + raise Exception(f"No tool response for this tool call with id {id}") + + sanitized_messages.append( + ChatCompletionRequestToolMessage(**message, name=function_name) # type: ignore + ) + + else: + sanitized_messages.append(message) + + return sanitized_messages diff --git a/autotx/utils/configuration.py b/autotx/utils/configuration.py index 1fd61734..60bd533f 100644 --- a/autotx/utils/configuration.py +++ b/autotx/utils/configuration.py @@ -12,7 +12,7 @@ from autotx.utils.ethereum import SafeManager from autotx.utils.ethereum.agent_account import get_or_create_agent_account from autotx.utils.ethereum.constants import DEVNET_RPC_URL -from autotx.utils.ethereum.eth_address import ETHAddress +from autotx.eth_address import ETHAddress from autotx.utils.ethereum.networks import NetworkInfo from autotx.utils.is_dev_env import is_dev_env from autotx.wallets.smart_wallet import SmartWallet diff --git a/autotx/utils/ethereum/SafeManager.py b/autotx/utils/ethereum/SafeManager.py index 835fdb60..9b453f2f 100644 --- a/autotx/utils/ethereum/SafeManager.py +++ b/autotx/utils/ethereum/SafeManager.py @@ -14,12 +14,12 @@ from gnosis.safe.api import TransactionServiceApi from eth_account.signers.local import LocalAccount -from autotx import models +from autotx.transactions import TransactionBase from autotx.utils.ethereum.get_native_balance import get_native_balance from autotx.utils.ethereum.cached_safe_address import get_cached_safe_address, save_cached_safe_address -from autotx.utils.ethereum.eth_address import ETHAddress +from autotx.eth_address import ETHAddress from autotx.utils.ethereum.is_valid_safe import is_valid_safe -from autotx.utils.ethereum.networks import ChainId +from autotx.utils.ethereum.networks import NetworkInfo from .deploy_safe_with_create2 import deploy_safe_with_create2 from .deploy_multicall import deploy_multicall from .get_erc20_balance import get_erc20_balance @@ -37,7 +37,7 @@ class SafeManager: safe_nonce: int | None = None gas_multiplier: float | None = GAS_PRICE_MULTIPLIER dev_account: LocalAccount | None = None - network: ChainId | None = None + network: NetworkInfo transaction_service_url: str | None = None address: ETHAddress use_tx_service: bool @@ -54,6 +54,7 @@ def __init__( self.safe = safe self.use_tx_service = False self.safe_nonce = None + self.network = NetworkInfo(client.w3.eth.chain_id) self.address = ETHAddress(safe.address) @@ -93,14 +94,12 @@ def connect( return manager - def connect_tx_service(self, network: ChainId, transaction_service_url: str) -> None: + def connect_tx_service(self, transaction_service_url: str) -> None: self.use_tx_service = True - self.network = network self.transaction_service_url = transaction_service_url def disconnect_tx_service(self) -> None: self.use_tx_service = False - self.network = None self.transaction_service_url = None def connect_multisend(self, address: ChecksumAddress) -> None: @@ -115,7 +114,7 @@ def deploy_multicall(self) -> None: multicall_addr = deploy_multicall(self.client, self.dev_account) self.connect_multicall(multicall_addr) - def build_multisend_tx(self, txs: list[TxParams], safe_nonce: Optional[int] = None) -> SafeTx: + def build_multisend_tx(self, txs: list[TxParams | dict[str, Any]], safe_nonce: Optional[int] = None) -> SafeTx: if not self.multisend: raise Exception("No multisend contract address has been set to SafeManager") @@ -136,7 +135,7 @@ def build_multisend_tx(self, txs: list[TxParams], safe_nonce: Optional[int] = No return safe_tx - def build_tx(self, tx: TxParams, safe_nonce: Optional[int] = None) -> SafeTx: + def build_tx(self, tx: TxParams | dict[str, Any], safe_nonce: Optional[int] = None, skip_estimate_gas: bool = False) -> SafeTx: safe_tx = SafeTx( self.client, self.address.hex, @@ -151,12 +150,14 @@ def build_tx(self, tx: TxParams, safe_nonce: Optional[int] = None) -> SafeTx: self.address.hex, safe_nonce=self.track_nonce(safe_nonce), ) - safe_tx.safe_tx_gas = self.safe.estimate_tx_gas(safe_tx.to, safe_tx.value, safe_tx.data, safe_tx.operation) - safe_tx.base_gas = self.safe.estimate_tx_base_gas(safe_tx.to, safe_tx.value, safe_tx.data, safe_tx.operation, NULL_ADDRESS, safe_tx.safe_tx_gas) + + if not skip_estimate_gas: + safe_tx.safe_tx_gas = self.safe.estimate_tx_gas(safe_tx.to, safe_tx.value, safe_tx.data, safe_tx.operation) + safe_tx.base_gas = self.safe.estimate_tx_base_gas(safe_tx.to, safe_tx.value, safe_tx.data, safe_tx.operation, NULL_ADDRESS, safe_tx.safe_tx_gas) return safe_tx - def execute_tx(self, tx: TxParams, safe_nonce: Optional[int] = None) -> HexBytes: + def execute_tx(self, tx: TxParams | dict[str, Any], safe_nonce: Optional[int] = None) -> HexBytes: if not self.dev_account: raise ValueError("Dev account not set. This function should not be called in production.") @@ -182,7 +183,7 @@ def execute_tx(self, tx: TxParams, safe_nonce: Optional[int] = None) -> HexBytes raise Exception("Unknown error executing transaction", e) - def execute_multisend_tx(self, txs: list[TxParams], safe_nonce: Optional[int] = None) -> HexBytes: + def execute_multisend_tx(self, txs: list[TxParams | dict[str, Any]], safe_nonce: Optional[int] = None) -> HexBytes: if not self.dev_account: raise ValueError("Dev account not set. This function should not be called in production.") @@ -198,25 +199,19 @@ def execute_multisend_tx(self, txs: list[TxParams], safe_nonce: Optional[int] = return tx_hash - def post_transaction(self, tx: TxParams, safe_nonce: Optional[int] = None) -> None: - if not self.network: - raise Exception("Network not defined for transaction service") - + def post_transaction(self, tx: TxParams | dict[str, Any], safe_nonce: Optional[int] = None) -> None: ts_api = TransactionServiceApi( - self.network, ethereum_client=self.client, base_url=self.transaction_service_url + self.network.chain_id, ethereum_client=self.client, base_url=self.transaction_service_url ) - safe_tx = self.build_tx(tx, safe_nonce) + safe_tx = self.build_tx(tx, safe_nonce, skip_estimate_gas=True) safe_tx.sign(self.agent.key.hex()) ts_api.post_transaction(safe_tx) - def post_multisend_transaction(self, txs: list[TxParams], safe_nonce: Optional[int] = None) -> None: - if not self.network: - raise Exception("Network not defined for transaction service") - + def post_multisend_transaction(self, txs: list[TxParams | dict[str, Any]], safe_nonce: Optional[int] = None) -> None: ts_api = TransactionServiceApi( - self.network, ethereum_client=self.client, base_url=self.transaction_service_url + self.network.chain_id, ethereum_client=self.client, base_url=self.transaction_service_url ) tx = self.build_multisend_tx(txs, safe_nonce) @@ -231,8 +226,22 @@ def send_tx(self, tx: TxParams | dict[str, Any], safe_nonce: Optional[int] = Non else: hash = self.execute_tx(cast(TxParams, tx), safe_nonce) return hash.hex() - - def send_tx_batch(self, txs: list[models.Transaction], require_approval: bool, safe_nonce: Optional[int] = None) -> bool | str: # True if sent, False if declined, str if feedback + + def send_multisend_tx(self, txs: list[TxParams | dict[str, Any]], safe_nonce: Optional[int] = None) -> str | None: + if self.use_tx_service: + if len(txs) == 1: + self.post_transaction(txs[0], safe_nonce) + elif len(txs) > 1: + self.post_multisend_transaction(txs, safe_nonce) + return None + else: + if len(txs) == 1: + hash = self.execute_tx(txs[0], safe_nonce) + elif len(txs) > 1: + hash = self.execute_multisend_tx(txs, safe_nonce) + return hash.hex() + + def send_tx_batch(self, txs: list[TransactionBase], require_approval: bool, safe_nonce: Optional[int] = None) -> bool | str: # True if sent, False if declined, str if feedback print("=" * 50) if not txs: @@ -306,6 +315,74 @@ def send_tx_batch(self, txs: list[models.Transaction], require_approval: bool, s return True + def send_multisend_tx_batch(self, txs: list[TransactionBase], require_approval: bool, safe_nonce: Optional[int] = None) -> bool | str: # True if sent, False if declined, str if feedback + print("=" * 50) + + if not txs: + print("No transactions to send.") + return True + + transactions_info = "\n".join( + [ + f"{i + 1}. {tx.summary}" + for i, tx in enumerate(txs) + ] + ) + + print(f"Prepared transactions:\n{transactions_info}") + + if self.use_tx_service: + if require_approval: + response = input("Do you want the above transactions to be sent to your smart account?\nRespond (y/n) or write feedback: ") + + if response.lower() == "n" or response.lower() == "no": + print("Transactions not sent to your smart account (declined).") + + return False + elif response.lower() != "y" and response.lower() != "yes": + + return response + else: + print("Non-interactive mode enabled. Transactions will be sent to your smart account without approval.") + + print("Sending batched transactions to your smart account...") + + self.send_multisend_tx([prepared_tx.params for prepared_tx in txs], safe_nonce) + + if len(txs) == 1: + print("Transaction sent to your smart account for signing.") + else: + print("Transactions sent as a single multi-send transaction to your smart account for signing.") + + return True + else: + if require_approval: + response = input("Do you want to execute the above transactions?\nRespond (y/n) or write feedback: ") + + if response.lower() == "n" or response.lower() == "no": + print("Transactions not executed (declined).") + + return False + elif response.lower() != "y" and response.lower() != "yes": + + return response + else: + print("Non-interactive mode enabled. Transactions will be executed without approval.") + + print("Executing transactions...") + + try: + self.send_multisend_tx([prepared_tx.params for prepared_tx in txs], safe_nonce) + except ExecutionRevertedError as e: + raise Exception(f"Executing transactions failed with error: {e}") + + if len(txs) == 1: + print("Transaction executed.") + else: + print("Transactions executed as a single multi-send transaction.") + + return True + def send_empty_tx(self, safe_nonce: Optional[int] = None) -> str | None: tx: TxParams = { "to": self.address.hex, diff --git a/autotx/utils/ethereum/build_approve_erc20.py b/autotx/utils/ethereum/build_approve_erc20.py index d99f4e37..71b844a3 100644 --- a/autotx/utils/ethereum/build_approve_erc20.py +++ b/autotx/utils/ethereum/build_approve_erc20.py @@ -1,6 +1,6 @@ from web3 import Web3 from web3.types import TxParams -from autotx.utils.ethereum.eth_address import ETHAddress +from autotx.eth_address import ETHAddress from autotx.utils.ethereum.erc20_abi import ERC20_ABI def build_approve_erc20(web3: Web3, token_address: ETHAddress, spender: ETHAddress, value: float) -> TxParams: diff --git a/autotx/utils/ethereum/build_transfer_erc20.py b/autotx/utils/ethereum/build_transfer_erc20.py index df57e18a..115b8d64 100644 --- a/autotx/utils/ethereum/build_transfer_erc20.py +++ b/autotx/utils/ethereum/build_transfer_erc20.py @@ -1,7 +1,7 @@ from web3 import Web3 from web3.types import TxParams, Wei -from autotx.utils.ethereum.eth_address import ETHAddress +from autotx.eth_address import ETHAddress from .constants import GAS_PRICE_MULTIPLIER from .erc20_abi import ERC20_ABI diff --git a/autotx/utils/ethereum/build_transfer_native.py b/autotx/utils/ethereum/build_transfer_native.py index 7392dc68..4d83d061 100644 --- a/autotx/utils/ethereum/build_transfer_native.py +++ b/autotx/utils/ethereum/build_transfer_native.py @@ -1,7 +1,7 @@ from web3 import Web3 from web3.types import TxParams -from autotx.utils.ethereum.eth_address import ETHAddress +from autotx.eth_address import ETHAddress def build_transfer_native(web3: Web3, from_address: ETHAddress, to: ETHAddress, value: float) -> TxParams: return { diff --git a/autotx/utils/ethereum/deploy_multicall.py b/autotx/utils/ethereum/deploy_multicall.py index ea6eff3f..af58c4c7 100644 --- a/autotx/utils/ethereum/deploy_multicall.py +++ b/autotx/utils/ethereum/deploy_multicall.py @@ -4,7 +4,7 @@ from eth_account.signers.local import LocalAccount from hexbytes import HexBytes -from autotx.utils.ethereum.eth_address import ETHAddress +from autotx.eth_address import ETHAddress from .cache import cache diff --git a/autotx/utils/ethereum/get_erc20_balance.py b/autotx/utils/ethereum/get_erc20_balance.py index 8ec2edca..2205dafd 100644 --- a/autotx/utils/ethereum/get_erc20_balance.py +++ b/autotx/utils/ethereum/get_erc20_balance.py @@ -1,6 +1,6 @@ from web3 import Web3 -from autotx.utils.ethereum.eth_address import ETHAddress +from autotx.eth_address import ETHAddress from .erc20_abi import ERC20_ABI def get_erc20_balance(web3: Web3, token_address: ETHAddress, account: ETHAddress) -> float: diff --git a/autotx/utils/ethereum/get_erc20_info.py b/autotx/utils/ethereum/get_erc20_info.py index a5fb430c..a3a8452f 100644 --- a/autotx/utils/ethereum/get_erc20_info.py +++ b/autotx/utils/ethereum/get_erc20_info.py @@ -1,6 +1,6 @@ from web3 import Web3 -from autotx.utils.ethereum.eth_address import ETHAddress +from autotx.eth_address import ETHAddress from .erc20_abi import ERC20_ABI def get_erc20_info(web3: Web3, token_address: ETHAddress) -> tuple[str, str, int]: diff --git a/autotx/utils/ethereum/get_native_balance.py b/autotx/utils/ethereum/get_native_balance.py index 9f86285c..dd04c82b 100644 --- a/autotx/utils/ethereum/get_native_balance.py +++ b/autotx/utils/ethereum/get_native_balance.py @@ -1,6 +1,6 @@ from web3 import Web3 -from autotx.utils.ethereum.eth_address import ETHAddress +from autotx.eth_address import ETHAddress def get_native_balance(web3: Web3, address: ETHAddress) -> float: return web3.eth.get_balance(address.hex) / 10 ** 18 \ No newline at end of file diff --git a/autotx/utils/ethereum/helpers/fill_dev_account_with_tokens.py b/autotx/utils/ethereum/helpers/fill_dev_account_with_tokens.py index 0ff8b49a..8e666797 100644 --- a/autotx/utils/ethereum/helpers/fill_dev_account_with_tokens.py +++ b/autotx/utils/ethereum/helpers/fill_dev_account_with_tokens.py @@ -1,6 +1,6 @@ from autotx.utils.ethereum import transfer_erc20 from autotx.utils.ethereum.constants import NATIVE_TOKEN_ADDRESS -from autotx.utils.ethereum.eth_address import ETHAddress +from autotx.eth_address import ETHAddress from autotx.utils.ethereum.helpers.swap_from_eoa import swap from autotx.utils.ethereum.networks import ChainId, NetworkInfo from eth_account.signers.local import LocalAccount diff --git a/autotx/utils/ethereum/helpers/show_address_balances.py b/autotx/utils/ethereum/helpers/show_address_balances.py index d6fd1eaa..3894a995 100644 --- a/autotx/utils/ethereum/helpers/show_address_balances.py +++ b/autotx/utils/ethereum/helpers/show_address_balances.py @@ -7,7 +7,7 @@ get_native_token_symbol, ) from autotx.utils.ethereum.networks import SUPPORTED_NETWORKS_CONFIGURATION_MAP, ChainId, NetworkConfiguration -from autotx.utils.ethereum.eth_address import ETHAddress +from autotx.eth_address import ETHAddress def show_address_balances(web3: Web3, network: ChainId, address: ETHAddress) -> None: diff --git a/autotx/utils/ethereum/helpers/swap_from_eoa.py b/autotx/utils/ethereum/helpers/swap_from_eoa.py index 3fdc4742..54a12765 100644 --- a/autotx/utils/ethereum/helpers/swap_from_eoa.py +++ b/autotx/utils/ethereum/helpers/swap_from_eoa.py @@ -4,7 +4,7 @@ from gnosis.eth import EthereumClient from web3.types import TxParams -from autotx.utils.ethereum.eth_address import ETHAddress +from autotx.eth_address import ETHAddress from autotx.utils.ethereum.lifi.swap import build_swap_transaction from autotx.utils.ethereum.networks import ChainId diff --git a/autotx/utils/ethereum/is_valid_safe.py b/autotx/utils/ethereum/is_valid_safe.py index 10ffa0c7..ef37cb8d 100644 --- a/autotx/utils/ethereum/is_valid_safe.py +++ b/autotx/utils/ethereum/is_valid_safe.py @@ -3,7 +3,7 @@ from web3 import Web3 from gnosis.safe import Safe -from autotx.utils.ethereum.eth_address import ETHAddress +from autotx.eth_address import ETHAddress from .constants import MASTER_COPY_ADDRESS, MASTER_COPY_L2_ADDRESSES def is_valid_safe(client: EthereumClient, safe_address: ETHAddress) -> bool: diff --git a/autotx/utils/ethereum/lifi/__init__.py b/autotx/utils/ethereum/lifi/__init__.py index ba13c02a..ccafabc6 100644 --- a/autotx/utils/ethereum/lifi/__init__.py +++ b/autotx/utils/ethereum/lifi/__init__.py @@ -5,7 +5,7 @@ import re from autotx.utils.constants import LIFI_API_KEY -from autotx.utils.ethereum.eth_address import ETHAddress +from autotx.eth_address import ETHAddress from autotx.utils.ethereum.networks import ChainId diff --git a/autotx/utils/ethereum/lifi/swap.py b/autotx/utils/ethereum/lifi/swap.py index c1fc71d6..8a2f163c 100644 --- a/autotx/utils/ethereum/lifi/swap.py +++ b/autotx/utils/ethereum/lifi/swap.py @@ -3,10 +3,11 @@ from typing import Any, cast from web3 import Web3 -from autotx import models +from autotx.token import Token +from autotx.transactions import ApproveTransaction, SwapTransaction, Transaction from autotx.utils.ethereum.constants import GAS_PRICE_MULTIPLIER, NATIVE_TOKEN_ADDRESS from autotx.utils.ethereum.erc20_abi import ERC20_ABI -from autotx.utils.ethereum.eth_address import ETHAddress +from autotx.eth_address import ETHAddress from autotx.utils.ethereum.helpers.get_native_token_symbol import ( get_native_token_symbol, ) @@ -102,7 +103,6 @@ def get_quote( quote["toolDetails"]["name"], ) - def build_swap_transaction( web3: Web3, amount: Decimal, @@ -111,7 +111,7 @@ def build_swap_transaction( _from: ETHAddress, is_exact_input: bool, chain: ChainId, -) -> list[models.Transaction]: +) -> list[Transaction]: native_token_symbol = get_native_token_symbol(chain) token_in_is_native = token_in_address.hex == NATIVE_TOKEN_ADDRESS token_in = web3.eth.contract( @@ -152,7 +152,7 @@ def build_swap_transaction( _from, ) - transactions: list[models.Transaction] = [] + transactions: list[Transaction] = [] if not token_in_is_native: approval_address = quote.approval_address allowance = token_in.functions.allowance(_from.hex, approval_address).call() @@ -168,23 +168,85 @@ def build_swap_transaction( } ) transactions.append( - models.ApproveTransaction.create( - token_symbol=token_in_symbol, - token_address=str(token_in_address), + ApproveTransaction.create( + token=Token(symbol=token_in_symbol, address=str(token_in_address)), amount=float(Decimal(str(quote.amount_in)) / 10 ** token_in_decimals), spender=approval_address, params=cast(dict[str, Any], tx), ) ) transactions.append( - models.SwapTransaction.create( - from_token_symbol=token_in_symbol, - to_token_symbol=token_out_symbol, - from_token_address=str(token_in_address), - to_token_address=str(token_out_address), + SwapTransaction.create( + from_token=Token(symbol=token_in_symbol, address=str(token_in_address)), + to_token=Token(symbol=token_out_symbol, address=str(token_out_address)), from_amount=float(Decimal(str(quote.amount_in)) / 10 ** token_in_decimals), to_amount=float(Decimal(str(int(quote.to_amount_min))) / 10 ** token_out_decimals), params=cast(dict[str, Any], quote.transaction), ) ) return transactions + +def can_build_swap_transaction( + web3: Web3, + amount: Decimal, + token_in_address: ETHAddress, + token_out_address: ETHAddress, + _from: ETHAddress, + is_exact_input: bool, + chain: ChainId, +) -> bool: + native_token_symbol = get_native_token_symbol(chain) + token_in_is_native = token_in_address.hex == NATIVE_TOKEN_ADDRESS + token_in = web3.eth.contract( + address=token_in_address.hex, abi=ERC20_ABI + ) + token_in_symbol = ( + native_token_symbol + if token_in_is_native + else token_in.functions.symbol().call() + ) + token_in_decimals = ( + 18 if token_in_is_native else token_in.functions.decimals().call() + ) + + token_out_is_native = token_out_address.hex == NATIVE_TOKEN_ADDRESS + token_out = web3.eth.contract( + address=token_out_address.hex, abi=ERC20_ABI + ) + token_out_decimals = ( + 18 if token_out_is_native else token_out.functions.decimals().call() + ) + + token_out_symbol = ( + native_token_symbol + if token_out_is_native + else token_out.functions.symbol().call() + ) + quote = get_quote( + token_in_address, + token_in_decimals, + token_in_symbol, + token_out_address, + token_out_decimals, + token_out_symbol, + chain, + amount, + not is_exact_input, + _from, + ) + + if not token_in_is_native: + approval_address = quote.approval_address + allowance = token_in.functions.allowance(_from.hex, approval_address).call() + if allowance < quote.amount_in: + tx = token_in.functions.approve( + approval_address, quote.amount_in + ).build_transaction( + { + "from": _from.hex, + "gasPrice": Wei( + int(web3.eth.gas_price * GAS_PRICE_MULTIPLIER) + ), + } + ) + return True diff --git a/autotx/utils/ethereum/send_native.py b/autotx/utils/ethereum/send_native.py index 4df80e50..41e34cb2 100644 --- a/autotx/utils/ethereum/send_native.py +++ b/autotx/utils/ethereum/send_native.py @@ -3,7 +3,7 @@ from web3 import Web3 from web3.types import TxParams, TxReceipt, Wei -from autotx.utils.ethereum.eth_address import ETHAddress +from autotx.eth_address import ETHAddress from .constants import GAS_PRICE_MULTIPLIER diff --git a/autotx/utils/ethereum/transfer_erc20.py b/autotx/utils/ethereum/transfer_erc20.py index a09085ba..9634e413 100644 --- a/autotx/utils/ethereum/transfer_erc20.py +++ b/autotx/utils/ethereum/transfer_erc20.py @@ -4,7 +4,7 @@ from web3.types import Wei from web3.middleware.signing import construct_sign_and_send_raw_middleware -from autotx.utils.ethereum.eth_address import ETHAddress +from autotx.eth_address import ETHAddress from .constants import GAS_PRICE_MULTIPLIER diff --git a/autotx/utils/format_amount.py b/autotx/utils/format_amount.py new file mode 100644 index 00000000..5740855c --- /dev/null +++ b/autotx/utils/format_amount.py @@ -0,0 +1,5 @@ +from decimal import Decimal + + +def format_amount(amount: float) -> str: + return format(Decimal(str(amount)), "f") \ No newline at end of file diff --git a/autotx/wallets/api_smart_wallet.py b/autotx/wallets/api_smart_wallet.py index fb05529c..e2f0e40f 100644 --- a/autotx/wallets/api_smart_wallet.py +++ b/autotx/wallets/api_smart_wallet.py @@ -1,7 +1,7 @@ -import uuid - from web3 import Web3 -from autotx import db, models +from autotx import db +from autotx.intents import Intent +from autotx.transactions import Transaction from autotx.utils.ethereum.SafeManager import SafeManager from autotx.wallets.smart_wallet import SmartWallet @@ -15,7 +15,7 @@ def __init__(self, web3: Web3, manager: SafeManager, tasks: db.TasksRepository, self.manager = manager self.tasks = tasks - def on_transactions_prepared(self, txs: list[models.Transaction]) -> None: + def on_intents_prepared(self, intents: list[Intent]) -> None: if self.task_id is None: raise ValueError("Task ID is required") @@ -23,12 +23,8 @@ def on_transactions_prepared(self, txs: list[models.Transaction]) -> None: if saved_task is None: raise ValueError("Task not found") - for tx in txs: - tx.id = str(uuid.uuid4()) - tx.task_id = self.task_id - - saved_task.transactions.extend(txs) + saved_task.intents.extend(intents) self.tasks.update(saved_task) - def on_transactions_ready(self, _txs: list[models.Transaction]) -> bool | str: + def on_intents_ready(self, _intents: list[Intent]) -> bool | str: return True \ No newline at end of file diff --git a/autotx/wallets/safe_smart_wallet.py b/autotx/wallets/safe_smart_wallet.py index 5da88f5f..c3db6031 100644 --- a/autotx/wallets/safe_smart_wallet.py +++ b/autotx/wallets/safe_smart_wallet.py @@ -1,4 +1,5 @@ -from autotx import models +from autotx.intents import Intent +from autotx.transactions import TransactionBase from autotx.utils.ethereum import SafeManager from autotx.wallets.smart_wallet import SmartWallet @@ -13,8 +14,13 @@ def __init__(self, manager: SafeManager, auto_submit_tx: bool): self.manager = manager self.auto_submit_tx = auto_submit_tx - def on_transactions_prepared(self, txs: list[models.Transaction]) -> None: + def on_intents_prepared(self, intents: list[Intent]) -> None: pass - def on_transactions_ready(self, txs: list[models.Transaction]) -> bool | str: - return self.manager.send_tx_batch(txs, not self.auto_submit_tx) + def on_intents_ready(self, intents: list[Intent]) -> bool | str: + transactions: list[TransactionBase] = [] + + for intent in intents: + transactions.extend(intent.build_transactions(self.manager.web3, self.manager.network, self.manager.address)) + + return self.manager.send_multisend_tx_batch(transactions, not self.auto_submit_tx) diff --git a/autotx/wallets/smart_wallet.py b/autotx/wallets/smart_wallet.py index fb639667..cc54425e 100644 --- a/autotx/wallets/smart_wallet.py +++ b/autotx/wallets/smart_wallet.py @@ -1,9 +1,9 @@ from abc import abstractmethod from web3 import Web3 -from autotx import models +from autotx.intents import Intent from autotx.utils.ethereum.get_erc20_balance import get_erc20_balance -from autotx.utils.ethereum.eth_address import ETHAddress +from autotx.eth_address import ETHAddress from autotx.utils.ethereum.get_native_balance import get_native_balance @@ -12,11 +12,11 @@ def __init__(self, web3: Web3, address: ETHAddress): self.web3 = web3 self.address = address - def on_transactions_prepared(self, txs: list[models.Transaction]) -> None: + def on_intents_prepared(self, intents: list[Intent]) -> None: pass @abstractmethod - def on_transactions_ready(self, txs: list[models.Transaction]) -> bool | str: # True if sent, False if declined, str if feedback + def on_intents_ready(self, intents: list[Intent]) -> bool | str: # True if sent, False if declined, str if feedback pass def balance_of(self, token_address: ETHAddress | None = None) -> float: diff --git a/poetry.lock b/poetry.lock index 9cd75a46..fff9099f 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1355,6 +1355,28 @@ files = [ [package.dependencies] referencing = ">=0.31.0" +[[package]] +name = "llama-cpp-python" +version = "0.2.78" +description = "Python bindings for the llama.cpp library" +optional = false +python-versions = ">=3.8" +files = [ + {file = "llama_cpp_python-0.2.78.tar.gz", hash = "sha256:3df7cfde84287faaf29675fba8939060c3ab3f0ce8db875dabf7df5d83bd8751"}, +] + +[package.dependencies] +diskcache = ">=5.6.1" +jinja2 = ">=2.11.3" +numpy = ">=1.20.0" +typing-extensions = ">=4.5.0" + +[package.extras] +all = ["llama_cpp_python[dev,server,test]"] +dev = ["black (>=23.3.0)", "httpx (>=0.24.1)", "mkdocs (>=1.4.3)", "mkdocs-material (>=9.1.18)", "mkdocstrings[python] (>=0.22.0)", "pytest (>=7.4.0)", "twine (>=4.0.2)"] +server = ["PyYAML (>=5.1)", "fastapi (>=0.100.0)", "pydantic-settings (>=2.0.1)", "sse-starlette (>=1.6.1)", "starlette-context (>=0.3.6,<0.4)", "uvicorn (>=0.22.0)"] +test = ["httpx (>=0.24.1)", "pytest (>=7.4.0)", "scipy (>=1.10)"] + [[package]] name = "lru-dict" version = "1.2.0" @@ -2336,6 +2358,7 @@ files = [ {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, + {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef"}, {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, @@ -3549,4 +3572,4 @@ multidict = ">=4.0" [metadata] lock-version = "2.0" python-versions = ">=3.10,<3.13" -content-hash = "57c468f2b88c0a5b9b152634cafef530545725cdeaa9e499c7e7b6c13cd5ce41" +content-hash = "5dfc1bc10f28a24e09c829fbdf2469dfcd0ab7dcd8c51312e7f15e8ffd02601f" diff --git a/pyproject.toml b/pyproject.toml index ccab2597..9b270787 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "autotx" -version = "0.1.0" +version = "0.1.1" description = "" authors = ["Nestor Amesty "] readme = "README.md" @@ -19,6 +19,7 @@ web3 = "^6.19.0" safe-eth-py = "^5.8.0" uvicorn = "^0.29.0" supabase = "^2.5.0" +llama-cpp-python = "^0.2.78" [tool.poetry.group.dev.dependencies] mypy = "^1.8.0" diff --git a/supabase/migrations/20240614104522_intents.sql b/supabase/migrations/20240614104522_intents.sql new file mode 100644 index 00000000..6fb0191e --- /dev/null +++ b/supabase/migrations/20240614104522_intents.sql @@ -0,0 +1,8 @@ +delete from "public"."submitted_batches"; +alter table "public"."submitted_batches" add column "transactions" json not null; + +delete from "public"."tasks"; +alter table "public"."tasks" drop column "transactions"; +alter table "public"."tasks" add column "intents" json not null; + + diff --git a/supabase/migrations/20240617165316_submitting-txs.sql b/supabase/migrations/20240617165316_submitting-txs.sql new file mode 100644 index 00000000..f56fed89 --- /dev/null +++ b/supabase/migrations/20240617165316_submitting-txs.sql @@ -0,0 +1,3 @@ +alter table "public"."submitted_batches" add column "submitted_on" timestamp with time zone; + +