diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 23ac279..acb725e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -48,7 +48,8 @@ jobs: - name: Install dependencies run: | poetry install - npm install -g ganache-cli + # TODO: Update to stable ganache when released! + npm install -g ganache@beta - name: Test env: PROVIDER: ${{ secrets.MAINNET_PROVIDER }} diff --git a/Makefile b/Makefile index e8a8eb8..ac1ebe7 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ .PHONY: test typecheck lint precommit docs test: - poetry run pytest -v --cov=uniswap --cov-report html --cov-report term --cov-report xml + poetry run pytest -v --tb=line --maxfail=4 --cov=uniswap --cov-report html --cov-report term --cov-report xml typecheck: poetry run mypy --pretty diff --git a/pyproject.toml b/pyproject.toml index 20d3052..be139bb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,6 +39,10 @@ Sphinx = "*" sphinx-book-theme = "*" sphinx-click = "*" +[tool.pytest.ini_options] +log_cli = false # to print logs during tests, set to true +#log_level = "NOTSET" + [build-system] requires = ["poetry>=0.12"] build-backend = "poetry.masonry.api" diff --git a/tests/test_uniswap.py b/tests/test_uniswap.py index 3764772..08ea0b7 100644 --- a/tests/test_uniswap.py +++ b/tests/test_uniswap.py @@ -18,6 +18,7 @@ logger = logging.getLogger(__name__) +logging.basicConfig(level=logging.INFO) ENV_UNISWAP_VERSION = os.getenv("UNISWAP_VERSION", None) if ENV_UNISWAP_VERSION: @@ -25,6 +26,8 @@ else: UNISWAP_VERSIONS = [1, 2, 3] +RECEIPT_TIMEOUT = 5 + @dataclass class GanacheInstance: @@ -36,7 +39,11 @@ class GanacheInstance: @pytest.fixture(scope="module", params=UNISWAP_VERSIONS) def client(request, web3: Web3, ganache: GanacheInstance): return Uniswap( - ganache.eth_address, ganache.eth_privkey, web3=web3, version=request.param + ganache.eth_address, + ganache.eth_privkey, + web3=web3, + version=request.param, + use_estimate_gas=False, # see note in _build_and_send_tx ) @@ -54,12 +61,12 @@ def test_assets(client: Uniswap): logger.info("Buying...") tx = client.make_trade_output(tokens["ETH"], token_addr, amount) - client.w3.eth.wait_for_transaction_receipt(tx) + client.w3.eth.wait_for_transaction_receipt(tx, timeout=RECEIPT_TIMEOUT) @pytest.fixture(scope="module") def web3(ganache: GanacheInstance): - w3 = Web3(Web3.HTTPProvider(ganache.provider, request_kwargs={"timeout": 60})) + w3 = Web3(Web3.HTTPProvider(ganache.provider, request_kwargs={"timeout": 30})) if 1 != int(w3.net.version): raise Exception("PROVIDER was not a mainnet provider, which the tests require") return w3 @@ -67,10 +74,10 @@ def web3(ganache: GanacheInstance): @pytest.fixture(scope="module") def ganache() -> Generator[GanacheInstance, None, None]: - """Fixture that runs ganache-cli which has forked off mainnet""" - if not shutil.which("ganache-cli"): + """Fixture that runs ganache which has forked off mainnet""" + if not shutil.which("ganache"): raise Exception( - "ganache-cli was not found in PATH, you can install it with `npm install -g ganache-cli`" + "ganache was not found in PATH, you can install it with `npm install -g ganache`" ) if "PROVIDER" not in os.environ: raise Exception( @@ -78,11 +85,22 @@ def ganache() -> Generator[GanacheInstance, None, None]: ) port = 10999 + defaultGasPrice = 1000_000_000_000 # 1000 gwei p = subprocess.Popen( - f"ganache-cli --port {port} -s test --networkId 1 --fork {os.environ['PROVIDER']}", + f"""ganache + --port {port} + --wallet.seed test + --chain.networkId 1 + --chain.chainId 1 + --fork.url {os.environ['PROVIDER']} + --miner.defaultGasPrice {defaultGasPrice} + --miner.legacyInstamine true + """.replace( + "\n", " " + ), shell=True, ) - # Address #1 when ganache is run with `-s test`, it starts with 100 ETH + # Address #1 when ganache is run with `--wallet.seed test`, it starts with 1000 ETH eth_address = "0x94e3361495bD110114ac0b6e35Ed75E77E6a6cFA" eth_privkey = "0x6f1313062db38875fb01ee52682cbf6a8420e92bfbc578c5d4fdc0a32c50266f" sleep(3) @@ -105,7 +123,7 @@ class TestUniswap(object): ZERO_ADDRESS = "0x0000000000000000000000000000000000000000" # TODO: Detect mainnet vs rinkeby and set accordingly, like _get_token_addresses in the Uniswap class - # For Mainnet testing (with `ganache-cli --fork` as per the ganache fixture) + # For Mainnet testing (with `ganache --fork` as per the ganache fixture) eth = "0x0000000000000000000000000000000000000000" weth = Web3.toChecksumAddress("0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2") bat = Web3.toChecksumAddress("0x0D8775F648430679A709E98d2b0Cb6250d2887EF") @@ -219,7 +237,7 @@ def get_exchange_rate( ) def test_add_liquidity(self, client: Uniswap, web3: Web3, token, max_eth): r = client.add_liquidity(token, max_eth) - tx = web3.eth.wait_for_transaction_receipt(r, timeout=6000) + tx = web3.eth.wait_for_transaction_receipt(r, timeout=RECEIPT_TIMEOUT) assert tx["status"] @pytest.mark.skip @@ -274,7 +292,7 @@ def test_make_trade( bal_in_before = client.get_token_balance(input_token) txid = client.make_trade(input_token, output_token, qty, recipient) - tx = web3.eth.wait_for_transaction_receipt(txid) + tx = web3.eth.wait_for_transaction_receipt(txid, timeout=RECEIPT_TIMEOUT) assert tx["status"] # TODO: Checks for ETH, taking gas into account @@ -324,7 +342,7 @@ def test_make_trade_output( balance_before = client.get_token_balance(output_token) r = client.make_trade_output(input_token, output_token, qty, recipient) - tx = web3.eth.wait_for_transaction_receipt(r, timeout=30) + tx = web3.eth.wait_for_transaction_receipt(r, timeout=RECEIPT_TIMEOUT) assert tx["status"] # TODO: Checks for ETH, taking gas into account diff --git a/uniswap/uniswap.py b/uniswap/uniswap.py index 6c54b70..fb543d6 100644 --- a/uniswap/uniswap.py +++ b/uniswap/uniswap.py @@ -45,6 +45,16 @@ class Uniswap: Wrapper around Uniswap contracts. """ + address: AddressLike + version: int + + w3: Web3 + netid: int + netname: str + + default_slippage: float + use_estimate_gas: bool + def __init__( self, address: Union[AddressLike, str, None], @@ -53,6 +63,8 @@ def __init__( web3: Web3 = None, version: int = 1, default_slippage: float = 0.01, + use_estimate_gas: bool = True, + # use_eip1559: bool = True, factory_contract_addr: str = None, router_contract_addr: str = None, ) -> None: @@ -66,7 +78,7 @@ def __init__( :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). """ - self.address: AddressLike = _str_to_addr( + self.address = _str_to_addr( address or "0x0000000000000000000000000000000000000000" ) self.private_key = ( @@ -78,22 +90,23 @@ def __init__( # TODO: Write tests for slippage self.default_slippage = default_slippage + self.use_estimate_gas = use_estimate_gas if web3: self.w3 = web3 else: # Initialize web3. Extra provider for testing. - self.provider = provider or os.environ["PROVIDER"] - self.w3 = Web3( - Web3.HTTPProvider(self.provider, request_kwargs={"timeout": 60}) - ) - - netid = int(self.w3.net.version) - if netid in _netid_to_name: - self.network = _netid_to_name[netid] + if not provider: + provider = os.environ["PROVIDER"] + self.w3 = Web3(Web3.HTTPProvider(provider, request_kwargs={"timeout": 60})) + + # Cache netid to avoid extra RPC calls + self.netid = int(self.w3.net.version) + if self.netid in _netid_to_name: + self.netname = _netid_to_name[self.netid] else: - raise Exception(f"Unknown netid: {netid}") - logger.info(f"Using {self.w3} ('{self.network}')") + raise Exception(f"Unknown netid: {self.netid}") + logger.info(f"Using {self.w3} ('{self.netname}', netid: {self.netid})") self.last_nonce: Nonce = self.w3.eth.get_transaction_count(self.address) @@ -102,14 +115,14 @@ def __init__( # max_approval_check checks that current approval is above a reasonable number # The program cannot check for max_approval each time because it decreases # with each trade. - self.max_approval_hex = f"0x{64 * 'f'}" - self.max_approval_int = int(self.max_approval_hex, 16) - self.max_approval_check_hex = f"0x{15 * '0'}{49 * 'f'}" - self.max_approval_check_int = int(self.max_approval_check_hex, 16) + max_approval_hex = f"0x{64 * 'f'}" + self.max_approval_int = int(max_approval_hex, 16) + max_approval_check_hex = f"0x{15 * '0'}{49 * 'f'}" + self.max_approval_check_int = int(max_approval_check_hex, 16) if self.version == 1: if factory_contract_addr is None: - factory_contract_addr = _factory_contract_addresses_v1[self.network] + factory_contract_addr = _factory_contract_addresses_v1[self.netname] self.factory_contract = _load_contract( self.w3, @@ -118,11 +131,11 @@ def __init__( ) elif self.version == 2: if router_contract_addr is None: - router_contract_addr = _router_contract_addresses_v2[self.network] + router_contract_addr = _router_contract_addresses_v2[self.netname] self.router_address: AddressLike = _str_to_addr(router_contract_addr) if factory_contract_addr is None: - factory_contract_addr = _factory_contract_addresses_v2[self.network] + factory_contract_addr = _factory_contract_addresses_v2[self.netname] self.factory_contract = _load_contract( self.w3, abi_name="uniswap-v2/factory", @@ -1085,8 +1098,19 @@ def _build_and_send_tx( if not tx_params: tx_params = self._get_tx_params() transaction = function.buildTransaction(tx_params) - # Uniswap3 uses 20% margin for transactions - transaction["gas"] = Wei(int(self.w3.eth.estimate_gas(transaction) * 1.2)) + + if "gas" not in tx_params: + # `use_estimate_gas` needs to be True for networks like Arbitrum (can't assume 250000 gas), + # but it breaks tests for unknown reasons because estimateGas takes forever on some tx's. + # Maybe an issue with ganache? (got GC warnings once...) + if self.use_estimate_gas: + # The Uniswap V3 UI uses 20% margin for transactions + transaction["gas"] = Wei( + int(self.w3.eth.estimate_gas(transaction) * 1.2) + ) + else: + transaction["gas"] = Wei(250000) + signed_txn = self.w3.eth.account.sign_transaction( transaction, private_key=self.private_key ) @@ -1098,15 +1122,18 @@ def _build_and_send_tx( logger.debug(f"nonce: {tx_params['nonce']}") self.last_nonce = Nonce(tx_params["nonce"] + 1) - def _get_tx_params(self, value: Wei = Wei(0)) -> TxParams: + def _get_tx_params(self, value: Wei = Wei(0), gas: Wei = None) -> TxParams: """Get generic transaction parameters.""" - return { + params: TxParams = { "from": _addr_to_str(self.address), "value": value, "nonce": max( self.last_nonce, self.w3.eth.get_transaction_count(self.address) ), } + if gas: + params["gas"] = gas + return params # ------ Price Calculation Utils --------------------------------------------------- def _calculate_max_input_token( @@ -1255,14 +1282,12 @@ def _get_token_addresses(self) -> Dict[str, ChecksumAddress]: Returns a dict with addresses for tokens for the current net. Used in testing. """ - netid = int(self.w3.net.version) - netname = _netid_to_name[netid] - if netname == "mainnet": + if self.netname == "mainnet": return tokens - elif netname == "rinkeby": + elif self.netname == "rinkeby": return tokens_rinkeby else: - raise Exception(f"Unknown net '{netname}'") + raise Exception(f"Unknown net '{self.netname}'") # ---- Old v1 utils ----