From a877d0b172062efc67d362f4b8bbedb4f9cbc9d0 Mon Sep 17 00:00:00 2001 From: Christopher Gerber Date: Wed, 29 Jan 2025 10:38:35 -0800 Subject: [PATCH] first raw pass implementing aerodrome deposit and withdraw for python --- cdp-agentkit-core/python/Makefile | 21 ++ .../actions/{defi => aerodrome}/__init__.py | 0 .../actions/aerodrome/constants.py | 174 +++++++++++ .../actions/aerodrome/deposit.py | 143 +++++++++ .../actions/aerodrome/quote.py | 108 +++++++ .../actions/aerodrome/utils.py | 278 ++++++++++++++++++ .../actions/aerodrome/withdraw.py | 134 +++++++++ .../cdp_agentkit_core/actions/constants.py | 10 + .../python/cdp_agentkit_core/actions/utils.py | 1 - .../tests/actions/aerodrome/test_deposit.py | 227 ++++++++++++++ .../actions/aerodrome/test_deposit_e2e.py | 130 ++++++++ .../tests/actions/aerodrome/test_quote_e2e.py | 132 +++++++++ .../tests/actions/aerodrome/test_utils.py | 1 + .../tests/actions/aerodrome/test_utils_e2e.py | 223 ++++++++++++++ .../tests/actions/aerodrome/test_withdraw.py | 0 .../actions/aerodrome/test_withdraw_e2e.py | 181 ++++++++++++ 16 files changed, 1762 insertions(+), 1 deletion(-) rename cdp-agentkit-core/python/cdp_agentkit_core/actions/{defi => aerodrome}/__init__.py (100%) create mode 100644 cdp-agentkit-core/python/cdp_agentkit_core/actions/aerodrome/constants.py create mode 100644 cdp-agentkit-core/python/cdp_agentkit_core/actions/aerodrome/deposit.py create mode 100644 cdp-agentkit-core/python/cdp_agentkit_core/actions/aerodrome/quote.py create mode 100644 cdp-agentkit-core/python/cdp_agentkit_core/actions/aerodrome/utils.py create mode 100644 cdp-agentkit-core/python/cdp_agentkit_core/actions/aerodrome/withdraw.py create mode 100644 cdp-agentkit-core/python/tests/actions/aerodrome/test_deposit.py create mode 100644 cdp-agentkit-core/python/tests/actions/aerodrome/test_deposit_e2e.py create mode 100644 cdp-agentkit-core/python/tests/actions/aerodrome/test_quote_e2e.py create mode 100644 cdp-agentkit-core/python/tests/actions/aerodrome/test_utils.py create mode 100644 cdp-agentkit-core/python/tests/actions/aerodrome/test_utils_e2e.py create mode 100644 cdp-agentkit-core/python/tests/actions/aerodrome/test_withdraw.py create mode 100644 cdp-agentkit-core/python/tests/actions/aerodrome/test_withdraw_e2e.py diff --git a/cdp-agentkit-core/python/Makefile b/cdp-agentkit-core/python/Makefile index 13f13e7ea..41bc134df 100644 --- a/cdp-agentkit-core/python/Makefile +++ b/cdp-agentkit-core/python/Makefile @@ -1,3 +1,8 @@ +ifneq (,$(wildcard ./.env)) + include .env + export +endif + .PHONY: format format: poetry run ruff format . @@ -21,3 +26,19 @@ local-docs: docs .PHONY: test test: poetry run pytest + +.PHONY: test-utils +test-utils: + poetry run pytest tests/actions/aerodrome/test_utils_e2e.py -s + +.PHONY: test-deposit +test-deposit: + poetry run pytest tests/actions/aerodrome/test_deposit_e2e.py -s + +.PHONY: test-quote +test-quote: + poetry run pytest tests/actions/aerodrome/test_quote_e2e.py -s + +.PHONY: test-withdraw +test-withdraw: + poetry run pytest tests/actions/aerodrome/test_withdraw_e2e.py -s diff --git a/cdp-agentkit-core/python/cdp_agentkit_core/actions/defi/__init__.py b/cdp-agentkit-core/python/cdp_agentkit_core/actions/aerodrome/__init__.py similarity index 100% rename from cdp-agentkit-core/python/cdp_agentkit_core/actions/defi/__init__.py rename to cdp-agentkit-core/python/cdp_agentkit_core/actions/aerodrome/__init__.py diff --git a/cdp-agentkit-core/python/cdp_agentkit_core/actions/aerodrome/constants.py b/cdp-agentkit-core/python/cdp_agentkit_core/actions/aerodrome/constants.py new file mode 100644 index 000000000..5e8ff8075 --- /dev/null +++ b/cdp-agentkit-core/python/cdp_agentkit_core/actions/aerodrome/constants.py @@ -0,0 +1,174 @@ +AERODROME_FACTORY_ADDRESS = "0x420DD381b31aEf6683db6B902084cB0FFECe40Da" +AERODROME_ROUTER_ADDRESS = "0xcF77a3Ba9A5CA399B7c97c74d54e5b1Beb874E43" +AERODROME_POOL_ADDRESS = "0xA4e46b4f701c62e14DF11B48dCe76A7d793CD6d7" +BASE_RPC_URL = "https://mainnet.base.org" + +AERODROME_FACTORY_ABI = [ + { + "inputs": [ + {"internalType": "address", "name": "tokenA", "type": "address"}, + {"internalType": "address", "name": "tokenB", "type": "address"}, + {"internalType": "bool", "name": "stable", "type": "bool"} + ], + "name": "getPool", + "outputs": [{"internalType": "address", "name": "", "type": "address"}], + "stateMutability": "view", + "type": "function" + } +] + +AERODROME_ROUTER_ABI = [ + { + "inputs": [ + {"internalType": "address", "name": "tokenA", "type": "address"}, + {"internalType": "address", "name": "tokenB", "type": "address"}, + {"internalType": "bool", "name": "stable", "type": "bool"}, + {"internalType": "uint256", "name": "amountADesired", "type": "uint256"}, + {"internalType": "uint256", "name": "amountBDesired", "type": "uint256"}, + {"internalType": "uint256", "name": "amountAMin", "type": "uint256"}, + {"internalType": "uint256", "name": "amountBMin", "type": "uint256"}, + {"internalType": "address", "name": "to", "type": "address"}, + {"internalType": "uint256", "name": "deadline", "type": "uint256"} + ], + "name": "addLiquidity", + "outputs": [ + {"internalType": "uint256", "name": "amountA", "type": "uint256"}, + {"internalType": "uint256", "name": "amountB", "type": "uint256"}, + {"internalType": "uint256", "name": "liquidity", "type": "uint256"} + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + {"internalType": "address", "name": "tokenA", "type": "address"}, + {"internalType": "address", "name": "tokenB", "type": "address"}, + {"internalType": "bool", "name": "stable", "type": "bool"}, + {"internalType": "address", "name": "_factory", "type": "address"}, + {"internalType": "uint256", "name": "amountADesired", "type": "uint256"}, + {"internalType": "uint256", "name": "amountBDesired", "type": "uint256"} + ], + "name": "quoteAddLiquidity", + "outputs": [ + {"internalType": "uint256", "name": "amountA", "type": "uint256"}, + {"internalType": "uint256", "name": "amountB", "type": "uint256"}, + {"internalType": "uint256", "name": "liquidity", "type": "uint256"} + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + {"internalType": "address", "name": "tokenA", "type": "address"}, + {"internalType": "address", "name": "tokenB", "type": "address"}, + {"internalType": "bool", "name": "stable", "type": "bool"}, + {"internalType": "uint256", "name": "liquidity", "type": "uint256"}, + {"internalType": "uint256", "name": "amountAMin", "type": "uint256"}, + {"internalType": "uint256", "name": "amountBMin", "type": "uint256"}, + {"internalType": "address", "name": "to", "type": "address"}, + {"internalType": "uint256", "name": "deadline", "type": "uint256"} + ], + "name": "removeLiquidity", + "outputs": [ + {"internalType": "uint256", "name": "amountA", "type": "uint256"}, + {"internalType": "uint256", "name": "amountB", "type": "uint256"} + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + {"internalType": "address", "name": "token", "type": "address"}, + {"internalType": "bool", "name": "stable", "type": "bool"}, + {"internalType": "uint256", "name": "amountTokenDesired", "type": "uint256"}, + {"internalType": "uint256", "name": "amountTokenMin", "type": "uint256"}, + {"internalType": "uint256", "name": "amountETHMin", "type": "uint256"}, + {"internalType": "address", "name": "to", "type": "address"}, + {"internalType": "uint256", "name": "deadline", "type": "uint256"} + ], + "name": "addLiquidityETH", + "outputs": [ + {"internalType": "uint256", "name": "amountToken", "type": "uint256"}, + {"internalType": "uint256", "name": "amountETH", "type": "uint256"}, + {"internalType": "uint256", "name": "liquidity", "type": "uint256"} + ], + "stateMutability": "payable", + "type": "function" + }, + { + "inputs": [ + {"internalType": "address", "name": "token", "type": "address"}, + {"internalType": "bool", "name": "stable", "type": "bool"}, + {"internalType": "uint256", "name": "liquidity", "type": "uint256"}, + {"internalType": "uint256", "name": "amountTokenMin", "type": "uint256"}, + {"internalType": "uint256", "name": "amountETHMin", "type": "uint256"}, + {"internalType": "address", "name": "to", "type": "address"}, + {"internalType": "uint256", "name": "deadline", "type": "uint256"} + ], + "name": "removeLiquidityETH", + "outputs": [ + {"internalType": "uint256", "name": "amountToken", "type": "uint256"}, + {"internalType": "uint256", "name": "amountETH", "type": "uint256"} + ], + "stateMutability": "nonpayable", + "type": "function" + } +] + +AERODROME_POOL_ABI = [ + { + "inputs": [], + "name": "getReserves", + "outputs": [ + { + "internalType": "uint256", + "name": "_reserve0", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "_reserve1", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "_blockTimestampLast", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "totalSupply", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "name": "balanceOf", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + } +] \ No newline at end of file diff --git a/cdp-agentkit-core/python/cdp_agentkit_core/actions/aerodrome/deposit.py b/cdp-agentkit-core/python/cdp_agentkit_core/actions/aerodrome/deposit.py new file mode 100644 index 000000000..68b40929c --- /dev/null +++ b/cdp-agentkit-core/python/cdp_agentkit_core/actions/aerodrome/deposit.py @@ -0,0 +1,143 @@ +from collections.abc import Callable +from decimal import Decimal + +from cdp import Asset, Wallet +from pydantic import BaseModel, Field + +from cdp_agentkit_core.actions import CdpAction +from cdp_agentkit_core.actions.aerodrome.constants import AERODROME_ROUTER_ABI, AERODROME_ROUTER_ADDRESS +from cdp_agentkit_core.actions.utils import approve + + +class AerodromeAddLiquidityInput(BaseModel): + """Input schema for Aerodrome liquidity deposit action.""" + + token_a: str = Field(..., description="The address of the first token in the pair") + token_b: str = Field(..., description="The address of the second token in the pair") + stable: bool = Field(..., description="Whether this is a stable or volatile pair") + amount_a_desired: str = Field(..., description="The amount of first token to add as liquidity") + amount_b_desired: str = Field(..., description="The amount of second token to add as liquidity") + amount_a_min: str = Field(..., description="The minimum amount of first token to add as liquidity") + amount_b_min: str = Field(..., description="The minimum amount of second token to add as liquidity") + to: str = Field(..., description="The address that will receive the liquidity tokens") + deadline: str = Field(..., description="The timestamp deadline for the transaction to be executed by") + + +DEPOSIT_PROMPT = """ +This tool allows adding liquidity to Aerodrome pools. +It takes: + +- token_a: The address of the first token in the pair +- token_b: The address of the second token in the pair +- stable: Whether this is a stable or volatile pair (true/false) +- amount_a_desired: The amount of first token to add as liquidity in whole units + Examples: + - 1 TOKEN + - 0.1 TOKEN + - 0.01 TOKEN +- amount_b_desired: The amount of second token to add as liquidity in whole units +- amount_a_min: The minimum amount of first token (to protect against slippage) +- amount_b_min: The minimum amount of second token (to protect against slippage) +- to: The address to receive the LP tokens +- deadline: The timestamp deadline for the transaction (in seconds since Unix epoch) + +Important notes: +- Make sure to use exact amounts provided. Do not convert units for amounts in this action. +- Please use token addresses (example 0x4200000000000000000000000000000000000006). If unsure of token addresses, please clarify before continuing. +- The deadline should be a future timestamp (current time + some buffer, e.g., 20 minutes) +- For stable pairs, the ratio between tokens should be close to 1:1 in USD value +""" + + +def deposit( + wallet: Wallet, + token_a: str, + token_b: str, + stable: bool, + amount_a_desired: str, + amount_b_desired: str, + amount_a_min: str, + amount_b_min: str, + to: str, + deadline: str, +) -> str: + """Add liquidity to an Aerodrome pool. + + Args: + wallet (Wallet): The wallet to execute the deposit from + token_a (str): The address of the first token + token_b (str): The address of the second token + stable (bool): Whether this is a stable or volatile pair + amount_a_desired (str): The desired amount of first token to add + amount_b_desired (str): The desired amount of second token to add + amount_a_min (str): The minimum amount of first token to add + amount_b_min (str): The minimum amount of second token to add + to (str): The address to receive LP tokens + deadline (str): The timestamp deadline for the transaction + + Returns: + str: A success message with transaction hash or error message + """ + try: + # Validate inputs + if float(amount_a_desired) <= 0 or float(amount_b_desired) <= 0: + return "Error: Desired amounts must be greater than 0" + + if float(amount_a_min) <= 0 or float(amount_b_min) <= 0: + return "Error: Minimum amounts must be greater than 0" + + # Convert amounts to atomic units + token_a_asset = Asset.fetch(wallet.network_id, token_a) + token_b_asset = Asset.fetch(wallet.network_id, token_b) + + atomic_amount_a_desired = str(int(token_a_asset.to_atomic_amount(Decimal(amount_a_desired)))) + atomic_amount_b_desired = str(int(token_b_asset.to_atomic_amount(Decimal(amount_b_desired)))) + atomic_amount_a_min = str(int(token_a_asset.to_atomic_amount(Decimal(amount_a_min)))) + atomic_amount_b_min = str(int(token_b_asset.to_atomic_amount(Decimal(amount_b_min)))) + + # atomic_amount_a_approved = str(int(token_a_asset.to_atomic_amount(Decimal(amount_a_desired)*Decimal(10)))) + # atomic_amount_b_approved = str(int(token_b_asset.to_atomic_amount(Decimal(amount_b_desired) * Decimal(10)))) + + # Approve router for both tokens + approval_a = approve(wallet, token_a, AERODROME_ROUTER_ADDRESS, atomic_amount_a_desired) + if approval_a.startswith("Error"): + return f"Error approving token A: {approval_a}" + + approval_b = approve(wallet, token_b, AERODROME_ROUTER_ADDRESS, atomic_amount_b_desired) + if approval_b.startswith("Error"): + return f"Error approving token B: {approval_b}" + + print(f"\ndesired a: {atomic_amount_a_desired}\ndesired b: {atomic_amount_b_desired}") + print(f"\nmin a: {atomic_amount_a_min}\nmin b: {atomic_amount_b_min}") + add_liquidity_args = { + "tokenA": token_a, + "tokenB": token_b, + "stable": stable, + "amountADesired": atomic_amount_a_desired, + "amountBDesired": atomic_amount_b_desired, + "amountAMin": atomic_amount_a_min, + "amountBMin": atomic_amount_b_min, + "to": to, + "deadline": deadline, + } + + invocation = wallet.invoke_contract( + contract_address=AERODROME_ROUTER_ADDRESS, + method="addLiquidity", + abi=AERODROME_ROUTER_ABI, + args=add_liquidity_args, + ).wait() + + return f"Added liquidity to Aerodrome pool with transaction hash: {invocation.transaction_hash} and transaction link: {invocation.transaction_link}" + + except Exception as e: + return f"Error adding liquidity to Aerodrome: {e!s}" + + +class AerodromeAddLiquidityAction(CdpAction): + """Aerodrome liquidity deposit action.""" + + name: str = "aerodrome_add_liquidity" + description: str = DEPOSIT_PROMPT + args_schema: type[BaseModel] = AerodromeAddLiquidityInput + func: Callable[..., str] = deposit diff --git a/cdp-agentkit-core/python/cdp_agentkit_core/actions/aerodrome/quote.py b/cdp-agentkit-core/python/cdp_agentkit_core/actions/aerodrome/quote.py new file mode 100644 index 000000000..1223d6ed4 --- /dev/null +++ b/cdp-agentkit-core/python/cdp_agentkit_core/actions/aerodrome/quote.py @@ -0,0 +1,108 @@ +from collections.abc import Callable +from decimal import Decimal + +from cdp import Asset, Wallet +from pydantic import BaseModel, Field + +from cdp_agentkit_core.actions import CdpAction +from cdp_agentkit_core.actions.aerodrome.constants import AERODROME_FACTORY_ADDRESS +from cdp_agentkit_core.actions.aerodrome.utils import quote_add_liquidity + + +class AerodromeQuoteAddLiquidityInput(BaseModel): + """Input schema for Aerodrome liquidity quote action.""" + + token_a: str = Field(..., description="The address of the first token in the pair") + token_b: str = Field(..., description="The address of the second token in the pair") + stable: bool = Field(..., description="Whether this is a stable or volatile pair") + amount_a_desired: str = Field(..., description="The amount of first token to add as liquidity") + amount_b_desired: str = Field(..., description="The amount of second token to add as liquidity") + + +QUOTE_PROMPT = """ +This tool allows getting a quote for adding liquidity to Aerodrome pools. +It takes: + +- token_a: The address of the first token in the pair +- token_b: The address of the second token in the pair +- stable: Whether this is a stable or volatile pair (true/false) +- amount_a_desired: The amount of first token to add as liquidity in whole units + Examples: + - 1 TOKEN + - 0.1 TOKEN + - 0.01 TOKEN +- amount_b_desired: The amount of second token to add as liquidity in whole units + +Important notes: +- Make sure to use exact amounts provided. Do not convert units for amounts in this action. +- Please use token addresses (example 0x4200000000000000000000000000000000000006). +- For stable pairs, the ratio between tokens should be close to 1:1 in USD value +""" + + +def quote_add_liquidity_action( + wallet: Wallet, + token_a: str, + token_b: str, + stable: bool, + amount_a_desired: str, + amount_b_desired: str, +) -> str: + """Get a quote for adding liquidity to an Aerodrome pool. + + Args: + wallet (Wallet): The wallet to execute the quote from + token_a (str): The address of the first token + token_b (str): The address of the second token + stable (bool): Whether this is a stable or volatile pair + amount_a_desired (str): The desired amount of first token to add + amount_b_desired (str): The desired amount of second token to add + + Returns: + str: A success message with quote details or error message + """ + try: + # Validate inputs + if float(amount_a_desired) <= 0 or float(amount_b_desired) <= 0: + return "Error: Desired amounts must be greater than 0" + + # Convert amounts to atomic units + token_a_asset = Asset.fetch(wallet.network_id, token_a) + token_b_asset = Asset.fetch(wallet.network_id, token_b) + + atomic_amount_a_desired = int(token_a_asset.to_atomic_amount(Decimal(amount_a_desired))) + atomic_amount_b_desired = int(token_b_asset.to_atomic_amount(Decimal(amount_b_desired))) + + # Get quote using utility function + amount_a, amount_b, liquidity = quote_add_liquidity( + wallet=wallet, + token_a=token_a, + token_b=token_b, + stable=stable, + factory=AERODROME_FACTORY_ADDRESS, + amount_a_desired=atomic_amount_a_desired, + amount_b_desired=atomic_amount_b_desired + ) + + # Convert atomic amounts back to human readable + human_amount_a = token_a_asset.from_atomic_amount(amount_a) + human_amount_b = token_b_asset.from_atomic_amount(amount_b) + + return ( + f"Quote for adding liquidity to Aerodrome pool:\n" + f"Token A amount: {human_amount_a}\n" + f"Token B amount: {human_amount_b}\n" + f"Expected liquidity tokens: {liquidity}" + ) + + except Exception as e: + return f"Error getting quote from Aerodrome: {e!s}" + + +class AerodromeQuoteAddLiquidityAction(CdpAction): + """Aerodrome liquidity quote action.""" + + name: str = "aerodrome_quote_add_liquidity" + description: str = QUOTE_PROMPT + args_schema: type[BaseModel] = AerodromeQuoteAddLiquidityInput + func: Callable[..., str] = quote_add_liquidity_action diff --git a/cdp-agentkit-core/python/cdp_agentkit_core/actions/aerodrome/utils.py b/cdp-agentkit-core/python/cdp_agentkit_core/actions/aerodrome/utils.py new file mode 100644 index 000000000..d23c85166 --- /dev/null +++ b/cdp-agentkit-core/python/cdp_agentkit_core/actions/aerodrome/utils.py @@ -0,0 +1,278 @@ +from cdp import Wallet +from web3 import Web3 +from decimal import Decimal + +from cdp_agentkit_core.actions.constants import ERC20_BALANCE_ABI +from cdp_agentkit_core.actions.aerodrome.constants import ( + AERODROME_FACTORY_ABI, + AERODROME_FACTORY_ADDRESS, + AERODROME_POOL_ABI, + BASE_RPC_URL, +) + +def create_web3(network_id: str = "base-mainnet") -> Web3: + """Create a Web3 instance based on network ID.""" + if network_id == "base-mainnet": + return Web3(Web3.HTTPProvider(BASE_RPC_URL)) + else: + raise ValueError(f"Unsupported network: {network_id}") + +def read_contract( + web3: Web3, + contract_address: str, + abi: list, + method: str, + args: list = None +): + """Read data from a contract using web3.""" + # Ensure contract address is checksummed + contract_address = Web3.to_checksum_address(contract_address) + + contract = web3.eth.contract( + address=contract_address, + abi=abi + ) + + if args is None: + args = [] + + # If any args are addresses, convert them to checksum + processed_args = [] + for arg in args: + if isinstance(arg, str) and arg.startswith('0x') and len(arg) == 42: + processed_args.append(Web3.to_checksum_address(arg)) + else: + processed_args.append(arg) + + return getattr(contract.functions, method)(*processed_args).call() + +def get_token_balance(wallet: Wallet, token_address: str, account: str) -> int: + """Get the token balance for a specific account. + + Args: + wallet (Wallet): The wallet instance to use for the query + token_address (str): The address of the token contract + account (str): The address to check the balance for + + Returns: + int: The token balance in atomic units + """ + try: + # Create Web3 instance + web3 = create_web3(wallet.network_id) + + result = read_contract( + web3=web3, + contract_address=token_address, + abi=ERC20_BALANCE_ABI, + method="balanceOf", + args=[account] + ) + + return int(result) + except Exception as e: + raise ValueError(f"Error getting token balance: {e!s}") + +def get_lp_token_address(wallet: Wallet, token_a: str, token_b: str, stable: bool) -> str: + """Get LP token address for a given pair.""" + try: + # Convert addresses to checksum format + token_a = Web3.to_checksum_address(token_a) + token_b = Web3.to_checksum_address(token_b) + + # Sort tokens for consistent ordering + sorted_tokens = sorted([token_a, token_b]) + print(f"\nGetting LP token address:") + print(f"Token A: {token_a}") + print(f"Token B: {token_b}") + print(f"Stable: {stable}") + print(f"Sorted tokens: {sorted_tokens}") + + # Query factory contract using web3 + lp_token_address = read_contract( + web3=create_web3(wallet.network_id), + contract_address=AERODROME_FACTORY_ADDRESS, + abi=AERODROME_FACTORY_ABI, + method="getPool", + args=[sorted_tokens[0], sorted_tokens[1], stable] + ) + + print(f"Returned LP address: {lp_token_address}") + + if lp_token_address == "0x0000000000000000000000000000000000000000": + # Try the reverse stable flag if the pool wasn't found + print(f"Pool not found, trying with stable={not stable}") + lp_token_address = read_contract( + web3=create_web3(wallet.network_id), + contract_address=AERODROME_FACTORY_ADDRESS, + abi=AERODROME_FACTORY_ABI, + method="getPool", + args=[sorted_tokens[0], sorted_tokens[1], not stable] + ) + print(f"Reverse stable flag LP address: {lp_token_address}") + + if lp_token_address == "0x0000000000000000000000000000000000000000": + raise ValueError("Pool does not exist for given token pair") + + return lp_token_address + + except Exception as e: + print(f"Error details: {str(e)}") + print(f"Error type: {type(e)}") + raise + +def quote_liquidity(amount_a: int, reserve_a: int, reserve_b: int) -> int: + """Quote liquidity for volatile pools. + + Args: + amount_a: Amount of token A + reserve_a: Reserve of token A + reserve_b: Reserve of token B + + Returns: + int: Optimal amount of token B + + Raises: + ValueError: If amount or reserves are invalid + """ + if amount_a == 0: + raise ValueError("Insufficient amount") + if reserve_a == 0 or reserve_b == 0: + raise ValueError("Insufficient liquidity") + + return (amount_a * reserve_b) // reserve_a + +def get_reserves(wallet: Wallet, token_a: str, token_b: str, stable: bool, factory: str) -> tuple[int, int]: + """Get reserves for a pool. + + Args: + wallet: Wallet instance + token_a: First token address + token_b: Second token address + stable: Whether it's a stable pool + factory: Factory contract address + + Returns: + tuple[int, int]: Reserve amounts for token A and token B + """ + try: + # Get pool address first + pool_address = get_lp_token_address(wallet, token_a, token_b, stable) + if not pool_address or pool_address == "0x0000000000000000000000000000000000000000": + print("Pool does not exist") + return 0, 0 + + print(f"Getting reserves from pool: {pool_address}") + + # Call getReserves on the pool contract + reserves = read_contract( + web3=create_web3(wallet.network_id), + contract_address=pool_address, # Use pool address instead of factory + abi=AERODROME_POOL_ABI, + method="getReserves", + args=[] + ) + + print(f"Raw reserves response: {reserves}") + + # Unpack reserves (returns reserve0, reserve1, timestamp) + reserve0, reserve1, _ = reserves + + # Return reserves in the same order as input tokens + if token_a.lower() < token_b.lower(): + return reserve0, reserve1 + return reserve1, reserve0 + + except Exception as e: + print(f"Error getting reserves: {str(e)}") + raise + +def quote_add_liquidity( + wallet: Wallet, + token_a: str, + token_b: str, + stable: bool, + factory: str, + amount_a_desired: int, + amount_b_desired: int +) -> tuple[int, int, int]: + """Quote amounts for adding liquidity. + + Args: + wallet: Wallet instance + token_a: Address of token A + token_b: Address of token B + stable: Whether the pool is stable + factory: Factory address + amount_a_desired: Desired amount of token A + amount_b_desired: Desired amount of token B + + Returns: + tuple[int, int, int]: (amount_a, amount_b, liquidity) + """ + MINIMUM_LIQUIDITY = 1000 # From Router contract + + # Get pool address + pool = read_contract( + web3=create_web3(wallet.network_id), + contract_address=factory, + abi=AERODROME_FACTORY_ABI, + method="getPool", + args=[token_a, token_b, stable] + ) + + # Initialize variables + reserve_a, reserve_b = 0, 0 + total_supply = 0 + + # If pool exists, get reserves and total supply + if pool != "0x0000000000000000000000000000000000000000": + total_supply = read_contract( + web3=create_web3(wallet.network_id), + contract_address=pool, + abi=AERODROME_POOL_ABI, + method="totalSupply", + args=[] + ) + reserve_a, reserve_b = get_reserves(wallet, token_a, token_b, stable, factory) + + # If pool is empty (new pool) + if reserve_a == 0 and reserve_b == 0: + amount_a = amount_a_desired + amount_b = amount_b_desired + liquidity = int((Decimal(amount_a * amount_b).sqrt() - MINIMUM_LIQUIDITY)) + else: + # Calculate optimal amounts + amount_b_optimal = quote_liquidity(amount_a_desired, reserve_a, reserve_b) + + if amount_b_optimal <= amount_b_desired: + amount_a = amount_a_desired + amount_b = amount_b_optimal + else: + amount_a_optimal = quote_liquidity(amount_b_desired, reserve_b, reserve_a) + amount_a = amount_a_optimal + amount_b = amount_b_desired + + # Calculate liquidity amount + liquidity = min( + (amount_a * total_supply) // reserve_a, + (amount_b * total_supply) // reserve_b + ) + + return amount_a, amount_b, liquidity + +def get_total_supply(wallet: Wallet, pool_address: str) -> int: + """Get total supply of LP tokens for a pool.""" + try: + total_supply = read_contract( + web3=create_web3(wallet.network_id), + contract_address=pool_address, + abi=AERODROME_POOL_ABI, + method="totalSupply", + args={} + ) + print(f"Total supply for pool {pool_address}: {total_supply}") + return total_supply + except Exception as e: + print(f"Error getting total supply: {str(e)}") + raise \ No newline at end of file diff --git a/cdp-agentkit-core/python/cdp_agentkit_core/actions/aerodrome/withdraw.py b/cdp-agentkit-core/python/cdp_agentkit_core/actions/aerodrome/withdraw.py new file mode 100644 index 000000000..ec7e2f233 --- /dev/null +++ b/cdp-agentkit-core/python/cdp_agentkit_core/actions/aerodrome/withdraw.py @@ -0,0 +1,134 @@ +import time +from decimal import Decimal +from collections.abc import Callable + +from cdp import Asset, Wallet +from pydantic import BaseModel, Field +from web3 import Web3 + +from cdp_agentkit_core.actions import CdpAction +from cdp_agentkit_core.actions.aerodrome.constants import AERODROME_ROUTER_ABI, AERODROME_ROUTER_ADDRESS +from cdp_agentkit_core.actions.aerodrome.utils import get_lp_token_address, get_token_balance, get_reserves +from cdp_agentkit_core.actions.utils import approve + +WITHDRAW_PROMPT = """ +This tool allows removing liquidity from Aerodrome pools. +It takes: + +- token_a: The address of the first token in the pair +- token_b: The address of the second token in the pair +- stable: Whether this is a stable or volatile pair (true/false) +- liquidity: The amount of LP tokens to remove +- amount_a_min: The minimum amount of first token to receive (for slippage protection) +- amount_b_min: The minimum amount of second token to receive (for slippage protection) +- to: The address that will receive the withdrawn tokens +- deadline: The timestamp deadline for the transaction (in seconds since Unix epoch) + +Important notes: +- Make sure to use exact amounts provided. Do not convert units for amounts in this action. +- Please use token addresses (example 0x4200000000000000000000000000000000000006). +- Set reasonable minimum amounts to protect against slippage. +- The deadline should be a future timestamp (current time + some buffer, e.g., 20 minutes). +""" + + +class AerodromeRemoveLiquidityInput(BaseModel): + """Input model for Aerodrome liquidity withdrawal.""" + token_a: str = Field(description="Address of the first token in the pair") + token_b: str = Field(description="Address of the second token in the pair") + stable: bool = Field(description="Whether this is a stable or volatile pair") + liquidity: str = Field(description="Amount of LP tokens to remove") + amount_a_min: str = Field(description="Minimum amount of first token to receive") + amount_b_min: str = Field(description="Minimum amount of second token to receive") + to: str = Field(description="Address that will receive the withdrawn tokens") + deadline: str = Field(description="Timestamp deadline for the transaction") + + +def remove_liquidity( + wallet: Wallet, + token_a: str, + token_b: str, + stable: bool, + liquidity: str, + amount_a_min: str, + amount_b_min: str, + to: str, + deadline: str, +) -> str: + """Remove liquidity from an Aerodrome pool.""" + try: + print("\nStarting remove_liquidity function...") + + # Convert addresses to checksum format + token_a = Web3.to_checksum_address(token_a) + token_b = Web3.to_checksum_address(token_b) + to = Web3.to_checksum_address(to) + + # Get LP token address + lp_token_address = get_lp_token_address(wallet, token_a, token_b, stable) + if not lp_token_address: + return "Error: Pool does not exist" + + print(f"LP token address: {lp_token_address}") + + # Convert amounts to atomic units + lp_token_asset = Asset.fetch(wallet.network_id, lp_token_address) + atomic_liquidity = int(lp_token_asset.to_atomic_amount(Decimal(liquidity))) + atomic_amount_a_min = int(amount_a_min) + atomic_amount_b_min = int(amount_b_min) + + print(f"\nAtomic values:") + print(f"Liquidity: {atomic_liquidity}") + print(f"Min amount A: {atomic_amount_a_min}") + print(f"Min amount B: {atomic_amount_b_min}") + + # Approve router to spend LP tokens + approve_result = approve( + wallet=wallet, + token_address=lp_token_address, + spender=AERODROME_ROUTER_ADDRESS, + amount=atomic_liquidity + ) + + if "Error" in approve_result: + return f"Failed to approve router: {approve_result}" + + # Remove liquidity with JSON-serializable arguments + remove_args = { + "tokenA": token_a, + "tokenB": token_b, + "stable": stable, + "liquidity": str(atomic_liquidity), + "amountAMin": str(atomic_amount_a_min), + "amountBMin": str(atomic_amount_b_min), + "to": to, + "deadline": str(int(deadline)) + } + + print(f"Calling removeLiquidity with args: {remove_args}") + + result = wallet.invoke_contract( + contract_address=AERODROME_ROUTER_ADDRESS, + method="removeLiquidity", + abi=AERODROME_ROUTER_ABI, + args=remove_args + ).wait() + + if result.transaction_hash: + return ( + f"Successfully removed liquidity with transaction hash: {result.transaction_hash}\n" + f"Transaction link: {result.transaction_link}" + ) + else: + return f"Failed to remove liquidity: {result}" + + except Exception as e: + return f"Error removing liquidity: {str(e)}" + + +class AerodromeRemoveLiquidityAction(CdpAction): + """Aerodrome liquidity withdrawal action.""" + name: str = "aerodrome_remove_liquidity" + description: str = WITHDRAW_PROMPT + args_schema: type[BaseModel] = AerodromeRemoveLiquidityInput + func: Callable[..., str] = remove_liquidity diff --git a/cdp-agentkit-core/python/cdp_agentkit_core/actions/constants.py b/cdp-agentkit-core/python/cdp_agentkit_core/actions/constants.py index a64d89c17..ff6786b1e 100644 --- a/cdp-agentkit-core/python/cdp_agentkit_core/actions/constants.py +++ b/cdp-agentkit-core/python/cdp_agentkit_core/actions/constants.py @@ -12,3 +12,13 @@ "type": "function", }, ] + +ERC20_BALANCE_ABI = [ + { + "inputs": [{"name": "account", "type": "address"}], + "name": "balanceOf", + "outputs": [{"name": "", "type": "uint256"}], + "stateMutability": "view", + "type": "function" + } +] diff --git a/cdp-agentkit-core/python/cdp_agentkit_core/actions/utils.py b/cdp-agentkit-core/python/cdp_agentkit_core/actions/utils.py index ba874bebb..c41b79f04 100644 --- a/cdp-agentkit-core/python/cdp_agentkit_core/actions/utils.py +++ b/cdp-agentkit-core/python/cdp_agentkit_core/actions/utils.py @@ -2,7 +2,6 @@ from cdp_agentkit_core.actions.constants import ERC20_APPROVE_ABI - def approve(wallet: Wallet, token_address: str, spender: str, amount: int) -> str: """Approve a spender to spend a specified amount of tokens. diff --git a/cdp-agentkit-core/python/tests/actions/aerodrome/test_deposit.py b/cdp-agentkit-core/python/tests/actions/aerodrome/test_deposit.py new file mode 100644 index 000000000..f25053c2e --- /dev/null +++ b/cdp-agentkit-core/python/tests/actions/aerodrome/test_deposit.py @@ -0,0 +1,227 @@ +from decimal import Decimal +from unittest.mock import patch + +import pytest +from eth_utils import to_checksum_address + +from cdp_agentkit_core.actions.aerodrome.constants import AERODROME_ROUTER_ABI, AERODROME_ROUTER_ADDRESS +from cdp_agentkit_core.actions.aerodrome.deposit import ( + AerodromeAddLiquidityInput, + add_liquidity_to_aerodrome, +) + +# Test constants +MOCK_WETH = to_checksum_address("0x4200000000000000000000000000000000000006") +MOCK_USDC = to_checksum_address("0x036CbD53842c5426634e7929541eC2318f3dCF7e") +MOCK_NETWORK_ID = "base-sepolia" +MOCK_WALLET_ADDRESS = "0x1234567890123456789012345678901234567890" +MOCK_AMOUNT_A = "0.001" # WETH amount +MOCK_AMOUNT_B = "1" # USDC amount +MOCK_AMOUNT_A_MIN = "0.00099" # With 1% slippage +MOCK_AMOUNT_B_MIN = "0.99" # With 1% slippage +MOCK_DEADLINE = "1735689600" # Future timestamp +MOCK_WETH_DECIMALS = 18 +MOCK_USDC_DECIMALS = 6 + +def test_add_liquidity_input_model_valid(): + """Test that AerodromeAddLiquidityInput accepts valid parameters.""" + input_model = AerodromeAddLiquidityInput( + token_a=MOCK_WETH, + token_b=MOCK_USDC, + stable=True, + amount_a_desired=MOCK_AMOUNT_A, + amount_b_desired=MOCK_AMOUNT_B, + amount_a_min=MOCK_AMOUNT_A_MIN, + amount_b_min=MOCK_AMOUNT_B_MIN, + to=MOCK_WALLET_ADDRESS, + deadline=MOCK_DEADLINE, + ) + + assert input_model.token_a == MOCK_WETH + assert input_model.token_b == MOCK_USDC + assert input_model.stable is True + assert input_model.amount_a_desired == MOCK_AMOUNT_A + assert input_model.amount_b_desired == MOCK_AMOUNT_B + assert input_model.amount_a_min == MOCK_AMOUNT_A_MIN + assert input_model.amount_b_min == MOCK_AMOUNT_B_MIN + assert input_model.to == MOCK_WALLET_ADDRESS + assert input_model.deadline == MOCK_DEADLINE + + +def test_add_liquidity_input_model_missing_params(): + """Test that AerodromeAddLiquidityInput raises error when params are missing.""" + with pytest.raises(ValueError): + AerodromeAddLiquidityInput() + + +def test_add_liquidity_success(wallet_factory, contract_invocation_factory, asset_factory): + """Test successful liquidity addition with valid parameters.""" + mock_wallet = wallet_factory() + mock_contract_instance = contract_invocation_factory() + mock_wallet.default_address.address_id = MOCK_WALLET_ADDRESS + mock_wallet.network_id = MOCK_NETWORK_ID + + # Create mock assets for both tokens + mock_asset_a = asset_factory(decimals=MOCK_WETH_DECIMALS) + mock_asset_b = asset_factory(decimals=MOCK_USDC_DECIMALS) + + with ( + patch( + "cdp_agentkit_core.actions.aerodrome.deposit.approve", + return_value="Approval successful" + ) as mock_approve, + patch( + "cdp_agentkit_core.actions.aerodrome.deposit.Asset.fetch", + side_effect=[mock_asset_a, mock_asset_b] + ) as mock_get_asset, + patch.object( + mock_asset_a, + "to_atomic_amount", + side_effect=["1000000000000000", "990000000000000"] # Desired and min amounts for token A + ) as mock_to_atomic_amount_a, + patch.object( + mock_asset_b, + "to_atomic_amount", + side_effect=["1000000", "990000"] # Desired and min amounts for token B + ) as mock_to_atomic_amount_b, + patch.object( + mock_wallet, + "invoke_contract", + return_value=mock_contract_instance + ) as mock_invoke, + patch.object( + mock_contract_instance, + "wait", + return_value=mock_contract_instance + ) as mock_contract_wait, + ): + action_response = add_liquidity_to_aerodrome( + mock_wallet, + MOCK_WETH, + MOCK_USDC, + True, # stable pair + MOCK_AMOUNT_A, + MOCK_AMOUNT_B, + MOCK_AMOUNT_A_MIN, + MOCK_AMOUNT_B_MIN, + MOCK_WALLET_ADDRESS, + MOCK_DEADLINE, + ) + + expected_response = f"Added liquidity to Aerodrome pool with transaction hash: {mock_contract_instance.transaction_hash} and transaction link: {mock_contract_instance.transaction_link}" + assert action_response == expected_response + + # Verify approvals were called for both tokens + assert mock_approve.call_count == 2 + mock_approve.assert_any_call( + mock_wallet, MOCK_WETH, AERODROME_ROUTER_ADDRESS, "1000000000000000" + ) + mock_approve.assert_any_call( + mock_wallet, MOCK_USDC, AERODROME_ROUTER_ADDRESS, "1000000" + ) + + # Verify asset fetching + assert mock_get_asset.call_count == 2 + mock_get_asset.assert_any_call(MOCK_NETWORK_ID, MOCK_WETH) + mock_get_asset.assert_any_call(MOCK_NETWORK_ID, MOCK_USDC) + + # Verify amount conversions + assert mock_to_atomic_amount_a.call_count == 2 + assert mock_to_atomic_amount_b.call_count == 2 + + # Verify contract invocation + mock_invoke.assert_called_once() + mock_contract_wait.assert_called_once() + + +def test_add_liquidity_api_error(wallet_factory, asset_factory): + """Test liquidity addition when API error occurs.""" + mock_wallet = wallet_factory() + mock_wallet.default_address.address_id = MOCK_WALLET_ADDRESS + mock_wallet.network_id = MOCK_NETWORK_ID + mock_asset_a = asset_factory(decimals=MOCK_WETH_DECIMALS) + mock_asset_b = asset_factory(decimals=MOCK_USDC_DECIMALS) + + with ( + patch( + "cdp_agentkit_core.actions.aerodrome.deposit.approve", + return_value="Approval successful" + ), + patch( + "cdp_agentkit_core.actions.aerodrome.deposit.Asset.fetch", + side_effect=[mock_asset_a, mock_asset_b] + ), + patch.object( + mock_asset_a, + "to_atomic_amount", + side_effect=["1000000000000000", "990000000000000"] + ), + patch.object( + mock_asset_b, + "to_atomic_amount", + side_effect=["1000000", "990000"] + ), + patch.object( + mock_wallet, + "invoke_contract", + side_effect=Exception("API error") + ), + ): + action_response = add_liquidity_to_aerodrome( + mock_wallet, + MOCK_WETH, + MOCK_USDC, + True, + MOCK_AMOUNT_A, + MOCK_AMOUNT_B, + MOCK_AMOUNT_A_MIN, + MOCK_AMOUNT_B_MIN, + MOCK_WALLET_ADDRESS, + MOCK_DEADLINE, + ) + + expected_response = "Error adding liquidity to Aerodrome: API error" + assert action_response == expected_response + + +def test_add_liquidity_approval_failure(wallet_factory, asset_factory): + """Test liquidity addition when approval fails.""" + mock_wallet = wallet_factory() + mock_wallet.default_address.address_id = MOCK_WALLET_ADDRESS + mock_wallet.network_id = MOCK_NETWORK_ID + mock_asset_a = asset_factory(decimals=MOCK_WETH_DECIMALS) + mock_asset_b = asset_factory(decimals=MOCK_USDC_DECIMALS) + + with ( + patch( + "cdp_agentkit_core.actions.aerodrome.deposit.approve", + return_value="Error: Approval failed" + ) as mock_approve, + patch( + "cdp_agentkit_core.actions.aerodrome.deposit.Asset.fetch", + side_effect=[mock_asset_a, mock_asset_b] + ) as mock_get_asset, + patch.object( + mock_asset_a, + "to_atomic_amount", + return_value="1000000000000000" + ), + ): + action_response = add_liquidity_to_aerodrome( + mock_wallet, + MOCK_WETH, + MOCK_USDC, + True, + MOCK_AMOUNT_A, + MOCK_AMOUNT_B, + MOCK_AMOUNT_A_MIN, + MOCK_AMOUNT_B_MIN, + MOCK_WALLET_ADDRESS, + MOCK_DEADLINE, + ) + + expected_response = "Error approving token A: Error: Approval failed" + assert action_response == expected_response + + mock_approve.assert_called_once() + mock_get_asset.assert_called_once() \ No newline at end of file diff --git a/cdp-agentkit-core/python/tests/actions/aerodrome/test_deposit_e2e.py b/cdp-agentkit-core/python/tests/actions/aerodrome/test_deposit_e2e.py new file mode 100644 index 000000000..b9ca6faac --- /dev/null +++ b/cdp-agentkit-core/python/tests/actions/aerodrome/test_deposit_e2e.py @@ -0,0 +1,130 @@ +import json +import os +import time +from decimal import Decimal + +import pytest +from cdp import Cdp, Wallet, WalletData +from eth_utils import to_checksum_address + +from cdp_agentkit_core.actions.aerodrome.deposit import deposit + +# Test constants +NETWORK = "base-mainnet" +WETH = to_checksum_address("0x4200000000000000000000000000000000000006") +USDC = to_checksum_address("0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913") +# USDC_TESTNET = to_checksum_address("0x036CbD53842c5426634e7929541eC2318f3dCF7e") +# WETH = "0x4200000000000000000000000000000000000006" +# USDC = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" +STABLE_PAIR = True +AMOUNT_A = "0.000001" # ETH amount +AMOUNT_B = "0.009303" # USDC amount +SLIPPAGE = 0.01 # 1% slippage + + +@pytest.fixture +def wallet(): + """Load base-mainnet wallet for testing.""" + Cdp.configure( + api_key_name=os.getenv("CDP_API_KEY_NAME"), + private_key=os.getenv("CDP_API_KEY_PRIVATE_KEY").replace("\\n", "\n"), + source="cdp-langchain", + source_version="0.0.13", + ) + # If running in CI, use environment variable for private key + # private_key = os.getenv("MENMONIC_PHRASE") + # if private_key: + # return Wallet.import({mnemonic_phrase: private_key}, network = NETWORK) + + wallet_data = WalletData.from_dict(json.loads(os.getenv("WALLET_DATA"))) + wallet = Wallet.import_data(wallet_data) + + # For local testing, create a new wallet and fund it + # wallet = Wallet.create(NETWORK) + # print(f"\nCreated test wallet: {wallet.default_address}") + + # wallet.faucet().wait() + # print(f"\nFunded test wallet: {wallet.default_address}") + + return wallet + + +@pytest.mark.e2e +def test_add_liquidity_e2e(wallet): + """Test adding liquidity to Aerodrome pool end-to-end.""" + + print(f"MY ETH BALANCE:{wallet.balance('eth')}") + print(f"MY USDC BALANCE:{wallet.balance('usdc')}") + print(f"MY WETH BALANCE:{wallet.balance('weth')}") + + # Get initial balances + # initial_balance_a = Decimal(wallet.balance("weth")) + # initial_balance_b = Decimal(wallet.balance("usdc")) + + # print(f"\nInitial WETH balance: {initial_balance_a}") + # print(f"Initial USDC balance: {initial_balance_b}") + + # Request funds from faucet if needed + # if initial_balance_a == 0 or initial_balance_b == 0: + # print("\nRequesting funds from faucet...") + # # Add faucet request logic here if needed + # time.sleep(5) # Wait for faucet tx to complete + + # Calculate min amounts based on slippage + amount_a_min = str(Decimal(AMOUNT_A) * Decimal((1 - SLIPPAGE))) + amount_b_min = str(Decimal(AMOUNT_B) * Decimal((1 - SLIPPAGE))) + + # Set deadline 20 minutes in the future + deadline = str(int(time.time() + 1200)) + + print("\nExecuting addLiquidity transaction...") + result = deposit( + wallet=wallet, + token_a=WETH, + token_b=USDC, + stable=STABLE_PAIR, + amount_a_desired=AMOUNT_A, + amount_b_desired=AMOUNT_B, + amount_a_min=amount_a_min, + amount_b_min=amount_b_min, + to=wallet.default_address.address_id, + deadline=deadline + ) + + print(f"\nTransaction result: {result}") + + # Verify the transaction was successful + assert not result.startswith("Error"), f"Transaction failed: {result}" + assert "transaction hash" in result, "No transaction hash in result" + + # Wait for transaction to be mined + time.sleep(5) + + # Get final balances + # final_balance_a = Decimal(wallet.balance("weth")) + # final_balance_b = Decimal(wallet.balance("usdc")) + + # print(f"\nFinal WETH balance: {final_balance_a}") + # print(f"Final USDC balance: {final_balance_b}") + + # Verify balances changed + # assert final_balance_a < initial_balance_a, "WETH balance did not decrease" + # assert final_balance_b < initial_balance_b, "USDC balance did not decrease" + + # Calculate expected balance changes + # expected_change_a = Decimal(AMOUNT_A) + # expected_change_b = Decimal(AMOUNT_B) + + # actual_change_a = initial_balance_a - final_balance_a + # actual_change_b = initial_balance_b - final_balance_b + + # # Allow for some deviation due to gas costs and slippage + # assert abs(actual_change_a - expected_change_a) <= expected_change_a * SLIPPAGE, \ + # f"Unexpected WETH balance change. Expected ~{expected_change_a}, got {actual_change_a}" + # assert abs(actual_change_b - expected_change_b) <= expected_change_b * SLIPPAGE, \ + # f"Unexpected USDC balance change. Expected ~{expected_change_b}, got {actual_change_b}" + + +if __name__ == "__main__": + wallet = wallet() + test_add_liquidity_e2e(wallet) diff --git a/cdp-agentkit-core/python/tests/actions/aerodrome/test_quote_e2e.py b/cdp-agentkit-core/python/tests/actions/aerodrome/test_quote_e2e.py new file mode 100644 index 000000000..3c7d56570 --- /dev/null +++ b/cdp-agentkit-core/python/tests/actions/aerodrome/test_quote_e2e.py @@ -0,0 +1,132 @@ +import json +import os +from decimal import Decimal + +import pytest +from cdp import Asset, Cdp, Wallet, WalletData +from eth_utils import to_checksum_address + +from cdp_agentkit_core.actions.aerodrome.quote import quote_add_liquidity_action +from cdp_agentkit_core.actions.aerodrome.utils import get_lp_token_address, get_token_balance + +# Test constants +NETWORK = "base-mainnet" +WETH = to_checksum_address("0x4200000000000000000000000000000000000006") +USDC = to_checksum_address("0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913") +STABLE_PAIR = True +AMOUNT_A = "0.000001" # ETH amount +AMOUNT_B = "0.903444" # USDC amount + +@pytest.fixture +def wallet(): + """Load base-mainnet wallet for testing.""" + Cdp.configure( + api_key_name=os.getenv("CDP_API_KEY_NAME"), + private_key=os.getenv("CDP_API_KEY_PRIVATE_KEY").replace("\\n", "\n"), + source="cdp-langchain", + source_version="0.0.13", + ) + + wallet_data = WalletData.from_dict(json.loads(os.getenv("WALLET_DATA"))) + wallet = Wallet.import_data(wallet_data) + return wallet + +@pytest.mark.e2e +def test_quote_add_liquidity_e2e(wallet): + """Test getting quote for adding liquidity to Aerodrome pool end-to-end.""" + print("\nExecuting quoteAddLiquidity query...") + + # Get LP token address to verify pool exists + lp_token_address = get_lp_token_address(wallet, WETH, USDC, STABLE_PAIR) + print(f"\nLP token address: {lp_token_address}") + + # Get initial pool balances + weth_balance = get_token_balance(wallet, WETH, lp_token_address) + usdc_balance = get_token_balance(wallet, USDC, lp_token_address) + print(f"\nPool balances - WETH: {weth_balance}, USDC: {usdc_balance}") + + # Get quote + result = quote_add_liquidity_action( + wallet=wallet, + token_a=WETH, + token_b=USDC, + stable=STABLE_PAIR, + amount_a_desired=AMOUNT_A, + amount_b_desired=AMOUNT_B, + ) + + print(f"\nQuote result: {result}") + + # Verify the quote was successful + assert not result.startswith("Error"), f"Quote failed: {result}" + assert "Quote for adding liquidity to Aerodrome pool" in result, "Expected quote information not found" + assert "Token A amount" in result, "Token A amount not found in quote" + assert "Token B amount" in result, "Token B amount not found in quote" + assert "Expected liquidity tokens" in result, "Liquidity tokens not found in quote" + + # Parse the quoted amounts + lines = result.split('\n') + token_a_amount = Decimal(lines[1].split(': ')[1]) + token_b_amount = Decimal(lines[2].split(': ')[1]) + liquidity = Decimal(lines[3].split(': ')[1]) + + # Verify the quoted amounts are reasonable + assert token_a_amount > 0, "Token A amount should be greater than 0" + assert token_b_amount > 0, "Token B amount should be greater than 0" + assert liquidity > 0, "Liquidity tokens should be greater than 0" + + # For stable pairs, verify the ratio is close to expected + if STABLE_PAIR: + # Get token decimals + token_a_asset = Asset.fetch(wallet.network_id, WETH) + token_b_asset = Asset.fetch(wallet.network_id, USDC) + + # Calculate the ratio in USD terms (assuming 1 ETH ≈ 3000 USDC) + eth_usd_value = token_a_amount * 3000 + usdc_value = token_b_amount + ratio = eth_usd_value / usdc_value if usdc_value else 0 + + print(f"\nRatio check:") + print(f"ETH amount: {token_a_amount}") + print(f"USDC amount: {token_b_amount}") + print(f"Calculated ratio: {ratio}") + + # Allow for a wider range due to market fluctuations + assert 0.1 <= ratio <= 10, f"Ratio {ratio} is outside expected range for stable pair" + +@pytest.mark.e2e +def test_quote_add_liquidity_invalid_amounts(wallet): + """Test quote with invalid amounts.""" + result = quote_add_liquidity_action( + wallet=wallet, + token_a=WETH, + token_b=USDC, + stable=STABLE_PAIR, + amount_a_desired="0", + amount_b_desired="0", + ) + + assert result.startswith("Error"), "Expected error for zero amounts" + assert "Desired amounts must be greater than 0" in result + +@pytest.mark.e2e +def test_quote_add_liquidity_invalid_tokens(wallet): + """Test quote with invalid token addresses.""" + invalid_token = to_checksum_address("0x0000000000000000000000000000000000000000") + + result = quote_add_liquidity_action( + wallet=wallet, + token_a=invalid_token, + token_b=USDC, + stable=STABLE_PAIR, + amount_a_desired=AMOUNT_A, + amount_b_desired=AMOUNT_B, + ) + + assert result.startswith("Error"), "Expected error for invalid token address" + +if __name__ == "__main__": + wallet = wallet() + test_quote_add_liquidity_e2e(wallet) + test_quote_add_liquidity_invalid_amounts(wallet) + test_quote_add_liquidity_invalid_tokens(wallet) diff --git a/cdp-agentkit-core/python/tests/actions/aerodrome/test_utils.py b/cdp-agentkit-core/python/tests/actions/aerodrome/test_utils.py new file mode 100644 index 000000000..0519ecba6 --- /dev/null +++ b/cdp-agentkit-core/python/tests/actions/aerodrome/test_utils.py @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/cdp-agentkit-core/python/tests/actions/aerodrome/test_utils_e2e.py b/cdp-agentkit-core/python/tests/actions/aerodrome/test_utils_e2e.py new file mode 100644 index 000000000..4d5ba4247 --- /dev/null +++ b/cdp-agentkit-core/python/tests/actions/aerodrome/test_utils_e2e.py @@ -0,0 +1,223 @@ +import json +import os +from decimal import Decimal + +import pytest +from cdp import Cdp, Wallet, WalletData +from eth_utils import to_checksum_address +from web3 import Web3 + +from cdp_agentkit_core.actions.aerodrome.utils import ( + read_contract, + get_lp_token_address, + AERODROME_FACTORY_ADDRESS, + AERODROME_FACTORY_ABI, + create_web3 +) + +# Test constants +NETWORK = "base-mainnet" +WETH = to_checksum_address("0x4200000000000000000000000000000000000006") +USDC = to_checksum_address("0x833589fcd6edb6e08f4c7c32d4f71b54bda02913") +EXPECTED_WETH_USDC_POOL = "0x3548029694fbb241d45fb24ba0cd9c9d4e745f16" +STABLE_PAIR = None # Will be set after pool detection + +# ERC20 constants for testing read_contract +ERC20_ABI = [ + { + "inputs": [], + "name": "decimals", + "outputs": [{"type": "uint8"}], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "symbol", + "outputs": [{"type": "string"}], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [{"type": "address"}], + "name": "balanceOf", + "outputs": [{"type": "uint256"}], + "stateMutability": "view", + "type": "function" + } +] + + +@pytest.fixture +def web3(wallet): + """Create web3 instance for testing.""" + return create_web3(wallet.network_id) + + +@pytest.fixture +def wallet(): + """Load base-mainnet wallet for testing.""" + Cdp.configure( + api_key_name=os.getenv("CDP_API_KEY_NAME"), + private_key=os.getenv("CDP_API_KEY_PRIVATE_KEY").replace("\\n", "\n"), + source="cdp-langchain", + source_version="0.0.13", + ) + + wallet_data = WalletData.from_dict(json.loads(os.getenv("WALLET_DATA"))) + wallet = Wallet.import_data(wallet_data) + return wallet + + +def test_factory_contract_setup(web3): + """Test that we can properly connect to the factory contract.""" + try: + contract = web3.eth.contract( + address=Web3.to_checksum_address(AERODROME_FACTORY_ADDRESS), + abi=AERODROME_FACTORY_ABI + ) + print(f"\nFactory contract methods: {contract.all_functions()}") + return contract + except Exception as e: + print(f"Error setting up factory contract: {e}") + raise + + +def test_read_contract_token_info(web3): + """Test read_contract with basic token info queries.""" + try: + # Test reading WETH decimals + decimals = read_contract( + web3=web3, + contract_address=WETH, + abi=ERC20_ABI, + method="decimals" + ) + print(f"\nWETH decimals: {decimals}") + assert decimals == 18, "WETH should have 18 decimals" + + # Test reading WETH symbol + symbol = read_contract( + web3=web3, + contract_address=WETH, + abi=ERC20_ABI, + method="symbol" + ) + print(f"WETH symbol: {symbol}") + assert symbol == "WETH", "Symbol should be WETH" + + except Exception as e: + print(f"\nError in token info test: {e}") + raise + + +def test_read_contract_balance(wallet): + """Test read_contract with balance query.""" + web3 = create_web3(wallet.network_id) + + # Test reading balance with args + balance = read_contract( + web3=web3, + contract_address=WETH, + abi=ERC20_ABI, + method="balanceOf", + args=[wallet.default_address.address_id] + ) + assert isinstance(balance, int), "Balance should be an integer" + assert balance >= 0, "Balance should be non-negative" + + +def test_read_contract_invalid_address(wallet): + """Test read_contract with invalid address.""" + web3 = create_web3(wallet.network_id) + invalid_address = "0x0000000000000000000000000000000000000000" + + with pytest.raises(Exception): + read_contract( + web3=web3, + contract_address=invalid_address, + abi=ERC20_ABI, + method="symbol" + ) + + +def test_get_lp_token_address_stable(wallet): + """Test getting LP token address for stable pair.""" + try: + print(f"\nTesting with tokens:") + print(f"Token A (WETH): {WETH}") + print(f"Token B (USDC): {USDC}") + print(f"Stable: {STABLE_PAIR}") + + lp_address = get_lp_token_address(wallet, WETH, USDC, True) + print(f"LP token address: {lp_address}") + + assert Web3.is_address(lp_address), "Should return a valid address" + assert lp_address != "0x0000000000000000000000000000000000000000", "Should not return zero address" + except Exception as e: + print(f"\nError in LP token test: {e}") + raise + + +def test_get_lp_token_address_volatile(wallet): + """Test getting LP token address for volatile pair.""" + lp_address = get_lp_token_address(wallet, WETH, USDC, False) + assert Web3.is_address(lp_address), "Should return a valid address" + assert lp_address != "0x0000000000000000000000000000000000000000", "Should not return zero address" + print(f"Volatile LP token address: {lp_address}") + + +def test_get_lp_token_address_nonexistent_pair(wallet): + """Test getting LP token address for non-existent pair.""" + random_token = "0x1111111111111111111111111111111111111111" + + with pytest.raises(ValueError, match="Pool does not exist for given token pair"): + get_lp_token_address(wallet, random_token, USDC, True) + + +def test_get_lp_token_address_token_order(wallet): + """Test that token order doesn't matter.""" + address1 = get_lp_token_address(wallet, WETH, USDC, True) + address2 = get_lp_token_address(wallet, USDC, WETH, True) + assert address1 == address2, "Token order should not affect LP token address" + + +def test_get_weth_usdc_pool(wallet): + """Test getting the WETH/USDC pool address.""" + print("\nTesting WETH/USDC pool detection...") + + # Try both stable and volatile + stable_pool = get_lp_token_address(wallet, WETH, USDC, True) + volatile_pool = get_lp_token_address(wallet, WETH, USDC, False) + + print(f"\nWETH/USDC Pools:") + print(f"Stable pool: {stable_pool}") + print(f"Volatile pool: {volatile_pool}") + print(f"Expected pool: {EXPECTED_WETH_USDC_POOL}") + + # At least one of them should match the expected pool + assert (stable_pool.lower() == EXPECTED_WETH_USDC_POOL or + volatile_pool.lower() == EXPECTED_WETH_USDC_POOL), \ + "Neither pool matches the expected WETH/USDC pool" + + # Return whether it's a stable pool + is_stable = stable_pool.lower() == EXPECTED_WETH_USDC_POOL + print("WETH/USDC is a", "stable" if is_stable else "volatile", "pool") + return is_stable + + +if __name__ == "__main__": + wallet = wallet() + web3 = create_web3(wallet.network_id) + + print("\nTesting WETH/USDC pool detection...") + STABLE_PAIR = test_get_weth_usdc_pool(wallet) + + print("\nTesting factory contract setup...") + test_factory_contract_setup(web3) + + print("\nTesting read_contract...") + test_read_contract_token_info(web3) + + print("\nTesting get_lp_token_address...") + test_get_lp_token_address_stable(wallet) diff --git a/cdp-agentkit-core/python/tests/actions/aerodrome/test_withdraw.py b/cdp-agentkit-core/python/tests/actions/aerodrome/test_withdraw.py new file mode 100644 index 000000000..e69de29bb diff --git a/cdp-agentkit-core/python/tests/actions/aerodrome/test_withdraw_e2e.py b/cdp-agentkit-core/python/tests/actions/aerodrome/test_withdraw_e2e.py new file mode 100644 index 000000000..cb3464e5f --- /dev/null +++ b/cdp-agentkit-core/python/tests/actions/aerodrome/test_withdraw_e2e.py @@ -0,0 +1,181 @@ +import json +import os +import time +from decimal import Decimal + +import pytest +from cdp import Asset, Cdp, Wallet, WalletData +from eth_utils import to_checksum_address + +from cdp_agentkit_core.actions.aerodrome.constants import AERODROME_ROUTER_ADDRESS +from cdp_agentkit_core.actions.aerodrome.utils import ( + get_lp_token_address, + get_token_balance, + get_reserves, + get_total_supply +) +from cdp_agentkit_core.actions.aerodrome.withdraw import remove_liquidity + +# Test constants +NETWORK = "base-mainnet" +WETH = to_checksum_address("0x4200000000000000000000000000000000000006") +USDC = to_checksum_address("0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913") +STABLE_PAIR = True +DEADLINE = str(int(time.time()) + 1200) # 20 minutes from now + +@pytest.fixture +def wallet(): + """Load base-mainnet wallet for testing.""" + Cdp.configure( + api_key_name=os.getenv("CDP_API_KEY_NAME"), + private_key=os.getenv("CDP_API_KEY_PRIVATE_KEY").replace("\\n", "\n"), + source="cdp-langchain", + source_version="0.0.13", + ) + + wallet_data = WalletData.from_dict(json.loads(os.getenv("WALLET_DATA"))) + wallet = Wallet.import_data(wallet_data) + return wallet + +@pytest.mark.e2e +def test_remove_liquidity_e2e(wallet): + """Test removing liquidity from Aerodrome pool end-to-end.""" + print("\nStarting remove liquidity test...") + + # Get LP token address + lp_token_address = get_lp_token_address(wallet, WETH, USDC, STABLE_PAIR) + print(f"\nLP token address: {lp_token_address}") + + # Get initial balances + initial_weth_balance = get_token_balance(wallet, WETH, wallet.default_address.address_id) + initial_usdc_balance = get_token_balance(wallet, USDC, wallet.default_address.address_id) + initial_lp_balance = get_token_balance(wallet, lp_token_address, wallet.default_address.address_id) + + print(f"\nInitial balances:") + print(f"WETH: {initial_weth_balance}") + print(f"USDC: {initial_usdc_balance}") + print(f"LP tokens: {initial_lp_balance}") + + if initial_lp_balance == 0: + pytest.skip("No LP tokens available for testing") + + # Get current reserves and calculate expected amounts + reserve_a, reserve_b = get_reserves(wallet, WETH, USDC, STABLE_PAIR, AERODROME_ROUTER_ADDRESS) + total_supply = get_total_supply(wallet, lp_token_address) + + print(f"\nPool state:") + print(f"Reserve A: {reserve_a}") + print(f"Reserve B: {reserve_b}") + print(f"Total Supply: {total_supply}") + + if total_supply == 0: + pytest.skip("Pool has no liquidity (total supply is 0)") + + # Try to remove 10% of current LP balance + lp_token_asset = Asset.fetch(wallet.network_id, lp_token_address) + withdraw_amount = Decimal(initial_lp_balance) / Decimal(10) + human_withdraw = str(withdraw_amount / Decimal(10 ** lp_token_asset.decimals)) + + # Calculate expected amounts based on reserves + atomic_liquidity = int(withdraw_amount) + expected_a = (atomic_liquidity * reserve_a) // total_supply + expected_b = (atomic_liquidity * reserve_b) // total_supply + + # Set minimum amounts to 99% of expected (1% slippage) + slippage = Decimal("0.99") # 1% slippage tolerance + min_amount_a = str(int(expected_a * slippage)) + min_amount_b = str(int(expected_b * slippage)) + + print(f"\nWithdraw parameters:") + print(f"LP amount: {human_withdraw}") + print(f"Expected amount A: {expected_a}") + print(f"Expected amount B: {expected_b}") + print(f"Min amount A (1% slippage): {min_amount_a}") + print(f"Min amount B (1% slippage): {min_amount_b}") + + # Execute remove liquidity with 1% slippage protection + result = remove_liquidity( + wallet=wallet, + token_a=WETH, + token_b=USDC, + stable=STABLE_PAIR, + liquidity=human_withdraw, + amount_a_min=min_amount_a, + amount_b_min=min_amount_b, + to=wallet.default_address.address_id, + deadline=DEADLINE, + ) + + print(f"\nRemove liquidity result: {result}") + + # Verify the transaction was successful + assert "Successfully removed liquidity" in result, f"Transaction failed: {result}" + + # Wait for transaction to be mined + time.sleep(5) + + # Get final balances + final_weth_balance = get_token_balance(wallet, WETH, wallet.default_address.address_id) + final_usdc_balance = get_token_balance(wallet, USDC, wallet.default_address.address_id) + final_lp_balance = get_token_balance(wallet, lp_token_address, wallet.default_address.address_id) + + print(f"\nFinal balances:") + print(f"WETH: {final_weth_balance}") + print(f"USDC: {final_usdc_balance}") + print(f"LP tokens: {final_lp_balance}") + + # Verify balances changed correctly + assert final_weth_balance > initial_weth_balance, "WETH balance did not increase" + assert final_usdc_balance > initial_usdc_balance, "USDC balance did not increase" + assert final_lp_balance < initial_lp_balance, "LP token balance did not decrease" + + # Verify LP token decrease matches input (within 1% tolerance) + lp_decrease = initial_lp_balance - final_lp_balance + expected_lp_decrease = int(withdraw_amount) + tolerance = Decimal("0.01") # 1% + assert abs(Decimal(lp_decrease) - expected_lp_decrease) <= expected_lp_decrease * tolerance, \ + f"Unexpected LP token decrease. Expected ~{expected_lp_decrease}, got {lp_decrease}" + +# @pytest.mark.e2e +# def test_remove_liquidity_invalid_amounts(wallet): +# """Test remove liquidity with invalid amounts.""" +# result = remove_liquidity( +# wallet=wallet, +# token_a=WETH, +# token_b=USDC, +# stable=STABLE_PAIR, +# liquidity="0", +# amount_a_min="0", +# amount_b_min="0", +# to=wallet.default_address.address_id, +# deadline=DEADLINE, +# ) + +# assert result.startswith("Error"), "Expected error for zero amounts" + + +# @pytest.mark.e2e +# def test_remove_liquidity_expired_deadline(wallet): +# """Test remove liquidity with expired deadline.""" +# expired_deadline = str(int(time.time()) - 3600) # 1 hour ago + +# result = remove_liquidity( +# wallet=wallet, +# token_a=WETH, +# token_b=USDC, +# stable=STABLE_PAIR, +# liquidity=LIQUIDITY, +# amount_a_min=AMOUNT_A_MIN, +# amount_b_min=AMOUNT_B_MIN, +# to=wallet.default_address.address_id, +# deadline=expired_deadline, +# ) + +# assert result.startswith("Error"), "Expected error for expired deadline" + + +if __name__ == "__main__": + wallet = wallet() + test_remove_liquidity_e2e(wallet) + # test_remove_liquidity_invalid_amounts(wallet) + # test_remove_liquidity_expired_deadline(wallet)