From 3f92702586a2237ccc552cc62c67068fc95f91cc Mon Sep 17 00:00:00 2001 From: Guillaume Koch <39165367+gkoch78@users.noreply.github.com> Date: Mon, 10 Feb 2025 17:45:41 -0800 Subject: [PATCH 01/14] returning more than the price when quoting a token --- alphaswarm/services/exchanges/__init__.py | 4 +- alphaswarm/services/exchanges/base.py | 9 ++++- .../services/exchanges/jupiter/jupiter.py | 37 +++++++++++++++---- .../exchanges/uniswap/uniswap_client_base.py | 6 +-- .../exchanges/uniswap/uniswap_client_v2.py | 13 +++---- .../exchanges/uniswap/uniswap_client_v3.py | 7 ++-- .../tools/exchanges/get_token_price_tool.py | 12 ++---- .../exchanges/jupiter/test_jupiter.py | 4 +- .../uniswap/test_uniswap_client_v3.py | 6 +-- tests/unit/services/exchanges/factory.py | 4 +- 10 files changed, 62 insertions(+), 40 deletions(-) diff --git a/alphaswarm/services/exchanges/__init__.py b/alphaswarm/services/exchanges/__init__.py index 80be77b5..12b8e79e 100644 --- a/alphaswarm/services/exchanges/__init__.py +++ b/alphaswarm/services/exchanges/__init__.py @@ -1,5 +1,5 @@ from .factory import DEXFactory -from .base import DEXClient, SwapResult +from .base import DEXClient, SwapResult, TokenPrice from .uniswap import UniswapClientBase -__all__ = ["DEXFactory", "DEXClient", "SwapResult", "UniswapClientBase"] +__all__ = ["DEXFactory", "DEXClient", "SwapResult", "TokenPrice", "UniswapClientBase"] diff --git a/alphaswarm/services/exchanges/base.py b/alphaswarm/services/exchanges/base.py index c327661e..e82b42f0 100644 --- a/alphaswarm/services/exchanges/base.py +++ b/alphaswarm/services/exchanges/base.py @@ -9,6 +9,13 @@ from hexbytes import HexBytes +@dataclass +class TokenPrice: + price: Decimal + pool: str + source: str = "" + + @dataclass class SwapResult: success: bool @@ -86,7 +93,7 @@ def chain_config(self) -> ChainConfig: return self._chain_config @abstractmethod - def get_token_price(self, token_out: TokenInfo, token_in: TokenInfo) -> Decimal: + def get_token_price(self, token_out: TokenInfo, token_in: TokenInfo) -> TokenPrice: """Get price/conversion rate for the pair of tokens. The price is returned in terms of token_out/token_in (how much token out per token in). diff --git a/alphaswarm/services/exchanges/jupiter/jupiter.py b/alphaswarm/services/exchanges/jupiter/jupiter.py index b812b70f..9c802077 100644 --- a/alphaswarm/services/exchanges/jupiter/jupiter.py +++ b/alphaswarm/services/exchanges/jupiter/jupiter.py @@ -2,24 +2,47 @@ import logging from decimal import Decimal -from typing import Any, Dict, List, Tuple +from typing import Annotated, Any, Dict, List, Optional, Tuple from urllib.parse import urlencode import requests from alphaswarm.config import ChainConfig, Config, JupiterSettings, JupiterVenue, TokenInfo from alphaswarm.services import ApiException -from alphaswarm.services.exchanges.base import DEXClient, SwapResult -from pydantic import Field +from alphaswarm.services.exchanges.base import DEXClient, SwapResult, TokenPrice +from pydantic import BaseModel, Field from pydantic.dataclasses import dataclass logger = logging.getLogger(__name__) +class SwapInfo(BaseModel): + amm_key: Annotated[str, Field(alias="ammKey")] + label: Annotated[Optional[str], Field(alias="label", default=None)] + input_mint: Annotated[str, Field(alias="inputMint")] + output_mint: Annotated[str, Field(alias="outputMint")] + in_amount: Annotated[str, Field(alias="inAmount")] + out_amount: Annotated[str, Field(alias="outAmount")] + fee_amount: Annotated[str, Field(alias="feeAmount")] + fee_mint: Annotated[str, Field(alias="feeMint")] + + def to_dict(self) -> Dict[str, Any]: + return self.model_dump(by_alias=True) + + +@dataclass +class RoutePlan: + swap_info: Annotated[SwapInfo, Field(alias="swapInfo")] + percent: int + + @dataclass class QuoteResponse: # TODO capture more fields if needed - out_amount: Decimal = Field(alias="outAmount") - route_plan: List[Dict[str, Any]] = Field(alias="routePlan") + out_amount: Annotated[Decimal, Field(alias="outAmount")] + route_plan: Annotated[List[RoutePlan], Field(alias="routePlan")] + + def route_plan_to_string(self) -> str: + return "/".join([route.swap_info.amm_key for route in self.route_plan]) class JupiterClient(DEXClient): @@ -45,7 +68,7 @@ def swap( ) -> SwapResult: raise NotImplementedError("Jupiter swap functionality is not yet implemented") - def get_token_price(self, token_out: TokenInfo, token_in: TokenInfo) -> Decimal: + def get_token_price(self, token_out: TokenInfo, token_in: TokenInfo) -> TokenPrice: # Verify tokens are on Solana if not token_out.chain == self.chain or not token_in.chain == self.chain: raise ValueError(f"Jupiter only supports Solana tokens. Got {token_out.chain} and {token_in.chain}") @@ -80,7 +103,7 @@ def get_token_price(self, token_out: TokenInfo, token_in: TokenInfo) -> Decimal: logger.debug(f"- Price: {price} {token_out.symbol}/{token_in.symbol}") logger.debug(f"- Route: {quote.route_plan}") - return price + return TokenPrice(price=price, source="jupiter", pool=quote.route_plan_to_string()) def get_markets_for_tokens(self, tokens: List[TokenInfo]) -> List[Tuple[TokenInfo, TokenInfo]]: """Get list of valid trading pairs between the provided tokens. diff --git a/alphaswarm/services/exchanges/uniswap/uniswap_client_base.py b/alphaswarm/services/exchanges/uniswap/uniswap_client_base.py index 4b3d97a1..45d9b861 100644 --- a/alphaswarm/services/exchanges/uniswap/uniswap_client_base.py +++ b/alphaswarm/services/exchanges/uniswap/uniswap_client_base.py @@ -5,7 +5,7 @@ from alphaswarm.config import ChainConfig, TokenInfo from alphaswarm.services.chains.evm import ERC20Contract, EVMClient, EVMSigner -from alphaswarm.services.exchanges.base import DEXClient, SwapResult +from alphaswarm.services.exchanges.base import DEXClient, SwapResult, TokenPrice from eth_typing import ChecksumAddress, HexAddress from web3.types import TxReceipt @@ -46,7 +46,7 @@ def _swap( pass @abstractmethod - def _get_token_price(self, token_out: TokenInfo, token_in: TokenInfo) -> Decimal: + def _get_token_price(self, token_out: TokenInfo, token_in: TokenInfo) -> TokenPrice: pass @abstractmethod @@ -166,7 +166,7 @@ def _approve_token_spending(self, token: TokenInfo, raw_amount: int) -> TxReceip tx_receipt = token_contract.approve(self.get_signer(), self._router, raw_amount) return tx_receipt - def get_token_price(self, token_out: TokenInfo, token_in: TokenInfo) -> Decimal: + def get_token_price(self, token_out: TokenInfo, token_in: TokenInfo) -> TokenPrice: logger.debug( f"Getting price for {token_out.symbol}/{token_in.symbol} on {self.chain} using Uniswap {self.version}" ) diff --git a/alphaswarm/services/exchanges/uniswap/uniswap_client_v2.py b/alphaswarm/services/exchanges/uniswap/uniswap_client_v2.py index adabfad1..f583e84f 100644 --- a/alphaswarm/services/exchanges/uniswap/uniswap_client_v2.py +++ b/alphaswarm/services/exchanges/uniswap/uniswap_client_v2.py @@ -1,12 +1,11 @@ from __future__ import annotations import logging -from decimal import Decimal from typing import List, Tuple from alphaswarm.config import ChainConfig, Config, TokenInfo from alphaswarm.services.chains.evm import ZERO_ADDRESS -from alphaswarm.services.exchanges.base import Slippage +from alphaswarm.services.exchanges.base import Slippage, TokenPrice from alphaswarm.services.exchanges.uniswap.constants_v2 import ( UNISWAP_V2_DEPLOYMENTS, UNISWAP_V2_FACTORY_ABI, @@ -40,13 +39,11 @@ def _swap( approval_receipt = self._approve_token_spending(token_in, wei_in) # Get price from V2 pair to calculate minimum output - price = self._get_token_price(token_out=token_out, token_in=token_in) - if not price: - raise ValueError(f"No V2 price found for {token_out.symbol}/{token_in.symbol}") + quote = self._get_token_price(token_out=token_out, token_in=token_in) # Calculate expected output input_amount_decimal = token_in.convert_from_wei(wei_in) - expected_output_decimal = input_amount_decimal * price + expected_output_decimal = input_amount_decimal * quote.price logger.info(f"Expected output: {expected_output_decimal} {token_out.symbol}") # Convert expected output to raw integer and apply slippage @@ -73,7 +70,7 @@ def _swap( swap_receipt = self._evm_client.process(swap, self.get_signer()) return [approval_receipt, swap_receipt] - def _get_token_price(self, token_out: TokenInfo, token_in: TokenInfo) -> Decimal: + def _get_token_price(self, token_out: TokenInfo, token_in: TokenInfo) -> TokenPrice: # Create factory contract instance factory_contract = self._web3.eth.contract(address=self._factory, abi=UNISWAP_V2_FACTORY_ABI) @@ -90,7 +87,7 @@ def _get_token_price(self, token_out: TokenInfo, token_in: TokenInfo) -> Decimal pair = fetch_pair_details(self._web3, pair_address, reverse_token_order=reverse) price = pair.get_current_mid_price() - return price + return TokenPrice(price=price, pool=pair.address) def _get_markets_for_tokens(self, tokens: List[TokenInfo]) -> List[Tuple[TokenInfo, TokenInfo]]: """Get all V2 pairs between the provided tokens.""" diff --git a/alphaswarm/services/exchanges/uniswap/uniswap_client_v3.py b/alphaswarm/services/exchanges/uniswap/uniswap_client_v3.py index 207cd03b..5f2fd4bd 100644 --- a/alphaswarm/services/exchanges/uniswap/uniswap_client_v3.py +++ b/alphaswarm/services/exchanges/uniswap/uniswap_client_v3.py @@ -6,7 +6,7 @@ from alphaswarm.config import ChainConfig, Config, TokenInfo, UniswapV3Settings from alphaswarm.services.chains.evm import ZERO_ADDRESS, EVMClient, EVMContract, EVMSigner -from alphaswarm.services.exchanges.base import Slippage +from alphaswarm.services.exchanges.base import Slippage, TokenPrice from alphaswarm.services.exchanges.uniswap.constants_v3 import ( UNISWAP_V3_DEPLOYMENTS, UNISWAP_V3_FACTORY_ABI, @@ -205,9 +205,10 @@ def _swap( return [approval_receipt, swap_receipt] - def _get_token_price(self, token_out: TokenInfo, token_in: TokenInfo) -> Decimal: + def _get_token_price(self, token_out: TokenInfo, token_in: TokenInfo) -> TokenPrice: pool = self._get_pool(token_out, token_in) - return self._get_token_price_from_pool(token_out, pool) + price = self._get_token_price_from_pool(token_out, pool) + return TokenPrice(price=price, pool=pool.address) @staticmethod def _get_token_price_from_pool(token_out: TokenInfo, pool: PoolContract) -> Decimal: diff --git a/alphaswarm/tools/exchanges/get_token_price_tool.py b/alphaswarm/tools/exchanges/get_token_price_tool.py index b28db090..a2a9a254 100644 --- a/alphaswarm/tools/exchanges/get_token_price_tool.py +++ b/alphaswarm/tools/exchanges/get_token_price_tool.py @@ -1,22 +1,15 @@ import logging from datetime import UTC, datetime -from decimal import Decimal from typing import List, Optional from alphaswarm.config import Config -from alphaswarm.services.exchanges import DEXFactory +from alphaswarm.services.exchanges import DEXFactory, TokenPrice from pydantic.dataclasses import dataclass from smolagents import Tool logger = logging.getLogger(__name__) -@dataclass -class TokenPrice: - price: Decimal - source: str - - @dataclass class TokenPriceResult: token_out: str @@ -83,7 +76,8 @@ def forward( dex = DEXFactory.create(dex_name=venue, config=self.config, chain=chain) price = dex.get_token_price(token_out_info, token_in_info) - prices.append(TokenPrice(price=price, source=venue)) + price.source = venue + prices.append(price) except Exception: logger.exception(f"Error getting price from {venue}") diff --git a/tests/integration/services/exchanges/jupiter/test_jupiter.py b/tests/integration/services/exchanges/jupiter/test_jupiter.py index e60eeaeb..889ec749 100644 --- a/tests/integration/services/exchanges/jupiter/test_jupiter.py +++ b/tests/integration/services/exchanges/jupiter/test_jupiter.py @@ -12,5 +12,5 @@ def test_get_token_price(default_config: Config) -> None: giga = tokens_config["GIGA"] sol = tokens_config["SOL"] - price = client.get_token_price(giga, sol) - assert price > 1000, "A Sol is worth many thousands of GIGA." + quote = client.get_token_price(giga, sol) + assert quote.price > 1000, "A Sol is worth many thousands of GIGA." diff --git a/tests/integration/services/exchanges/uniswap/test_uniswap_client_v3.py b/tests/integration/services/exchanges/uniswap/test_uniswap_client_v3.py index f54a8e8b..54c11fe6 100644 --- a/tests/integration/services/exchanges/uniswap/test_uniswap_client_v3.py +++ b/tests/integration/services/exchanges/uniswap/test_uniswap_client_v3.py @@ -29,10 +29,10 @@ def eth_sepolia_client(default_config: Config) -> UniswapClientV3: def test_get_price(base_client: UniswapClientV3) -> None: usdc = base_client.chain_config.get_token_info("USDC") weth = base_client.chain_config.get_token_info("WETH") - usdc_per_weth = base_client.get_token_price(token_out=usdc, token_in=weth) + quote_usdc_per_weth = base_client.get_token_price(token_out=usdc, token_in=weth) - print(f"1 {weth.symbol} is {usdc_per_weth} {usdc.symbol}") - assert usdc_per_weth > 1000, "A WETH is worth many thousands of USDC" + print(f"1 {weth.symbol} is {quote_usdc_per_weth.price} {usdc.symbol}") + assert quote_usdc_per_weth.price > 1000, "A WETH is worth many thousands of USDC" def test_quote_from_pool(base_client: UniswapClientV3) -> None: diff --git a/tests/unit/services/exchanges/factory.py b/tests/unit/services/exchanges/factory.py index 5e362a64..bebf796f 100644 --- a/tests/unit/services/exchanges/factory.py +++ b/tests/unit/services/exchanges/factory.py @@ -5,7 +5,7 @@ import pytest from alphaswarm.config import Config, TokenInfo, ChainConfig -from alphaswarm.services.exchanges import DEXClient, DEXFactory, SwapResult +from alphaswarm.services.exchanges import DEXClient, DEXFactory, SwapResult, TokenPrice class MockDex(DEXClient): @@ -21,7 +21,7 @@ def swap( def get_markets_for_tokens(self, tokens: List[TokenInfo]) -> List[Tuple[TokenInfo, TokenInfo]]: raise NotImplementedError("For test only") - def get_token_price(self, token_out: TokenInfo, token_in: TokenInfo) -> Decimal: + def get_token_price(self, token_out: TokenInfo, token_in: TokenInfo) -> TokenPrice: raise NotImplementedError("For test only") def __init__(self, chain_config: ChainConfig) -> None: From c5afa0fac1265d187d6da605c5a20045a166d10b Mon Sep 17 00:00:00 2001 From: Guillaume Koch <39165367+gkoch78@users.noreply.github.com> Date: Mon, 10 Feb 2025 18:13:05 -0800 Subject: [PATCH 02/14] use pool address for swap --- alphaswarm/services/exchanges/base.py | 2 ++ .../services/exchanges/jupiter/jupiter.py | 1 + .../exchanges/uniswap/uniswap_client_base.py | 11 +++++++++- .../exchanges/uniswap/uniswap_client_v2.py | 22 ++++++++++++++----- .../exchanges/uniswap/uniswap_client_v3.py | 10 +++++++-- .../exchanges/execute_token_swap_tool.py | 3 +++ .../uniswap/test_uniswap_client_v3.py | 2 +- .../exchanges/test_execute_token_swap_tool.py | 8 ++++++- tests/unit/services/exchanges/factory.py | 7 +++++- 9 files changed, 55 insertions(+), 11 deletions(-) diff --git a/alphaswarm/services/exchanges/base.py b/alphaswarm/services/exchanges/base.py index e82b42f0..170b55b0 100644 --- a/alphaswarm/services/exchanges/base.py +++ b/alphaswarm/services/exchanges/base.py @@ -116,6 +116,7 @@ def swap( token_out: TokenInfo, token_in: TokenInfo, amount_in: Decimal, + pool: str, slippage_bps: int = 100, ) -> SwapResult: """Execute a token swap on the DEX @@ -124,6 +125,7 @@ def swap( token_out (TokenInfo): The token to be bought (going out from the pool) token_in (TokenInfo): The token to be sold (going into the pool) amount_in: Amount of token_in to spend + pool (str): Pool to use slippage_bps: Maximum allowed slippage in basis points (1 bp = 0.01%) Returns: diff --git a/alphaswarm/services/exchanges/jupiter/jupiter.py b/alphaswarm/services/exchanges/jupiter/jupiter.py index 9c802077..a3dbae57 100644 --- a/alphaswarm/services/exchanges/jupiter/jupiter.py +++ b/alphaswarm/services/exchanges/jupiter/jupiter.py @@ -64,6 +64,7 @@ def swap( token_out: TokenInfo, token_in: TokenInfo, amount_in: Decimal, + pool: str, slippage_bps: int = 100, ) -> SwapResult: raise NotImplementedError("Jupiter swap functionality is not yet implemented") diff --git a/alphaswarm/services/exchanges/uniswap/uniswap_client_base.py b/alphaswarm/services/exchanges/uniswap/uniswap_client_base.py index 45d9b861..c60222b2 100644 --- a/alphaswarm/services/exchanges/uniswap/uniswap_client_base.py +++ b/alphaswarm/services/exchanges/uniswap/uniswap_client_base.py @@ -41,7 +41,14 @@ def _get_factory(self) -> ChecksumAddress: @abstractmethod def _swap( - self, *, token_out: TokenInfo, token_in: TokenInfo, address: str, wei_in: int, slippage_bps: int + self, + *, + token_out: TokenInfo, + token_in: TokenInfo, + address: ChecksumAddress, + wei_in: int, + pool_address: ChecksumAddress, + slippage_bps: int, ) -> List[TxReceipt]: pass @@ -98,6 +105,7 @@ def swap( token_out: TokenInfo, token_in: TokenInfo, amount_in: Decimal, + pool: str, slippage_bps: int = 100, ) -> SwapResult: logger.info(f"Initiating token swap for {token_in.symbol} to {token_out.symbol}") @@ -134,6 +142,7 @@ def swap( token_in=token_in, address=self.wallet_address, wei_in=wei_in, + pool_address=EVMClient.to_checksum_address(pool), slippage_bps=slippage_bps, ) diff --git a/alphaswarm/services/exchanges/uniswap/uniswap_client_v2.py b/alphaswarm/services/exchanges/uniswap/uniswap_client_v2.py index f583e84f..3ecc59dc 100644 --- a/alphaswarm/services/exchanges/uniswap/uniswap_client_v2.py +++ b/alphaswarm/services/exchanges/uniswap/uniswap_client_v2.py @@ -1,6 +1,7 @@ from __future__ import annotations import logging +from decimal import Decimal from typing import List, Tuple from alphaswarm.config import ChainConfig, Config, TokenInfo @@ -32,18 +33,24 @@ def _get_factory(self) -> ChecksumAddress: return self._evm_client.to_checksum_address(UNISWAP_V2_DEPLOYMENTS[self.chain]["factory"]) def _swap( - self, token_out: TokenInfo, token_in: TokenInfo, address: str, wei_in: int, slippage_bps: int + self, + token_out: TokenInfo, + token_in: TokenInfo, + address: ChecksumAddress, + wei_in: int, + pool_address: ChecksumAddress, + slippage_bps: int, ) -> List[TxReceipt]: """Execute a swap on Uniswap V2.""" # Handle token approval and get fresh nonce approval_receipt = self._approve_token_spending(token_in, wei_in) # Get price from V2 pair to calculate minimum output - quote = self._get_token_price(token_out=token_out, token_in=token_in) + price = self._get_price_from_pool(pair_address=pool_address, token_out=token_out, token_in=token_in) # Calculate expected output input_amount_decimal = token_in.convert_from_wei(wei_in) - expected_output_decimal = input_amount_decimal * quote.price + expected_output_decimal = input_amount_decimal * price logger.info(f"Expected output: {expected_output_decimal} {token_out.symbol}") # Convert expected output to raw integer and apply slippage @@ -83,11 +90,16 @@ def _get_token_price(self, token_out: TokenInfo, token_in: TokenInfo) -> TokenPr # Get V2 pair details - if reverse false, mid_price = token1_amount / token0_amount # token0 of the pair has the lowest address. Reverse if needed + price = self._get_price_from_pool(pair_address=pair_address, token_out=token_out, token_in=token_in) + return TokenPrice(price=price, pool=pair_address) + + def _get_price_from_pool( + self, *, pair_address: ChecksumAddress, token_out: TokenInfo, token_in: TokenInfo + ) -> Decimal: reverse = token_out.checksum_address.lower() < token_in.checksum_address.lower() pair = fetch_pair_details(self._web3, pair_address, reverse_token_order=reverse) price = pair.get_current_mid_price() - - return TokenPrice(price=price, pool=pair.address) + return price def _get_markets_for_tokens(self, tokens: List[TokenInfo]) -> List[Tuple[TokenInfo, TokenInfo]]: """Get all V2 pairs between the provided tokens.""" diff --git a/alphaswarm/services/exchanges/uniswap/uniswap_client_v3.py b/alphaswarm/services/exchanges/uniswap/uniswap_client_v3.py index 5f2fd4bd..106529a3 100644 --- a/alphaswarm/services/exchanges/uniswap/uniswap_client_v3.py +++ b/alphaswarm/services/exchanges/uniswap/uniswap_client_v3.py @@ -138,14 +138,20 @@ def _get_factory(self) -> ChecksumAddress: return self._evm_client.to_checksum_address(UNISWAP_V3_DEPLOYMENTS[self.chain]["factory"]) def _swap( - self, token_out: TokenInfo, token_in: TokenInfo, address: str, wei_in: int, slippage_bps: int + self, + token_out: TokenInfo, + token_in: TokenInfo, + address: ChecksumAddress, + wei_in: int, + pool_address: ChecksumAddress, + slippage_bps: int, ) -> List[TxReceipt]: """Execute a swap on Uniswap V3.""" # Handle token approval and get fresh nonce approval_receipt = self._approve_token_spending(token_in, wei_in) # Build a swap transaction - pool = self._get_pool(token_out, token_in) + pool = self._get_pool_by_address(pool_address) logger.info(f"Using Uniswap V3 pool at address: {pool.address} (raw fee tier: {pool.raw_fee})") # Get the on-chain price from the pool and reverse if necessary diff --git a/alphaswarm/tools/exchanges/execute_token_swap_tool.py b/alphaswarm/tools/exchanges/execute_token_swap_tool.py index 1e6fd124..e33b1686 100644 --- a/alphaswarm/tools/exchanges/execute_token_swap_tool.py +++ b/alphaswarm/tools/exchanges/execute_token_swap_tool.py @@ -24,6 +24,7 @@ class ExecuteTokenSwapTool(Tool): "description": "The address of the token being sold (in the pool)", }, "amount_in": {"type": "number", "description": "The amount token_in to be sold", "required": True}, + "pool": {"type": "string", "description": "Pool address, usually coming from the quote", "required": True}, "chain": { "type": "string", "description": "The chain to execute the swap on (e.g., 'ethereum', 'ethereum_sepolia', 'base')", @@ -53,6 +54,7 @@ def forward( token_out: str, token_in: str, amount_in: Decimal, + pool: str, chain: str = "ethereum", dex_type: str = "uniswap_v3", slippage_bps: int = 100, @@ -76,5 +78,6 @@ def forward( token_out=token_out_info, token_in=token_in_info, amount_in=amount_in, + pool=pool, slippage_bps=slippage_bps, ) diff --git a/tests/integration/services/exchanges/uniswap/test_uniswap_client_v3.py b/tests/integration/services/exchanges/uniswap/test_uniswap_client_v3.py index 54c11fe6..f09a6f62 100644 --- a/tests/integration/services/exchanges/uniswap/test_uniswap_client_v3.py +++ b/tests/integration/services/exchanges/uniswap/test_uniswap_client_v3.py @@ -102,5 +102,5 @@ def test_swap_eth_sepolia(eth_sepolia_client: UniswapClientV3) -> None: print(f"1 {usdc.symbol} is {quote} {weth.symbol}") # Buy X Weth for 1 USDC - result = eth_sepolia_client.swap(token_out=weth, token_in=usdc, amount_in=Decimal(100)) + result = eth_sepolia_client.swap(token_out=weth, token_in=usdc, amount_in=Decimal(100), pool=pool.address) print(result) diff --git a/tests/integration/tools/exchanges/test_execute_token_swap_tool.py b/tests/integration/tools/exchanges/test_execute_token_swap_tool.py index 558f8fd2..da03aa17 100644 --- a/tests/integration/tools/exchanges/test_execute_token_swap_tool.py +++ b/tests/integration/tools/exchanges/test_execute_token_swap_tool.py @@ -21,7 +21,13 @@ def sepolia_client(default_config: Config) -> EVMClient: def test_token_swap_tool(token_swap_tool: ExecuteTokenSwapTool, sepolia_client: EVMClient) -> None: weth = sepolia_client.get_token_info_by_name("WETH") usdc = sepolia_client.get_token_info_by_name("USDC") + pool_address = "0x3289680dD4d6C10bb19b899729cda5eEF58AEfF1" result = token_swap_tool.forward( - token_out=weth.address, token_in=usdc.address, amount_in=Decimal(1), chain="ethereum_sepolia" + token_out=weth.address, + token_in=usdc.address, + amount_in=Decimal(1), + chain="ethereum_sepolia", + pool=pool_address, + dex_type="uniswap_v3", ) print(result) diff --git a/tests/unit/services/exchanges/factory.py b/tests/unit/services/exchanges/factory.py index bebf796f..5579f867 100644 --- a/tests/unit/services/exchanges/factory.py +++ b/tests/unit/services/exchanges/factory.py @@ -14,7 +14,12 @@ def from_config(cls, config: Config, chain: str) -> MockDex: return MockDex(chain_config=config.get_chain_config(chain)) def swap( - self, token_out: TokenInfo, token_in: TokenInfo, amount_in: Decimal, slippage_bps: int = 100 + self, + token_out: TokenInfo, + token_in: TokenInfo, + amount_in: Decimal, + pool: str, + slippage_bps: int = 100, ) -> SwapResult: raise NotImplementedError("For test only") From 3139b70263b9b2402168712b5d7c501c3897fa50 Mon Sep 17 00:00:00 2001 From: Guillaume Koch <39165367+gkoch78@users.noreply.github.com> Date: Wed, 12 Feb 2025 11:57:53 -0800 Subject: [PATCH 03/14] WIP swap execute from a quote --- alphaswarm/services/exchanges/base.py | 19 +++---- .../exchanges/uniswap/uniswap_client_base.py | 44 ++++++++-------- .../exchanges/uniswap/uniswap_client_v2.py | 27 ++++++---- .../exchanges/uniswap/uniswap_client_v3.py | 28 +++++++---- .../exchanges/execute_token_swap_tool.py | 50 +++++-------------- .../tools/exchanges/get_token_price_tool.py | 31 +++++++----- 6 files changed, 98 insertions(+), 101 deletions(-) diff --git a/alphaswarm/services/exchanges/base.py b/alphaswarm/services/exchanges/base.py index 170b55b0..d18417eb 100644 --- a/alphaswarm/services/exchanges/base.py +++ b/alphaswarm/services/exchanges/base.py @@ -3,7 +3,7 @@ from abc import ABC, abstractmethod from dataclasses import dataclass from decimal import Decimal -from typing import List, Optional, Tuple, Type, TypeVar, Union +from typing import Any, List, Optional, Tuple, Type, TypeVar, Union from alphaswarm.config import ChainConfig, Config, TokenInfo from hexbytes import HexBytes @@ -11,9 +11,7 @@ @dataclass class TokenPrice: - price: Decimal - pool: str - source: str = "" + quote_details: Any @dataclass @@ -93,7 +91,7 @@ def chain_config(self) -> ChainConfig: return self._chain_config @abstractmethod - def get_token_price(self, token_out: TokenInfo, token_in: TokenInfo) -> TokenPrice: + def get_token_price(self, token_out: TokenInfo, token_in: TokenInfo, amount_in: Decimal) -> TokenPrice: """Get price/conversion rate for the pair of tokens. The price is returned in terms of token_out/token_in (how much token out per token in). @@ -101,6 +99,7 @@ def get_token_price(self, token_out: TokenInfo, token_in: TokenInfo) -> TokenPri Args: token_out (TokenInfo): The token to be bought (going out from the pool) token_in (TokenInfo): The token to be sold (going into the pool) + amount_in (Decimal): The amount of the token to be sold Example: eth_token = TokenInfo(address="0x...", decimals=18, symbol="ETH", chain="ethereum") @@ -113,19 +112,13 @@ def get_token_price(self, token_out: TokenInfo, token_in: TokenInfo) -> TokenPri @abstractmethod def swap( self, - token_out: TokenInfo, - token_in: TokenInfo, - amount_in: Decimal, - pool: str, + quote: TokenPrice, slippage_bps: int = 100, ) -> SwapResult: """Execute a token swap on the DEX Args: - token_out (TokenInfo): The token to be bought (going out from the pool) - token_in (TokenInfo): The token to be sold (going into the pool) - amount_in: Amount of token_in to spend - pool (str): Pool to use + quote (TokenPrice): The quote to execute slippage_bps: Maximum allowed slippage in basis points (1 bp = 0.01%) Returns: diff --git a/alphaswarm/services/exchanges/uniswap/uniswap_client_base.py b/alphaswarm/services/exchanges/uniswap/uniswap_client_base.py index c60222b2..3fa8c24d 100644 --- a/alphaswarm/services/exchanges/uniswap/uniswap_client_base.py +++ b/alphaswarm/services/exchanges/uniswap/uniswap_client_base.py @@ -7,12 +7,21 @@ from alphaswarm.services.chains.evm import ERC20Contract, EVMClient, EVMSigner from alphaswarm.services.exchanges.base import DEXClient, SwapResult, TokenPrice from eth_typing import ChecksumAddress, HexAddress +from pydantic import BaseModel from web3.types import TxReceipt # Set up logger logger = logging.getLogger(__name__) +class QuoteDetail(BaseModel): + token_in: TokenInfo + token_out: TokenInfo + amount_in: Decimal + amount_out: Decimal + pool_address: ChecksumAddress + + class UniswapClientBase(DEXClient): def __init__(self, chain_config: ChainConfig, version: str) -> None: super().__init__(chain_config) @@ -43,17 +52,13 @@ def _get_factory(self) -> ChecksumAddress: def _swap( self, *, - token_out: TokenInfo, - token_in: TokenInfo, - address: ChecksumAddress, - wei_in: int, - pool_address: ChecksumAddress, + quote: QuoteDetail, slippage_bps: int, ) -> List[TxReceipt]: pass @abstractmethod - def _get_token_price(self, token_out: TokenInfo, token_in: TokenInfo) -> TokenPrice: + def _get_token_price(self, token_out: TokenInfo, token_in: TokenInfo, amount_in: Decimal) -> TokenPrice: pass @abstractmethod @@ -102,18 +107,22 @@ def _get_final_swap_amount_received( def swap( self, - token_out: TokenInfo, - token_in: TokenInfo, - amount_in: Decimal, - pool: str, + quote: TokenPrice, slippage_bps: int = 100, ) -> SwapResult: - logger.info(f"Initiating token swap for {token_in.symbol} to {token_out.symbol}") - logger.info(f"Wallet address: {self.wallet_address}") + quote_details = quote.quote_details + if not isinstance(quote_details, QuoteDetail): + raise ValueError("incorrect quote details") # Create contract instances + token_out = quote_details.token_out token_out_contract = ERC20Contract(self._evm_client, token_out.checksum_address) + token_in = quote_details.token_in token_in_contract = ERC20Contract(self._evm_client, token_in.checksum_address) + amount_in = quote_details.amount_in + + logger.info(f"Initiating token swap for {token_in.symbol} to {token_out.symbol}") + logger.info(f"Wallet address: {self.wallet_address}") # Gas balance gas_balance = self._evm_client.get_native_balance(self.wallet_address) @@ -126,7 +135,6 @@ def swap( logger.info(f"Balance of {token_out.symbol}: {out_balance:,.8f}") logger.info(f"Balance of {token_in.symbol}: {in_balance:,.8f}") logger.info(f"ETH balance for gas: {eth_balance:,.6f}") - wei_in = token_in.convert_to_wei(amount_in) if in_balance < amount_in: raise ValueError( @@ -138,11 +146,7 @@ def swap( # 2) swap (various functions) receipts = self._swap( - token_out=token_out, - token_in=token_in, - address=self.wallet_address, - wei_in=wei_in, - pool_address=EVMClient.to_checksum_address(pool), + quote=quote_details, slippage_bps=slippage_bps, ) @@ -175,12 +179,12 @@ def _approve_token_spending(self, token: TokenInfo, raw_amount: int) -> TxReceip tx_receipt = token_contract.approve(self.get_signer(), self._router, raw_amount) return tx_receipt - def get_token_price(self, token_out: TokenInfo, token_in: TokenInfo) -> TokenPrice: + def get_token_price(self, token_out: TokenInfo, token_in: TokenInfo, amount_in: Decimal) -> TokenPrice: logger.debug( f"Getting price for {token_out.symbol}/{token_in.symbol} on {self.chain} using Uniswap {self.version}" ) - return self._get_token_price(token_out=token_out, token_in=token_in) + return self._get_token_price(token_out=token_out, token_in=token_in, amount_in=amount_in) def get_markets_for_tokens(self, tokens: List[TokenInfo]) -> List[Tuple[TokenInfo, TokenInfo]]: """Get list of valid trading pairs between the provided tokens. diff --git a/alphaswarm/services/exchanges/uniswap/uniswap_client_v2.py b/alphaswarm/services/exchanges/uniswap/uniswap_client_v2.py index 3ecc59dc..ac4f4c0d 100644 --- a/alphaswarm/services/exchanges/uniswap/uniswap_client_v2.py +++ b/alphaswarm/services/exchanges/uniswap/uniswap_client_v2.py @@ -13,7 +13,7 @@ UNISWAP_V2_ROUTER_ABI, UNISWAP_V2_VERSION, ) -from alphaswarm.services.exchanges.uniswap.uniswap_client_base import UniswapClientBase +from alphaswarm.services.exchanges.uniswap.uniswap_client_base import QuoteDetail, UniswapClientBase from eth_defi.uniswap_v2.pair import fetch_pair_details from eth_typing import ChecksumAddress from web3.types import TxReceipt @@ -34,15 +34,17 @@ def _get_factory(self) -> ChecksumAddress: def _swap( self, - token_out: TokenInfo, - token_in: TokenInfo, - address: ChecksumAddress, - wei_in: int, - pool_address: ChecksumAddress, + quote: QuoteDetail, slippage_bps: int, ) -> List[TxReceipt]: """Execute a swap on Uniswap V2.""" # Handle token approval and get fresh nonce + token_in = quote.token_in + token_out = quote.token_out + amount_in = quote.amount_in + pool_address = quote.pool_address + wei_in = token_in.convert_to_wei(amount_in) + approval_receipt = self._approve_token_spending(token_in, wei_in) # Get price from V2 pair to calculate minimum output @@ -69,7 +71,7 @@ def _swap( wei_in, # amount in min_output_raw, # minimum amount out path, # swap path - address, # recipient + self.wallet_address, # recipient deadline, # deadline ) @@ -77,7 +79,7 @@ def _swap( swap_receipt = self._evm_client.process(swap, self.get_signer()) return [approval_receipt, swap_receipt] - def _get_token_price(self, token_out: TokenInfo, token_in: TokenInfo) -> TokenPrice: + def _get_token_price(self, token_out: TokenInfo, token_in: TokenInfo, amount_in: Decimal) -> TokenPrice: # Create factory contract instance factory_contract = self._web3.eth.contract(address=self._factory, abi=UNISWAP_V2_FACTORY_ABI) @@ -91,7 +93,14 @@ def _get_token_price(self, token_out: TokenInfo, token_in: TokenInfo) -> TokenPr # Get V2 pair details - if reverse false, mid_price = token1_amount / token0_amount # token0 of the pair has the lowest address. Reverse if needed price = self._get_price_from_pool(pair_address=pair_address, token_out=token_out, token_in=token_in) - return TokenPrice(price=price, pool=pair_address) + details = QuoteDetail( + token_in=token_in, + token_out=token_out, + amount_in=amount_in, + amount_out=price * amount_in, # TODO: substract fees? + pool_address=pair_address, + ) + return TokenPrice(quote_details=details) def _get_price_from_pool( self, *, pair_address: ChecksumAddress, token_out: TokenInfo, token_in: TokenInfo diff --git a/alphaswarm/services/exchanges/uniswap/uniswap_client_v3.py b/alphaswarm/services/exchanges/uniswap/uniswap_client_v3.py index 106529a3..0ec31ea7 100644 --- a/alphaswarm/services/exchanges/uniswap/uniswap_client_v3.py +++ b/alphaswarm/services/exchanges/uniswap/uniswap_client_v3.py @@ -14,7 +14,7 @@ UNISWAP_V3_ROUTER_ABI, UNISWAP_V3_VERSION, ) -from alphaswarm.services.exchanges.uniswap.uniswap_client_base import UniswapClientBase +from alphaswarm.services.exchanges.uniswap.uniswap_client_base import QuoteDetail, UniswapClientBase from eth_defi.uniswap_v3.pool import PoolDetails, fetch_pool_details from eth_defi.uniswap_v3.price import get_onchain_price from eth_typing import ChecksumAddress, HexAddress @@ -139,19 +139,20 @@ def _get_factory(self) -> ChecksumAddress: def _swap( self, - token_out: TokenInfo, - token_in: TokenInfo, - address: ChecksumAddress, - wei_in: int, - pool_address: ChecksumAddress, + quote: QuoteDetail, slippage_bps: int, ) -> List[TxReceipt]: """Execute a swap on Uniswap V3.""" # Handle token approval and get fresh nonce + + token_in = quote.token_in + token_out = quote.token_out + amount_in = quote.amount_in + wei_in = token_in.convert_to_wei(amount_in) approval_receipt = self._approve_token_spending(token_in, wei_in) # Build a swap transaction - pool = self._get_pool_by_address(pool_address) + pool = self._get_pool_by_address(quote.pool_address) logger.info(f"Using Uniswap V3 pool at address: {pool.address} (raw fee tier: {pool.raw_fee})") # Get the on-chain price from the pool and reverse if necessary @@ -198,7 +199,7 @@ def _swap( token_in=token_in.checksum_address, token_out=token_out.checksum_address, fee=pool.raw_fee, - recipient=self._evm_client.to_checksum_address(address), + recipient=self.wallet_address, deadline=int(self._evm_client.get_block_latest()["timestamp"] + 300), amount_in=wei_in, amount_out_minimum=min_output_raw, @@ -211,10 +212,17 @@ def _swap( return [approval_receipt, swap_receipt] - def _get_token_price(self, token_out: TokenInfo, token_in: TokenInfo) -> TokenPrice: + def _get_token_price(self, token_out: TokenInfo, token_in: TokenInfo, amount_in: Decimal) -> TokenPrice: pool = self._get_pool(token_out, token_in) price = self._get_token_price_from_pool(token_out, pool) - return TokenPrice(price=price, pool=pool.address) + details = QuoteDetail( + token_in=token_in, + token_out=token_out, + amount_in=amount_in, + amount_out=price * amount_in, # TODO: substract fees? + pool_address=pool.address, + ) + return TokenPrice(quote_details=details) @staticmethod def _get_token_price_from_pool(token_out: TokenInfo, pool: PoolContract) -> Decimal: diff --git a/alphaswarm/tools/exchanges/execute_token_swap_tool.py b/alphaswarm/tools/exchanges/execute_token_swap_tool.py index e33b1686..31b0c4d9 100644 --- a/alphaswarm/tools/exchanges/execute_token_swap_tool.py +++ b/alphaswarm/tools/exchanges/execute_token_swap_tool.py @@ -1,9 +1,9 @@ import logging -from decimal import Decimal from typing import Any from alphaswarm.config import Config from alphaswarm.services.exchanges import DEXFactory, SwapResult +from alphaswarm.tools.exchanges.get_token_price_tool import TokenQuote from smolagents import Tool logger = logging.getLogger(__name__) @@ -15,25 +15,9 @@ class ExecuteTokenSwapTool(Tool): name = "execute_token_swap" description = "Execute a token swap on a supported DEX (Uniswap V2/V3 on Ethereum and Base chains)." inputs = { - "token_out": { - "type": "string", - "description": "The address of the token being bought (out from the pool)", - }, - "token_in": { - "type": "string", - "description": "The address of the token being sold (in the pool)", - }, - "amount_in": {"type": "number", "description": "The amount token_in to be sold", "required": True}, - "pool": {"type": "string", "description": "Pool address, usually coming from the quote", "required": True}, - "chain": { - "type": "string", - "description": "The chain to execute the swap on (e.g., 'ethereum', 'ethereum_sepolia', 'base')", - "nullable": True, - }, - "dex_type": { - "type": "string", - "description": "The DEX type to use (e.g., 'uniswap_v2', 'uniswap_v3')", - "nullable": True, + "quote": { + "type": "object", + "description": f"A {TokenQuote.__name__} previously generated", }, "slippage_bps": { "type": "integer", @@ -51,33 +35,25 @@ def __init__(self, config: Config, *args: Any, **kwargs: Any) -> None: def forward( self, *, - token_out: str, - token_in: str, - amount_in: Decimal, - pool: str, - chain: str = "ethereum", - dex_type: str = "uniswap_v3", + quote: TokenQuote, slippage_bps: int = 100, ) -> SwapResult: """Execute a token swap.""" # Create DEX client - dex_client = DEXFactory.create(dex_name=dex_type, config=self.config, chain=chain) + dex_client = DEXFactory.create(dex_name=quote.dex, config=self.config, chain=quote.chain) # Get wallet address and private key from chain config - chain_config = self.config.get_chain_config(chain) - token_in_info = chain_config.get_token_info_by_address(token_in) - token_out_info = chain_config.get_token_info_by_address(token_out) + # chain_config = self.config.get_chain_config(quote.chain) + # token_in_info = chain_config.get_token_info_by_address(token_in) + # token_out_info = chain_config.get_token_info_by_address(token_out) # Log token details - logger.info( - f"Swapping {amount_in} {token_in_info.symbol} ({token_in_info.address}) for {token_out_info.symbol} ({token_out_info.address}) on {chain}" - ) + # logger.info( + # f"Swapping {amount_in} {token_in_info.symbol} ({token_in_info.address}) for {token_out_info.symbol} ({token_out_info.address}) on {chain}" + # ) # Execute swap return dex_client.swap( - token_out=token_out_info, - token_in=token_in_info, - amount_in=amount_in, - pool=pool, + quote=quote.quote, slippage_bps=slippage_bps, ) diff --git a/alphaswarm/tools/exchanges/get_token_price_tool.py b/alphaswarm/tools/exchanges/get_token_price_tool.py index b30ed972..5943ed9f 100644 --- a/alphaswarm/tools/exchanges/get_token_price_tool.py +++ b/alphaswarm/tools/exchanges/get_token_price_tool.py @@ -1,5 +1,6 @@ import logging from datetime import UTC, datetime +from decimal import Decimal from typing import List, Optional from alphaswarm.config import Config @@ -10,20 +11,24 @@ logger = logging.getLogger(__name__) +@dataclass +class TokenQuote: + datetime: str + dex: str + chain: str + quote: TokenPrice + + @dataclass class TokenPriceResult: - token_out: str - token_in: str - timestamp: str - prices: List[TokenPrice] + quotes: List[TokenQuote] class GetTokenPriceTool(Tool): name = "get_token_price" description = ( "Get the current price of a token pair from available DEXes. " - "Returns a list of TokenPriceResult object. " - "Result is expressed in amount of token_out per token_in. " + f"Returns a list of {TokenQuote.__name__} object." "Examples: 'Get the price of ETH in USDC on ethereum', 'Get the price of GIGA in SOL on solana'" ) inputs = { @@ -35,6 +40,7 @@ class GetTokenPriceTool(Tool): "type": "string", "description": "The address of the token we want to sell", }, + "amount_in": {"type": "number", "description": "The amount token_in to be sold", "required": True}, "chain": { "type": "string", "description": "Blockchain to use. Must be 'solana' for Solana tokens, 'base' for Base tokens, 'ethereum' for Ethereum tokens, 'ethereum_sepolia' for Ethereum Sepolia tokens.", @@ -55,6 +61,7 @@ def forward( self, token_out: str, token_in: str, + amount_in: Decimal, chain: str, dex_type: Optional[str] = None, ) -> TokenPriceResult: @@ -70,14 +77,15 @@ def forward( # Get prices from all available venues venues = self.config.get_trading_venues_for_chain(chain) if dex_type is None else [dex_type] - prices = [] + prices: List[TokenQuote] = [] for venue in venues: try: dex = DEXFactory.create(dex_name=venue, config=self.config, chain=chain) - price = dex.get_token_price(token_out_info, token_in_info) - price.source = venue - prices.append(price) + price = dex.get_token_price(token_out_info, token_in_info, amount_in=amount_in) + timestamp = datetime.now(UTC).strftime("%Y-%m-%d %H:%M UTC") + + prices.append(TokenQuote(dex=dex_type, chain=chain, quote=price, datetime=timestamp)) except Exception: logger.exception(f"Error getting price from {venue}") @@ -86,9 +94,8 @@ def forward( raise RuntimeError(f"No valid prices found for {token_out}/{token_in}") # Get current timestamp - timestamp = datetime.now(UTC).strftime("%Y-%m-%d %H:%M UTC") # If we have multiple prices, return them all - result = TokenPriceResult(token_out=token_out, token_in=token_in, timestamp=timestamp, prices=prices) + result = TokenPriceResult(quotes=prices) logger.debug(f"Returning result: {result}") return result From 10a6788de45518973343bf11625728ca4233d404 Mon Sep 17 00:00:00 2001 From: Guillaume Koch <39165367+gkoch78@users.noreply.github.com> Date: Wed, 12 Feb 2025 11:59:26 -0800 Subject: [PATCH 04/14] WIP jupiter --- alphaswarm/services/exchanges/jupiter/jupiter.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/alphaswarm/services/exchanges/jupiter/jupiter.py b/alphaswarm/services/exchanges/jupiter/jupiter.py index a3dbae57..a69b2287 100644 --- a/alphaswarm/services/exchanges/jupiter/jupiter.py +++ b/alphaswarm/services/exchanges/jupiter/jupiter.py @@ -61,15 +61,12 @@ def _validate_chain(self, chain: str) -> None: def swap( self, - token_out: TokenInfo, - token_in: TokenInfo, - amount_in: Decimal, - pool: str, + quote: TokenPrice, slippage_bps: int = 100, ) -> SwapResult: raise NotImplementedError("Jupiter swap functionality is not yet implemented") - def get_token_price(self, token_out: TokenInfo, token_in: TokenInfo) -> TokenPrice: + def get_token_price(self, token_out: TokenInfo, token_in: TokenInfo, amount_in: Decimal) -> TokenPrice: # Verify tokens are on Solana if not token_out.chain == self.chain or not token_in.chain == self.chain: raise ValueError(f"Jupiter only supports Solana tokens. Got {token_out.chain} and {token_in.chain}") @@ -104,7 +101,7 @@ def get_token_price(self, token_out: TokenInfo, token_in: TokenInfo) -> TokenPri logger.debug(f"- Price: {price} {token_out.symbol}/{token_in.symbol}") logger.debug(f"- Route: {quote.route_plan}") - return TokenPrice(price=price, source="jupiter", pool=quote.route_plan_to_string()) + return TokenPrice(quote_details=quote) def get_markets_for_tokens(self, tokens: List[TokenInfo]) -> List[Tuple[TokenInfo, TokenInfo]]: """Get list of valid trading pairs between the provided tokens. From 2364582212a76ee0a58c94b2fb9e81c52f1cdc90 Mon Sep 17 00:00:00 2001 From: Guillaume Koch <39165367+gkoch78@users.noreply.github.com> Date: Wed, 12 Feb 2025 13:40:31 -0800 Subject: [PATCH 05/14] refactor DexClient to be generic on Quote --- alphaswarm/services/exchanges/base.py | 20 +++++++++---- .../services/exchanges/jupiter/jupiter.py | 16 +++++------ .../exchanges/uniswap/uniswap_client_base.py | 28 ++++++++----------- .../exchanges/uniswap/uniswap_client_v2.py | 11 ++++---- .../exchanges/uniswap/uniswap_client_v3.py | 11 ++++---- .../exchanges/execute_token_swap_tool.py | 2 +- .../tools/exchanges/get_token_price_tool.py | 18 ++++++------ 7 files changed, 56 insertions(+), 50 deletions(-) diff --git a/alphaswarm/services/exchanges/base.py b/alphaswarm/services/exchanges/base.py index d18417eb..60a088d0 100644 --- a/alphaswarm/services/exchanges/base.py +++ b/alphaswarm/services/exchanges/base.py @@ -3,11 +3,13 @@ from abc import ABC, abstractmethod from dataclasses import dataclass from decimal import Decimal -from typing import Any, List, Optional, Tuple, Type, TypeVar, Union +from typing import Any, Generic, List, Optional, Tuple, Type, TypeGuard, TypeVar, Union from alphaswarm.config import ChainConfig, Config, TokenInfo from hexbytes import HexBytes +TQuote = TypeVar("TQuote") + @dataclass class TokenPrice: @@ -74,13 +76,14 @@ def __repr__(self) -> str: T = TypeVar("T", bound="DEXClient") -class DEXClient(ABC): +class DEXClient(Generic[TQuote], ABC): """Base class for DEX clients""" @abstractmethod - def __init__(self, chain_config: ChainConfig) -> None: + def __init__(self, chain_config: ChainConfig, quote_type: Type[TQuote]) -> None: """Initialize the DEX client with configuration""" self._chain_config = chain_config + self._quote_type = quote_type @property def chain(self) -> str: @@ -91,7 +94,7 @@ def chain_config(self) -> ChainConfig: return self._chain_config @abstractmethod - def get_token_price(self, token_out: TokenInfo, token_in: TokenInfo, amount_in: Decimal) -> TokenPrice: + def get_token_price(self, token_out: TokenInfo, token_in: TokenInfo, amount_in: Decimal) -> TQuote: """Get price/conversion rate for the pair of tokens. The price is returned in terms of token_out/token_in (how much token out per token in). @@ -112,7 +115,7 @@ def get_token_price(self, token_out: TokenInfo, token_in: TokenInfo, amount_in: @abstractmethod def swap( self, - quote: TokenPrice, + quote: TQuote, slippage_bps: int = 100, ) -> SwapResult: """Execute a token swap on the DEX @@ -157,3 +160,10 @@ def from_config(cls: Type[T], config: Config, chain: str) -> T: An instance of the DEX client """ pass + + def raise_if_not_quote(self, value: Any) -> None: + if self.is_quote(value): + raise TypeError(f"Expected {self._quote_type} but got {type(value)}") + + def is_quote(self, value: Any) -> TypeGuard[TQuote]: + return isinstance(value, self._quote_type) diff --git a/alphaswarm/services/exchanges/jupiter/jupiter.py b/alphaswarm/services/exchanges/jupiter/jupiter.py index a69b2287..2299b946 100644 --- a/alphaswarm/services/exchanges/jupiter/jupiter.py +++ b/alphaswarm/services/exchanges/jupiter/jupiter.py @@ -8,7 +8,7 @@ import requests from alphaswarm.config import ChainConfig, Config, JupiterSettings, JupiterVenue, TokenInfo from alphaswarm.services import ApiException -from alphaswarm.services.exchanges.base import DEXClient, SwapResult, TokenPrice +from alphaswarm.services.exchanges.base import DEXClient, SwapResult from pydantic import BaseModel, Field from pydantic.dataclasses import dataclass @@ -36,7 +36,7 @@ class RoutePlan: @dataclass -class QuoteResponse: +class JupiterQuote: # TODO capture more fields if needed out_amount: Annotated[Decimal, Field(alias="outAmount")] route_plan: Annotated[List[RoutePlan], Field(alias="routePlan")] @@ -45,12 +45,12 @@ def route_plan_to_string(self) -> str: return "/".join([route.swap_info.amm_key for route in self.route_plan]) -class JupiterClient(DEXClient): +class JupiterClient(DEXClient[JupiterQuote]): """Client for Jupiter DEX on Solana""" def __init__(self, chain_config: ChainConfig, venue_config: JupiterVenue, settings: JupiterSettings) -> None: self._validate_chain(chain_config.chain) - super().__init__(chain_config) + super().__init__(chain_config, JupiterQuote) self._settings = settings self._venue_config = venue_config logger.info(f"Initialized JupiterClient on chain '{self.chain}'") @@ -61,12 +61,12 @@ def _validate_chain(self, chain: str) -> None: def swap( self, - quote: TokenPrice, + quote: JupiterQuote, slippage_bps: int = 100, ) -> SwapResult: raise NotImplementedError("Jupiter swap functionality is not yet implemented") - def get_token_price(self, token_out: TokenInfo, token_in: TokenInfo, amount_in: Decimal) -> TokenPrice: + def get_token_price(self, token_out: TokenInfo, token_in: TokenInfo, amount_in: Decimal) -> JupiterQuote: # Verify tokens are on Solana if not token_out.chain == self.chain or not token_in.chain == self.chain: raise ValueError(f"Jupiter only supports Solana tokens. Got {token_out.chain} and {token_in.chain}") @@ -89,7 +89,7 @@ def get_token_price(self, token_out: TokenInfo, token_in: TokenInfo, amount_in: raise ApiException(response) result = response.json() - quote = QuoteResponse(**result) + quote = JupiterQuote(**result) # Calculate price (token_out per token_in) amount_out = quote.out_amount @@ -101,7 +101,7 @@ def get_token_price(self, token_out: TokenInfo, token_in: TokenInfo, amount_in: logger.debug(f"- Price: {price} {token_out.symbol}/{token_in.symbol}") logger.debug(f"- Route: {quote.route_plan}") - return TokenPrice(quote_details=quote) + return quote def get_markets_for_tokens(self, tokens: List[TokenInfo]) -> List[Tuple[TokenInfo, TokenInfo]]: """Get list of valid trading pairs between the provided tokens. diff --git a/alphaswarm/services/exchanges/uniswap/uniswap_client_base.py b/alphaswarm/services/exchanges/uniswap/uniswap_client_base.py index 3fa8c24d..7424c934 100644 --- a/alphaswarm/services/exchanges/uniswap/uniswap_client_base.py +++ b/alphaswarm/services/exchanges/uniswap/uniswap_client_base.py @@ -5,7 +5,7 @@ from alphaswarm.config import ChainConfig, TokenInfo from alphaswarm.services.chains.evm import ERC20Contract, EVMClient, EVMSigner -from alphaswarm.services.exchanges.base import DEXClient, SwapResult, TokenPrice +from alphaswarm.services.exchanges.base import DEXClient, SwapResult from eth_typing import ChecksumAddress, HexAddress from pydantic import BaseModel from web3.types import TxReceipt @@ -14,7 +14,7 @@ logger = logging.getLogger(__name__) -class QuoteDetail(BaseModel): +class UniswapQuote(BaseModel): token_in: TokenInfo token_out: TokenInfo amount_in: Decimal @@ -22,9 +22,9 @@ class QuoteDetail(BaseModel): pool_address: ChecksumAddress -class UniswapClientBase(DEXClient): +class UniswapClientBase(DEXClient[UniswapQuote]): def __init__(self, chain_config: ChainConfig, version: str) -> None: - super().__init__(chain_config) + super().__init__(chain_config, UniswapQuote) self.version = version self._evm_client = EVMClient(chain_config) self._router = self._get_router() @@ -52,13 +52,13 @@ def _get_factory(self) -> ChecksumAddress: def _swap( self, *, - quote: QuoteDetail, + quote: UniswapQuote, slippage_bps: int, ) -> List[TxReceipt]: pass @abstractmethod - def _get_token_price(self, token_out: TokenInfo, token_in: TokenInfo, amount_in: Decimal) -> TokenPrice: + def _get_token_price(self, token_out: TokenInfo, token_in: TokenInfo, amount_in: Decimal) -> UniswapQuote: pass @abstractmethod @@ -107,19 +107,15 @@ def _get_final_swap_amount_received( def swap( self, - quote: TokenPrice, + quote: UniswapQuote, slippage_bps: int = 100, ) -> SwapResult: - quote_details = quote.quote_details - if not isinstance(quote_details, QuoteDetail): - raise ValueError("incorrect quote details") - # Create contract instances - token_out = quote_details.token_out + token_out = quote.token_out token_out_contract = ERC20Contract(self._evm_client, token_out.checksum_address) - token_in = quote_details.token_in + token_in = quote.token_in token_in_contract = ERC20Contract(self._evm_client, token_in.checksum_address) - amount_in = quote_details.amount_in + amount_in = quote.amount_in logger.info(f"Initiating token swap for {token_in.symbol} to {token_out.symbol}") logger.info(f"Wallet address: {self.wallet_address}") @@ -146,7 +142,7 @@ def swap( # 2) swap (various functions) receipts = self._swap( - quote=quote_details, + quote=quote, slippage_bps=slippage_bps, ) @@ -179,7 +175,7 @@ def _approve_token_spending(self, token: TokenInfo, raw_amount: int) -> TxReceip tx_receipt = token_contract.approve(self.get_signer(), self._router, raw_amount) return tx_receipt - def get_token_price(self, token_out: TokenInfo, token_in: TokenInfo, amount_in: Decimal) -> TokenPrice: + def get_token_price(self, token_out: TokenInfo, token_in: TokenInfo, amount_in: Decimal) -> UniswapQuote: logger.debug( f"Getting price for {token_out.symbol}/{token_in.symbol} on {self.chain} using Uniswap {self.version}" ) diff --git a/alphaswarm/services/exchanges/uniswap/uniswap_client_v2.py b/alphaswarm/services/exchanges/uniswap/uniswap_client_v2.py index ac4f4c0d..b7ae19e6 100644 --- a/alphaswarm/services/exchanges/uniswap/uniswap_client_v2.py +++ b/alphaswarm/services/exchanges/uniswap/uniswap_client_v2.py @@ -6,14 +6,14 @@ from alphaswarm.config import ChainConfig, Config, TokenInfo from alphaswarm.services.chains.evm import ZERO_ADDRESS -from alphaswarm.services.exchanges.base import Slippage, TokenPrice +from alphaswarm.services.exchanges.base import Slippage from alphaswarm.services.exchanges.uniswap.constants_v2 import ( UNISWAP_V2_DEPLOYMENTS, UNISWAP_V2_FACTORY_ABI, UNISWAP_V2_ROUTER_ABI, UNISWAP_V2_VERSION, ) -from alphaswarm.services.exchanges.uniswap.uniswap_client_base import QuoteDetail, UniswapClientBase +from alphaswarm.services.exchanges.uniswap.uniswap_client_base import UniswapClientBase, UniswapQuote from eth_defi.uniswap_v2.pair import fetch_pair_details from eth_typing import ChecksumAddress from web3.types import TxReceipt @@ -34,7 +34,7 @@ def _get_factory(self) -> ChecksumAddress: def _swap( self, - quote: QuoteDetail, + quote: UniswapQuote, slippage_bps: int, ) -> List[TxReceipt]: """Execute a swap on Uniswap V2.""" @@ -79,7 +79,7 @@ def _swap( swap_receipt = self._evm_client.process(swap, self.get_signer()) return [approval_receipt, swap_receipt] - def _get_token_price(self, token_out: TokenInfo, token_in: TokenInfo, amount_in: Decimal) -> TokenPrice: + def _get_token_price(self, token_out: TokenInfo, token_in: TokenInfo, amount_in: Decimal) -> UniswapQuote: # Create factory contract instance factory_contract = self._web3.eth.contract(address=self._factory, abi=UNISWAP_V2_FACTORY_ABI) @@ -93,14 +93,13 @@ def _get_token_price(self, token_out: TokenInfo, token_in: TokenInfo, amount_in: # Get V2 pair details - if reverse false, mid_price = token1_amount / token0_amount # token0 of the pair has the lowest address. Reverse if needed price = self._get_price_from_pool(pair_address=pair_address, token_out=token_out, token_in=token_in) - details = QuoteDetail( + return UniswapQuote( token_in=token_in, token_out=token_out, amount_in=amount_in, amount_out=price * amount_in, # TODO: substract fees? pool_address=pair_address, ) - return TokenPrice(quote_details=details) def _get_price_from_pool( self, *, pair_address: ChecksumAddress, token_out: TokenInfo, token_in: TokenInfo diff --git a/alphaswarm/services/exchanges/uniswap/uniswap_client_v3.py b/alphaswarm/services/exchanges/uniswap/uniswap_client_v3.py index 0ec31ea7..08194517 100644 --- a/alphaswarm/services/exchanges/uniswap/uniswap_client_v3.py +++ b/alphaswarm/services/exchanges/uniswap/uniswap_client_v3.py @@ -6,7 +6,7 @@ from alphaswarm.config import ChainConfig, Config, TokenInfo, UniswapV3Settings from alphaswarm.services.chains.evm import ZERO_ADDRESS, EVMClient, EVMContract, EVMSigner -from alphaswarm.services.exchanges.base import Slippage, TokenPrice +from alphaswarm.services.exchanges.base import Slippage from alphaswarm.services.exchanges.uniswap.constants_v3 import ( UNISWAP_V3_DEPLOYMENTS, UNISWAP_V3_FACTORY_ABI, @@ -14,7 +14,7 @@ UNISWAP_V3_ROUTER_ABI, UNISWAP_V3_VERSION, ) -from alphaswarm.services.exchanges.uniswap.uniswap_client_base import QuoteDetail, UniswapClientBase +from alphaswarm.services.exchanges.uniswap.uniswap_client_base import UniswapClientBase, UniswapQuote from eth_defi.uniswap_v3.pool import PoolDetails, fetch_pool_details from eth_defi.uniswap_v3.price import get_onchain_price from eth_typing import ChecksumAddress, HexAddress @@ -139,7 +139,7 @@ def _get_factory(self) -> ChecksumAddress: def _swap( self, - quote: QuoteDetail, + quote: UniswapQuote, slippage_bps: int, ) -> List[TxReceipt]: """Execute a swap on Uniswap V3.""" @@ -212,17 +212,16 @@ def _swap( return [approval_receipt, swap_receipt] - def _get_token_price(self, token_out: TokenInfo, token_in: TokenInfo, amount_in: Decimal) -> TokenPrice: + def _get_token_price(self, token_out: TokenInfo, token_in: TokenInfo, amount_in: Decimal) -> UniswapQuote: pool = self._get_pool(token_out, token_in) price = self._get_token_price_from_pool(token_out, pool) - details = QuoteDetail( + return UniswapQuote( token_in=token_in, token_out=token_out, amount_in=amount_in, amount_out=price * amount_in, # TODO: substract fees? pool_address=pool.address, ) - return TokenPrice(quote_details=details) @staticmethod def _get_token_price_from_pool(token_out: TokenInfo, pool: PoolContract) -> Decimal: diff --git a/alphaswarm/tools/exchanges/execute_token_swap_tool.py b/alphaswarm/tools/exchanges/execute_token_swap_tool.py index 31b0c4d9..d3445a5c 100644 --- a/alphaswarm/tools/exchanges/execute_token_swap_tool.py +++ b/alphaswarm/tools/exchanges/execute_token_swap_tool.py @@ -13,7 +13,7 @@ class ExecuteTokenSwapTool(Tool): """Tool for executing token swaps on supported DEXes.""" name = "execute_token_swap" - description = "Execute a token swap on a supported DEX (Uniswap V2/V3 on Ethereum and Base chains)." + description = "Execute a token swap on a supported DEX (Uniswap V2/V3 on Ethereum and Base chains). Returns the effective amount of token bought." inputs = { "quote": { "type": "object", diff --git a/alphaswarm/tools/exchanges/get_token_price_tool.py b/alphaswarm/tools/exchanges/get_token_price_tool.py index 5943ed9f..122af852 100644 --- a/alphaswarm/tools/exchanges/get_token_price_tool.py +++ b/alphaswarm/tools/exchanges/get_token_price_tool.py @@ -1,10 +1,12 @@ import logging from datetime import UTC, datetime from decimal import Decimal -from typing import List, Optional +from typing import List, Optional, Union from alphaswarm.config import Config -from alphaswarm.services.exchanges import DEXFactory, TokenPrice +from alphaswarm.services.exchanges import DEXFactory +from alphaswarm.services.exchanges.jupiter.jupiter import JupiterQuote +from alphaswarm.services.exchanges.uniswap.uniswap_client_base import UniswapQuote from pydantic.dataclasses import dataclass from smolagents import Tool @@ -16,7 +18,7 @@ class TokenQuote: datetime: str dex: str chain: str - quote: TokenPrice + quote: Union[UniswapQuote, JupiterQuote] @dataclass @@ -29,7 +31,7 @@ class GetTokenPriceTool(Tool): description = ( "Get the current price of a token pair from available DEXes. " f"Returns a list of {TokenQuote.__name__} object." - "Examples: 'Get the price of ETH in USDC on ethereum', 'Get the price of GIGA in SOL on solana'" + "Examples: 'Get the price of 1 ETH in USDC on ethereum', 'Get the price of 1 GIGA in SOL on solana'" ) inputs = { "token_out": { @@ -40,7 +42,7 @@ class GetTokenPriceTool(Tool): "type": "string", "description": "The address of the token we want to sell", }, - "amount_in": {"type": "number", "description": "The amount token_in to be sold", "required": True}, + "amount_in": {"type": "string", "description": "The amount token_in to be sold, in Token", "required": True}, "chain": { "type": "string", "description": "Blockchain to use. Must be 'solana' for Solana tokens, 'base' for Base tokens, 'ethereum' for Ethereum tokens, 'ethereum_sepolia' for Ethereum Sepolia tokens.", @@ -61,7 +63,7 @@ def forward( self, token_out: str, token_in: str, - amount_in: Decimal, + amount_in: str, chain: str, dex_type: Optional[str] = None, ) -> TokenPriceResult: @@ -82,10 +84,10 @@ def forward( try: dex = DEXFactory.create(dex_name=venue, config=self.config, chain=chain) - price = dex.get_token_price(token_out_info, token_in_info, amount_in=amount_in) + price = dex.get_token_price(token_out_info, token_in_info, amount_in=Decimal(amount_in)) timestamp = datetime.now(UTC).strftime("%Y-%m-%d %H:%M UTC") - prices.append(TokenQuote(dex=dex_type, chain=chain, quote=price, datetime=timestamp)) + prices.append(TokenQuote(dex=venue, chain=chain, quote=price, datetime=timestamp)) except Exception: logger.exception(f"Error getting price from {venue}") From ce9ecb744ad285e3eaa1a7cc680d7af69155f342 Mon Sep 17 00:00:00 2001 From: Guillaume Koch <39165367+gkoch78@users.noreply.github.com> Date: Wed, 12 Feb 2025 15:24:32 -0800 Subject: [PATCH 06/14] refactor QuoteResult and fix tests --- alphaswarm/services/exchanges/__init__.py | 4 +- alphaswarm/services/exchanges/base.py | 21 ++++--- .../services/exchanges/jupiter/jupiter.py | 18 ++++-- .../exchanges/uniswap/uniswap_client_base.py | 23 ++++---- .../exchanges/uniswap/uniswap_client_v2.py | 20 ++++--- .../exchanges/uniswap/uniswap_client_v3.py | 14 +++-- .../tools/exchanges/get_token_price_tool.py | 4 +- .../exchanges/jupiter/test_jupiter.py | 6 +- .../uniswap/test_uniswap_client_v2.py | 31 ++++++++++ .../uniswap/test_uniswap_client_v3.py | 58 +++++++++++-------- .../exchanges/test_execute_token_swap_tool.py | 25 +++++--- .../exchanges/test_get_token_price_tool.py | 32 +++++----- tests/unit/services/exchanges/factory.py | 13 ++--- 13 files changed, 168 insertions(+), 101 deletions(-) diff --git a/alphaswarm/services/exchanges/__init__.py b/alphaswarm/services/exchanges/__init__.py index 12b8e79e..b3f4d100 100644 --- a/alphaswarm/services/exchanges/__init__.py +++ b/alphaswarm/services/exchanges/__init__.py @@ -1,5 +1,5 @@ from .factory import DEXFactory -from .base import DEXClient, SwapResult, TokenPrice +from .base import DEXClient, SwapResult, QuoteResult from .uniswap import UniswapClientBase -__all__ = ["DEXFactory", "DEXClient", "SwapResult", "TokenPrice", "UniswapClientBase"] +__all__ = ["DEXFactory", "DEXClient", "SwapResult", "QuoteResult", "UniswapClientBase"] diff --git a/alphaswarm/services/exchanges/base.py b/alphaswarm/services/exchanges/base.py index 60a088d0..21ed592d 100644 --- a/alphaswarm/services/exchanges/base.py +++ b/alphaswarm/services/exchanges/base.py @@ -8,12 +8,18 @@ from alphaswarm.config import ChainConfig, Config, TokenInfo from hexbytes import HexBytes +T = TypeVar("T", bound="DEXClient") TQuote = TypeVar("TQuote") @dataclass -class TokenPrice: - quote_details: Any +class QuoteResult(Generic[TQuote]): + quote: TQuote + + token_in: TokenInfo + token_out: TokenInfo + amount_in: Decimal + amount_out: Decimal @dataclass @@ -73,9 +79,6 @@ def __repr__(self) -> str: return f"Slippage(bps={self.bps})" -T = TypeVar("T", bound="DEXClient") - - class DEXClient(Generic[TQuote], ABC): """Base class for DEX clients""" @@ -94,7 +97,7 @@ def chain_config(self) -> ChainConfig: return self._chain_config @abstractmethod - def get_token_price(self, token_out: TokenInfo, token_in: TokenInfo, amount_in: Decimal) -> TQuote: + def get_token_price(self, token_out: TokenInfo, token_in: TokenInfo, amount_in: Decimal) -> QuoteResult[TQuote]: """Get price/conversion rate for the pair of tokens. The price is returned in terms of token_out/token_in (how much token out per token in). @@ -115,7 +118,7 @@ def get_token_price(self, token_out: TokenInfo, token_in: TokenInfo, amount_in: @abstractmethod def swap( self, - quote: TQuote, + quote: QuoteResult[TQuote], slippage_bps: int = 100, ) -> SwapResult: """Execute a token swap on the DEX @@ -165,5 +168,5 @@ def raise_if_not_quote(self, value: Any) -> None: if self.is_quote(value): raise TypeError(f"Expected {self._quote_type} but got {type(value)}") - def is_quote(self, value: Any) -> TypeGuard[TQuote]: - return isinstance(value, self._quote_type) + def is_quote(self, value: Any) -> TypeGuard[QuoteResult[TQuote]]: + return isinstance(value, QuoteResult) and isinstance(value.quote, self._quote_type) diff --git a/alphaswarm/services/exchanges/jupiter/jupiter.py b/alphaswarm/services/exchanges/jupiter/jupiter.py index 2299b946..a1a2fc74 100644 --- a/alphaswarm/services/exchanges/jupiter/jupiter.py +++ b/alphaswarm/services/exchanges/jupiter/jupiter.py @@ -8,7 +8,7 @@ import requests from alphaswarm.config import ChainConfig, Config, JupiterSettings, JupiterVenue, TokenInfo from alphaswarm.services import ApiException -from alphaswarm.services.exchanges.base import DEXClient, SwapResult +from alphaswarm.services.exchanges.base import DEXClient, QuoteResult, SwapResult from pydantic import BaseModel, Field from pydantic.dataclasses import dataclass @@ -61,12 +61,14 @@ def _validate_chain(self, chain: str) -> None: def swap( self, - quote: JupiterQuote, + quote: QuoteResult[JupiterQuote], slippage_bps: int = 100, ) -> SwapResult: raise NotImplementedError("Jupiter swap functionality is not yet implemented") - def get_token_price(self, token_out: TokenInfo, token_in: TokenInfo, amount_in: Decimal) -> JupiterQuote: + def get_token_price( + self, token_out: TokenInfo, token_in: TokenInfo, amount_in: Decimal + ) -> QuoteResult[JupiterQuote]: # Verify tokens are on Solana if not token_out.chain == self.chain or not token_in.chain == self.chain: raise ValueError(f"Jupiter only supports Solana tokens. Got {token_out.chain} and {token_in.chain}") @@ -78,7 +80,7 @@ def get_token_price(self, token_out: TokenInfo, token_in: TokenInfo, amount_in: "inputMint": token_in.address, "outputMint": token_out.address, "swapMode": "ExactIn", - "amount": str(token_in.convert_to_wei(Decimal(1))), # Get price spending exactly 1 token_in + "amount": str(token_in.convert_to_wei(amount_in)), "slippageBps": self._settings.slippage_bps, } @@ -101,7 +103,13 @@ def get_token_price(self, token_out: TokenInfo, token_in: TokenInfo, amount_in: logger.debug(f"- Price: {price} {token_out.symbol}/{token_in.symbol}") logger.debug(f"- Route: {quote.route_plan}") - return quote + return QuoteResult( + quote=quote, + token_in=token_in, + token_out=token_out, + amount_in=amount_in, + amount_out=price, + ) def get_markets_for_tokens(self, tokens: List[TokenInfo]) -> List[Tuple[TokenInfo, TokenInfo]]: """Get list of valid trading pairs between the provided tokens. diff --git a/alphaswarm/services/exchanges/uniswap/uniswap_client_base.py b/alphaswarm/services/exchanges/uniswap/uniswap_client_base.py index 7424c934..5b1a43dc 100644 --- a/alphaswarm/services/exchanges/uniswap/uniswap_client_base.py +++ b/alphaswarm/services/exchanges/uniswap/uniswap_client_base.py @@ -5,20 +5,17 @@ from alphaswarm.config import ChainConfig, TokenInfo from alphaswarm.services.chains.evm import ERC20Contract, EVMClient, EVMSigner -from alphaswarm.services.exchanges.base import DEXClient, SwapResult +from alphaswarm.services.exchanges.base import DEXClient, QuoteResult, SwapResult from eth_typing import ChecksumAddress, HexAddress -from pydantic import BaseModel +from pydantic.dataclasses import dataclass from web3.types import TxReceipt # Set up logger logger = logging.getLogger(__name__) -class UniswapQuote(BaseModel): - token_in: TokenInfo - token_out: TokenInfo - amount_in: Decimal - amount_out: Decimal +@dataclass +class UniswapQuote: pool_address: ChecksumAddress @@ -52,13 +49,15 @@ def _get_factory(self) -> ChecksumAddress: def _swap( self, *, - quote: UniswapQuote, + quote: QuoteResult[UniswapQuote], slippage_bps: int, ) -> List[TxReceipt]: pass @abstractmethod - def _get_token_price(self, token_out: TokenInfo, token_in: TokenInfo, amount_in: Decimal) -> UniswapQuote: + def _get_token_price( + self, token_out: TokenInfo, token_in: TokenInfo, amount_in: Decimal + ) -> QuoteResult[UniswapQuote]: pass @abstractmethod @@ -107,7 +106,7 @@ def _get_final_swap_amount_received( def swap( self, - quote: UniswapQuote, + quote: QuoteResult[UniswapQuote], slippage_bps: int = 100, ) -> SwapResult: # Create contract instances @@ -175,7 +174,9 @@ def _approve_token_spending(self, token: TokenInfo, raw_amount: int) -> TxReceip tx_receipt = token_contract.approve(self.get_signer(), self._router, raw_amount) return tx_receipt - def get_token_price(self, token_out: TokenInfo, token_in: TokenInfo, amount_in: Decimal) -> UniswapQuote: + def get_token_price( + self, token_out: TokenInfo, token_in: TokenInfo, amount_in: Decimal + ) -> QuoteResult[UniswapQuote]: logger.debug( f"Getting price for {token_out.symbol}/{token_in.symbol} on {self.chain} using Uniswap {self.version}" ) diff --git a/alphaswarm/services/exchanges/uniswap/uniswap_client_v2.py b/alphaswarm/services/exchanges/uniswap/uniswap_client_v2.py index b7ae19e6..a4ac353c 100644 --- a/alphaswarm/services/exchanges/uniswap/uniswap_client_v2.py +++ b/alphaswarm/services/exchanges/uniswap/uniswap_client_v2.py @@ -6,7 +6,7 @@ from alphaswarm.config import ChainConfig, Config, TokenInfo from alphaswarm.services.chains.evm import ZERO_ADDRESS -from alphaswarm.services.exchanges.base import Slippage +from alphaswarm.services.exchanges.base import QuoteResult, Slippage from alphaswarm.services.exchanges.uniswap.constants_v2 import ( UNISWAP_V2_DEPLOYMENTS, UNISWAP_V2_FACTORY_ABI, @@ -34,7 +34,7 @@ def _get_factory(self) -> ChecksumAddress: def _swap( self, - quote: UniswapQuote, + quote: QuoteResult[UniswapQuote], slippage_bps: int, ) -> List[TxReceipt]: """Execute a swap on Uniswap V2.""" @@ -42,7 +42,7 @@ def _swap( token_in = quote.token_in token_out = quote.token_out amount_in = quote.amount_in - pool_address = quote.pool_address + pool_address = quote.quote.pool_address wei_in = token_in.convert_to_wei(amount_in) approval_receipt = self._approve_token_spending(token_in, wei_in) @@ -79,7 +79,9 @@ def _swap( swap_receipt = self._evm_client.process(swap, self.get_signer()) return [approval_receipt, swap_receipt] - def _get_token_price(self, token_out: TokenInfo, token_in: TokenInfo, amount_in: Decimal) -> UniswapQuote: + def _get_token_price( + self, token_out: TokenInfo, token_in: TokenInfo, amount_in: Decimal + ) -> QuoteResult[UniswapQuote]: # Create factory contract instance factory_contract = self._web3.eth.contract(address=self._factory, abi=UNISWAP_V2_FACTORY_ABI) @@ -93,14 +95,14 @@ def _get_token_price(self, token_out: TokenInfo, token_in: TokenInfo, amount_in: # Get V2 pair details - if reverse false, mid_price = token1_amount / token0_amount # token0 of the pair has the lowest address. Reverse if needed price = self._get_price_from_pool(pair_address=pair_address, token_out=token_out, token_in=token_in) - return UniswapQuote( - token_in=token_in, - token_out=token_out, - amount_in=amount_in, - amount_out=price * amount_in, # TODO: substract fees? + quote = UniswapQuote( pool_address=pair_address, ) + return QuoteResult( + quote=quote, token_in=token_in, token_out=token_out, amount_in=amount_in, amount_out=price * amount_in + ) + def _get_price_from_pool( self, *, pair_address: ChecksumAddress, token_out: TokenInfo, token_in: TokenInfo ) -> Decimal: diff --git a/alphaswarm/services/exchanges/uniswap/uniswap_client_v3.py b/alphaswarm/services/exchanges/uniswap/uniswap_client_v3.py index 08194517..d7641719 100644 --- a/alphaswarm/services/exchanges/uniswap/uniswap_client_v3.py +++ b/alphaswarm/services/exchanges/uniswap/uniswap_client_v3.py @@ -6,7 +6,7 @@ from alphaswarm.config import ChainConfig, Config, TokenInfo, UniswapV3Settings from alphaswarm.services.chains.evm import ZERO_ADDRESS, EVMClient, EVMContract, EVMSigner -from alphaswarm.services.exchanges.base import Slippage +from alphaswarm.services.exchanges.base import QuoteResult, Slippage from alphaswarm.services.exchanges.uniswap.constants_v3 import ( UNISWAP_V3_DEPLOYMENTS, UNISWAP_V3_FACTORY_ABI, @@ -139,7 +139,7 @@ def _get_factory(self) -> ChecksumAddress: def _swap( self, - quote: UniswapQuote, + quote: QuoteResult[UniswapQuote], slippage_bps: int, ) -> List[TxReceipt]: """Execute a swap on Uniswap V3.""" @@ -152,7 +152,7 @@ def _swap( approval_receipt = self._approve_token_spending(token_in, wei_in) # Build a swap transaction - pool = self._get_pool_by_address(quote.pool_address) + pool = self._get_pool_by_address(quote.quote.pool_address) logger.info(f"Using Uniswap V3 pool at address: {pool.address} (raw fee tier: {pool.raw_fee})") # Get the on-chain price from the pool and reverse if necessary @@ -212,15 +212,17 @@ def _swap( return [approval_receipt, swap_receipt] - def _get_token_price(self, token_out: TokenInfo, token_in: TokenInfo, amount_in: Decimal) -> UniswapQuote: + def _get_token_price( + self, token_out: TokenInfo, token_in: TokenInfo, amount_in: Decimal + ) -> QuoteResult[UniswapQuote]: pool = self._get_pool(token_out, token_in) price = self._get_token_price_from_pool(token_out, pool) - return UniswapQuote( + return QuoteResult( token_in=token_in, token_out=token_out, amount_in=amount_in, amount_out=price * amount_in, # TODO: substract fees? - pool_address=pool.address, + quote=UniswapQuote(pool_address=pool.address), ) @staticmethod diff --git a/alphaswarm/tools/exchanges/get_token_price_tool.py b/alphaswarm/tools/exchanges/get_token_price_tool.py index 122af852..054fd3d1 100644 --- a/alphaswarm/tools/exchanges/get_token_price_tool.py +++ b/alphaswarm/tools/exchanges/get_token_price_tool.py @@ -4,7 +4,7 @@ from typing import List, Optional, Union from alphaswarm.config import Config -from alphaswarm.services.exchanges import DEXFactory +from alphaswarm.services.exchanges import DEXFactory, QuoteResult from alphaswarm.services.exchanges.jupiter.jupiter import JupiterQuote from alphaswarm.services.exchanges.uniswap.uniswap_client_base import UniswapQuote from pydantic.dataclasses import dataclass @@ -18,7 +18,7 @@ class TokenQuote: datetime: str dex: str chain: str - quote: Union[UniswapQuote, JupiterQuote] + quote: QuoteResult[Union[UniswapQuote, JupiterQuote]] @dataclass diff --git a/tests/integration/services/exchanges/jupiter/test_jupiter.py b/tests/integration/services/exchanges/jupiter/test_jupiter.py index 889ec749..d6342b30 100644 --- a/tests/integration/services/exchanges/jupiter/test_jupiter.py +++ b/tests/integration/services/exchanges/jupiter/test_jupiter.py @@ -1,3 +1,5 @@ +from decimal import Decimal + from alphaswarm.config import Config from alphaswarm.services.exchanges.jupiter.jupiter import JupiterClient @@ -12,5 +14,5 @@ def test_get_token_price(default_config: Config) -> None: giga = tokens_config["GIGA"] sol = tokens_config["SOL"] - quote = client.get_token_price(giga, sol) - assert quote.price > 1000, "A Sol is worth many thousands of GIGA." + quote = client.get_token_price(token_out=giga, token_in=sol, amount_in=Decimal(1)) + assert 10000 > quote.amount_out > 1000, "A Sol is worth many thousands of GIGA." diff --git a/tests/integration/services/exchanges/uniswap/test_uniswap_client_v2.py b/tests/integration/services/exchanges/uniswap/test_uniswap_client_v2.py index 9b08a04f..bce721da 100644 --- a/tests/integration/services/exchanges/uniswap/test_uniswap_client_v2.py +++ b/tests/integration/services/exchanges/uniswap/test_uniswap_client_v2.py @@ -1,3 +1,7 @@ +from decimal import Decimal + +import pytest + from alphaswarm.config import Config from alphaswarm.services.exchanges import DEXFactory from alphaswarm.services.exchanges.uniswap import UniswapClientV2 @@ -27,3 +31,30 @@ def test_get_markets_for_tokens_v2(default_config: Config) -> None: assert {base_token.symbol, quote_token.symbol} == {"USDC", "WETH"} assert base_token.chain == chain assert quote_token.chain == chain + + +@pytest.fixture +def client(default_config: Config, chain: str) -> UniswapClientV2: + return UniswapClientV2.from_config(default_config, chain) + + +chains = [ + "ethereum", + "ethereum_sepolia", + "base", + # "base_sepolia", +] + + +@pytest.mark.skip("Need a funded wallet.") +@pytest.mark.parametrize("chain", chains) +def test_swap_eth_sepolia(client: UniswapClientV2, chain: str) -> None: + usdc = client.chain_config.get_token_info("USDC") + weth = client.chain_config.get_token_info("WETH") + + quote = client.get_token_price(token_out=usdc, token_in=weth, amount_in=Decimal("0.0001")) + assert quote.amount_out > quote.amount_in + + result = client.swap(quote) + print(result) + assert result.amount_out == pytest.approx(quote.amount_out, rel=Decimal("0.05")) diff --git a/tests/integration/services/exchanges/uniswap/test_uniswap_client_v3.py b/tests/integration/services/exchanges/uniswap/test_uniswap_client_v3.py index f09a6f62..7adb0d54 100644 --- a/tests/integration/services/exchanges/uniswap/test_uniswap_client_v3.py +++ b/tests/integration/services/exchanges/uniswap/test_uniswap_client_v3.py @@ -20,21 +20,6 @@ def eth_client(default_config: Config) -> UniswapClientV3: return UniswapClientV3(chain_config=chain_config, settings=default_config.get_venue_settings_uniswap_v3()) -@pytest.fixture -def eth_sepolia_client(default_config: Config) -> UniswapClientV3: - chain_config = default_config.get_chain_config(chain="ethereum_sepolia") - return UniswapClientV3(chain_config=chain_config, settings=default_config.get_venue_settings_uniswap_v3()) - - -def test_get_price(base_client: UniswapClientV3) -> None: - usdc = base_client.chain_config.get_token_info("USDC") - weth = base_client.chain_config.get_token_info("WETH") - quote_usdc_per_weth = base_client.get_token_price(token_out=usdc, token_in=weth) - - print(f"1 {weth.symbol} is {quote_usdc_per_weth.price} {usdc.symbol}") - assert quote_usdc_per_weth.price > 1000, "A WETH is worth many thousands of USDC" - - def test_quote_from_pool(base_client: UniswapClientV3) -> None: pool = base_client._get_pool_by_address(BASE_WETH_USDC_005) usdc: TokenInfo = base_client.chain_config.get_token_info("USDC") @@ -90,17 +75,40 @@ def test_get_markets_for_tokens(eth_client: UniswapClientV3) -> None: assert quote_token.chain == eth_client.chain -@pytest.mark.skip("Needs a wallet with USDC to perform the swap to WETH. Run manually") -def test_swap_eth_sepolia(eth_sepolia_client: UniswapClientV3) -> None: - usdc = eth_sepolia_client.chain_config.get_token_info("USDC") - weth = eth_sepolia_client.chain_config.get_token_info("WETH") +@pytest.fixture +def client(default_config: Config, chain: str) -> UniswapClientV3: + return UniswapClientV3.from_config(default_config, chain) + + +chains = [ + "ethereum", + "ethereum_sepolia", + "base", + # "base_sepolia", +] + + +@pytest.mark.parametrize("chain", chains) +def test_quote_weth_to_usdc(client: UniswapClientV3, chain: str) -> None: + usdc = client.chain_config.get_token_info("USDC") + weth = client.chain_config.get_token_info("WETH") + quote = client.get_token_price(token_out=usdc, token_in=weth, amount_in=Decimal("0.01")) + print(quote) + assert 10_000 > quote.amount_out > 10 + - pool = eth_sepolia_client._get_pool(usdc, weth) - print(f"find pool {pool.address}") +@pytest.mark.skip("Need a funded wallet.") +@pytest.mark.parametrize("chain", chains) +def test_swap_weth_to_usdc(client: UniswapClientV3, chain: str) -> None: + usdc = client.chain_config.get_token_info("USDC") + weth = client.chain_config.get_token_info("WETH") + amount_in = Decimal("0.0001") - quote = eth_sepolia_client._get_token_price_from_pool(token_out=weth, pool=pool) - print(f"1 {usdc.symbol} is {quote} {weth.symbol}") + quote = client.get_token_price(token_out=usdc, token_in=weth, amount_in=amount_in) + assert quote.amount_out > amount_in, "1 USDC is worth a fraction of WETH" - # Buy X Weth for 1 USDC - result = eth_sepolia_client.swap(token_out=weth, token_in=usdc, amount_in=Decimal(100), pool=pool.address) + result = client.swap(quote) print(result) + assert result.success + assert result.amount_in == amount_in + assert result.amount_out == pytest.approx(quote.amount_out, rel=Decimal("0.05")) diff --git a/tests/integration/tools/exchanges/test_execute_token_swap_tool.py b/tests/integration/tools/exchanges/test_execute_token_swap_tool.py index da03aa17..fe90431a 100644 --- a/tests/integration/tools/exchanges/test_execute_token_swap_tool.py +++ b/tests/integration/tools/exchanges/test_execute_token_swap_tool.py @@ -4,7 +4,12 @@ from alphaswarm.config import Config from alphaswarm.services.chains import EVMClient -from alphaswarm.tools.exchanges import ExecuteTokenSwapTool +from alphaswarm.tools.exchanges import ExecuteTokenSwapTool, GetTokenPriceTool + + +@pytest.fixture +def token_quote_tool(default_config: Config) -> GetTokenPriceTool: + return GetTokenPriceTool(default_config) @pytest.fixture @@ -18,16 +23,22 @@ def sepolia_client(default_config: Config) -> EVMClient: @pytest.mark.skip("Requires a founded wallet. Run manually") -def test_token_swap_tool(token_swap_tool: ExecuteTokenSwapTool, sepolia_client: EVMClient) -> None: +def test_token_swap_tool( + token_quote_tool: GetTokenPriceTool, token_swap_tool: ExecuteTokenSwapTool, sepolia_client: EVMClient +) -> None: weth = sepolia_client.get_token_info_by_name("WETH") usdc = sepolia_client.get_token_info_by_name("USDC") - pool_address = "0x3289680dD4d6C10bb19b899729cda5eEF58AEfF1" - result = token_swap_tool.forward( + amount_in = Decimal(10) + + quotes = token_quote_tool.forward( token_out=weth.address, token_in=usdc.address, - amount_in=Decimal(1), - chain="ethereum_sepolia", - pool=pool_address, + amount_in=str(amount_in), + chain=sepolia_client.chain, dex_type="uniswap_v3", ) + assert len(quotes.quotes) == 1 + result = token_swap_tool.forward(quote=quotes.quotes[0]) print(result) + assert result.success + assert result.amount_out < amount_in diff --git a/tests/integration/tools/exchanges/test_get_token_price_tool.py b/tests/integration/tools/exchanges/test_get_token_price_tool.py index 577b5c60..730aac3f 100644 --- a/tests/integration/tools/exchanges/test_get_token_price_tool.py +++ b/tests/integration/tools/exchanges/test_get_token_price_tool.py @@ -7,27 +7,29 @@ @pytest.mark.parametrize( - "dex,chain,token_out,token_in,ratio", + "dex,chain,token_out,token_in,min_out,max_out", [ - ("jupiter", "solana", "GIGA", "SOL", 1000), - ("uniswap_v3", "base", "VIRTUAL", "WETH", 1000), - ("uniswap_v3", "ethereum_sepolia", "USDC", "WETH", 100), - ("uniswap_v3", "ethereum", "USDC", "WETH", 100), - ("uniswap_v2", "ethereum", "USDC", "WETH", 100), - (None, "ethereum", "USDC", "WETH", 100), + ("jupiter", "solana", "GIGA", "SOL", 1_000, 10_000), + ("uniswap_v3", "base", "VIRTUAL", "WETH", 1_000, 10_000), + ("uniswap_v3", "ethereum_sepolia", "USDC", "WETH", 10_000, 1_000_000), + ("uniswap_v3", "ethereum", "USDC", "WETH", 100, 10_000), + ("uniswap_v2", "ethereum", "USDC", "WETH", 100, 10_000), + (None, "ethereum", "USDC", "WETH", 100, 10_000), ], ) def test_get_token_price_tool( - dex: Optional[str], chain: str, token_out: str, token_in: str, ratio: int, default_config: Config + dex: Optional[str], chain: str, token_out: str, token_in: str, min_out: int, max_out: int, default_config: Config ) -> None: config = default_config tool = GetTokenPriceTool(config) - chaing_config = config.get_chain_config(chain) - token_info_out = chaing_config.get_token_info(token_out) - token_info_in = chaing_config.get_token_info(token_in) - result = tool.forward(token_out=token_info_out.address, token_in=token_info_in.address, dex_type=dex, chain=chain) + chain_config = config.get_chain_config(chain) + token_info_out = chain_config.get_token_info(token_out) + token_info_in = chain_config.get_token_info(token_in) + result = tool.forward( + token_out=token_info_out.address, token_in=token_info_in.address, amount_in="1", dex_type=dex, chain=chain + ) - assert len(result.prices) > 0, "at least one price is expected" - item = result.prices[0] - assert item.price > ratio, f"1 {token_in} is > {ratio} ({token_out}), got {item.price}" + assert len(result.quotes) > 0, "at least one price is expected" + item = result.quotes[0] + assert min_out < item.quote.amount_out < max_out diff --git a/tests/unit/services/exchanges/factory.py b/tests/unit/services/exchanges/factory.py index 5579f867..307c4fbe 100644 --- a/tests/unit/services/exchanges/factory.py +++ b/tests/unit/services/exchanges/factory.py @@ -5,20 +5,17 @@ import pytest from alphaswarm.config import Config, TokenInfo, ChainConfig -from alphaswarm.services.exchanges import DEXClient, DEXFactory, SwapResult, TokenPrice +from alphaswarm.services.exchanges import DEXClient, DEXFactory, QuoteResult, SwapResult -class MockDex(DEXClient): +class MockDex(DEXClient[str]): @classmethod def from_config(cls, config: Config, chain: str) -> MockDex: return MockDex(chain_config=config.get_chain_config(chain)) def swap( self, - token_out: TokenInfo, - token_in: TokenInfo, - amount_in: Decimal, - pool: str, + quote: QuoteResult[str], slippage_bps: int = 100, ) -> SwapResult: raise NotImplementedError("For test only") @@ -26,11 +23,11 @@ def swap( def get_markets_for_tokens(self, tokens: List[TokenInfo]) -> List[Tuple[TokenInfo, TokenInfo]]: raise NotImplementedError("For test only") - def get_token_price(self, token_out: TokenInfo, token_in: TokenInfo) -> TokenPrice: + def get_token_price(self, token_out: TokenInfo, token_in: TokenInfo, amount_in: Decimal) -> QuoteResult[str]: raise NotImplementedError("For test only") def __init__(self, chain_config: ChainConfig) -> None: - super().__init__(chain_config=chain_config) + super().__init__(chain_config=chain_config, quote_type=str) def test_register(default_config: Config) -> None: From 403c64dfe463402d7a7448a12a434dbda3884fc2 Mon Sep 17 00:00:00 2001 From: Guillaume Koch <39165367+gkoch78@users.noreply.github.com> Date: Wed, 12 Feb 2025 15:34:59 -0800 Subject: [PATCH 07/14] Update execute_token_swap_tool.py --- alphaswarm/tools/exchanges/execute_token_swap_tool.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/alphaswarm/tools/exchanges/execute_token_swap_tool.py b/alphaswarm/tools/exchanges/execute_token_swap_tool.py index d3445a5c..fb26a925 100644 --- a/alphaswarm/tools/exchanges/execute_token_swap_tool.py +++ b/alphaswarm/tools/exchanges/execute_token_swap_tool.py @@ -13,7 +13,8 @@ class ExecuteTokenSwapTool(Tool): """Tool for executing token swaps on supported DEXes.""" name = "execute_token_swap" - description = "Execute a token swap on a supported DEX (Uniswap V2/V3 on Ethereum and Base chains). Returns the effective amount of token bought." + description = ("Execute a token swap on a supported DEX (Uniswap V2/V3 on Ethereum and Base chains). " + f"Returns a {SwapResult.__name__} details of the transaction.") inputs = { "quote": { "type": "object", From d7adaeeeb872c2f2eaeb027b93a787ad44188771 Mon Sep 17 00:00:00 2001 From: Guillaume Koch <39165367+gkoch78@users.noreply.github.com> Date: Wed, 12 Feb 2025 15:40:45 -0800 Subject: [PATCH 08/14] Update constants_v2.py --- alphaswarm/services/exchanges/uniswap/constants_v2.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/alphaswarm/services/exchanges/uniswap/constants_v2.py b/alphaswarm/services/exchanges/uniswap/constants_v2.py index 49a65e6b..8f3ccb3f 100644 --- a/alphaswarm/services/exchanges/uniswap/constants_v2.py +++ b/alphaswarm/services/exchanges/uniswap/constants_v2.py @@ -90,6 +90,10 @@ "factory": "0x5C69bEe701ef814a2B6a3EDD4B1652CB9cc5aA6f", "router": "0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D", }, + "ethereum_sepolia": { + "factory": "0xF62c03E08ada871A0bEb309762E260a7a6a880E6", + "router": "0xeE567Fe1712Faf6149d80dA1E6934E354124CfE3", + }, "base": { "factory": "0x8909Dc15e40173Ff4699343b6eB8132c65e18eC6", "router": "0x4752ba5dbc23f44d87826276bf6fd6b1c372ad24", From 8c5ab95913f234a733970485d3e6b70136239977 Mon Sep 17 00:00:00 2001 From: Guillaume Koch <39165367+gkoch78@users.noreply.github.com> Date: Wed, 12 Feb 2025 15:51:06 -0800 Subject: [PATCH 09/14] clean up --- alphaswarm/services/exchanges/jupiter/jupiter.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/alphaswarm/services/exchanges/jupiter/jupiter.py b/alphaswarm/services/exchanges/jupiter/jupiter.py index a1a2fc74..f8eef053 100644 --- a/alphaswarm/services/exchanges/jupiter/jupiter.py +++ b/alphaswarm/services/exchanges/jupiter/jupiter.py @@ -73,7 +73,7 @@ def get_token_price( if not token_out.chain == self.chain or not token_in.chain == self.chain: raise ValueError(f"Jupiter only supports Solana tokens. Got {token_out.chain} and {token_in.chain}") - logger.debug(f"Getting price for {token_out.symbol}/{token_in.symbol} on {token_out.chain} using Jupiter") + logger.debug(f"Getting amount_out for {token_out.symbol}/{token_in.symbol} on {token_out.chain} using Jupiter") # Prepare query parameters params = { @@ -93,14 +93,14 @@ def get_token_price( result = response.json() quote = JupiterQuote(**result) - # Calculate price (token_out per token_in) - amount_out = quote.out_amount - price = token_out.convert_from_wei(amount_out) + # Calculate amount_out (token_out per token_in) + raw_out = quote.out_amount + amount_out = token_out.convert_from_wei(raw_out) # Log quote details logger.debug("Quote successful:") - logger.debug(f"- Input: 1 {token_in.symbol}") - logger.debug(f"- Output: {amount_out} {token_out.symbol} lamports") - logger.debug(f"- Price: {price} {token_out.symbol}/{token_in.symbol}") + logger.debug(f"- Input: {amount_in} {token_in.symbol}") + logger.debug(f"- Output: {amount_out} {token_out.symbol}") + logger.debug(f"- Ratio: {amount_out/amount_in} {token_out.symbol}/{token_in.symbol}") logger.debug(f"- Route: {quote.route_plan}") return QuoteResult( @@ -108,7 +108,7 @@ def get_token_price( token_in=token_in, token_out=token_out, amount_in=amount_in, - amount_out=price, + amount_out=amount_out, ) def get_markets_for_tokens(self, tokens: List[TokenInfo]) -> List[Tuple[TokenInfo, TokenInfo]]: From 6df2def18543f73f1e22cbd9648c59920aa2c846 Mon Sep 17 00:00:00 2001 From: Guillaume Koch <39165367+gkoch78@users.noreply.github.com> Date: Wed, 12 Feb 2025 15:51:35 -0800 Subject: [PATCH 10/14] lint --- alphaswarm/tools/exchanges/execute_token_swap_tool.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/alphaswarm/tools/exchanges/execute_token_swap_tool.py b/alphaswarm/tools/exchanges/execute_token_swap_tool.py index dd1aff2b..6aadda33 100644 --- a/alphaswarm/tools/exchanges/execute_token_swap_tool.py +++ b/alphaswarm/tools/exchanges/execute_token_swap_tool.py @@ -13,8 +13,10 @@ class ExecuteTokenSwapTool(Tool): """Tool for executing token swaps on supported DEXes.""" name = "execute_token_swap" - description = ("Execute a token swap on a supported DEX (Uniswap V2/V3 on Ethereum and Base chains). " - f"Returns a {SwapResult.__name__} details of the transaction.") + description = ( + "Execute a token swap on a supported DEX (Uniswap V2/V3 on Ethereum and Base chains). " + f"Returns a {SwapResult.__name__} details of the transaction." + ) inputs = { "quote": { "type": "object", From 40541562cebb90f96ef185d64ec978907a628d6f Mon Sep 17 00:00:00 2001 From: Guillaume Koch <39165367+gkoch78@users.noreply.github.com> Date: Wed, 12 Feb 2025 16:08:47 -0800 Subject: [PATCH 11/14] clean up --- .../exchanges/uniswap/uniswap_client_v2.py | 14 ++------------ .../exchanges/uniswap/uniswap_client_v3.py | 17 ++--------------- 2 files changed, 4 insertions(+), 27 deletions(-) diff --git a/alphaswarm/services/exchanges/uniswap/uniswap_client_v2.py b/alphaswarm/services/exchanges/uniswap/uniswap_client_v2.py index a4ac353c..97326c64 100644 --- a/alphaswarm/services/exchanges/uniswap/uniswap_client_v2.py +++ b/alphaswarm/services/exchanges/uniswap/uniswap_client_v2.py @@ -41,23 +41,13 @@ def _swap( # Handle token approval and get fresh nonce token_in = quote.token_in token_out = quote.token_out - amount_in = quote.amount_in - pool_address = quote.quote.pool_address - wei_in = token_in.convert_to_wei(amount_in) + wei_in = token_in.convert_to_wei(quote.amount_out) approval_receipt = self._approve_token_spending(token_in, wei_in) - # Get price from V2 pair to calculate minimum output - price = self._get_price_from_pool(pair_address=pool_address, token_out=token_out, token_in=token_in) - - # Calculate expected output - input_amount_decimal = token_in.convert_from_wei(wei_in) - expected_output_decimal = input_amount_decimal * price - logger.info(f"Expected output: {expected_output_decimal} {token_out.symbol}") - # Convert expected output to raw integer and apply slippage slippage = Slippage(slippage_bps) - min_output_raw = slippage.calculate_minimum_amount(token_out.convert_to_wei(expected_output_decimal)) + min_output_raw = slippage.calculate_minimum_amount(token_out.convert_to_wei(quote.amount_out)) logger.info(f"Minimum output with {slippage} slippage (raw): {min_output_raw}") # Build swap path diff --git a/alphaswarm/services/exchanges/uniswap/uniswap_client_v3.py b/alphaswarm/services/exchanges/uniswap/uniswap_client_v3.py index d7641719..d31e3583 100644 --- a/alphaswarm/services/exchanges/uniswap/uniswap_client_v3.py +++ b/alphaswarm/services/exchanges/uniswap/uniswap_client_v3.py @@ -147,28 +147,15 @@ def _swap( token_in = quote.token_in token_out = quote.token_out - amount_in = quote.amount_in - wei_in = token_in.convert_to_wei(amount_in) + wei_in = token_in.convert_to_wei(quote.amount_out) approval_receipt = self._approve_token_spending(token_in, wei_in) # Build a swap transaction pool = self._get_pool_by_address(quote.quote.pool_address) logger.info(f"Using Uniswap V3 pool at address: {pool.address} (raw fee tier: {pool.raw_fee})") - # Get the on-chain price from the pool and reverse if necessary - price = self._get_token_price_from_pool(token_out, pool) - logger.info(f"Pool raw price: {price} ({token_out.symbol} per {token_in.symbol})") - - # Convert to decimal for calculations - amount_in_decimal = token_in.convert_from_wei(wei_in) - logger.info(f"Actual input amount: {amount_in_decimal} {token_in.symbol}") - - # Calculate expected output - expected_output_decimal = amount_in_decimal * price - logger.info(f"Expected output: {expected_output_decimal} {token_out.symbol}") - # Convert expected output to raw integer - raw_output = token_out.convert_to_wei(expected_output_decimal) + raw_output = token_out.convert_to_wei(quote.amount_out) logger.info(f"Expected output amount (raw): {raw_output}") # Calculate price impact From 08cb016f205d69e784e1fabd0835e36450386415 Mon Sep 17 00:00:00 2001 From: Guillaume Koch <39165367+gkoch78@users.noreply.github.com> Date: Wed, 12 Feb 2025 16:28:27 -0800 Subject: [PATCH 12/14] Update execute_token_swap_tool.py --- .../tools/exchanges/execute_token_swap_tool.py | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/alphaswarm/tools/exchanges/execute_token_swap_tool.py b/alphaswarm/tools/exchanges/execute_token_swap_tool.py index 6aadda33..97b36304 100644 --- a/alphaswarm/tools/exchanges/execute_token_swap_tool.py +++ b/alphaswarm/tools/exchanges/execute_token_swap_tool.py @@ -44,15 +44,10 @@ def forward( # Create DEX client dex_client = DEXFactory.create(dex_name=quote.dex, config=self.config, chain=quote.chain) - # Get wallet address and private key from chain config - # chain_config = self.config.get_chain_config(quote.chain) - # token_in_info = chain_config.get_token_info_by_address(token_in) - # token_out_info = chain_config.get_token_info_by_address(token_out) - - # Log token details - # logger.info( - # f"Swapping {amount_in} {token_in_info.symbol} ({token_in_info.address}) for {token_out_info.symbol} ({token_out_info.address}) on {chain}" - # ) + inner = quote.quote + logger.info( + f"Swapping {inner.amount_in} {inner.token_in.symbol} ({inner.token_in.address}) for {inner.token_out.symbol} ({inner.token_out.address}) on {quote.chain}" + ) # Execute swap return dex_client.swap( From 85fd63de5bdb34b0e56b4c1822f5b417e6629410 Mon Sep 17 00:00:00 2001 From: Guillaume Koch <39165367+gkoch78@users.noreply.github.com> Date: Thu, 13 Feb 2025 11:04:21 -0800 Subject: [PATCH 13/14] quick fix --- alphaswarm/services/exchanges/uniswap/uniswap_client_v2.py | 2 +- alphaswarm/services/exchanges/uniswap/uniswap_client_v3.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/alphaswarm/services/exchanges/uniswap/uniswap_client_v2.py b/alphaswarm/services/exchanges/uniswap/uniswap_client_v2.py index 97326c64..b5fb5149 100644 --- a/alphaswarm/services/exchanges/uniswap/uniswap_client_v2.py +++ b/alphaswarm/services/exchanges/uniswap/uniswap_client_v2.py @@ -41,7 +41,7 @@ def _swap( # Handle token approval and get fresh nonce token_in = quote.token_in token_out = quote.token_out - wei_in = token_in.convert_to_wei(quote.amount_out) + wei_in = token_in.convert_to_wei(quote.amount_in) approval_receipt = self._approve_token_spending(token_in, wei_in) diff --git a/alphaswarm/services/exchanges/uniswap/uniswap_client_v3.py b/alphaswarm/services/exchanges/uniswap/uniswap_client_v3.py index d31e3583..9efa2427 100644 --- a/alphaswarm/services/exchanges/uniswap/uniswap_client_v3.py +++ b/alphaswarm/services/exchanges/uniswap/uniswap_client_v3.py @@ -147,7 +147,7 @@ def _swap( token_in = quote.token_in token_out = quote.token_out - wei_in = token_in.convert_to_wei(quote.amount_out) + wei_in = token_in.convert_to_wei(quote.amount_in) approval_receipt = self._approve_token_spending(token_in, wei_in) # Build a swap transaction From 7e1d566a374bf8f08d02264826b0ab24c99a8afa Mon Sep 17 00:00:00 2001 From: Guillaume Koch <39165367+gkoch78@users.noreply.github.com> Date: Thu, 13 Feb 2025 11:09:34 -0800 Subject: [PATCH 14/14] clean up --- alphaswarm/services/exchanges/uniswap/uniswap_client_base.py | 1 - 1 file changed, 1 deletion(-) diff --git a/alphaswarm/services/exchanges/uniswap/uniswap_client_base.py b/alphaswarm/services/exchanges/uniswap/uniswap_client_base.py index 5b1a43dc..1e4941bf 100644 --- a/alphaswarm/services/exchanges/uniswap/uniswap_client_base.py +++ b/alphaswarm/services/exchanges/uniswap/uniswap_client_base.py @@ -48,7 +48,6 @@ def _get_factory(self) -> ChecksumAddress: @abstractmethod def _swap( self, - *, quote: QuoteResult[UniswapQuote], slippage_bps: int, ) -> List[TxReceipt]: