From 13a77abe27fc0a6b7adb64ac7c1cdb05fcd2dbe8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Erik=20Bj=C3=A4reholt?= Date: Wed, 19 May 2021 14:06:16 +0200 Subject: [PATCH 01/10] refactor: massive refactor --- tests/test_cli.py | 4 - uniswap/cli.py | 16 +-- uniswap/constants.py | 39 +++++++ uniswap/decorators.py | 60 +++++++++++ uniswap/exceptions.py | 15 +++ uniswap/token.py | 20 ++++ uniswap/types.py | 7 ++ uniswap/uniswap.py | 243 +++++++++--------------------------------- uniswap/util.py | 58 ++++++++++ 9 files changed, 259 insertions(+), 203 deletions(-) create mode 100644 uniswap/constants.py create mode 100644 uniswap/decorators.py create mode 100644 uniswap/exceptions.py create mode 100644 uniswap/token.py create mode 100644 uniswap/types.py create mode 100644 uniswap/util.py diff --git a/tests/test_cli.py b/tests/test_cli.py index 2490e54..e62adf2 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -39,10 +39,6 @@ def test_get_token(): print_result(result) assert result.exit_code == 0 - out = json.loads(result.stdout.replace("'", '"')) - assert out["symbol"] == "WETH" - assert out["decimals"] == 18 - def test_get_tokendb(): runner = CliRunner(mix_stderr=False) diff --git a/uniswap/cli.py b/uniswap/cli.py index 47d13c4..50e01fd 100644 --- a/uniswap/cli.py +++ b/uniswap/cli.py @@ -6,6 +6,7 @@ from web3 import Web3 from .uniswap import Uniswap, AddressLike, _str_to_addr +from .token import BaseToken, Token from .tokens import tokens @@ -65,14 +66,14 @@ def price( quantity: int = None, ) -> None: """Returns the price of ``quantity`` tokens of ``token_in`` quoted in ``token_out``.""" - uni = ctx.obj["UNISWAP"] + uni: Uniswap = ctx.obj["UNISWAP"] if quantity is None: - quantity = 10 ** uni.get_token(token_in)["decimals"] + quantity = 10 ** uni.get_token(token_in).decimals price = uni.get_token_token_input_price(token_in, token_out, qty=quantity) if raw: print(price) else: - decimals = uni.get_token(token_out)["decimals"] + decimals = uni.get_token(token_out).decimals print(price / 10 ** decimals) @@ -81,7 +82,7 @@ def price( @click.pass_context def token(ctx: click.Context, token: AddressLike) -> None: """Show metadata for token""" - uni = ctx.obj["UNISWAP"] + uni: Uniswap = ctx.obj["UNISWAP"] t1 = uni.get_token(token) print(t1) @@ -91,12 +92,11 @@ def token(ctx: click.Context, token: AddressLike) -> None: @click.pass_context def tokendb(ctx: click.Context, metadata: bool) -> None: """List known token addresses""" - uni = ctx.obj["UNISWAP"] + uni: Uniswap = ctx.obj["UNISWAP"] for symbol, addr in tokens.items(): if metadata and addr != "0x0000000000000000000000000000000000000000": data = uni.get_token(_str_to_addr(addr)) - data["address"] = addr - assert data["symbol"].lower() == symbol.lower() + assert data.symbol.lower() == symbol.lower() print(data) else: - print({"symbol": symbol, "address": addr}) + print(BaseToken(symbol, addr)) diff --git a/uniswap/constants.py b/uniswap/constants.py new file mode 100644 index 0000000..7092685 --- /dev/null +++ b/uniswap/constants.py @@ -0,0 +1,39 @@ +ETH_ADDRESS = "0x0000000000000000000000000000000000000000" +WETH9_ADDRESS = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2" + +# see: https://chainid.network/chains/ +_netid_to_name = { + 1: "mainnet", + 3: "ropsten", + 4: "rinkeby", + 56: "binance", + 97: "binance_testnet", + 100: "xdai", +} + +_factory_contract_addresses_v1 = { + "mainnet": "0xc0a47dFe034B400B47bDaD5FecDa2621de6c4d95", + "ropsten": "0x9c83dCE8CA20E9aAF9D3efc003b2ea62aBC08351", + "rinkeby": "0xf5D915570BC477f9B8D6C0E980aA81757A3AaC36", + "kovan": "0xD3E51Ef092B2845f10401a0159B2B96e8B6c3D30", + "görli": "0x6Ce570d02D73d4c384b46135E87f8C592A8c86dA", +} + + +# For v2 the address is the same on mainnet, Ropsten, Rinkeby, Görli, and Kovan +# https://uniswap.org/docs/v2/smart-contracts/factory +_factory_contract_addresses_v2 = { + "mainnet": "0x5C69bEe701ef814a2B6a3EDD4B1652CB9cc5aA6f", + "ropsten": "0x5C69bEe701ef814a2B6a3EDD4B1652CB9cc5aA6f", + "rinkeby": "0x5C69bEe701ef814a2B6a3EDD4B1652CB9cc5aA6f", + "görli": "0x5C69bEe701ef814a2B6a3EDD4B1652CB9cc5aA6f", + "xdai": "0xA818b4F111Ccac7AA31D0BCc0806d64F2E0737D7", +} + +_router_contract_addresses_v2 = { + "mainnet": "0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D", + "ropsten": "0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D", + "rinkeby": "0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D", + "görli": "0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D", + "xdai": "0x1C232F01118CB8B424793ae03F870aa7D0ac7f77", +} diff --git a/uniswap/decorators.py b/uniswap/decorators.py new file mode 100644 index 0000000..d0f0ed3 --- /dev/null +++ b/uniswap/decorators.py @@ -0,0 +1,60 @@ +import functools +from typing import Callable, Any, List, Dict, TYPE_CHECKING + +from .constants import ETH_ADDRESS + +if TYPE_CHECKING: + from .uniswap import Uniswap + + +def check_approval(method: Callable) -> Callable: + """Decorator to check if user is approved for a token. It approves them if they + need to be approved.""" + + @functools.wraps(method) + def approved(self: Any, *args: Any, **kwargs: Any) -> Any: + # Check to see if the first token is actually ETH + token = args[0] if args[0] != ETH_ADDRESS else None + token_two = None + + # Check second token, if needed + if method.__name__ == "make_trade" or method.__name__ == "make_trade_output": + token_two = args[1] if args[1] != ETH_ADDRESS else None + + # Approve both tokens, if needed + if token: + is_approved = self._is_approved(token) + # logger.warning(f"Approved? {token}: {is_approved}") + if not is_approved: + self.approve(token) + if token_two: + is_approved = self._is_approved(token_two) + # logger.warning(f"Approved? {token_two}: {is_approved}") + if not is_approved: + self.approve(token_two) + return method(self, *args, **kwargs) + + return approved + + +def supports(versions: List[int]) -> Callable: + def g(f: Callable) -> Callable: + if f.__doc__ is None: + f.__doc__ = "" + f.__doc__ += """\n\n + Supports Uniswap + """ + ", ".join( + "v" + str(ver) for ver in versions + ) + + @functools.wraps(f) + def check_version(self: "Uniswap", *args: List, **kwargs: Dict) -> Any: + if self.version not in versions: + raise Exception( + f"Function {f.__name__} does not support version {self.version} of Uniswap passed to constructor" + ) + return f(self, *args, **kwargs) + + return check_version + + return g diff --git a/uniswap/exceptions.py b/uniswap/exceptions.py new file mode 100644 index 0000000..8080b77 --- /dev/null +++ b/uniswap/exceptions.py @@ -0,0 +1,15 @@ +from typing import Any + + +class InvalidToken(Exception): + """Raised when an invalid token address is used.""" + + def __init__(self, address: Any) -> None: + Exception.__init__(self, f"Invalid token address: {address}") + + +class InsufficientBalance(Exception): + """Raised when the account has insufficient balance for a transaction.""" + + def __init__(self, had: int, needed: int) -> None: + Exception.__init__(self, f"Insufficient balance. Had {had}, needed {needed}") diff --git a/uniswap/token.py b/uniswap/token.py new file mode 100644 index 0000000..be33226 --- /dev/null +++ b/uniswap/token.py @@ -0,0 +1,20 @@ +from dataclasses import dataclass +from .types import AddressLike + + +@dataclass +class BaseToken: + symbol: str + address: AddressLike + + def __repr__(self) -> str: + return f"BaseToken({self.symbol}, {self.address!r})" + + +@dataclass +class Token(BaseToken): + name: str + decimals: int + + def __repr__(self) -> str: + return f"Token({self.symbol}, {self.address!r}, {self.decimals})" diff --git a/uniswap/types.py b/uniswap/types.py new file mode 100644 index 0000000..89e37b9 --- /dev/null +++ b/uniswap/types.py @@ -0,0 +1,7 @@ +from typing import Union +from web3.eth import Contract # noqa: F401 +from web3.types import Address, ChecksumAddress, ENS + + +# TODO: Consider dropping support for ENS altogether and instead use AnyAddress +AddressLike = Union[Address, ChecksumAddress, ENS] diff --git a/uniswap/uniswap.py b/uniswap/uniswap.py index abd3bc8..abe96ae 100644 --- a/uniswap/uniswap.py +++ b/uniswap/uniswap.py @@ -20,163 +20,29 @@ from eth_utils import is_same_address from eth_typing import AnyAddress +from .types import AddressLike +from .token import Token from .tokens import tokens, tokens_rinkeby - -ETH_ADDRESS = "0x0000000000000000000000000000000000000000" -WETH9_ADDRESS = Web3.toChecksumAddress("0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2") +from .exceptions import InvalidToken, InsufficientBalance +from .util import ( + _str_to_addr, + _addr_to_str, + _validate_address, + _load_contract, + _load_contract_erc20, +) +from .decorators import supports, check_approval +from .constants import ( + _netid_to_name, + _factory_contract_addresses_v1, + _factory_contract_addresses_v2, + _router_contract_addresses_v2, + ETH_ADDRESS, +) logger = logging.getLogger(__name__) -# TODO: Consider dropping support for ENS altogether and instead use AnyAddress -AddressLike = Union[Address, ChecksumAddress, ENS] - - -class InvalidToken(Exception): - """Raised when an invalid token address is used.""" - - def __init__(self, address: Any) -> None: - Exception.__init__(self, f"Invalid token address: {address}") - - -class InsufficientBalance(Exception): - """Raised when the account has insufficient balance for a transaction.""" - - def __init__(self, had: int, needed: int) -> None: - Exception.__init__(self, f"Insufficient balance. Had {had}, needed {needed}") - - -def _load_abi(name: str) -> str: - path = f"{os.path.dirname(os.path.abspath(__file__))}/assets/" - with open(os.path.abspath(path + f"{name}.abi")) as f: - abi: str = json.load(f) - return abi - - -def check_approval(method: Callable) -> Callable: - """Decorator to check if user is approved for a token. It approves them if they - need to be approved.""" - - @functools.wraps(method) - def approved(self: Any, *args: Any, **kwargs: Any) -> Any: - # Check to see if the first token is actually ETH - token = args[0] if args[0] != ETH_ADDRESS else None - token_two = None - - # Check second token, if needed - if method.__name__ == "make_trade" or method.__name__ == "make_trade_output": - token_two = args[1] if args[1] != ETH_ADDRESS else None - - # Approve both tokens, if needed - if token: - is_approved = self._is_approved(token) - # logger.warning(f"Approved? {token}: {is_approved}") - if not is_approved: - self.approve(token) - if token_two: - is_approved = self._is_approved(token_two) - # logger.warning(f"Approved? {token_two}: {is_approved}") - if not is_approved: - self.approve(token_two) - return method(self, *args, **kwargs) - - return approved - - -def supports(versions: List[int]) -> Callable: - def g(f: Callable) -> Callable: - if f.__doc__ is None: - f.__doc__ = "" - f.__doc__ += """\n\n - Supports Uniswap - """ + ", ".join( - "v" + str(ver) for ver in versions - ) - - @functools.wraps(f) - def check_version(self: "Uniswap", *args: List, **kwargs: Dict) -> Any: - if self.version not in versions: - raise Exception( - f"Function {f.__name__} does not support version {self.version} of Uniswap passed to constructor" - ) - return f(self, *args, **kwargs) - - return check_version - - return g - - -def _str_to_addr(s: Union[str, Address]) -> AddressLike: - """Idempotent""" - if isinstance(s, str): - if s.startswith("0x"): - return Address(bytes.fromhex(s[2:])) - elif s.endswith(".eth"): - return ENS(s) - else: - raise Exception(f"Couldn't convert string '{s}' to AddressLike") - else: - return s - - -def _addr_to_str(a: AddressLike) -> str: - if isinstance(a, bytes): - # Address or ChecksumAddress - addr: str = Web3.toChecksumAddress("0x" + bytes(a).hex()) - return addr - elif isinstance(a, str): - if a.endswith(".eth"): - # Address is ENS - raise Exception("ENS not supported for this operation") - elif a.startswith("0x"): - addr = Web3.toChecksumAddress(a) - return addr - - raise InvalidToken(a) - - -def _validate_address(a: AddressLike) -> None: - assert _addr_to_str(a) - - -# see: https://chainid.network/chains/ -_netid_to_name = { - 1: "mainnet", - 3: "ropsten", - 4: "rinkeby", - 56: "binance", - 97: "binance_testnet", - 100: "xdai", -} - -_factory_contract_addresses_v1 = { - "mainnet": "0xc0a47dFe034B400B47bDaD5FecDa2621de6c4d95", - "ropsten": "0x9c83dCE8CA20E9aAF9D3efc003b2ea62aBC08351", - "rinkeby": "0xf5D915570BC477f9B8D6C0E980aA81757A3AaC36", - "kovan": "0xD3E51Ef092B2845f10401a0159B2B96e8B6c3D30", - "görli": "0x6Ce570d02D73d4c384b46135E87f8C592A8c86dA", -} - - -# For v2 the address is the same on mainnet, Ropsten, Rinkeby, Görli, and Kovan -# https://uniswap.org/docs/v2/smart-contracts/factory -_factory_contract_addresses_v2 = { - "mainnet": "0x5C69bEe701ef814a2B6a3EDD4B1652CB9cc5aA6f", - "ropsten": "0x5C69bEe701ef814a2B6a3EDD4B1652CB9cc5aA6f", - "rinkeby": "0x5C69bEe701ef814a2B6a3EDD4B1652CB9cc5aA6f", - "görli": "0x5C69bEe701ef814a2B6a3EDD4B1652CB9cc5aA6f", - "xdai": "0xA818b4F111Ccac7AA31D0BCc0806d64F2E0737D7", -} - -_router_contract_addresses_v2 = { - "mainnet": "0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D", - "ropsten": "0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D", - "rinkeby": "0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D", - "görli": "0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D", - "xdai": "0x1C232F01118CB8B424793ae03F870aa7D0ac7f77", -} - - class Uniswap: """ Wrapper around Uniswap v1 and v2 contracts. @@ -248,7 +114,8 @@ def __init__( if factory_contract_addr is None: factory_contract_addr = _factory_contract_addresses_v1[self.network] - self.factory_contract = self._load_contract( + self.factory_contract = _load_contract( + self.w3, abi_name="uniswap-v1/factory", address=_str_to_addr(factory_contract_addr), ) @@ -259,13 +126,14 @@ def __init__( if factory_contract_addr is None: factory_contract_addr = _factory_contract_addresses_v2[self.network] - self.factory_contract = self._load_contract( + self.factory_contract = _load_contract( + self.w3, abi_name="uniswap-v2/factory", address=_str_to_addr(factory_contract_addr), ) # Documented here: https://uniswap.org/docs/v2/smart-contracts/router02/ - self.router = self._load_contract( - abi_name="uniswap-v2/router02", address=self.router_address, + self.router = _load_contract( + self.w3, abi_name="uniswap-v2/router02", address=self.router_address, ) elif self.version == 3: # https://github.com/Uniswap/uniswap-v3-periphery/blob/main/deploys.md @@ -273,11 +141,11 @@ def __init__( self.router_address = _str_to_addr( "0xE592427A0AEce92De3Edee1F18E0157C05861564" ) - self.quoter = self._load_contract( - abi_name="uniswap-v3/quoter", address=quoter_addr + self.quoter = _load_contract( + self.w3, abi_name="uniswap-v3/quoter", address=quoter_addr ) - self.router = self._load_contract( - abi_name="uniswap-v3/router", address=self.router_address + self.router = _load_contract( + self.w3, abi_name="uniswap-v3/router", address=self.router_address ) else: raise Exception(f"Invalid version '{self.version}', only 1 or 2 supported") @@ -285,8 +153,26 @@ def __init__( if hasattr(self, "factory_contract"): logger.info(f"Using factory contract: {self.factory_contract}") + def get_token(self, address: AddressLike) -> Token: + """ + 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 + token_contract = _load_contract(self.w3, abi_name="erc20", 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) + return Token(symbol, address, name, decimals) + @supports([1]) - def get_all_tokens(self) -> List[dict]: + def get_all_tokens(self) -> List[Token]: """ Retrieves all token pairs. @@ -304,24 +190,6 @@ def get_all_tokens(self) -> List[dict]: tokens.append(token) return tokens - def get_token(self, address: AddressLike) -> dict: - """ - 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 - token_contract = self._load_contract(abi_name="erc20", 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) - return {"name": name, "symbol": symbol, "decimals": decimals} - @supports([1]) def exchange_address_from_token(self, token_addr: AddressLike) -> AddressLike: ex_addr: AddressLike = self.factory_contract.functions.getExchange( @@ -350,14 +218,10 @@ def exchange_contract( if ex_addr is None: raise InvalidToken(token_addr) abi_name = "uniswap-v1/exchange" - contract = self._load_contract(abi_name=abi_name, address=ex_addr) + contract = _load_contract(self.w3, abi_name=abi_name, address=ex_addr) logger.info(f"Loaded exchange contract {contract} at {contract.address}") return contract - @functools.lru_cache() - def erc20_contract(self, token_addr: AddressLike) -> Contract: - return self._load_contract(abi_name="erc20", address=token_addr) - @functools.lru_cache() @supports([2, 3]) def get_weth_address(self) -> ChecksumAddress: @@ -368,9 +232,6 @@ def get_weth_address(self) -> ChecksumAddress: address = self.router.functions.WETH9().call() return address - def _load_contract(self, abi_name: str, address: AddressLike) -> Contract: - return self.w3.eth.contract(address=address, abi=_load_abi(abi_name)) - # ------ Exchange ------------------------------------------------------------------ @supports([1, 2]) def get_fee_maker(self) -> float: @@ -553,7 +414,7 @@ def get_token_balance(self, token: AddressLike) -> int: _validate_address(token) if _addr_to_str(token) == ETH_ADDRESS: return self.get_eth_balance() - erc20 = self.erc20_contract(token) + erc20 = _load_contract_erc20(self.w3, token) balance: int = erc20.functions.balanceOf(self.address).call() return balance @@ -567,7 +428,7 @@ def get_ex_eth_balance(self, token: AddressLike) -> int: @supports([1]) def get_ex_token_balance(self, token: AddressLike) -> int: """Get the balance of a token in an exchange contract.""" - erc20 = self.erc20_contract(token) + erc20 = _load_contract_erc20(self.w3, token) balance: int = erc20.functions.balanceOf( self.exchange_address_from_token(token) ).call() @@ -980,7 +841,7 @@ def approve(self, token: AddressLike, max_approval: Optional[int] = None) -> Non if self.version == 1 else self.router_address ) - function = self.erc20_contract(token).functions.approve( + function = _load_contract_erc20(self.w3, token).functions.approve( contract_addr, max_approval ) logger.warning(f"Approving {_addr_to_str(token)}...") @@ -998,7 +859,7 @@ def _is_approved(self, token: AddressLike) -> bool: elif self.version in [2, 3]: contract_addr = self.router_address amount = ( - self.erc20_contract(token) + _load_contract_erc20(self.w3, token) .functions.allowance(self.address, contract_addr) .call() ) diff --git a/uniswap/util.py b/uniswap/util.py new file mode 100644 index 0000000..1440cfb --- /dev/null +++ b/uniswap/util.py @@ -0,0 +1,58 @@ +import os +import json +import functools +from typing import Union + +from web3 import Web3 + +from .types import AddressLike, Address, ENS, Contract +from .exceptions import InvalidToken + + +def _str_to_addr(s: Union[str, Address]) -> AddressLike: + """Idempotent""" + if isinstance(s, str): + if s.startswith("0x"): + return Address(bytes.fromhex(s[2:])) + elif s.endswith(".eth"): + return ENS(s) + else: + raise Exception(f"Couldn't convert string '{s}' to AddressLike") + else: + return s + + +def _addr_to_str(a: AddressLike) -> str: + if isinstance(a, bytes): + # Address or ChecksumAddress + addr: str = Web3.toChecksumAddress("0x" + bytes(a).hex()) + return addr + elif isinstance(a, str): + if a.endswith(".eth"): + # Address is ENS + raise Exception("ENS not supported for this operation") + elif a.startswith("0x"): + addr = Web3.toChecksumAddress(a) + return addr + + raise InvalidToken(a) + + +def _validate_address(a: AddressLike) -> None: + assert _addr_to_str(a) + + +def _load_abi(name: str) -> str: + path = f"{os.path.dirname(os.path.abspath(__file__))}/assets/" + with open(os.path.abspath(path + f"{name}.abi")) as f: + abi: str = json.load(f) + return abi + + +@functools.lru_cache() +def _load_contract(w3: Web3, abi_name: str, address: AddressLike) -> Contract: + return w3.eth.contract(address=address, abi=_load_abi(abi_name)) + + +def _load_contract_erc20(w3: Web3, address: AddressLike) -> Contract: + return _load_contract(w3, "erc20", address) From 9e2c60821d8832a8a3aebd132e366b7053b26b7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Erik=20Bj=C3=A4reholt?= Date: Wed, 19 May 2021 14:21:54 +0200 Subject: [PATCH 02/10] fix: fixed docs after refactor --- docs/api.rst | 14 ++++++++++++++ uniswap/__init__.py | 3 ++- uniswap/cli.py | 2 +- uniswap/token.py | 12 +++++++++++- uniswap/uniswap.py | 12 +++++------- 5 files changed, 33 insertions(+), 10 deletions(-) diff --git a/docs/api.rst b/docs/api.rst index 6da6b49..5a0114f 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -9,9 +9,23 @@ Uniswap class .. autoclass:: Uniswap :members: +Token class +----------- + +.. automodule:: uniswap.token + +.. autoclass:: BaseToken + :members: + +.. autoclass:: ERC20Token + :inherited-members: + :members: + Exceptions ---------- +.. automodule:: uniswap.exceptions + .. autoexception:: InvalidToken .. autoexception:: InsufficientBalance diff --git a/uniswap/__init__.py b/uniswap/__init__.py index 7dc75c5..53d0a5b 100644 --- a/uniswap/__init__.py +++ b/uniswap/__init__.py @@ -1,2 +1,3 @@ -from .uniswap import Uniswap, InvalidToken, InsufficientBalance, _str_to_addr +from . import exceptions +from .uniswap import Uniswap, _str_to_addr from .cli import main diff --git a/uniswap/cli.py b/uniswap/cli.py index 50e01fd..4c8ab8c 100644 --- a/uniswap/cli.py +++ b/uniswap/cli.py @@ -6,7 +6,7 @@ from web3 import Web3 from .uniswap import Uniswap, AddressLike, _str_to_addr -from .token import BaseToken, Token +from .token import BaseToken from .tokens import tokens diff --git a/uniswap/token.py b/uniswap/token.py index be33226..bcbeaf5 100644 --- a/uniswap/token.py +++ b/uniswap/token.py @@ -4,17 +4,27 @@ @dataclass class BaseToken: + """Base for tokens of all kinds""" + symbol: str + """Symbol such as ETH, DAI, etc.""" + address: AddressLike + """Address of the token contract.""" def __repr__(self) -> str: return f"BaseToken({self.symbol}, {self.address!r})" @dataclass -class Token(BaseToken): +class ERC20Token(BaseToken): + """Represents an ERC20 token""" + name: str + """Name of the token, as specified in the contract.""" + decimals: int + """Decimals used to denominate the token.""" def __repr__(self) -> str: return f"Token({self.symbol}, {self.address!r}, {self.decimals})" diff --git a/uniswap/uniswap.py b/uniswap/uniswap.py index abe96ae..7c37d7f 100644 --- a/uniswap/uniswap.py +++ b/uniswap/uniswap.py @@ -1,9 +1,8 @@ import os -import json import time import logging import functools -from typing import List, Any, Optional, Callable, Union, Tuple, Dict +from typing import List, Any, Optional, Union, Tuple, Dict from web3 import Web3 from web3.eth import Contract @@ -13,7 +12,6 @@ Wei, Address, ChecksumAddress, - ENS, Nonce, HexBytes, ) @@ -21,7 +19,7 @@ from eth_typing import AnyAddress from .types import AddressLike -from .token import Token +from .token import ERC20Token from .tokens import tokens, tokens_rinkeby from .exceptions import InvalidToken, InsufficientBalance from .util import ( @@ -153,7 +151,7 @@ def __init__( if hasattr(self, "factory_contract"): logger.info(f"Using factory contract: {self.factory_contract}") - def get_token(self, address: AddressLike) -> Token: + def get_token(self, address: AddressLike) -> ERC20Token: """ Retrieves metadata from the ERC20 contract of a given token, like its name, symbol, and decimals. """ @@ -169,10 +167,10 @@ def get_token(self, address: AddressLike) -> Token: f"Exception occurred while trying to get token {_addr_to_str(address)}: {e}" ) raise InvalidToken(address) - return Token(symbol, address, name, decimals) + return ERC20Token(symbol, address, name, decimals) @supports([1]) - def get_all_tokens(self) -> List[Token]: + def get_all_tokens(self) -> List[ERC20Token]: """ Retrieves all token pairs. From 1ca9dc89799289c9a16f6efaf30227eea6cab6d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Erik=20Bj=C3=A4reholt?= Date: Wed, 19 May 2021 16:14:06 +0200 Subject: [PATCH 03/10] feat: added ability to set slippage per swap, better fee handling for V3 --- tests/test_uniswap.py | 3 +- uniswap/uniswap.py | 158 +++++++++++++++++++++++++++++------------- 2 files changed, 113 insertions(+), 48 deletions(-) diff --git a/tests/test_uniswap.py b/tests/test_uniswap.py index ee70f60..09d909a 100644 --- a/tests/test_uniswap.py +++ b/tests/test_uniswap.py @@ -10,7 +10,8 @@ from web3 import Web3 -from uniswap import Uniswap, InvalidToken, InsufficientBalance +from uniswap import Uniswap +from uniswap.exceptions import InvalidToken, InsufficientBalance logger = logging.getLogger(__name__) diff --git a/uniswap/uniswap.py b/uniswap/uniswap.py index 7c37d7f..0ae2520 100644 --- a/uniswap/uniswap.py +++ b/uniswap/uniswap.py @@ -53,7 +53,7 @@ def __init__( provider: str = None, web3: Web3 = None, version: int = 1, - max_slippage: float = 0.1, + default_slippage: float = 0.01, factory_contract_addr: str = None, router_contract_addr: str = None, ) -> None: @@ -63,7 +63,7 @@ def __init__( :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 version: Which version of the Uniswap contracts to use. - :param max_slippage: Max allowed slippage for a trade. + :param default_slippage: Default slippage for a trade, as a float (0.01 is 1%). WARNING: slippage is untested. :param factory_contract_addr: Can be optionally set to override the address of the factory contract. :param router_contract_addr: Can be optionally set to override the address of the router contract (v2 only). """ @@ -78,7 +78,7 @@ def __init__( self.version = version # TODO: Write tests for slippage - self.max_slippage = max_slippage + self.default_slippage = default_slippage if web3: self.w3 = web3 @@ -244,7 +244,7 @@ def get_fee_taker(self) -> float: # ------ Market -------------------------------------------------------------------- @supports([1, 2, 3]) def get_eth_token_input_price( - self, token: AddressLike, qty: Wei, fee: int = 3000 + self, token: AddressLike, qty: Wei, fee: int = None ) -> Wei: """Public price for ETH to Token trades with an exact input.""" if self.version == 1: @@ -255,6 +255,9 @@ def get_eth_token_input_price( qty, [self.get_weth_address(), token] ).call()[-1] elif self.version == 3: + if not fee: + logger.warning("No fee set, assuming 0.3%") + fee = 3000 price = self.get_token_token_input_price( self.get_weth_address(), token, qty, fee=fee ) @@ -262,7 +265,7 @@ def get_eth_token_input_price( @supports([1, 2, 3]) def get_token_eth_input_price( - self, token: AddressLike, qty: int, fee: int = 3000 + self, token: AddressLike, qty: int, fee: int = None ) -> int: """Public price for token to ETH trades with an exact input.""" if self.version == 1: @@ -273,6 +276,9 @@ def get_token_eth_input_price( qty, [token, self.get_weth_address()] ).call()[-1] elif self.version == 3: + if not fee: + logger.warning("No fee set, assuming 0.3%") + fee = 3000 price = self.get_token_token_input_price( token, self.get_weth_address(), qty, fee=fee ) @@ -285,7 +291,7 @@ def get_token_token_input_price( token1: AnyAddress, qty: int, route: Optional[List[AnyAddress]] = None, - fee: int = 3000, + fee: int = None, ) -> int: """ Public price for token to token trades with an exact input. @@ -307,14 +313,14 @@ def get_token_token_input_price( if self.version == 2: price: int = self.router.functions.getAmountsOut(qty, route).call()[-1] elif self.version == 3: + if not fee: + logger.warning("No fee set, assuming 0.3%") + fee = 3000 if route: # NOTE: to support custom routes we need to support the Path data encoding: https://github.com/Uniswap/uniswap-v3-periphery/blob/main/contracts/libraries/Path.sol # result: tuple = self.quoter.functions.quoteExactInput(route, qty).call() raise Exception("custom route not yet supported for v3") - # FIXME: This comment is no longer valid, but some indication is needed if fee is unset in V3 - logger.warning("Assuming 0.3% fee") - # FIXME: How to calculate this properly? See https://docs.uniswap.org/reference/libraries/SqrtPriceMath sqrtPriceLimitX96 = 0 price = self.quoter.functions.quoteExactInputSingle( @@ -324,7 +330,7 @@ def get_token_token_input_price( @supports([1, 2, 3]) def get_eth_token_output_price( - self, token: AddressLike, qty: int, fee: int = 3000 + self, token: AddressLike, qty: int, fee: int = None ) -> Wei: """Public price for ETH to Token trades with an exact output.""" if self.version == 1: @@ -334,6 +340,9 @@ def get_eth_token_output_price( route = [self.get_weth_address(), token] price = self.router.functions.getAmountsIn(qty, route).call()[0] elif self.version == 3: + if not fee: + logger.warning("No fee set, assuming 0.3%") + fee = 3000 price = self.get_token_token_output_price( self.get_weth_address(), token, qty, fee=fee ) @@ -341,17 +350,19 @@ def get_eth_token_output_price( @supports([1, 2, 3]) def get_token_eth_output_price( - self, token: AddressLike, qty: Wei, fee: int = 3000 + self, token: AddressLike, qty: Wei, fee: int = None ) -> int: """Public price for token to ETH trades with an exact output.""" if self.version == 1: ex = self.exchange_contract(token) price: int = ex.functions.getTokenToEthOutputPrice(qty).call() elif self.version == 2: - price = self.router.functions.getAmountsIn( - qty, [token, self.get_weth_address()] - ).call()[0] + route = [token, self.get_weth_address()] + price = self.router.functions.getAmountsIn(qty, route).call()[0] elif self.version == 3: + if not fee: + logger.warning("No fee set, assuming 0.3%") + fee = 3000 price = self.get_token_token_output_price( token, self.get_weth_address(), qty, fee=fee ) @@ -364,7 +375,7 @@ def get_token_token_output_price( token1: AnyAddress, qty: int, route: Optional[List[AnyAddress]] = None, - fee: int = 3000, + fee: int = None, ) -> int: """ Public price for token to token trades with an exact output. @@ -387,12 +398,14 @@ def get_token_token_output_price( if self.version == 2: price: int = self.router.functions.getAmountsIn(qty, route).call()[0] elif self.version == 3: + if not fee: + logger.warning("No fee set, assuming 0.3%") + fee = 3000 if route: # NOTE: to support custom routes we need to support the Path data encoding: https://github.com/Uniswap/uniswap-v3-periphery/blob/main/contracts/libraries/Path.sol # result: tuple = self.quoter.functions.quoteExactOutput(route, qty).call() raise Exception("custom route not yet supported for v3") - logger.warning("Assuming 0.3% fee") # FIXME: How to calculate this properly? # - https://docs.uniswap.org/reference/libraries/SqrtPriceMath # - https://github.com/Uniswap/uniswap-v3-sdk/blob/main/src/swapRouter.ts @@ -471,19 +484,33 @@ def make_trade( output_token: AddressLike, qty: Union[int, Wei], recipient: AddressLike = None, + fee: int = None, + slippage: float = None, ) -> HexBytes: """Make a trade by defining the qty of the input token.""" + if fee is None: + fee = 3000 + if self.version == 3: + logger.warning("No fee set, assuming 0.3%") + + if slippage is None: + slippage = self.default_slippage + if input_token == ETH_ADDRESS: - return self._eth_to_token_swap_input(output_token, Wei(qty), recipient) + return self._eth_to_token_swap_input( + output_token, Wei(qty), recipient, fee, slippage + ) else: balance = self.get_token_balance(input_token) if balance < qty: raise InsufficientBalance(balance, qty) if output_token == ETH_ADDRESS: - return self._token_to_eth_swap_input(input_token, qty, recipient) + return self._token_to_eth_swap_input( + input_token, qty, recipient, fee, slippage + ) else: return self._token_to_token_swap_input( - input_token, output_token, qty, recipient + input_token, output_token, qty, recipient, fee, slippage ) @check_approval @@ -493,24 +520,43 @@ def make_trade_output( output_token: AddressLike, qty: Union[int, Wei], recipient: AddressLike = None, + fee: int = None, + slippage: float = None, ) -> HexBytes: """Make a trade by defining the qty of the output token.""" + if fee is None: + fee = 3000 + if self.version == 3: + logger.warning("No fee set, assuming 0.3%") + + if slippage is None: + slippage = self.default_slippage + if input_token == ETH_ADDRESS: balance = self.get_eth_balance() need = self.get_eth_token_output_price(output_token, qty) if balance < need: raise InsufficientBalance(balance, need) - return self._eth_to_token_swap_output(output_token, qty, recipient) + return self._eth_to_token_swap_output( + output_token, qty, recipient, fee, slippage + ) elif output_token == ETH_ADDRESS: qty = Wei(qty) - return self._token_to_eth_swap_output(input_token, qty, recipient) + return self._token_to_eth_swap_output( + input_token, qty, recipient, fee, slippage + ) else: return self._token_to_token_swap_output( - input_token, output_token, qty, recipient + input_token, output_token, qty, recipient, fee, slippage ) def _eth_to_token_swap_input( - self, output_token: AddressLike, qty: Wei, recipient: Optional[AddressLike] + self, + output_token: AddressLike, + qty: Wei, + recipient: Optional[AddressLike], + fee: int, + slippage: float, ) -> HexBytes: """Convert ETH to tokens given an input amount.""" eth_balance = self.get_eth_balance() @@ -532,8 +578,7 @@ def _eth_to_token_swap_input( if recipient is None: recipient = self.address amount_out_min = int( - (1 - self.max_slippage) - * self.get_eth_token_input_price(output_token, qty) + (1 - slippage) * self.get_eth_token_input_price(output_token, qty) ) return self._build_and_send_tx( self.router.functions.swapExactETHForTokens( @@ -546,13 +591,18 @@ def _eth_to_token_swap_input( ) elif self.version == 3: return self._token_to_token_swap_input( - self.get_weth_address(), output_token, qty, recipient + self.get_weth_address(), output_token, qty, recipient, fee, slippage ) else: raise ValueError def _token_to_eth_swap_input( - self, input_token: AddressLike, qty: int, recipient: Optional[AddressLike] + self, + input_token: AddressLike, + qty: int, + recipient: Optional[AddressLike], + fee: int, + slippage: float, ) -> HexBytes: """Convert tokens to ETH given an input amount.""" # Balance check @@ -573,8 +623,7 @@ def _token_to_eth_swap_input( if recipient is None: recipient = self.address amount_out_min = int( - (1 - self.max_slippage) - * self.get_token_eth_input_price(input_token, qty) + (1 - slippage) * self.get_token_eth_input_price(input_token, qty) ) return self._build_and_send_tx( self.router.functions.swapExactTokensForETH( @@ -587,7 +636,7 @@ def _token_to_eth_swap_input( ) elif self.version == 3: return self._token_to_token_swap_input( - input_token, self.get_weth_address(), qty, recipient + input_token, self.get_weth_address(), qty, recipient, fee, slippage ) else: raise ValueError @@ -598,7 +647,8 @@ def _token_to_token_swap_input( output_token: AddressLike, qty: int, recipient: Optional[AddressLike], - fee: int = 3000, + fee: int, + slippage: float, ) -> HexBytes: """Convert tokens to tokens given an input amount.""" if recipient is None: @@ -624,8 +674,10 @@ def _token_to_token_swap_input( return self._build_and_send_tx(function) elif self.version == 2: min_tokens_bought = int( - (1 - self.max_slippage) - * self.get_token_token_input_price(input_token, output_token, qty) + (1 - slippage) + * self.get_token_token_input_price( + input_token, output_token, qty, fee=fee + ) ) return self._build_and_send_tx( self.router.functions.swapExactTokensForTokens( @@ -638,7 +690,7 @@ def _token_to_token_swap_input( ) elif self.version == 3: min_tokens_bought = int( - (1 - self.max_slippage) + (1 - slippage) * self.get_token_token_input_price( input_token, output_token, qty, fee=fee ) @@ -665,7 +717,12 @@ def _token_to_token_swap_input( raise ValueError def _eth_to_token_swap_output( - self, output_token: AddressLike, qty: int, recipient: Optional[AddressLike] + self, + output_token: AddressLike, + qty: int, + recipient: Optional[AddressLike], + fee: int, + slippage: float, ) -> HexBytes: """Convert ETH to tokens given an output amount.""" if self.version == 1: @@ -683,8 +740,7 @@ def _eth_to_token_swap_output( if recipient is None: recipient = self.address eth_qty = int( - (1 + self.max_slippage) - * self.get_eth_token_output_price(output_token, qty) + (1 + slippage) * self.get_eth_token_output_price(output_token, qty) ) return self._build_and_send_tx( self.router.functions.swapETHForExactTokens( @@ -697,18 +753,23 @@ def _eth_to_token_swap_output( ) elif self.version == 3: return self._token_to_token_swap_output( - self.get_weth_address(), output_token, qty, recipient + self.get_weth_address(), output_token, qty, recipient, fee, slippage ) else: raise ValueError def _token_to_eth_swap_output( - self, input_token: AddressLike, qty: Wei, recipient: Optional[AddressLike] + self, + input_token: AddressLike, + qty: Wei, + recipient: Optional[AddressLike], + fee: int, + slippage: float, ) -> HexBytes: """Convert tokens to ETH given an output amount.""" # Balance check input_balance = self.get_token_balance(input_token) - cost = self.get_token_eth_output_price(input_token, qty) + cost = self.get_token_eth_output_price(input_token, qty, fee) if cost > input_balance: raise InsufficientBalance(input_balance, cost) @@ -725,7 +786,7 @@ def _token_to_eth_swap_output( denominator = (outputReserve - outputAmount) * 997 inputAmount = numerator / denominator + 1 - max_tokens = int((1 + self.max_slippage) * inputAmount) + max_tokens = int((1 + slippage) * inputAmount) func_params: List[Any] = [qty, max_tokens, self._deadline()] if not recipient: @@ -735,7 +796,7 @@ def _token_to_eth_swap_output( function = token_funcs.tokenToEthTransferOutput(*func_params) return self._build_and_send_tx(function) elif self.version == 2: - max_tokens = int((1 + self.max_slippage) * cost) + max_tokens = int((1 + slippage) * cost) return self._build_and_send_tx( self.router.functions.swapTokensForExactETH( qty, @@ -747,7 +808,7 @@ def _token_to_eth_swap_output( ) elif self.version == 3: return self._token_to_token_swap_output( - input_token, self.get_weth_address(), qty, recipient + input_token, self.get_weth_address(), qty, recipient, fee, slippage ) else: raise ValueError @@ -758,7 +819,8 @@ def _token_to_token_swap_output( output_token: AddressLike, qty: int, recipient: Optional[AddressLike], - fee: int = 3000, + fee: int, + slippage: float, ) -> HexBytes: """ Convert tokens to tokens given an output amount. @@ -787,8 +849,10 @@ def _token_to_token_swap_output( elif self.version == 2: if recipient is None: recipient = self.address - cost = self.get_token_token_output_price(input_token, output_token, qty) - amount_in_max = int((1 + self.max_slippage) * cost) + cost = self.get_token_token_output_price( + input_token, output_token, qty, fee=fee + ) + amount_in_max = int((1 + slippage) * cost) return self._build_and_send_tx( self.router.functions.swapTokensForExactTokens( qty, @@ -805,7 +869,7 @@ def _token_to_token_swap_output( cost = self.get_token_token_output_price( input_token, output_token, qty, fee=fee ) - amount_in_max = int((1 + self.max_slippage) * cost) + amount_in_max = int((1 + slippage) * cost) sqrtPriceLimitX96 = 0 return self._build_and_send_tx( From b12a170ffbbdc6cad90b220b28e4c00771f58881 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Erik=20Bj=C3=A4reholt?= Date: Wed, 19 May 2021 16:17:00 +0200 Subject: [PATCH 04/10] docs: fixed outdated docstring --- uniswap/uniswap.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/uniswap/uniswap.py b/uniswap/uniswap.py index 0ae2520..d49365f 100644 --- a/uniswap/uniswap.py +++ b/uniswap/uniswap.py @@ -43,7 +43,7 @@ class Uniswap: """ - Wrapper around Uniswap v1 and v2 contracts. + Wrapper around Uniswap contracts. """ def __init__( From 02966eb08f328dc77595b7761cc22265d040ca3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Erik=20Bj=C3=A4reholt?= Date: Fri, 21 May 2021 16:38:04 +0200 Subject: [PATCH 05/10] refactor: new methods get_price_input and get_price_output for simpler use, removed ENS support, misc fixes --- tests/test_uniswap.py | 119 +++++++------------ uniswap/cli.py | 2 +- uniswap/types.py | 5 +- uniswap/uniswap.py | 259 +++++++++++++++++++++++------------------- uniswap/util.py | 23 ++-- 5 files changed, 200 insertions(+), 208 deletions(-) diff --git a/tests/test_uniswap.py b/tests/test_uniswap.py index 09d909a..877b8e7 100644 --- a/tests/test_uniswap.py +++ b/tests/test_uniswap.py @@ -11,6 +11,7 @@ from web3 import Web3 from uniswap import Uniswap +from uniswap.constants import ETH_ADDRESS from uniswap.exceptions import InvalidToken, InsufficientBalance @@ -46,7 +47,7 @@ def test_assets(client: Uniswap): for token_name, amount in [("DAI", 100 * 10 ** 18), ("USDC", 100 * 10 ** 6)]: token_addr = tokens[token_name] - price = client.get_eth_token_output_price(token_addr, amount) + price = client.get_price_output(ETH_ADDRESS, token_addr, amount) logger.info(f"Cost of {amount} {token_name}: {price}") logger.info("Buying...") @@ -93,8 +94,8 @@ def does_not_raise(): # TODO: Change pytest.param(..., mark=pytest.mark.xfail) to the expectation/raises method @pytest.mark.usefixtures("client", "web3") class TestUniswap(object): - ONE_WEI = 1 - ONE_ETH = 10 ** 18 * ONE_WEI + ONE_ETH = 10 ** 18 + ONE_USDC = 10 ** 6 ZERO_ADDRESS = "0x0000000000000000000000000000000000000000" @@ -125,90 +126,48 @@ def test_get_fee_taker(self, client: Uniswap): assert r == 0.003 # ------ Market -------------------------------------------------------------------- - @pytest.mark.parametrize( - "token, qty", - [ - (bat, ONE_ETH), - (dai, ONE_ETH), - (bat, 2 * ONE_ETH), - pytest.param("btc", ONE_ETH, marks=pytest.mark.xfail), - ], - ) - def test_get_eth_token_input_price(self, client, token, qty): - r = client.get_eth_token_input_price(token, qty) - assert r - - @pytest.mark.parametrize( - "token, qty", - [ - (bat, ONE_ETH), - (dai, ONE_ETH), - (bat, 2 * ONE_ETH), - pytest.param("btc", ONE_ETH, marks=pytest.mark.xfail), - ], - ) - def test_get_token_eth_input_price(self, client, token, qty): - r = client.get_token_eth_input_price(token, qty) - assert r - @pytest.mark.parametrize( "token0, token1, qty, kwargs", [ - # BAT/DAI has no liquidity in V3 - # (bat, dai, ONE_ETH), - # (dai, bat, ONE_ETH), - # (bat, dai, 2 * ONE_ETH), - (dai, usdc, ONE_ETH, {"fee": 500}), + (eth, bat, ONE_ETH, {}), + (bat, eth, ONE_ETH, {}), + (eth, dai, ONE_ETH, {}), + (dai, eth, ONE_ETH, {}), + (eth, bat, 2 * ONE_ETH, {}), + (bat, eth, 2 * ONE_ETH, {}), (weth, dai, ONE_ETH, {}), (dai, weth, ONE_ETH, {}), + (dai, usdc, ONE_ETH, {"fee": 500}), + pytest.param(eth, "btc", ONE_ETH, {}, marks=pytest.mark.xfail), + pytest.param("btc", eth, ONE_ETH, {}, marks=pytest.mark.xfail), ], ) - def test_get_token_token_input_price(self, client, token0, token1, qty, kwargs): - if client.version not in [2, 3]: - pytest.skip("Tested method not supported in this Uniswap version") - r = client.get_token_token_input_price(token0, token1, qty, **kwargs) - assert r - - @pytest.mark.parametrize( - "token, qty", - [ - (bat, ONE_ETH), - (dai, ONE_ETH), - (bat, 2 * ONE_ETH), - pytest.param("btc", ONE_ETH, marks=pytest.mark.xfail), - ], - ) - def test_get_eth_token_output_price(self, client, token, qty): - r = client.get_eth_token_output_price(token, qty) - assert r - - @pytest.mark.parametrize( - "token, qty", - [ - (bat, ONE_ETH), - (dai, ONE_ETH), - (bat, 2 * ONE_ETH), - pytest.param("btc", ONE_ETH, marks=pytest.mark.xfail), - ], - ) - def test_get_token_eth_output_price(self, client, token, qty): - r = client.get_token_eth_output_price(token, qty) + def test_get_price_input(self, client, token0, token1, qty, kwargs): + if client.version == 1 and ETH_ADDRESS not in [token0, token1]: + pytest.skip("Not supported in this version of Uniswap") + r = client.get_price_input(token0, token1, qty, **kwargs) assert r @pytest.mark.parametrize( "token0, token1, qty, kwargs", [ - # (bat, dai, ONE_ETH), - (dai, usdc, ONE_ETH, {"fee": 500}), - # (bat, dai, 2 * ONE_ETH), + (eth, bat, ONE_ETH, {}), + (bat, eth, ONE_ETH, {}), + (eth, dai, ONE_ETH, {}), + (dai, eth, ONE_ETH, {}), + (eth, bat, 2 * ONE_ETH, {}), + (bat, eth, 2 * ONE_ETH, {}), (weth, dai, ONE_ETH, {}), (dai, weth, ONE_ETH, {}), + (dai, usdc, ONE_USDC, {"fee": 500}), + pytest.param(eth, "btc", ONE_ETH, {}, marks=pytest.mark.xfail), + pytest.param("btc", eth, ONE_ETH, {}, marks=pytest.mark.xfail), ], ) - def test_get_token_token_output_price(self, client, token0, token1, qty, kwargs): - if client.version not in [2, 3]: - pytest.skip("Tested method not supported in this Uniswap version") - r = client.get_token_token_output_price(token0, token1, qty, **kwargs) + def test_get_price_output(self, client, token0, token1, qty, kwargs): + if client.version == 1 and ETH_ADDRESS not in [token0, token1]: + pytest.skip("Not supported in this version of Uniswap") + r = client.get_price_output(token0, token1, qty, **kwargs) assert r # ------ ERC20 Pool ---------------------------------------------------------------- @@ -274,11 +233,11 @@ def test_remove_liquidity( "input_token, output_token, qty, recipient, expectation", [ # ETH -> Token - (eth, dai, 1_000_000_000 * ONE_WEI, None, does_not_raise), + (eth, dai, ONE_ETH, None, does_not_raise), # Token -> Token - (dai, usdc, 1_000_000_000 * ONE_WEI, None, does_not_raise), + (dai, usdc, ONE_ETH, None, does_not_raise), # Token -> ETH - (usdc, eth, 1_000_000 * ONE_WEI, None, does_not_raise), + (usdc, eth, 100 * ONE_USDC, None, does_not_raise), # (eth, bat, 0.00001 * ONE_ETH, ZERO_ADDRESS, does_not_raise), # (bat, eth, 0.00001 * ONE_ETH, ZERO_ADDRESS, does_not_raise), # (dai, bat, 0.00001 * ONE_ETH, ZERO_ADDRESS, does_not_raise), @@ -296,6 +255,10 @@ def test_make_trade( recipient, expectation, ): + if client.version == 1 and ETH_ADDRESS not in [input_token, output_token]: + pytest.skip( + "Not supported in this version of Uniswap, or at least no liquidity" + ) with expectation(): bal_in_before = client.get_token_balance(input_token) @@ -312,11 +275,11 @@ def test_make_trade( "input_token, output_token, qty, recipient, expectation", [ # ETH -> Token - (eth, dai, 1_000_000 * ONE_WEI, None, does_not_raise), + (eth, dai, 10 ** 18, None, does_not_raise), # Token -> Token - (dai, usdc, 1_000_000 * ONE_WEI, None, does_not_raise), + (dai, usdc, ONE_USDC, None, does_not_raise), # Token -> ETH - (dai, eth, 1_000_000 * ONE_WEI, None, does_not_raise), + (dai, eth, ONE_ETH, None, does_not_raise), # FIXME: These should probably be uncommented eventually # (eth, bat, int(0.000001 * ONE_ETH), ZERO_ADDRESS), # (bat, eth, int(0.000001 * ONE_ETH), ZERO_ADDRESS), @@ -324,7 +287,7 @@ def test_make_trade( ( dai, eth, - 10 ** 18 * ONE_WEI, + 1000 * 10 ** 18, None, lambda: pytest.raises(InsufficientBalance), ), diff --git a/uniswap/cli.py b/uniswap/cli.py index 4c8ab8c..f68b9b0 100644 --- a/uniswap/cli.py +++ b/uniswap/cli.py @@ -69,7 +69,7 @@ def price( uni: Uniswap = ctx.obj["UNISWAP"] if quantity is None: quantity = 10 ** uni.get_token(token_in).decimals - price = uni.get_token_token_input_price(token_in, token_out, qty=quantity) + price = uni.get_price_input(token_in, token_out, qty=quantity) if raw: print(price) else: diff --git a/uniswap/types.py b/uniswap/types.py index 89e37b9..62f2b4d 100644 --- a/uniswap/types.py +++ b/uniswap/types.py @@ -1,7 +1,6 @@ from typing import Union from web3.eth import Contract # noqa: F401 -from web3.types import Address, ChecksumAddress, ENS +from web3.types import Address, ChecksumAddress -# TODO: Consider dropping support for ENS altogether and instead use AnyAddress -AddressLike = Union[Address, ChecksumAddress, ENS] +AddressLike = Union[Address, ChecksumAddress] diff --git a/uniswap/uniswap.py b/uniswap/uniswap.py index d49365f..a3668f4 100644 --- a/uniswap/uniswap.py +++ b/uniswap/uniswap.py @@ -16,7 +16,6 @@ HexBytes, ) from eth_utils import is_same_address -from eth_typing import AnyAddress from .types import AddressLike from .token import ERC20Token @@ -48,7 +47,7 @@ class Uniswap: def __init__( self, - address: Union[str, AddressLike, None], + address: Union[AddressLike, str, None], private_key: Optional[str], provider: str = None, web3: Web3 = None, @@ -169,57 +168,6 @@ def get_token(self, address: AddressLike) -> ERC20Token: raise InvalidToken(address) return ERC20Token(symbol, address, name, decimals) - @supports([1]) - def get_all_tokens(self) -> List[ERC20Token]: - """ - Retrieves all token pairs. - - Note: This is a *very* expensive operation and might therefore not work properly. - """ - # FIXME: This is a very expensive operation, would benefit greatly from caching. - tokenCount = self.factory_contract.functions.tokenCount().call() - tokens = [] - for i in range(tokenCount): - address = self.factory_contract.functions.getTokenWithId(i).call() - if address == "0x0000000000000000000000000000000000000000": - # Token is ETH - continue - token = self.get_token(address) - tokens.append(token) - return tokens - - @supports([1]) - def exchange_address_from_token(self, token_addr: AddressLike) -> AddressLike: - ex_addr: AddressLike = self.factory_contract.functions.getExchange( - token_addr - ).call() - # TODO: What happens if the token doesn't have an exchange/doesn't exist? - # Should probably raise an Exception (and test it) - return ex_addr - - @supports([1]) - def token_address_from_exchange(self, exchange_addr: AddressLike) -> Address: - token_addr: Address = ( - self.exchange_contract(ex_addr=exchange_addr) - .functions.tokenAddress(exchange_addr) - .call() - ) - return token_addr - - @functools.lru_cache() - @supports([1]) - def exchange_contract( - self, token_addr: AddressLike = None, ex_addr: AddressLike = None - ) -> Contract: - if not ex_addr and token_addr: - ex_addr = self.exchange_address_from_token(token_addr) - if ex_addr is None: - raise InvalidToken(token_addr) - abi_name = "uniswap-v1/exchange" - contract = _load_contract(self.w3, abi_name=abi_name, address=ex_addr) - logger.info(f"Loaded exchange contract {contract} at {contract.address}") - return contract - @functools.lru_cache() @supports([2, 3]) def get_weth_address(self) -> ChecksumAddress: @@ -242,10 +190,50 @@ def get_fee_taker(self) -> float: return 0.003 # ------ Market -------------------------------------------------------------------- + @supports([1, 2, 3]) - def get_eth_token_input_price( - self, token: AddressLike, qty: Wei, fee: int = None - ) -> Wei: + def get_price_input( + self, + token0: AddressLike, + token1: AddressLike, + qty: int, + fee: int = None, + route: Optional[List[AddressLike]] = None, + ) -> int: + if fee is None: + fee = 3000 + if self.version == 3: + logger.warning("No fee set, assuming 0.3%") + + if token0 == ETH_ADDRESS: + return self._get_eth_token_input_price(token1, Wei(qty), fee) + elif token1 == ETH_ADDRESS: + return self._get_token_eth_input_price(token0, qty, fee) + else: + return self._get_token_token_input_price(token0, token1, qty, fee, route) + + @supports([1, 2, 3]) + def get_price_output( + self, + token0: AddressLike, + token1: AddressLike, + qty: int, + fee: int = None, + route: Optional[List[AddressLike]] = None, + ) -> int: + if fee is None: + fee = 3000 + if self.version == 3: + logger.warning("No fee set, assuming 0.3%") + + if token0 == ETH_ADDRESS: + return self._get_eth_token_output_price(token1, qty, fee) + elif token1 == ETH_ADDRESS: + return self._get_token_eth_output_price(token0, Wei(qty), fee) + else: + return self._get_token_token_output_price(token0, token1, qty, fee, route) + + def _get_eth_token_input_price(self, token: AddressLike, qty: Wei, fee: int) -> Wei: """Public price for ETH to Token trades with an exact input.""" if self.version == 1: ex = self.exchange_contract(token) @@ -255,18 +243,12 @@ def get_eth_token_input_price( qty, [self.get_weth_address(), token] ).call()[-1] elif self.version == 3: - if not fee: - logger.warning("No fee set, assuming 0.3%") - fee = 3000 - price = self.get_token_token_input_price( + price = self._get_token_token_input_price( self.get_weth_address(), token, qty, fee=fee - ) + ) # type: ignore return price - @supports([1, 2, 3]) - def get_token_eth_input_price( - self, token: AddressLike, qty: int, fee: int = None - ) -> int: + def _get_token_eth_input_price(self, token: AddressLike, qty: int, fee: int) -> int: """Public price for token to ETH trades with an exact input.""" if self.version == 1: ex = self.exchange_contract(token) @@ -276,22 +258,18 @@ def get_token_eth_input_price( qty, [token, self.get_weth_address()] ).call()[-1] elif self.version == 3: - if not fee: - logger.warning("No fee set, assuming 0.3%") - fee = 3000 - price = self.get_token_token_input_price( + price = self._get_token_token_input_price( token, self.get_weth_address(), qty, fee=fee ) return price - @supports([2, 3]) - def get_token_token_input_price( + def _get_token_token_input_price( self, - token0: AnyAddress, - token1: AnyAddress, + token0: AddressLike, + token1: AddressLike, qty: int, - route: Optional[List[AnyAddress]] = None, - fee: int = None, + fee: int, + route: Optional[List[AddressLike]] = None, ) -> int: """ Public price for token to token trades with an exact input. @@ -302,10 +280,10 @@ def get_token_token_input_price( if self.version == 2: # If one of the tokens are WETH, delegate to appropriate call. # See: https://github.com/shanefontaine/uniswap-python/issues/22 - if is_same_address(token0, self.get_weth_address()): - return int(self.get_eth_token_input_price(token1, qty)) - elif is_same_address(token1, self.get_weth_address()): - return int(self.get_token_eth_input_price(token0, qty)) + if is_same_address(token0, self.get_weth_address()): # type: ignore + return int(self._get_eth_token_input_price(token1, Wei(qty), fee)) + elif is_same_address(token1, self.get_weth_address()): # type: ignore + return int(self._get_token_eth_input_price(token0, qty, fee)) route = [token0, self.get_weth_address(), token1] logger.warning(f"No route specified, assuming route: {route}") @@ -313,9 +291,6 @@ def get_token_token_input_price( if self.version == 2: price: int = self.router.functions.getAmountsOut(qty, route).call()[-1] elif self.version == 3: - if not fee: - logger.warning("No fee set, assuming 0.3%") - fee = 3000 if route: # NOTE: to support custom routes we need to support the Path data encoding: https://github.com/Uniswap/uniswap-v3-periphery/blob/main/contracts/libraries/Path.sol # result: tuple = self.quoter.functions.quoteExactInput(route, qty).call() @@ -326,10 +301,11 @@ def get_token_token_input_price( price = self.quoter.functions.quoteExactInputSingle( token0, token1, fee, qty, sqrtPriceLimitX96 ).call() + else: + raise ValueError("function not supported for this version of Uniswap") return price - @supports([1, 2, 3]) - def get_eth_token_output_price( + def _get_eth_token_output_price( self, token: AddressLike, qty: int, fee: int = None ) -> Wei: """Public price for ETH to Token trades with an exact output.""" @@ -343,13 +319,14 @@ def get_eth_token_output_price( if not fee: logger.warning("No fee set, assuming 0.3%") fee = 3000 - price = self.get_token_token_output_price( - self.get_weth_address(), token, qty, fee=fee + price = Wei( + self._get_token_token_output_price( + self.get_weth_address(), token, qty, fee=fee + ) ) return price - @supports([1, 2, 3]) - def get_token_eth_output_price( + def _get_token_eth_output_price( self, token: AddressLike, qty: Wei, fee: int = None ) -> int: """Public price for token to ETH trades with an exact output.""" @@ -363,19 +340,18 @@ def get_token_eth_output_price( if not fee: logger.warning("No fee set, assuming 0.3%") fee = 3000 - price = self.get_token_token_output_price( + price = self._get_token_token_output_price( token, self.get_weth_address(), qty, fee=fee ) return price - @supports([2, 3]) - def get_token_token_output_price( + def _get_token_token_output_price( self, - token0: AnyAddress, - token1: AnyAddress, + token0: AddressLike, + token1: AddressLike, qty: int, - route: Optional[List[AnyAddress]] = None, fee: int = None, + route: Optional[List[AddressLike]] = None, ) -> int: """ Public price for token to token trades with an exact output. @@ -387,10 +363,10 @@ def get_token_token_output_price( # If one of the tokens are WETH, delegate to appropriate call. # See: https://github.com/shanefontaine/uniswap-python/issues/22 # TODO: Will these equality checks always work? (Address vs ChecksumAddress vs str) - if is_same_address(token0, self.get_weth_address()): - return int(self.get_eth_token_output_price(token1, qty)) - elif is_same_address(token1, self.get_weth_address()): - return int(self.get_token_eth_output_price(token0, qty)) + if is_same_address(token0, self.get_weth_address()): # type: ignore + return int(self._get_eth_token_output_price(token1, qty, fee)) + elif is_same_address(token1, self.get_weth_address()): # type: ignore + return int(self._get_token_eth_output_price(token0, Wei(qty), fee)) route = [token0, self.get_weth_address(), token1] logger.warning(f"No route specified, assuming route: {route}") @@ -413,6 +389,8 @@ def get_token_token_output_price( price = self.quoter.functions.quoteExactOutputSingle( token0, token1, fee, qty, sqrtPriceLimitX96 ).call() + else: + raise ValueError("function not supported for this version of Uniswap") return price # ------ Wallet balance ------------------------------------------------------------ @@ -534,7 +512,7 @@ def make_trade_output( if input_token == ETH_ADDRESS: balance = self.get_eth_balance() - need = self.get_eth_token_output_price(output_token, qty) + need = self._get_eth_token_output_price(output_token, qty) if balance < need: raise InsufficientBalance(balance, need) return self._eth_to_token_swap_output( @@ -578,7 +556,7 @@ def _eth_to_token_swap_input( if recipient is None: recipient = self.address amount_out_min = int( - (1 - slippage) * self.get_eth_token_input_price(output_token, qty) + (1 - slippage) * self._get_eth_token_input_price(output_token, qty, fee) ) return self._build_and_send_tx( self.router.functions.swapExactETHForTokens( @@ -623,7 +601,7 @@ def _token_to_eth_swap_input( if recipient is None: recipient = self.address amount_out_min = int( - (1 - slippage) * self.get_token_eth_input_price(input_token, qty) + (1 - slippage) * self._get_token_eth_input_price(input_token, qty, fee) ) return self._build_and_send_tx( self.router.functions.swapExactTokensForETH( @@ -656,7 +634,7 @@ def _token_to_token_swap_input( if self.version == 1: token_funcs = self.exchange_contract(input_token).functions # TODO: This might not be correct - min_tokens_bought, min_eth_bought = self._calculate_max_input_token( + min_tokens_bought, min_eth_bought = self._calculate_max_output_token( input_token, qty, output_token ) func_params = [ @@ -675,7 +653,7 @@ def _token_to_token_swap_input( elif self.version == 2: min_tokens_bought = int( (1 - slippage) - * self.get_token_token_input_price( + * self._get_token_token_input_price( input_token, output_token, qty, fee=fee ) ) @@ -691,7 +669,7 @@ def _token_to_token_swap_input( elif self.version == 3: min_tokens_bought = int( (1 - slippage) - * self.get_token_token_input_price( + * self._get_token_token_input_price( input_token, output_token, qty, fee=fee ) ) @@ -727,7 +705,7 @@ def _eth_to_token_swap_output( """Convert ETH to tokens given an output amount.""" if self.version == 1: token_funcs = self.exchange_contract(output_token).functions - eth_qty = self.get_eth_token_output_price(output_token, qty) + eth_qty = self._get_eth_token_output_price(output_token, qty) tx_params = self._get_tx_params(eth_qty) func_params: List[Any] = [qty, self._deadline()] if not recipient: @@ -740,8 +718,9 @@ def _eth_to_token_swap_output( if recipient is None: recipient = self.address eth_qty = int( - (1 + slippage) * self.get_eth_token_output_price(output_token, qty) - ) + (1 + slippage) + * self._get_eth_token_output_price(output_token, qty, fee) + ) # type: ignore return self._build_and_send_tx( self.router.functions.swapETHForExactTokens( qty, @@ -769,13 +748,11 @@ def _token_to_eth_swap_output( """Convert tokens to ETH given an output amount.""" # Balance check input_balance = self.get_token_balance(input_token) - cost = self.get_token_eth_output_price(input_token, qty, fee) + cost = self._get_token_eth_output_price(input_token, qty, fee) if cost > input_balance: raise InsufficientBalance(input_balance, cost) if self.version == 1: - token_funcs = self.exchange_contract(input_token).functions - # From https://uniswap.org/docs/v1/frontend-integration/trade-tokens/ # Is all this really necessary? Can't we just use `cost` for max_tokens? outputAmount = qty @@ -788,12 +765,13 @@ def _token_to_eth_swap_output( max_tokens = int((1 + slippage) * inputAmount) + ex = self.exchange_contract(input_token) func_params: List[Any] = [qty, max_tokens, self._deadline()] if not recipient: - function = token_funcs.tokenToEthSwapOutput(*func_params) + function = ex.functions.tokenToEthSwapOutput(*func_params) else: func_params.append(recipient) - function = token_funcs.tokenToEthTransferOutput(*func_params) + function = ex.functions.tokenToEthTransferOutput(*func_params) return self._build_and_send_tx(function) elif self.version == 2: max_tokens = int((1 + slippage) * cost) @@ -849,7 +827,7 @@ def _token_to_token_swap_output( elif self.version == 2: if recipient is None: recipient = self.address - cost = self.get_token_token_output_price( + cost = self._get_token_token_output_price( input_token, output_token, qty, fee=fee ) amount_in_max = int((1 + slippage) * cost) @@ -866,7 +844,7 @@ def _token_to_token_swap_output( if recipient is None: recipient = self.address - cost = self.get_token_token_output_price( + cost = self._get_token_token_output_price( input_token, output_token, qty, fee=fee ) amount_in_max = int((1 + slippage) * cost) @@ -1041,3 +1019,56 @@ def _get_token_addresses(self) -> Dict[str, ChecksumAddress]: return tokens_rinkeby else: raise Exception(f"Unknown net '{netname}'") + + # ---- Old v1 utils ---- + + @supports([1]) + def exchange_address_from_token(self, token_addr: AddressLike) -> AddressLike: + ex_addr: AddressLike = self.factory_contract.functions.getExchange( + token_addr + ).call() + # TODO: What happens if the token doesn't have an exchange/doesn't exist? + # Should probably raise an Exception (and test it) + return ex_addr + + @supports([1]) + def token_address_from_exchange(self, exchange_addr: AddressLike) -> Address: + token_addr: Address = ( + self.exchange_contract(ex_addr=exchange_addr) + .functions.tokenAddress(exchange_addr) + .call() + ) + return token_addr + + @functools.lru_cache() + @supports([1]) + def exchange_contract( + self, token_addr: AddressLike = None, ex_addr: AddressLike = None + ) -> Contract: + if not ex_addr and token_addr: + ex_addr = self.exchange_address_from_token(token_addr) + if ex_addr is None: + raise InvalidToken(token_addr) + abi_name = "uniswap-v1/exchange" + contract = _load_contract(self.w3, abi_name=abi_name, address=ex_addr) + logger.info(f"Loaded exchange contract {contract} at {contract.address}") + return contract + + @supports([1]) + def get_all_tokens(self) -> List[ERC20Token]: + """ + Retrieves all token pairs. + + Note: This is a *very* expensive operation and might therefore not work properly. + """ + # FIXME: This is a very expensive operation, would benefit greatly from caching. + tokenCount = self.factory_contract.functions.tokenCount().call() + tokens = [] + for i in range(tokenCount): + address = self.factory_contract.functions.getTokenWithId(i).call() + if address == "0x0000000000000000000000000000000000000000": + # Token is ETH + continue + token = self.get_token(address) + tokens.append(token) + return tokens diff --git a/uniswap/util.py b/uniswap/util.py index 1440cfb..b58b6a6 100644 --- a/uniswap/util.py +++ b/uniswap/util.py @@ -1,21 +1,19 @@ import os import json import functools -from typing import Union +from typing import Union, List, Tuple from web3 import Web3 -from .types import AddressLike, Address, ENS, Contract +from .types import AddressLike, Address, Contract from .exceptions import InvalidToken -def _str_to_addr(s: Union[str, Address]) -> AddressLike: +def _str_to_addr(s: Union[AddressLike, str]) -> Address: """Idempotent""" if isinstance(s, str): if s.startswith("0x"): return Address(bytes.fromhex(s[2:])) - elif s.endswith(".eth"): - return ENS(s) else: raise Exception(f"Couldn't convert string '{s}' to AddressLike") else: @@ -27,13 +25,9 @@ def _addr_to_str(a: AddressLike) -> str: # Address or ChecksumAddress addr: str = Web3.toChecksumAddress("0x" + bytes(a).hex()) return addr - elif isinstance(a, str): - if a.endswith(".eth"): - # Address is ENS - raise Exception("ENS not supported for this operation") - elif a.startswith("0x"): - addr = Web3.toChecksumAddress(a) - return addr + elif isinstance(a, str) and a.startswith("0x"): + addr = Web3.toChecksumAddress(a) + return addr raise InvalidToken(a) @@ -51,8 +45,13 @@ def _load_abi(name: str) -> str: @functools.lru_cache() def _load_contract(w3: Web3, abi_name: str, address: AddressLike) -> Contract: + address = Web3.toChecksumAddress(address) return w3.eth.contract(address=address, abi=_load_abi(abi_name)) def _load_contract_erc20(w3: Web3, address: AddressLike) -> Contract: return _load_contract(w3, "erc20", address) + + +def _encode_path(token_in: AddressLike, route: List[Tuple[int, AddressLike]]) -> bytes: + raise NotImplementedError From 5949dc46859bc046e4ff577106ef04d1d7d72d2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Erik=20Bj=C3=A4reholt?= Date: Fri, 21 May 2021 16:38:55 +0200 Subject: [PATCH 06/10] docs: moved getting started guide in README to docs, with various improvements --- README.md | 172 +-------------------------------------- docs/conf.py | 10 ++- docs/getting-started.rst | 162 ++++++++++++++++++++++++++++++++++++ 3 files changed, 173 insertions(+), 171 deletions(-) create mode 100644 docs/getting-started.rst diff --git a/README.md b/README.md index 19c8726..900317a 100644 --- a/README.md +++ b/README.md @@ -32,178 +32,10 @@ Documentation is available at https://uniswap-python.com/ ## Getting Started -This README is documentation on the syntax of the python client presented in this repository. See function docstrings for full syntax details. - -This library attempts to present a clean interface to Uniswap, but in order to use it to its full potential, you must familiarize yourself with the official Uniswap documentation. - -* https://docs.uniswap.io/ - -You may manually install the project or use pip: - -```python -pip install uniswap-python - -# or - -pip install git+git://github.com/shanefontaine/uniswap-python.git -``` - -### Environment Variables -The program expects an environment variables to be set in order to run the program. You can use an Infura node, since the transactions are being signed locally and broadcast as a raw transaction. The environment variable is: - -``` -PROVIDER # HTTP Provider for web3 -``` - -### Gas pricing - -To modify the gas pricing strategy you need to pass a custom `Web3` instance to the `Uniswap` constructor. You can find details for how to configure Web3 gas strategies in their [documentation](https://web3py.readthedocs.io/en/stable/gas_price.html). - -### Examples and API - -**Note:** These examples are in the process of being moved to the [docs](https://shanefontaine.github.io/uniswap-python/). - -The `Uniswap` class takes several optional parameters, read the code to see which ones are available. - -```python -from uniswap import Uniswap -address = "YOUR ADDRESS" # or "0x0000000000000000000000000000000000000000", if you're not making transactions -private_key = "YOUR PRIVATE KEY" # or None, if you're not going to make transactions -uniswap_wrapper = Uniswap(address, private_key, version=2) # pass version=2 to use Uniswap v2 -eth = "0x0000000000000000000000000000000000000000" -bat = "0x0D8775F648430679A709E98d2b0Cb6250d2887EF" -dai = "0x89d24A6b4CcB1B6fAA2625fE562bDD9a23260359" -``` - -#### Price Methods - -> **Note:** These methods assume a certain route for the swap to take, which may not be the optimal route. See https://github.com/shanefontaine/uniswap-python/issues/69 for details. - -These methods return the price as an integer in the smallest unit of the token. You need to ensure that you know how many decimals the token you're trying to trade uses to get prices in the common decimal format. See https://github.com/shanefontaine/uniswap-python/issues/12 for details. - -Decimals for common tokens: - - - ETH, DAI, and BAT uses 18 decimals (as you can see in code below) - - WBTC uses 8 decimals - - USDC and USDT uses 6 decimals - -You can look up the number of decimals used by a particular token by looking up the contract on Etherscan. As an example, here is the one for WBTC: https://etherscan.io/token/0x2260fac5e5542a773aa44fbcfedf7c193bc2c599 - -* [get_eth_token_input_price](https://github.com/Uniswap/contracts-vyper/blob/master/contracts/uniswap_exchange.vy#L416) -```python -# Get the public price for ETH to Token trades with an exact input. -uniswap_wrapper.get_eth_token_input_price(bat, 1*10**18) -uniswap_wrapper.get_eth_token_input_price(dai, 5*10**18) -``` - -* [get_token_eth_input_price](https://github.com/Uniswap/contracts-vyper/blob/master/contracts/uniswap_exchange.vy#L437) -```python -# Get the public price for token to ETH trades with an exact input. -uniswap_wrapper.get_token_eth_input_price(bat, 1*10**18) -uniswap_wrapper.get_token_eth_input_price(dai, 5*10**18) -``` - -* [get_eth_token_output_price](https://github.com/Uniswap/contracts-vyper/blob/master/contracts/uniswap_exchange.vy#L426) -```python -# Get the public price for ETH to Token trades with an exact output -uniswap_wrapper.get_eth_token_output_price(bat, 1*10**18) -uniswap_wrapper.get_eth_token_output_price(dai, 5*10**18) -``` - -* [get_token_eth_output_price](https://github.com/Uniswap/contracts-vyper/blob/master/contracts/uniswap_exchange.vy#L448) -```python -# Get the public price for token to ETH trades with an exact output. -uniswap_wrapper.get_token_eth_output_price(bat, 1*10**18) -uniswap_wrapper.get_token_eth_output_price(dai, 5*10**18) -``` - -#### Trading - -> **Note:** The same route assumptions and need for handling decimals apply here as those mentioned in the previous section. - -* make_trade - * eth_to_token_input - * [ethToTokenSwapInput](https://github.com/Uniswap/contracts-vyper/blob/master/contracts/uniswap_exchange.vy#L127) - * [ethToTokenTransferInput](https://github.com/Uniswap/contracts-vyper/blob/master/contracts/uniswap_exchange.vy#L162) - * token_to_eth_input - * [tokenToEthSwapInput](https://github.com/Uniswap/contracts-vyper/blob/master/contracts/uniswap_exchange.vy#L202) - * [tokenToEthTransferInput](https://github.com/Uniswap/contracts-vyper/blob/master/contracts/uniswap_exchange.vy#L232) - * token_to_token_input - * [tokenToTokenSwapInput](https://github.com/Uniswap/contracts-vyper/blob/master/contracts/uniswap_exchange.vy#L271) - * [tokenToTokenTransferInput](https://github.com/Uniswap/contracts-vyper/blob/master/contracts/uniswap_exchange.vy#L307) - -```python -# Make a trade based on the input parameters -uniswap_wrapper.make_trade(eth, bat, 1*10**18) # calls _eth_to_token_input -uniswap_wrapper.make_trade(bat, eth, 1*10**18) # calls _token_to_eth_input -uniswap_wrapper.make_trade(bat, dai, 1*10**18) # calls _token_to_token_input -uniswap_wrapper.make_trade(eth, bat, 1*10**18, "0x123...") # calls _eth_to_token_input -``` - -* make_trade_output - * eth_to_token_swap_output - * [ethToTokenSwapOutput](https://github.com/Uniswap/contracts-vyper/blob/master/contracts/uniswap_exchange.vy#L167) - * [ethToTokenTransferOutput](https://github.com/Uniswap/contracts-vyper/blob/master/contracts/uniswap_exchange.vy#L197) - * token_to_eth_swap_output - * [tokenToEthSwapOutput](https://github.com/Uniswap/contracts-vyper/blob/master/contracts/uniswap_exchange.vy#L237) - * [tokenToEthTransferOutput](https://github.com/Uniswap/contracts-vyper/blob/master/contracts/uniswap_exchange.vy#L266) - * token_to_token_swap_output - * [tokenToTokenSwapOutput](https://github.com/Uniswap/contracts-vyper/blob/master/contracts/uniswap_exchange.vy#L312) - * [tokenToTokenTransferOutput](https://github.com/Uniswap/contracts-vyper/blob/master/contracts/uniswap_exchange.vy#L349)) - -```python -# Make a trade where the output qty is known based on the input parameters -uniswap_wrapper.make_trade_output(eth, bat, 1*10**18) # calls _eth_to_token_swap_output -uniswap_wrapper.make_trade_output(bat, eth, 1*10**18) # calls _token_to_eth_swap_output -uniswap_wrapper.make_trade_output(bat, dai, 1*10**18, "0x123...") # calls _token_to_token_swap_output -``` - -#### Fee Methods -* [get_fee_maker](https://docs.uniswap.io/) -```python -uniswap_wrapper.get_fee_maker() -``` - -* [get_fee_taker](https://docs.uniswap.io/) -```python -uniswap_wrapper.get_fee_taker() -``` - - -#### ERC20 Pool Methods (v1 only) -* [get_ex_eth_balance](https://docs.uniswap.io/smart-contract-integration/vyper) -```python -# Get the balance of ETH in an exchange contract. -uniswap_wrapper.get_ex_eth_balance(bat) -``` - -* [get_ex_token_balance](https://github.com/Uniswap/contracts-vyper/blob/master/contracts/uniswap_exchange.vy#L469) -```python -# Get the balance of a token in an exchange contract. -uniswap_wrapper.get_ex_token_balance(bat) -``` - -* [get_exchange_rate](https://github.com/Uniswap/uniswap-frontend/blob/master/src/pages/Pool/AddLiquidity.js#L351) -```python -# Get the exchange rate of token/ETH -uniswap_wrapper.get_exchange_rate(bat) -``` - -#### Liquidity Methods (v1 only) - -* [add_liquidity](https://github.com/Uniswap/contracts-vyper/blob/master/contracts/uniswap_exchange.vy#L48) -```python -# Add liquidity to the pool. -uniswap_wrapper.add_liquidity(bat, 1*10**18) -``` - -* [remove_liquidity](https://github.com/Uniswap/contracts-vyper/blob/master/contracts/uniswap_exchange.vy#L83) -```python -# Remove liquidity from the pool. -uniswap_wrapper.remove_liquidity(bat, 1*10**18) -``` +See our [Getting started guide](https://uniswap-python.com/getting-started.html) in the documentation. ## Testing + Unit tests are under development using the pytest framework. Contributions are welcome! Test are run on a fork of the main net using ganache-cli. You need to install it with `npm install -g ganache-cli` before running tests. diff --git a/docs/conf.py b/docs/conf.py index cf0df88..cd0d5d6 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -28,7 +28,7 @@ # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. -extensions = ["sphinx.ext.autodoc", "sphinx_click"] +extensions = ["sphinx.ext.autodoc", "sphinx.ext.extlinks", "sphinx_click"] # Add any paths that contain templates here, relative to this directory. templates_path = ["_templates"] @@ -38,6 +38,10 @@ # This pattern also affects html_static_path and html_extra_path. exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] +extlinks = { + "issue": ("https://github.com/shanefontaine/uniswap-python/issues/%s", "issue #"), +} + # -- Options for HTML output ------------------------------------------------- @@ -60,6 +64,10 @@ "path_to_docs": "docs", "use_repository_button": True, "use_edit_page_button": True, + "extra_navbar": """ +

+ Back to GitHub +

""", } show_navbar_depth = 2 diff --git a/docs/getting-started.rst b/docs/getting-started.rst new file mode 100644 index 0000000..6d558af --- /dev/null +++ b/docs/getting-started.rst @@ -0,0 +1,162 @@ +Getting started +=============== + +This library attempts to present a clean interface to Uniswap, but in order to use it to its full potential, you must familiarize yourself with the official Uniswap documentation: + +- V1: https://uniswap.org/docs/v1/ +- V2: https://uniswap.org/docs/v2/ +- V3: https://docs.uniswap.org/ + +.. contents:: Table of contents + :local: + :depth: 3 + +Installation +------------ + +You can install the latest release from PyPI, or install the latest commit directly from git: + +.. code:: sh + + pip install uniswap-python + + # or + + pip install git+git://github.com/shanefontaine/uniswap-python.git + + +Initializing the Uniswap class +------------------------------ + +If you want to trade you need to provide your address and private key. If not, you can set them to ``None``. + +In addition, the :class:`~uniswap.Uniswap` class takes several optional parameters, as documented in the `API Reference`. + +.. code:: python + + from uniswap import Uniswap + + address = "YOUR ADDRESS" # or None if you're not going to make transactions + private_key = "YOUR PRIVATE KEY" # or None if you're not going to make transactions + version = 2 # specify which version of Uniswap to use + provider = "WEB3 PROVIDER URL" # can also be set through the environment variable `PROVIDER` + uniswap = Uniswap(address=address, private_key=private_key, version=version, provider=provider) + + # Some token addresses we'll be using later in this guide + eth = "0x0000000000000000000000000000000000000000" + bat = "0x0D8775F648430679A709E98d2b0Cb6250d2887EF" + dai = "0x89d24A6b4CcB1B6fAA2625fE562bDD9a23260359" + + +Environment Variables +````````````````````` + +The program expects an environment variables to be set in order to run the program. You can use an Infura node, since the transactions are being signed locally and broadcast as a raw transaction. The environment variable is: + +.. code:: sh + + PROVIDER # HTTP Provider for web3 + +Gas pricing +``````````` + +To modify the gas pricing strategy you need to pass a custom `Web3` instance to the :class:`~uniswap.Uniswap` constructor. You can find details for how to configure Web3 gas strategies in their `documentation `_. + + +Quoting prices +-------------- + +.. note:: + + These methods assume a certain route for the swap to take, which may not be the optimal route. See :issue:`69` for details. + +There are two functions to retrieve the price for a given pair, one for specifying how much you get given a certain amount of the input token, and another for specifying how much you need to pay to receive a certain amount of the output token. + +:func:`~uniswap.Uniswap.get_price_input` +```````````````````````````````````````` + +Returns the cost of the given number of input tokens, priced in the output token. + +.. code:: python + + # Returns the amount of DAI you get for 1 ETH (10^18 wei) + uniswap.get_price_input(eth, dai, 10**18) + +:func:`~uniswap.Uniswap.get_price_output` +````````````````````````````````````````` + +Returns the amount of input token you need for the given amount of output tokens. + +.. code:: python + + # Returns the amount of ETH you need to pay (in wei) to get 1000 DAI + uniswap.get_price_output(eth, dai, 1_000 * 10**18) + + +.. note:: + + These methods return the price as an integer in the smallest unit of the token. You need to ensure that you know how many decimals the token you're trying to trade uses to get prices in the common decimal format. See :issue:`12` for details. + + Decimals for common tokens: + + - ETH, DAI, and BAT uses 18 decimals (as you can see in code below) + - WBTC uses 8 decimals + - USDC and USDT uses 6 decimals + + You can look up the number of decimals used by a particular token by looking up the contract on Etherscan. + +Making trades +------------- + +.. note:: + + The same route assumptions and need for handling decimals apply here as those mentioned in the previous section. + +:func:`~uniswap.Uniswap.make_trade` +``````````````````````````````````` + +.. code:: python + + # Make a trade based on the input parameters + uniswap.make_trade(eth, bat, 1*10**18) + uniswap.make_trade(bat, eth, 1*10**18) + uniswap.make_trade(bat, dai, 1*10**18) + uniswap.make_trade(eth, bat, 1*10**18, "0x123...") + +:func:`~uniswap.Uniswap.make_trade_output` +`````````````````````````````````````````` + +.. code:: python + + # Make a trade where the output qty isearch)Lknown based on the input parameters + uniswap.make_trade_output(eth, bat, 1*10**18) # calls _eth_to_token_swap_output + uniswap.make_trade_output(bat, eth, 1*10**18) # calls _token_to_eth_swap_output + uniswap.make_trade_output(bat, dai, 1*10**18, "0x123...") # calls _token_to_token_swap_output + + +Pool Methods (v1 only) +--------------------------- + +.. code:: python + + # Get the balance of ETH in an exchange contract. + uniswap.get_ex_eth_balance(bat) + + # Get the balance of a token in an exchange contract. + uniswap.get_ex_token_balance(bat) + + # Get the exchange rate of token/ETH + uniswap.get_exchange_rate(bat) + + +Liquidity Methods (v1 only) +--------------------------- + +.. code:: python + + # Add liquidity to the pool. + uniswap.add_liquidity(bat, 1*10**18) + + # Remove liquidity from the pool. + uniswap.remove_liquidity(bat, 1*10**18) + From d98251773bec247e4c88b3a9dfd2c1b5ac24d6f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Erik=20Bj=C3=A4reholt?= Date: Fri, 21 May 2021 16:46:33 +0200 Subject: [PATCH 07/10] test: fixed test --- tests/test_uniswap.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_uniswap.py b/tests/test_uniswap.py index 877b8e7..4220439 100644 --- a/tests/test_uniswap.py +++ b/tests/test_uniswap.py @@ -279,7 +279,7 @@ def test_make_trade( # Token -> Token (dai, usdc, ONE_USDC, None, does_not_raise), # Token -> ETH - (dai, eth, ONE_ETH, None, does_not_raise), + (dai, eth, 10 ** 16, None, does_not_raise), # FIXME: These should probably be uncommented eventually # (eth, bat, int(0.000001 * ONE_ETH), ZERO_ADDRESS), # (bat, eth, int(0.000001 * ONE_ETH), ZERO_ADDRESS), From b25b0ac4977925a5c5f1ac2da01354d3062afba5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Erik=20Bj=C3=A4reholt?= Date: Fri, 21 May 2021 16:52:36 +0200 Subject: [PATCH 08/10] docs: added docstring --- uniswap/util.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/uniswap/util.py b/uniswap/util.py index b58b6a6..cdf0017 100644 --- a/uniswap/util.py +++ b/uniswap/util.py @@ -54,4 +54,9 @@ def _load_contract_erc20(w3: Web3, address: AddressLike) -> Contract: def _encode_path(token_in: AddressLike, route: List[Tuple[int, AddressLike]]) -> bytes: + """ + Needed for multi-hop swaps in V3. + + https://github.com/Uniswap/uniswap-v3-sdk/blob/1a74d5f0a31040fec4aeb1f83bba01d7c03f4870/src/utils/encodeRouteToPath.ts + """ raise NotImplementedError From 11edac1e097e025a625abcc3f732b01458e9b75f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Erik=20Bj=C3=A4reholt?= Date: Fri, 21 May 2021 17:04:59 +0200 Subject: [PATCH 09/10] docs: added getting-started to index --- docs/index.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/index.rst b/docs/index.rst index 11bc946..19697e9 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -15,6 +15,7 @@ Welcome to uniswap-python's documentation! :maxdepth: 2 :caption: Contents: + getting-started api cli examples From a7fde8fe974359f6250f86e8c655e3676deadaaf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Erik=20Bj=C3=A4reholt?= Date: Fri, 21 May 2021 17:05:54 +0200 Subject: [PATCH 10/10] style: reordered functions in the Uniswap class (for better member order in docs) --- uniswap/uniswap.py | 205 +++++++++++++++++++++++---------------------- 1 file changed, 105 insertions(+), 100 deletions(-) diff --git a/uniswap/uniswap.py b/uniswap/uniswap.py index a3668f4..b940cf3 100644 --- a/uniswap/uniswap.py +++ b/uniswap/uniswap.py @@ -150,45 +150,6 @@ def __init__( if hasattr(self, "factory_contract"): logger.info(f"Using factory contract: {self.factory_contract}") - def get_token(self, address: AddressLike) -> 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 - token_contract = _load_contract(self.w3, abi_name="erc20", 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) - return ERC20Token(symbol, address, name, decimals) - - @functools.lru_cache() - @supports([2, 3]) - def get_weth_address(self) -> ChecksumAddress: - if self.version == 2: - # Contract calls should always return checksummed addresses - address: ChecksumAddress = self.router.functions.WETH().call() - elif self.version == 3: - address = self.router.functions.WETH9().call() - return address - - # ------ Exchange ------------------------------------------------------------------ - @supports([1, 2]) - def get_fee_maker(self) -> float: - """Get the maker fee.""" - return 0 - - @supports([1, 2]) - def get_fee_taker(self) -> float: - """Get the taker fee.""" - return 0.003 - # ------ Market -------------------------------------------------------------------- @supports([1, 2, 3]) @@ -200,6 +161,7 @@ def get_price_input( fee: int = None, route: Optional[List[AddressLike]] = None, ) -> int: + """Returns the amount of the input token you get for `qty` of the output token""" if fee is None: fee = 3000 if self.version == 3: @@ -221,6 +183,7 @@ def get_price_output( fee: int = None, route: Optional[List[AddressLike]] = None, ) -> int: + """Returns the amount of input token you need to get `qty` of the output token""" if fee is None: fee = 3000 if self.version == 3: @@ -393,67 +356,6 @@ def _get_token_token_output_price( raise ValueError("function not supported for this version of Uniswap") return price - # ------ Wallet balance ------------------------------------------------------------ - def get_eth_balance(self) -> Wei: - """Get the balance of ETH in a wallet.""" - return self.w3.eth.getBalance(self.address) - - def get_token_balance(self, token: AddressLike) -> int: - """Get the balance of a token in a wallet.""" - _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 - - # ------ ERC20 Pool ---------------------------------------------------------------- - @supports([1]) - def get_ex_eth_balance(self, token: AddressLike) -> int: - """Get the balance of ETH in an exchange contract.""" - ex_addr: AddressLike = self.exchange_address_from_token(token) - return self.w3.eth.getBalance(ex_addr) - - @supports([1]) - def get_ex_token_balance(self, token: AddressLike) -> int: - """Get the balance of a token in an exchange contract.""" - erc20 = _load_contract_erc20(self.w3, token) - balance: int = erc20.functions.balanceOf( - self.exchange_address_from_token(token) - ).call() - return balance - - # TODO: ADD TOTAL SUPPLY - @supports([1]) - def get_exchange_rate(self, token: AddressLike) -> float: - """Get the current ETH/token exchange rate of the token.""" - eth_reserve = self.get_ex_eth_balance(token) - token_reserve = self.get_ex_token_balance(token) - return float(token_reserve / eth_reserve) - - # ------ Liquidity ----------------------------------------------------------------- - @supports([1]) - @check_approval - def add_liquidity( - self, token: AddressLike, max_eth: Wei, min_liquidity: int = 1 - ) -> HexBytes: - """Add liquidity to the pool.""" - tx_params = self._get_tx_params(max_eth) - # Add 1 to avoid rounding errors, per - # https://hackmd.io/hthz9hXKQmSyXfMbPsut1g#Add-Liquidity-Calculations - max_token = int(max_eth * self.get_exchange_rate(token)) + 10 - func_params = [min_liquidity, max_token, self._deadline()] - function = self.exchange_contract(token).functions.addLiquidity(*func_params) - return self._build_and_send_tx(function, tx_params) - - @supports([1]) - @check_approval - def remove_liquidity(self, token: str, max_token: int) -> HexBytes: - """Remove liquidity from the pool.""" - func_params = [int(max_token), 1, 1, self._deadline()] - function = self.exchange_contract(token).functions.removeLiquidity(*func_params) - return self._build_and_send_tx(function) - # ------ Make Trade ---------------------------------------------------------------- @check_approval def make_trade( @@ -872,6 +774,67 @@ def _token_to_token_swap_output( else: raise ValueError + # ------ Wallet balance ------------------------------------------------------------ + def get_eth_balance(self) -> Wei: + """Get the balance of ETH for your address.""" + return self.w3.eth.getBalance(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 + + # ------ ERC20 Pool ---------------------------------------------------------------- + @supports([1]) + def get_ex_eth_balance(self, token: AddressLike) -> int: + """Get the balance of ETH in an exchange contract.""" + ex_addr: AddressLike = self.exchange_address_from_token(token) + return self.w3.eth.getBalance(ex_addr) + + @supports([1]) + def get_ex_token_balance(self, token: AddressLike) -> int: + """Get the balance of a token in an exchange contract.""" + erc20 = _load_contract_erc20(self.w3, token) + balance: int = erc20.functions.balanceOf( + self.exchange_address_from_token(token) + ).call() + return balance + + # TODO: ADD TOTAL SUPPLY + @supports([1]) + def get_exchange_rate(self, token: AddressLike) -> float: + """Get the current ETH/token exchange rate of the token.""" + eth_reserve = self.get_ex_eth_balance(token) + token_reserve = self.get_ex_token_balance(token) + return float(token_reserve / eth_reserve) + + # ------ Liquidity ----------------------------------------------------------------- + @supports([1]) + @check_approval + def add_liquidity( + self, token: AddressLike, max_eth: Wei, min_liquidity: int = 1 + ) -> HexBytes: + """Add liquidity to the pool.""" + tx_params = self._get_tx_params(max_eth) + # Add 1 to avoid rounding errors, per + # https://hackmd.io/hthz9hXKQmSyXfMbPsut1g#Add-Liquidity-Calculations + max_token = int(max_eth * self.get_exchange_rate(token)) + 10 + func_params = [min_liquidity, max_token, self._deadline()] + function = self.exchange_contract(token).functions.addLiquidity(*func_params) + return self._build_and_send_tx(function, tx_params) + + @supports([1]) + @check_approval + def remove_liquidity(self, token: str, max_token: int) -> HexBytes: + """Remove liquidity from the pool.""" + func_params = [int(max_token), 1, 1, self._deadline()] + function = self.exchange_contract(token).functions.removeLiquidity(*func_params) + return self._build_and_send_tx(function) + # ------ Approval Utils ------------------------------------------------------------ def approve(self, token: AddressLike, max_approval: Optional[int] = None) -> None: """Give an exchange/router max approval of a token.""" @@ -1004,6 +967,48 @@ def _calculate_max_output_token( return int(outputAmountB), int(1.2 * outputAmountA) + # ------ Helpers ------------------------------------------------------------ + + def get_token(self, address: AddressLike) -> 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 + token_contract = _load_contract(self.w3, abi_name="erc20", 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) + return ERC20Token(symbol, address, name, decimals) + + @functools.lru_cache() + @supports([2, 3]) + def get_weth_address(self) -> ChecksumAddress: + """Retrieves the WETH address from the contracts (which may vary between chains).""" + if self.version == 2: + # Contract calls should always return checksummed addresses + address: ChecksumAddress = self.router.functions.WETH().call() + elif self.version == 3: + address = self.router.functions.WETH9().call() + return address + + # ------ Exchange ------------------------------------------------------------------ + @supports([1, 2]) + def get_fee_maker(self) -> float: + """Get the maker fee.""" + return 0 + + @supports([1, 2]) + def get_fee_taker(self) -> float: + """Get the taker fee.""" + return 0.003 + # ------ Test utilities ------------------------------------------------------------ def _get_token_addresses(self) -> Dict[str, ChecksumAddress]: