From de63ce45856bec5556d62f9f832df4191148c162 Mon Sep 17 00:00:00 2001 From: liquid-8 Date: Tue, 27 Jun 2023 22:22:36 +0300 Subject: [PATCH 1/5] v4 support pre-alpha --- .gitignore | 1 + .vs/slnx.sqlite | Bin 0 -> 98304 bytes uniswap/__init__.py | 1 + uniswap/uniswap4.py | 362 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 364 insertions(+) create mode 100644 .vs/slnx.sqlite create mode 100644 uniswap/uniswap4.py diff --git a/.gitignore b/.gitignore index 71a12ed6..c72a8d31 100644 --- a/.gitignore +++ b/.gitignore @@ -81,3 +81,4 @@ ENV/ # mkdocs documentation /site +/.vs/ProjectSettings.json diff --git a/.vs/slnx.sqlite b/.vs/slnx.sqlite new file mode 100644 index 0000000000000000000000000000000000000000..4aa01272d4cd812dd5c4a8548a89b623738fdf2d GIT binary patch literal 98304 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 literal 0 HcmV?d00001 diff --git a/uniswap/__init__.py b/uniswap/__init__.py index 53d0a5bb..4c23bbb3 100644 --- a/uniswap/__init__.py +++ b/uniswap/__init__.py @@ -1,3 +1,4 @@ from . import exceptions from .uniswap import Uniswap, _str_to_addr +from .uniswap4 import Uniswap4 from .cli import main diff --git a/uniswap/uniswap4.py b/uniswap/uniswap4.py new file mode 100644 index 00000000..32c6bf9b --- /dev/null +++ b/uniswap/uniswap4.py @@ -0,0 +1,362 @@ +import os +import time +import logging +import functools +from typing import List, Any, Optional, Union, Tuple, Dict + +from web3 import Web3 +from web3.eth import Contract +from web3.contract import ContractFunction +from web3.exceptions import BadFunctionCallOutput, ContractLogicError +from web3.types import ( + TxParams, + Wei, + Address, + ChecksumAddress, + Nonce, + HexBytes, +) + +from .types import AddressLike +from .token import ERC20Token +from .tokens import tokens, tokens_rinkeby +from .exceptions import InvalidToken, InsufficientBalance +from .util import ( + _str_to_addr, + _addr_to_str, + _validate_address, + _load_contract, + _load_contract_erc20, + is_same_address, +) +from .decorators import supports, check_approval +from .constants import ( + _netid_to_name, + _poolmanager_contract_addresses, + ETH_ADDRESS, +) + +logger = logging.getLogger(__name__) + + +class Uniswap4: + """ + Wrapper around Uniswap v4 contracts. + """ + + def __init__( + self, + address: Union[AddressLike, str, None], + private_key: Optional[str], + provider: str = None, + web3: Web3 = None, + default_slippage: float = 0.01, + poolmanager_contract_addr: str = None, + ) -> None: + """ + :param address: The public address of the ETH wallet to use. + :param private_key: The private key of the ETH wallet to use. + :param provider: Can be optionally set to a Web3 provider URI. If none set, will fall back to the PROVIDER environment variable, or web3 if set. + :param web3: Can be optionally set to a custom Web3 instance. + :param poolmanager_contract_addr: Can be optionally set to override the address of the PoolManager contract. + """ + self.address: AddressLike = _str_to_addr( + address or "0x0000000000000000000000000000000000000000" + ) + self.private_key = ( + private_key + or "0x0000000000000000000000000000000000000000000000000000000000000000" + ) + + if web3: + self.w3 = web3 + else: + # Initialize web3. Extra provider for testing. + self.provider = provider or os.environ["PROVIDER"] + self.w3 = Web3( + Web3.HTTPProvider(self.provider, request_kwargs={"timeout": 60}) + ) + + netid = int(self.w3.net.version) + if netid in _netid_to_name: + self.network = _netid_to_name[netid] + else: + raise Exception(f"Unknown netid: {netid}") + logger.info(f"Using {self.w3} ('{self.network}')") + + self.last_nonce: Nonce = self.w3.eth.get_transaction_count(self.address) + + if poolmanager_contract_addr is None: + poolmanager_contract_addr = _poolmanager_contract_addresses[self.network] + + self.poolmanager_contract = _load_contract( + self.w3, + abi_name="uniswap-v4/poolmanager", + address=_str_to_addr(poolmanager_contract_addr), + ) + + if hasattr(self, "poolmanager_contract"): + logger.info(f"Using factory contract: {self.poolmanager_contract}") + + # ------ Market -------------------------------------------------------------------- + + def get_price( + self, + token0: AddressLike, # input token + token1: AddressLike, # output token + qty: int, + fee: int, + route: Optional[List[AddressLike]] = None, + zero_to_one: bool = true, + ) -> int: + """ + :if `zero_to_one` is true: given `qty` amount of the input `token0`, returns the maximum output amount of output `token1`. + :if `zero_to_one` is false: returns the minimum amount of `token0` required to buy `qty` amount of `token1`. + """ + + # WIP + + return 0 + + # ------ Make Trade ---------------------------------------------------------------- + def make_trade( + self, + currency0: ERC20Token, + currency1: ERC20Token, + qty: Union[int, Wei], + fee: int, + tick_spacing: int, + sqrt_price_limit_x96: int = 0, + zero_for_one: bool = true, + hooks: AddressLike = ETH, + ) -> HexBytes: + """ + :Swap against the given pool + : + :`currency0`:The lower currency of the pool, sorted numerically + :`currency1`:The higher currency of the pool, sorted numerically + :`fee`: The pool swap fee, capped at 1_000_000. The upper 4 bits determine if the hook sets any fees. + :`tickSpacing`: Ticks that involve positions must be a multiple of tick spacing + :`hooks`: The hooks of the pool + :if `zero_for_one` is true: make a trade by defining the qty of the input token. + :if `zero_for_one` is false: make a trade by defining the qty of the output token. + """ + if currency0 == currency1: + raise ValueError + + pool_key = { + "currency0": currency0.address, + "currency1": currency1.address, + "fee": fee, + "tickSpacing": tick_spacing, + "hooks": hooks, + } + + swap_params = { + "zeroForOne": zero_for_one, + "amountSpecified": qty, + "sqrtPriceLimitX96": sqrt_price_limit_x96, + } + + return self._build_and_send_tx( + self.router.functions.swap( + { + "key": pool_key, + "params": swap_params, + } + ), + self._get_tx_params(value=qty), + ) + + # ------ Wallet balance ------------------------------------------------------------ + def get_eth_balance(self) -> Wei: + """Get the balance of ETH for your address.""" + return self.w3.eth.get_balance(self.address) + + def get_token_balance(self, token: AddressLike) -> int: + """Get the balance of a token for your address.""" + _validate_address(token) + if _addr_to_str(token) == ETH_ADDRESS: + return self.get_eth_balance() + erc20 = _load_contract_erc20(self.w3, token) + balance: int = erc20.functions.balanceOf(self.address).call() + return balance + + # ------ Liquidity ----------------------------------------------------------------- + def initialize( + self, + currency0: ERC20Token, + currency1: ERC20Token, + qty: Union[int, Wei], + fee: int, + tick_spacing: int, + hooks: AddressLike, + sqrt_price_limit_x96: int, + ) -> HexBytes: + """ + :Initialize the state for a given pool ID + : + :`currency0`:The lower currency of the pool, sorted numerically + :`currency1`:The higher currency of the pool, sorted numerically + :`fee`: The pool swap fee, capped at 1_000_000. The upper 4 bits determine if the hook sets any fees. + :`tickSpacing`: Ticks that involve positions must be a multiple of tick spacing + :`hooks`: The hooks of the pool + """ + if currency0 == currency1: + raise ValueError + + pool_key = { + "currency0": currency0.address, + "currency1": currency1.address, + "fee": fee, + "tickSpacing": tick_spacing, + "hooks": hooks, + } + + return self._build_and_send_tx( + self.router.functions.initialize( + { + "key": pool_key, + "sqrtPriceX96": sqrt_price_limit_x96, + } + ), + self._get_tx_params(value=qty), + ) + + def modify_position( + self, + currency0: ERC20Token, + currency1: ERC20Token, + qty: Union[int, Wei], + fee: int, + tick_spacing: int, + tick_upper: int, + tick_lower: int, + hooks: AddressLike, + ) -> HexBytes: + if currency0 == currency1: + raise ValueError + + pool_key = { + "currency0": currency0.address, + "currency1": currency1.address, + "fee": fee, + "tickSpacing": tick_spacing, + "hooks": hooks, + } + + modify_position_params = { + "tickLower": tick_lower, + "tickUpper": tick_upper, + "liquidityDelta": qty, + } + + return self._build_and_send_tx( + self.router.functions.modifyPosition( + { + "key": pool_key, + "params": modify_position_params, + } + ), + self._get_tx_params(value=qty), + ) + + # ------ Approval Utils ------------------------------------------------------------ + def approve(self, token: AddressLike, max_approval: Optional[int] = None) -> None: + """Give an exchange/router max approval of a token.""" + max_approval = self.max_approval_int if not max_approval else max_approval + contract_addr = ( + self._exchange_address_from_token(token) + if self.version == 1 + else self.router_address + ) + function = _load_contract_erc20(self.w3, token).functions.approve( + contract_addr, max_approval + ) + logger.warning(f"Approving {_addr_to_str(token)}...") + tx = self._build_and_send_tx(function) + self.w3.eth.wait_for_transaction_receipt(tx, timeout=6000) + + # Add extra sleep to let tx propogate correctly + time.sleep(1) + + # ------ Tx Utils ------------------------------------------------------------------ + def _deadline(self) -> int: + """Get a predefined deadline. 10min by default (same as the Uniswap SDK).""" + return int(time.time()) + 10 * 60 + + def _build_and_send_tx( + self, function: ContractFunction, tx_params: Optional[TxParams] = None + ) -> HexBytes: + """Build and send a transaction.""" + if not tx_params: + tx_params = self._get_tx_params() + transaction = function.buildTransaction(tx_params) + # Uniswap3 uses 20% margin for transactions + transaction["gas"] = Wei(int(self.w3.eth.estimate_gas(transaction) * 1.2)) + signed_txn = self.w3.eth.account.sign_transaction( + transaction, private_key=self.private_key + ) + # TODO: This needs to get more complicated if we want to support replacing a transaction + # FIXME: This does not play nice if transactions are sent from other places using the same wallet. + try: + return self.w3.eth.send_raw_transaction(signed_txn.rawTransaction) + finally: + logger.debug(f"nonce: {tx_params['nonce']}") + self.last_nonce = Nonce(tx_params["nonce"] + 1) + + def _get_tx_params(self, value: Wei = Wei(0)) -> TxParams: + """Get generic transaction parameters.""" + return { + "from": _addr_to_str(self.address), + "value": value, + "nonce": max( + self.last_nonce, self.w3.eth.get_transaction_count(self.address) + ), + } + + # ------ Helpers ------------------------------------------------------------ + + def get_token(self, address: AddressLike, abi_name: str = "erc20") -> ERC20Token: + """ + Retrieves metadata from the ERC20 contract of a given token, like its name, symbol, and decimals. + """ + # FIXME: This function should always return the same output for the same input + # and would therefore benefit from caching + if address == ETH_ADDRESS: + return ERC20Token("ETH", ETH_ADDRESS, "Ether", 18) + token_contract = _load_contract(self.w3, abi_name, address=address) + try: + _name = token_contract.functions.name().call() + _symbol = token_contract.functions.symbol().call() + decimals = token_contract.functions.decimals().call() + except Exception as e: + logger.warning( + f"Exception occurred while trying to get token {_addr_to_str(address)}: {e}" + ) + raise InvalidToken(address) + try: + name = _name.decode() + except: + name = _name + try: + symbol = _symbol.decode() + except: + symbol = _symbol + return ERC20Token(symbol, address, name, decimals) + + # ------ Test utilities ------------------------------------------------------------ + + def _get_token_addresses(self) -> Dict[str, ChecksumAddress]: + """ + Returns a dict with addresses for tokens for the current net. + Used in testing. + """ + netid = int(self.w3.net.version) + netname = _netid_to_name[netid] + if netname == "mainnet": + return tokens + elif netname == "rinkeby": + return tokens_rinkeby + else: + raise Exception(f"Unknown net '{netname}'") From fa7563256d3c4955035dc6727ad1e185d1e978c6 Mon Sep 17 00:00:00 2001 From: liquid-8 Date: Tue, 27 Jun 2023 22:34:54 +0300 Subject: [PATCH 2/5] v4 support pre-alpha --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index c72a8d31..702ece3f 100644 --- a/.gitignore +++ b/.gitignore @@ -81,4 +81,4 @@ ENV/ # mkdocs documentation /site -/.vs/ProjectSettings.json +/.vs From 0f0a1898be361da8e50a4b858e485b29e16a309b Mon Sep 17 00:00:00 2001 From: liquid-8 Date: Tue, 27 Jun 2023 22:40:10 +0300 Subject: [PATCH 3/5] Revert "v4 support pre-alpha" This reverts commit fa7563256d3c4955035dc6727ad1e185d1e978c6. --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 702ece3f..c72a8d31 100644 --- a/.gitignore +++ b/.gitignore @@ -81,4 +81,4 @@ ENV/ # mkdocs documentation /site -/.vs +/.vs/ProjectSettings.json From d9b28e84f92f9a002c6f347ac79254fd08290ade Mon Sep 17 00:00:00 2001 From: liquid-8 Date: Tue, 27 Jun 2023 22:40:46 +0300 Subject: [PATCH 4/5] Revert "v4 support pre-alpha" This reverts commit de63ce45856bec5556d62f9f832df4191148c162. --- .gitignore | 1 - .vs/slnx.sqlite | Bin 98304 -> 0 bytes uniswap/__init__.py | 1 - uniswap/uniswap4.py | 362 -------------------------------------------- 4 files changed, 364 deletions(-) delete mode 100644 .vs/slnx.sqlite delete mode 100644 uniswap/uniswap4.py diff --git a/.gitignore b/.gitignore index c72a8d31..71a12ed6 100644 --- a/.gitignore +++ b/.gitignore @@ -81,4 +81,3 @@ ENV/ # mkdocs documentation /site -/.vs/ProjectSettings.json diff --git a/.vs/slnx.sqlite b/.vs/slnx.sqlite deleted file mode 100644 index 4aa01272d4cd812dd5c4a8548a89b623738fdf2d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 98304 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 diff --git a/uniswap/__init__.py b/uniswap/__init__.py index 4c23bbb3..53d0a5bb 100644 --- a/uniswap/__init__.py +++ b/uniswap/__init__.py @@ -1,4 +1,3 @@ from . import exceptions from .uniswap import Uniswap, _str_to_addr -from .uniswap4 import Uniswap4 from .cli import main diff --git a/uniswap/uniswap4.py b/uniswap/uniswap4.py deleted file mode 100644 index 32c6bf9b..00000000 --- a/uniswap/uniswap4.py +++ /dev/null @@ -1,362 +0,0 @@ -import os -import time -import logging -import functools -from typing import List, Any, Optional, Union, Tuple, Dict - -from web3 import Web3 -from web3.eth import Contract -from web3.contract import ContractFunction -from web3.exceptions import BadFunctionCallOutput, ContractLogicError -from web3.types import ( - TxParams, - Wei, - Address, - ChecksumAddress, - Nonce, - HexBytes, -) - -from .types import AddressLike -from .token import ERC20Token -from .tokens import tokens, tokens_rinkeby -from .exceptions import InvalidToken, InsufficientBalance -from .util import ( - _str_to_addr, - _addr_to_str, - _validate_address, - _load_contract, - _load_contract_erc20, - is_same_address, -) -from .decorators import supports, check_approval -from .constants import ( - _netid_to_name, - _poolmanager_contract_addresses, - ETH_ADDRESS, -) - -logger = logging.getLogger(__name__) - - -class Uniswap4: - """ - Wrapper around Uniswap v4 contracts. - """ - - def __init__( - self, - address: Union[AddressLike, str, None], - private_key: Optional[str], - provider: str = None, - web3: Web3 = None, - default_slippage: float = 0.01, - poolmanager_contract_addr: str = None, - ) -> None: - """ - :param address: The public address of the ETH wallet to use. - :param private_key: The private key of the ETH wallet to use. - :param provider: Can be optionally set to a Web3 provider URI. If none set, will fall back to the PROVIDER environment variable, or web3 if set. - :param web3: Can be optionally set to a custom Web3 instance. - :param poolmanager_contract_addr: Can be optionally set to override the address of the PoolManager contract. - """ - self.address: AddressLike = _str_to_addr( - address or "0x0000000000000000000000000000000000000000" - ) - self.private_key = ( - private_key - or "0x0000000000000000000000000000000000000000000000000000000000000000" - ) - - if web3: - self.w3 = web3 - else: - # Initialize web3. Extra provider for testing. - self.provider = provider or os.environ["PROVIDER"] - self.w3 = Web3( - Web3.HTTPProvider(self.provider, request_kwargs={"timeout": 60}) - ) - - netid = int(self.w3.net.version) - if netid in _netid_to_name: - self.network = _netid_to_name[netid] - else: - raise Exception(f"Unknown netid: {netid}") - logger.info(f"Using {self.w3} ('{self.network}')") - - self.last_nonce: Nonce = self.w3.eth.get_transaction_count(self.address) - - if poolmanager_contract_addr is None: - poolmanager_contract_addr = _poolmanager_contract_addresses[self.network] - - self.poolmanager_contract = _load_contract( - self.w3, - abi_name="uniswap-v4/poolmanager", - address=_str_to_addr(poolmanager_contract_addr), - ) - - if hasattr(self, "poolmanager_contract"): - logger.info(f"Using factory contract: {self.poolmanager_contract}") - - # ------ Market -------------------------------------------------------------------- - - def get_price( - self, - token0: AddressLike, # input token - token1: AddressLike, # output token - qty: int, - fee: int, - route: Optional[List[AddressLike]] = None, - zero_to_one: bool = true, - ) -> int: - """ - :if `zero_to_one` is true: given `qty` amount of the input `token0`, returns the maximum output amount of output `token1`. - :if `zero_to_one` is false: returns the minimum amount of `token0` required to buy `qty` amount of `token1`. - """ - - # WIP - - return 0 - - # ------ Make Trade ---------------------------------------------------------------- - def make_trade( - self, - currency0: ERC20Token, - currency1: ERC20Token, - qty: Union[int, Wei], - fee: int, - tick_spacing: int, - sqrt_price_limit_x96: int = 0, - zero_for_one: bool = true, - hooks: AddressLike = ETH, - ) -> HexBytes: - """ - :Swap against the given pool - : - :`currency0`:The lower currency of the pool, sorted numerically - :`currency1`:The higher currency of the pool, sorted numerically - :`fee`: The pool swap fee, capped at 1_000_000. The upper 4 bits determine if the hook sets any fees. - :`tickSpacing`: Ticks that involve positions must be a multiple of tick spacing - :`hooks`: The hooks of the pool - :if `zero_for_one` is true: make a trade by defining the qty of the input token. - :if `zero_for_one` is false: make a trade by defining the qty of the output token. - """ - if currency0 == currency1: - raise ValueError - - pool_key = { - "currency0": currency0.address, - "currency1": currency1.address, - "fee": fee, - "tickSpacing": tick_spacing, - "hooks": hooks, - } - - swap_params = { - "zeroForOne": zero_for_one, - "amountSpecified": qty, - "sqrtPriceLimitX96": sqrt_price_limit_x96, - } - - return self._build_and_send_tx( - self.router.functions.swap( - { - "key": pool_key, - "params": swap_params, - } - ), - self._get_tx_params(value=qty), - ) - - # ------ Wallet balance ------------------------------------------------------------ - def get_eth_balance(self) -> Wei: - """Get the balance of ETH for your address.""" - return self.w3.eth.get_balance(self.address) - - def get_token_balance(self, token: AddressLike) -> int: - """Get the balance of a token for your address.""" - _validate_address(token) - if _addr_to_str(token) == ETH_ADDRESS: - return self.get_eth_balance() - erc20 = _load_contract_erc20(self.w3, token) - balance: int = erc20.functions.balanceOf(self.address).call() - return balance - - # ------ Liquidity ----------------------------------------------------------------- - def initialize( - self, - currency0: ERC20Token, - currency1: ERC20Token, - qty: Union[int, Wei], - fee: int, - tick_spacing: int, - hooks: AddressLike, - sqrt_price_limit_x96: int, - ) -> HexBytes: - """ - :Initialize the state for a given pool ID - : - :`currency0`:The lower currency of the pool, sorted numerically - :`currency1`:The higher currency of the pool, sorted numerically - :`fee`: The pool swap fee, capped at 1_000_000. The upper 4 bits determine if the hook sets any fees. - :`tickSpacing`: Ticks that involve positions must be a multiple of tick spacing - :`hooks`: The hooks of the pool - """ - if currency0 == currency1: - raise ValueError - - pool_key = { - "currency0": currency0.address, - "currency1": currency1.address, - "fee": fee, - "tickSpacing": tick_spacing, - "hooks": hooks, - } - - return self._build_and_send_tx( - self.router.functions.initialize( - { - "key": pool_key, - "sqrtPriceX96": sqrt_price_limit_x96, - } - ), - self._get_tx_params(value=qty), - ) - - def modify_position( - self, - currency0: ERC20Token, - currency1: ERC20Token, - qty: Union[int, Wei], - fee: int, - tick_spacing: int, - tick_upper: int, - tick_lower: int, - hooks: AddressLike, - ) -> HexBytes: - if currency0 == currency1: - raise ValueError - - pool_key = { - "currency0": currency0.address, - "currency1": currency1.address, - "fee": fee, - "tickSpacing": tick_spacing, - "hooks": hooks, - } - - modify_position_params = { - "tickLower": tick_lower, - "tickUpper": tick_upper, - "liquidityDelta": qty, - } - - return self._build_and_send_tx( - self.router.functions.modifyPosition( - { - "key": pool_key, - "params": modify_position_params, - } - ), - self._get_tx_params(value=qty), - ) - - # ------ Approval Utils ------------------------------------------------------------ - def approve(self, token: AddressLike, max_approval: Optional[int] = None) -> None: - """Give an exchange/router max approval of a token.""" - max_approval = self.max_approval_int if not max_approval else max_approval - contract_addr = ( - self._exchange_address_from_token(token) - if self.version == 1 - else self.router_address - ) - function = _load_contract_erc20(self.w3, token).functions.approve( - contract_addr, max_approval - ) - logger.warning(f"Approving {_addr_to_str(token)}...") - tx = self._build_and_send_tx(function) - self.w3.eth.wait_for_transaction_receipt(tx, timeout=6000) - - # Add extra sleep to let tx propogate correctly - time.sleep(1) - - # ------ Tx Utils ------------------------------------------------------------------ - def _deadline(self) -> int: - """Get a predefined deadline. 10min by default (same as the Uniswap SDK).""" - return int(time.time()) + 10 * 60 - - def _build_and_send_tx( - self, function: ContractFunction, tx_params: Optional[TxParams] = None - ) -> HexBytes: - """Build and send a transaction.""" - if not tx_params: - tx_params = self._get_tx_params() - transaction = function.buildTransaction(tx_params) - # Uniswap3 uses 20% margin for transactions - transaction["gas"] = Wei(int(self.w3.eth.estimate_gas(transaction) * 1.2)) - signed_txn = self.w3.eth.account.sign_transaction( - transaction, private_key=self.private_key - ) - # TODO: This needs to get more complicated if we want to support replacing a transaction - # FIXME: This does not play nice if transactions are sent from other places using the same wallet. - try: - return self.w3.eth.send_raw_transaction(signed_txn.rawTransaction) - finally: - logger.debug(f"nonce: {tx_params['nonce']}") - self.last_nonce = Nonce(tx_params["nonce"] + 1) - - def _get_tx_params(self, value: Wei = Wei(0)) -> TxParams: - """Get generic transaction parameters.""" - return { - "from": _addr_to_str(self.address), - "value": value, - "nonce": max( - self.last_nonce, self.w3.eth.get_transaction_count(self.address) - ), - } - - # ------ Helpers ------------------------------------------------------------ - - def get_token(self, address: AddressLike, abi_name: str = "erc20") -> ERC20Token: - """ - Retrieves metadata from the ERC20 contract of a given token, like its name, symbol, and decimals. - """ - # FIXME: This function should always return the same output for the same input - # and would therefore benefit from caching - if address == ETH_ADDRESS: - return ERC20Token("ETH", ETH_ADDRESS, "Ether", 18) - token_contract = _load_contract(self.w3, abi_name, address=address) - try: - _name = token_contract.functions.name().call() - _symbol = token_contract.functions.symbol().call() - decimals = token_contract.functions.decimals().call() - except Exception as e: - logger.warning( - f"Exception occurred while trying to get token {_addr_to_str(address)}: {e}" - ) - raise InvalidToken(address) - try: - name = _name.decode() - except: - name = _name - try: - symbol = _symbol.decode() - except: - symbol = _symbol - return ERC20Token(symbol, address, name, decimals) - - # ------ Test utilities ------------------------------------------------------------ - - def _get_token_addresses(self) -> Dict[str, ChecksumAddress]: - """ - Returns a dict with addresses for tokens for the current net. - Used in testing. - """ - netid = int(self.w3.net.version) - netname = _netid_to_name[netid] - if netname == "mainnet": - return tokens - elif netname == "rinkeby": - return tokens_rinkeby - else: - raise Exception(f"Unknown net '{netname}'") From 18fad99c8514a6380d9b17ebda72552482561b4e Mon Sep 17 00:00:00 2001 From: Yohan K <72107640+liquid-8@users.noreply.github.com> Date: Thu, 25 Dec 2025 18:45:39 +0200 Subject: [PATCH 5/5] Update __init__.py --- uniswap/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/uniswap/__init__.py b/uniswap/__init__.py index a1612f65..f9f37116 100644 --- a/uniswap/__init__.py +++ b/uniswap/__init__.py @@ -1,5 +1,6 @@ from . import exceptions from .cli import main from .uniswap import Uniswap, _str_to_addr +from .uniswap4 import Uniswap4 __all__ = ["Uniswap", "exceptions", "_str_to_addr", "main"]